Compare commits

..

No commits in common. "main" and "master" have entirely different histories.
main ... master

96 changed files with 26952 additions and 2 deletions

1
.env.development Normal file
View File

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

1
.env.production Normal file
View File

@ -0,0 +1 @@
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

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

View File

@ -1,2 +0,0 @@
# yuanjing

46
index.html Normal file
View File

@ -0,0 +1,46 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/public/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>开元云元境 - 一站式AI创作平台</title>
</head>
<body>
<div id="app"></div>
<script>
;(function () {
var syncPath = '#/getCookie'
var reloadPrefix = 'yuanjing-get-cookie-reload:'
function isGetCookieRoute() {
return window.location.hash.indexOf(syncPath) === 0
}
function reloadOnce(reason) {
if (!isGetCookieRoute()) return
var key = reloadPrefix + reason + ':' + window.location.hash
if (window.sessionStorage.getItem(key)) return
window.sessionStorage.setItem(key, '1')
var url = new URL(window.location.href)
url.searchParams.set('_syncReload', Date.now().toString())
window.location.replace(url.toString())
}
window.addEventListener('hashchange', function () {
reloadOnce('hashchange')
})
window.setTimeout(function () {
var app = document.getElementById('app')
if (app && app.childElementCount === 0) {
reloadOnce('empty-app')
}
}, 1200)
})()
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3655
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "yuanjing",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:test": "vite build --mode test",
"build:prod": "vite build --mode production",
"preview": "vite preview"
},
"dependencies": {
"@yeger/vue-masonry-wall": "^6.1.0",
"antd": "^6.3.3",
"axios": "^1.13.6",
"element-plus": "^2.13.5",
"less": "^4.5.1",
"less-loader": "^12.3.1",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"router": "^2.2.0",
"swiper": "^12.1.2",
"vue": "^3.5.13",
"vue-router": "^5.0.3",
"vue-waterfall-plugin-next": "^3.0.1"
},
"devDependencies": {
"@types/node": "^25.4.0",
"@types/swiper": "^6.0.0",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vue-tsc": "^2.2.12"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

30
src/App.vue Normal file
View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useLoginStore } from './store/LoginStore';
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
const loginStore = useLoginStore();
onMounted(() => {
//
loginStore.initAutoLogout();
});
</script>
<template>
<el-config-provider :locale="zhCn">
<div>
<router-view>
</router-view>
</div>
</el-config-provider>
</template>
<style lang="less">
*{
margin: 0;
padding: 0;
box-sizing: border-box;
}
</style>

204
src/apis/Model.ts Normal file
View File

@ -0,0 +1,204 @@
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)
// 图生视频
export const imagetovideo = <T,D>(data:D):Promise<T> =>post('/llmage/vidu_inference.dspy',data)
// 参考生视频
export const referencevideo = <T,D>(data:D):Promise<T> =>post('/llmage/video',data)
// 文生图
export const 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)
// 查询当前模型价格
export const queryModelPrice = <T,D>(data:D):Promise<T> =>post('/platformbiz/product_query_price.dspy',data)
// 获取当天任务列表
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 getHistoryRecord = <T,D>(data:D):Promise<T> =>get('/llmage/api/get_inference_history.dspy',data)

10
src/apis/apiKey.ts Normal file
View File

@ -0,0 +1,10 @@
import { get, post } from '@/utlis/request';
// 获取当前账号下可查看的 API Key 应用列表
export const getApiKeyList = <T>(): Promise<T> => get('/dapi/downapps.dspy');
// 申请新的 API Key
export const applyApiKey = <T, D>(data: D): Promise<T> => post('/dapi/apply_apikey.dspy', data);
// 根据应用 id 查看对应的 API Key 详情
export const getApiKeyDetail = <T, D>(id:D ): Promise<T> => get('/dapi/get_apikey.dspy', id);

16
src/apis/home.ts Normal file
View File

@ -0,0 +1,16 @@
import { get, post } from '../utlis/request'
// 获取视频列表
export const getVideos = <T, D>(data: D): Promise<T> => get('https://vidu.opencomputing.cn/v1/feed/list', data)
// 充值
export const recharge = <T, D>(data: D): Promise<T> => post('/unipay/user_recharge.dspy', data)
// 开户
export const openAccount = <T>(): Promise<T> => post('/accounting/open_customer_accounts.dspy')
// 余额
export const balance = <T>(): Promise<T> => get('/accounting/mybalance.dspy')
// 充值记录
export const consumptionRecord = <T,D>(data:D):Promise<T> => get('/platformbiz/query_orders.dspy',data)

5
src/apis/login.ts Normal file
View File

@ -0,0 +1,5 @@
import {get, post} from '@/utlis/request'
export const Code = <T,D>(data:D):Promise<T> =>get('/rbac/gen_sms_code.dspy',data)
export const LoginApi = <T,D>(data:D):Promise<T> =>post('/rbac/phone_login.dspy',data)

View File

@ -0,0 +1,539 @@
/* Logo 字体 */
@font-face {
font-family: "iconfont logo";
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834');
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix') format('embedded-opentype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg');
}
.logo {
font-family: "iconfont logo";
font-size: 160px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* tabs */
.nav-tabs {
position: relative;
}
.nav-tabs .nav-more {
position: absolute;
right: 0;
bottom: 0;
height: 42px;
line-height: 42px;
color: #666;
}
#tabs {
border-bottom: 1px solid #eee;
}
#tabs li {
cursor: pointer;
width: 100px;
height: 40px;
line-height: 40px;
text-align: center;
font-size: 16px;
border-bottom: 2px solid transparent;
position: relative;
z-index: 1;
margin-bottom: -1px;
color: #666;
}
#tabs .active {
border-bottom-color: #f00;
color: #222;
}
.tab-container .content {
display: none;
}
/* 页面布局 */
.main {
padding: 30px 100px;
width: 960px;
margin: 0 auto;
}
.main .logo {
color: #333;
text-align: left;
margin-bottom: 30px;
line-height: 1;
height: 110px;
margin-top: -50px;
overflow: hidden;
*zoom: 1;
}
.main .logo a {
font-size: 160px;
color: #333;
}
.helps {
margin-top: 40px;
}
.helps pre {
padding: 20px;
margin: 10px 0;
border: solid 1px #e7e1cd;
background-color: #fffdef;
overflow: auto;
}
.icon_lists {
width: 100% !important;
overflow: hidden;
*zoom: 1;
}
.icon_lists li {
width: 100px;
margin-bottom: 10px;
margin-right: 20px;
text-align: center;
list-style: none !important;
cursor: default;
}
.icon_lists li .code-name {
line-height: 1.2;
}
.icon_lists .icon {
display: block;
height: 100px;
line-height: 100px;
font-size: 42px;
margin: 10px auto;
color: #333;
-webkit-transition: font-size 0.25s linear, width 0.25s linear;
-moz-transition: font-size 0.25s linear, width 0.25s linear;
transition: font-size 0.25s linear, width 0.25s linear;
}
.icon_lists .icon:hover {
font-size: 100px;
}
.icon_lists .svg-icon {
/* 通过设置 font-size 来改变图标大小 */
width: 1em;
/* 图标和文字相邻时,垂直对齐 */
vertical-align: -0.15em;
/* 通过设置 color 来改变 SVG 的颜色/fill */
fill: currentColor;
/* path stroke 溢出 viewBox 部分在 IE 下会显示
normalize.css 中也包含这行 */
overflow: hidden;
}
.icon_lists li .name,
.icon_lists li .code-name {
color: #666;
}
/* markdown 样式 */
.markdown {
color: #666;
font-size: 14px;
line-height: 1.8;
}
.highlight {
line-height: 1.5;
}
.markdown img {
vertical-align: middle;
max-width: 100%;
}
.markdown h1 {
color: #404040;
font-weight: 500;
line-height: 40px;
margin-bottom: 24px;
}
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
color: #404040;
margin: 1.6em 0 0.6em 0;
font-weight: 500;
clear: both;
}
.markdown h1 {
font-size: 28px;
}
.markdown h2 {
font-size: 22px;
}
.markdown h3 {
font-size: 16px;
}
.markdown h4 {
font-size: 14px;
}
.markdown h5 {
font-size: 12px;
}
.markdown h6 {
font-size: 12px;
}
.markdown hr {
height: 1px;
border: 0;
background: #e9e9e9;
margin: 16px 0;
clear: both;
}
.markdown p {
margin: 1em 0;
}
.markdown>p,
.markdown>blockquote,
.markdown>.highlight,
.markdown>ol,
.markdown>ul {
width: 80%;
}
.markdown ul>li {
list-style: circle;
}
.markdown>ul li,
.markdown blockquote ul>li {
margin-left: 20px;
padding-left: 4px;
}
.markdown>ul li p,
.markdown>ol li p {
margin: 0.6em 0;
}
.markdown ol>li {
list-style: decimal;
}
.markdown>ol li,
.markdown blockquote ol>li {
margin-left: 20px;
padding-left: 4px;
}
.markdown code {
margin: 0 3px;
padding: 0 5px;
background: #eee;
border-radius: 3px;
}
.markdown strong,
.markdown b {
font-weight: 600;
}
.markdown>table {
border-collapse: collapse;
border-spacing: 0px;
empty-cells: show;
border: 1px solid #e9e9e9;
width: 95%;
margin-bottom: 24px;
}
.markdown>table th {
white-space: nowrap;
color: #333;
font-weight: 600;
}
.markdown>table th,
.markdown>table td {
border: 1px solid #e9e9e9;
padding: 8px 16px;
text-align: left;
}
.markdown>table th {
background: #F7F7F7;
}
.markdown blockquote {
font-size: 90%;
color: #999;
border-left: 4px solid #e9e9e9;
padding-left: 0.8em;
margin: 1em 0;
}
.markdown blockquote p {
margin: 0;
}
.markdown .anchor {
opacity: 0;
transition: opacity 0.3s ease;
margin-left: 8px;
}
.markdown .waiting {
color: #ccc;
}
.markdown h1:hover .anchor,
.markdown h2:hover .anchor,
.markdown h3:hover .anchor,
.markdown h4:hover .anchor,
.markdown h5:hover .anchor,
.markdown h6:hover .anchor {
opacity: 1;
display: inline-block;
}
.markdown>br,
.markdown>p>br {
clear: both;
}
.hljs {
display: block;
background: white;
padding: 0.5em;
color: #333333;
overflow-x: auto;
}
.hljs-comment,
.hljs-meta {
color: #969896;
}
.hljs-string,
.hljs-variable,
.hljs-template-variable,
.hljs-strong,
.hljs-emphasis,
.hljs-quote {
color: #df5000;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-type {
color: #a71d5d;
}
.hljs-literal,
.hljs-symbol,
.hljs-bullet,
.hljs-attribute {
color: #0086b3;
}
.hljs-section,
.hljs-name {
color: #63a35c;
}
.hljs-tag {
color: #333333;
}
.hljs-title,
.hljs-attr,
.hljs-selector-id,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo {
color: #795da3;
}
.hljs-addition {
color: #55a532;
background-color: #eaffea;
}
.hljs-deletion {
color: #bd2c00;
background-color: #ffecec;
}
.hljs-link {
text-decoration: underline;
}
/* 代码高亮 */
/* PrismJS 1.15.0
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */
/**
* prism.js default theme for JavaScript, CSS and HTML
* Based on dabblet (http://dabblet.com)
* @author Lea Verou
*/
code[class*="language-"],
pre[class*="language-"] {
color: black;
background: none;
text-shadow: 0 1px white;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"]::-moz-selection,
pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection,
code[class*="language-"] ::-moz-selection {
text-shadow: none;
background: #b3d4fc;
}
pre[class*="language-"]::selection,
pre[class*="language-"] ::selection,
code[class*="language-"]::selection,
code[class*="language-"] ::selection {
text-shadow: none;
background: #b3d4fc;
}
@media print {
code[class*="language-"],
pre[class*="language-"] {
text-shadow: none;
}
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
}
:not(pre)>code[class*="language-"],
pre[class*="language-"] {
background: #f5f2f0;
}
/* Inline code */
:not(pre)>code[class*="language-"] {
padding: .1em;
border-radius: .3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: slategray;
}
.token.punctuation {
color: #999;
}
.namespace {
opacity: .7;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #905;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #690;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #9a6e3a;
background: hsla(0, 0%, 100%, .5);
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #07a;
}
.token.function,
.token.class-name {
color: #DD4A68;
}
.token.regex,
.token.important,
.token.variable {
color: #e90;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

View File

@ -0,0 +1,832 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>iconfont Demo</title>
<link rel="shortcut icon" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg" type="image/x-icon"/>
<link rel="icon" type="image/svg+xml" href="//img.alicdn.com/imgextra/i4/O1CN01Z5paLz1O0zuCC7osS_!!6000000001644-55-tps-83-82.svg"/>
<link rel="stylesheet" href="https://g.alicdn.com/thx/cube/1.3.2/cube.min.css">
<link rel="stylesheet" href="demo.css">
<link rel="stylesheet" href="iconfont.css">
<script src="iconfont.js"></script>
<!-- jQuery -->
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/7bfddb60-08e8-11e9-9b04-53e73bb6408b.js"></script>
<!-- 代码高亮 -->
<script src="https://a1.alicdn.com/oss/uploads/2018/12/26/a3f714d0-08e6-11e9-8a15-ebf944d7534c.js"></script>
<style>
.main .logo {
margin-top: 0;
height: auto;
}
.main .logo a {
display: flex;
align-items: center;
}
.main .logo .sub-title {
margin-left: 0.5em;
font-size: 22px;
color: #fff;
background: linear-gradient(-45deg, #3967FF, #B500FE);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
</style>
</head>
<body>
<div class="main">
<h1 class="logo"><a href="https://www.iconfont.cn/" title="iconfont 首页" target="_blank">
<img width="200" src="https://img.alicdn.com/imgextra/i3/O1CN01Mn65HV1FfSEzR6DKv_!!6000000000514-55-tps-228-59.svg">
</a></h1>
<div class="nav-tabs">
<ul id="tabs" class="dib-box">
<li class="dib active"><span>Unicode</span></li>
<li class="dib"><span>Font class</span></li>
<li class="dib"><span>Symbol</span></li>
</ul>
<a href="https://www.iconfont.cn/manage/index?manage_type=myprojects&projectId=5136943" target="_blank" class="nav-more">查看项目</a>
</div>
<div class="tab-container">
<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>
<div class="code-name">&amp;#xe606;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe617;</span>
<div class="name">apikey</div>
<div class="code-name">&amp;#xe617;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe654;</span>
<div class="name">消费记录</div>
<div class="code-name">&amp;#xe654;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe615;</span>
<div class="name">余额</div>
<div class="code-name">&amp;#xe615;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe634;</span>
<div class="name">支付宝支付</div>
<div class="code-name">&amp;#xe634;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe84f;</span>
<div class="name">警告</div>
<div class="code-name">&amp;#xe84f;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe716;</span>
<div class="name"></div>
<div class="code-name">&amp;#xe716;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe623;</span>
<div class="name">验证码</div>
<div class="code-name">&amp;#xe623;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe61c;</span>
<div class="name">003_手机号-53</div>
<div class="code-name">&amp;#xe61c;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe603;</span>
<div class="name">数字人</div>
<div class="code-name">&amp;#xe603;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe604;</span>
<div class="name">音频生成</div>
<div class="code-name">&amp;#xe604;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe622;</span>
<div class="name"></div>
<div class="code-name">&amp;#xe622;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe60f;</span>
<div class="name"></div>
<div class="code-name">&amp;#xe60f;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe600;</span>
<div class="name">img2video</div>
<div class="code-name">&amp;#xe600;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe601;</span>
<div class="name">image-edit</div>
<div class="code-name">&amp;#xe601;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe602;</span>
<div class="name">无限画布</div>
<div class="code-name">&amp;#xe602;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe636;</span>
<div class="name">图片生成</div>
<div class="code-name">&amp;#xe636;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe62e;</span>
<div class="name">首页</div>
<div class="code-name">&amp;#xe62e;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe63c;</span>
<div class="name">个人中心</div>
<div class="code-name">&amp;#xe63c;</div>
</li>
<li class="dib">
<span class="icon iconfont">&#xe605;</span>
<div class="name">发现</div>
<div class="code-name">&amp;#xe605;</div>
</li>
</ul>
<div class="article markdown">
<h2 id="unicode-">Unicode 引用</h2>
<hr>
<p>Unicode 是字体在网页端最原始的应用方式,特点是:</p>
<ul>
<li>支持按字体的方式去动态调整图标大小,颜色等等。</li>
<li>默认情况下不支持多色,直接添加多色图标会自动去色。</li>
</ul>
<blockquote>
<p>注意:新版 iconfont 支持两种方式引用多色图标SVG symbol 引用方式和彩色字体图标模式。(使用彩色字体图标需要在「编辑项目」中开启「彩色」选项后并重新生成。)</p>
</blockquote>
<p>Unicode 使用步骤如下:</p>
<h3 id="-font-face">第一步:拷贝项目下面生成的 <code>@font-face</code></h3>
<pre><code class="language-css"
>@font-face {
font-family: 'iconfont';
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>
<pre><code class="language-css"
>.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</code></pre>
<h3 id="-">第三步:挑选相应图标并获取字体编码,应用于页面</h3>
<pre>
<code class="language-html"
>&lt;span class="iconfont"&gt;&amp;#x33;&lt;/span&gt;
</code></pre>
<blockquote>
<p>"iconfont" 是你项目下的 font-family。可以通过编辑项目查看默认是 "iconfont"。</p>
</blockquote>
</div>
</div>
<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">
414126695
</div>
<div class="code-name">.icon-a-414126695
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-apikey"></span>
<div class="name">
apikey
</div>
<div class="code-name">.icon-apikey
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-xiaofeijilu"></span>
<div class="name">
消费记录
</div>
<div class="code-name">.icon-xiaofeijilu
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-yue"></span>
<div class="name">
余额
</div>
<div class="code-name">.icon-yue
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-zhifubaozhifu"></span>
<div class="name">
支付宝支付
</div>
<div class="code-name">.icon-zhifubaozhifu
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-jinggao"></span>
<div class="name">
警告
</div>
<div class="code-name">.icon-jinggao
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-jia"></span>
<div class="name">
</div>
<div class="code-name">.icon-jia
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-yanzhengma"></span>
<div class="name">
验证码
</div>
<div class="code-name">.icon-yanzhengma
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-003_shoujihao-53"></span>
<div class="name">
003_手机号-53
</div>
<div class="code-name">.icon-a-003_shoujihao-53
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-387845729"></span>
<div class="name">
数字人
</div>
<div class="code-name">.icon-a-387845729
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-387846145"></span>
<div class="name">
音频生成
</div>
<div class="code-name">.icon-a-387846145
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-zuo"></span>
<div class="name">
</div>
<div class="code-name">.icon-zuo
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-you"></span>
<div class="name">
</div>
<div class="code-name">.icon-you
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-img2video"></span>
<div class="name">
img2video
</div>
<div class="code-name">.icon-img2video
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-image-edit"></span>
<div class="name">
image-edit
</div>
<div class="code-name">.icon-image-edit
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-a-386885351"></span>
<div class="name">
无限画布
</div>
<div class="code-name">.icon-a-386885351
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-tupianshengcheng"></span>
<div class="name">
图片生成
</div>
<div class="code-name">.icon-tupianshengcheng
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-shouye"></span>
<div class="name">
首页
</div>
<div class="code-name">.icon-shouye
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-gerenzhongxin"></span>
<div class="name">
个人中心
</div>
<div class="code-name">.icon-gerenzhongxin
</div>
</li>
<li class="dib">
<span class="icon iconfont icon-faxian"></span>
<div class="name">
发现
</div>
<div class="code-name">.icon-faxian
</div>
</li>
</ul>
<div class="article markdown">
<h2 id="font-class-">font-class 引用</h2>
<hr>
<p>font-class 是 Unicode 使用方式的一种变种,主要是解决 Unicode 书写不直观,语意不明确的问题。</p>
<p>与 Unicode 使用方式相比,具有如下特点:</p>
<ul>
<li>相比于 Unicode 语意明确,书写更直观。可以很容易分辨这个 icon 是什么。</li>
<li>因为使用 class 来定义图标,所以当要替换图标时,只需要修改 class 里面的 Unicode 引用。</li>
</ul>
<p>使用步骤如下:</p>
<h3 id="-fontclass-">第一步:引入项目下面生成的 fontclass 代码:</h3>
<pre><code class="language-html">&lt;link rel="stylesheet" href="./iconfont.css"&gt;
</code></pre>
<h3 id="-">第二步:挑选相应图标并获取类名,应用于页面:</h3>
<pre><code class="language-html">&lt;span class="iconfont icon-xxx"&gt;&lt;/span&gt;
</code></pre>
<blockquote>
<p>"
iconfont" 是你项目下的 font-family。可以通过编辑项目查看默认是 "iconfont"。</p>
</blockquote>
</div>
</div>
<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>
</svg>
<div class="name">414126695</div>
<div class="code-name">#icon-a-414126695</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-apikey"></use>
</svg>
<div class="name">apikey</div>
<div class="code-name">#icon-apikey</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-xiaofeijilu"></use>
</svg>
<div class="name">消费记录</div>
<div class="code-name">#icon-xiaofeijilu</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-yue"></use>
</svg>
<div class="name">余额</div>
<div class="code-name">#icon-yue</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-zhifubaozhifu"></use>
</svg>
<div class="name">支付宝支付</div>
<div class="code-name">#icon-zhifubaozhifu</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-jinggao"></use>
</svg>
<div class="name">警告</div>
<div class="code-name">#icon-jinggao</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-jia"></use>
</svg>
<div class="name"></div>
<div class="code-name">#icon-jia</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-yanzhengma"></use>
</svg>
<div class="name">验证码</div>
<div class="code-name">#icon-yanzhengma</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-003_shoujihao-53"></use>
</svg>
<div class="name">003_手机号-53</div>
<div class="code-name">#icon-a-003_shoujihao-53</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-387845729"></use>
</svg>
<div class="name">数字人</div>
<div class="code-name">#icon-a-387845729</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-387846145"></use>
</svg>
<div class="name">音频生成</div>
<div class="code-name">#icon-a-387846145</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-zuo"></use>
</svg>
<div class="name"></div>
<div class="code-name">#icon-zuo</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-you"></use>
</svg>
<div class="name"></div>
<div class="code-name">#icon-you</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-img2video"></use>
</svg>
<div class="name">img2video</div>
<div class="code-name">#icon-img2video</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-image-edit"></use>
</svg>
<div class="name">image-edit</div>
<div class="code-name">#icon-image-edit</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-a-386885351"></use>
</svg>
<div class="name">无限画布</div>
<div class="code-name">#icon-a-386885351</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-tupianshengcheng"></use>
</svg>
<div class="name">图片生成</div>
<div class="code-name">#icon-tupianshengcheng</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-shouye"></use>
</svg>
<div class="name">首页</div>
<div class="code-name">#icon-shouye</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-gerenzhongxin"></use>
</svg>
<div class="name">个人中心</div>
<div class="code-name">#icon-gerenzhongxin</div>
</li>
<li class="dib">
<svg class="icon svg-icon" aria-hidden="true">
<use xlink:href="#icon-faxian"></use>
</svg>
<div class="name">发现</div>
<div class="code-name">#icon-faxian</div>
</li>
</ul>
<div class="article markdown">
<h2 id="symbol-">Symbol 引用</h2>
<hr>
<p>这是一种全新的使用方式,应该说这才是未来的主流,也是平台目前推荐的用法。相关介绍可以参考这篇<a href="">文章</a>
这种用法其实是做了一个 SVG 的集合,与另外两种相比具有如下特点:</p>
<ul>
<li>支持多色图标了,不再受单色限制。</li>
<li>通过一些技巧,支持像字体那样,通过 <code>font-size</code>, <code>color</code> 来调整样式。</li>
<li>兼容性较差,支持 IE9+,及现代浏览器。</li>
<li>浏览器渲染 SVG 的性能一般,还不如 png。</li>
</ul>
<p>使用步骤如下:</p>
<h3 id="-symbol-">第一步:引入项目下面生成的 symbol 代码:</h3>
<pre><code class="language-html">&lt;script src="./iconfont.js"&gt;&lt;/script&gt;
</code></pre>
<h3 id="-css-">第二步:加入通用 CSS 代码(引入一次就行):</h3>
<pre><code class="language-html">&lt;style&gt;
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
&lt;/style&gt;
</code></pre>
<h3 id="-">第三步:挑选相应图标并获取类名,应用于页面:</h3>
<pre><code class="language-html">&lt;svg class="icon" aria-hidden="true"&gt;
&lt;use xlink:href="#icon-xxx"&gt;&lt;/use&gt;
&lt;/svg&gt;
</code></pre>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function () {
$('.tab-container .content:first').show()
$('#tabs li').click(function (e) {
var tabContent = $('.tab-container .content')
var index = $(this).index()
if ($(this).hasClass('active')) {
return
} else {
$('#tabs li').removeClass('active')
$(this).addClass('active')
tabContent.hide().eq(index).fadeIn()
}
})
})
</script>
</body>
</html>

View File

@ -0,0 +1,127 @@
@font-face {
font-family: "iconfont"; /* Project id 5136943 */
src: url('iconfont.woff2?t=1780995416168') format('woff2'),
url('iconfont.woff?t=1780995416168') format('woff'),
url('iconfont.ttf?t=1780995416168') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-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";
}
.icon-apikey:before {
content: "\e617";
}
.icon-xiaofeijilu:before {
content: "\e654";
}
.icon-yue:before {
content: "\e615";
}
.icon-zhifubaozhifu:before {
content: "\e634";
}
.icon-jinggao:before {
content: "\e84f";
}
.icon-jia:before {
content: "\e716";
}
.icon-yanzhengma:before {
content: "\e623";
}
.icon-a-003_shoujihao-53:before {
content: "\e61c";
}
.icon-a-387845729:before {
content: "\e603";
}
.icon-a-387846145:before {
content: "\e604";
}
.icon-zuo:before {
content: "\e622";
}
.icon-you:before {
content: "\e60f";
}
.icon-img2video:before {
content: "\e600";
}
.icon-image-edit:before {
content: "\e601";
}
.icon-a-386885351:before {
content: "\e602";
}
.icon-tupianshengcheng:before {
content: "\e636";
}
.icon-shouye:before {
content: "\e62e";
}
.icon-gerenzhongxin:before {
content: "\e63c";
}
.icon-faxian:before {
content: "\e605";
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,205 @@
{
"id": "5136943",
"name": "yuanjing",
"font_family": "iconfont",
"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",
"font_class": "a-414126695",
"unicode": "e606",
"unicode_decimal": 58886
},
{
"icon_id": "44608574",
"name": "apikey",
"font_class": "apikey",
"unicode": "e617",
"unicode_decimal": 58903
},
{
"icon_id": "1020316",
"name": "消费记录",
"font_class": "xiaofeijilu",
"unicode": "e654",
"unicode_decimal": 58964
},
{
"icon_id": "2681721",
"name": "余额",
"font_class": "yue",
"unicode": "e615",
"unicode_decimal": 58901
},
{
"icon_id": "1486848",
"name": "支付宝支付",
"font_class": "zhifubaozhifu",
"unicode": "e634",
"unicode_decimal": 58932
},
{
"icon_id": "8288853",
"name": "警告",
"font_class": "jinggao",
"unicode": "e84f",
"unicode_decimal": 59471
},
{
"icon_id": "16350239",
"name": "加",
"font_class": "jia",
"unicode": "e716",
"unicode_decimal": 59158
},
{
"icon_id": "5022863",
"name": "验证码",
"font_class": "yanzhengma",
"unicode": "e623",
"unicode_decimal": 58915
},
{
"icon_id": "36126728",
"name": "003_手机号-53",
"font_class": "a-003_shoujihao-53",
"unicode": "e61c",
"unicode_decimal": 58908
},
{
"icon_id": "47059243",
"name": "数字人",
"font_class": "a-387845729",
"unicode": "e603",
"unicode_decimal": 58883
},
{
"icon_id": "47059246",
"name": "音频生成",
"font_class": "a-387846145",
"unicode": "e604",
"unicode_decimal": 58884
},
{
"icon_id": "8483536",
"name": "左",
"font_class": "zuo",
"unicode": "e622",
"unicode_decimal": 58914
},
{
"icon_id": "13000530",
"name": "右",
"font_class": "you",
"unicode": "e60f",
"unicode_decimal": 58895
},
{
"icon_id": "45643295",
"name": "img2video",
"font_class": "img2video",
"unicode": "e600",
"unicode_decimal": 58880
},
{
"icon_id": "46889707",
"name": "image-edit",
"font_class": "image-edit",
"unicode": "e601",
"unicode_decimal": 58881
},
{
"icon_id": "47046108",
"name": "无限画布",
"font_class": "a-386885351",
"unicode": "e602",
"unicode_decimal": 58882
},
{
"icon_id": "45825147",
"name": "图片生成",
"font_class": "tupianshengcheng",
"unicode": "e636",
"unicode_decimal": 58934
},
{
"icon_id": "2675009",
"name": "首页",
"font_class": "shouye",
"unicode": "e62e",
"unicode_decimal": 58926
},
{
"icon_id": "5532917",
"name": "个人中心",
"font_class": "gerenzhongxin",
"unicode": "e63c",
"unicode_decimal": 58940
},
{
"icon_id": "11200583",
"name": "发现",
"font_class": "faxian",
"unicode": "e605",
"unicode_decimal": 58885
}
]
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Binary file not shown.

BIN
src/assets/images/login.mp4 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,262 @@
.header-container {
position: relative;
z-index: 100;
}
.header {
width: 100%;
height: 80px;
display: flex;
justify-content: space-between;
align-items: center;
padding-right: 2rem;
background: rgba(10, 14, 20, 0.8);
backdrop-filter: blur(12px);
}
@media (max-width: 768px) {
.header {
padding: 0 1rem;
height: 70px;
}
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo img {
width: 48px;
height: 48px;
border-radius: 12px;
transition: all 0.3s ease;
}
.logo .header-title {
font-family: "Microsoft Yahei", "PingFang SC", sans-serif;
font-size: 28px;
font-weight: 600;
color: #fff;
transform: skew(-8deg);
-webkit-transform: skew(-12deg);
text-shadow: 0 0 10px rgba(0, 255, 204, 0.3);
}
@media (max-width: 768px) {
.logo .header-title {
font-size: 24px;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 20px;
}
@media (max-width: 768px) {
.header-actions {
gap: 12px;
}
}
.balance-recharge-combo {
height: 44px;
padding: 0 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 500;
color: #fff;
background: rgba(255, 142, 83, 0.1);
border: 1px solid rgba(255, 142, 83, 0.4);
border-radius: 22px;
cursor: pointer;
transition: all 0.3s ease;
}
.balance-recharge-combo .iconfont {
font-size: 16px;
color: #ff8e53;
}
.balance-recharge-combo .balance-text {
font-weight: 600;
color: #ff8e53;
}
.balance-recharge-combo .recharge-text {
height: 100%;
width: 60px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 10px;
font-size: 13px;
color: #ff8e53;
font-weight: 600;
}
.balance-recharge-combo:hover {
background: rgba(255, 142, 83, 0.15);
box-shadow: 0 4px 12px rgba(255, 142, 83, 0.2);
}
@media (max-width: 768px) {
.balance-recharge-combo {
height: 36px;
padding: 0 14px;
font-size: 13px;
gap: 6px;
}
.balance-recharge-combo .recharge-text {
padding: 1px 6px;
font-size: 12px;
}
}
.btn {
min-width: 120px;
height: 48px;
font-size: 16px;
background-image: linear-gradient(270deg, #00f2fe 11.11%, #4facfe);
border-radius: 24px;
display: flex;
justify-content: center;
align-items: center;
font-weight: 600;
color: #0a1428;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 242, 254, 0.3);
}
.btn:hover {
background-image: linear-gradient(270deg, #4facfe 11.11%, #00f2fe);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 242, 254, 0.4);
}
@media (max-width: 768px) {
.btn {
min-width: 100px;
height: 40px;
font-size: 14px;
}
}
.user-profile {
position: relative;
display: inline-block;
}
.user-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 0 20px;
height: 44px;
background: rgba(255, 255, 255, 0.1);
border-radius: 22px;
color: #fff;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid transparent;
}
.user-trigger:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(0, 255, 204, 0.3);
box-shadow: 0 0 15px rgba(0, 255, 204, 0.2);
}
.user-trigger .user-name {
font-size: 15px;
font-weight: 500;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-trigger .dropdown-icon {
width: 16px;
height: 16px;
transition: transform 0.3s ease;
color: rgba(255, 255, 255, 0.8);
}
.user-trigger:hover .dropdown-icon {
transform: rotate(180deg);
color: #00ffcc;
}
@media (max-width: 768px) {
.user-trigger {
height: 36px;
padding: 0 14px;
}
.user-trigger .user-name {
max-width: 80px;
font-size: 13px;
}
}
.user-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
width: 220px;
background: rgba(10, 14, 20, 0.95);
backdrop-filter: blur(12px);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
overflow: hidden;
z-index: 1000;
}
.user-dropdown::before {
content: '';
position: absolute;
top: -8px;
right: 20px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid rgba(10, 14, 20, 0.95);
}
.dropdown-item {
display: flex;
align-items: center;
padding: 12px 16px;
color: #fff;
transition: all 0.2s ease;
cursor: pointer;
}
.dropdown-item:hover {
background: rgba(0, 255, 204, 0.1);
}
.dropdown-item.user-id {
justify-content: space-between;
font-size: 14px;
}
.dropdown-item.user-id .item-label {
color: rgba(255, 255, 255, 0.7);
}
.dropdown-item.user-id .item-value {
color: #00ffcc;
font-family: monospace;
font-size: 13px;
}
.dropdown-item.logout {
gap: 8px;
font-size: 14px;
}
.dropdown-item.logout .item-icon {
width: 16px;
height: 16px;
color: rgba(255, 255, 255, 0.8);
}
.dropdown-item.logout:hover {
color: #ff6b6b;
}
.dropdown-item.logout:hover .item-icon {
color: #ff6b6b;
}
.dropdown-divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 4px 0;
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.3s ease;
}
.dropdown-enter-from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}

View File

@ -0,0 +1,393 @@
// 头部容器
.header-container {
position: relative;
z-index: 100;
}
// 头部主容器
.header {
width: 100%;
height: 80px;
display: flex;
justify-content: space-between;
align-items: center;
padding-right: 2rem;
background: rgba(10, 12, 18, 0.74);
backdrop-filter: blur(16px);
@media (max-width: 768px) {
padding: 0 1rem;
height: 70px;
}
}
// Logo样式
.logo {
display: flex;
align-items: center;
gap: 12px;
img {
width: 48px;
height: 48px;
border-radius: 12px;
transition: all 0.3s ease;
}
.header-title {
font-family: "Microsoft Yahei", "PingFang SC", sans-serif;
font-size: 28px;
font-weight: 700;
color: #fff;
letter-spacing: 0.04em;
@media (max-width: 768px) {
font-size: 24px;
}
}
}
// 操作区
.header-actions {
display: flex;
align-items: center;
gap: 14px;
@media (max-width: 768px) {
gap: 12px;
}
}
// ==============================================
// 【一体化】余额 + 充值按钮(核心修改)
// ==============================================
.balance-recharge-combo {
height: 52px;
padding: 0 10px 0 12px;
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
font-weight: 500;
color: #fff;
background: linear-gradient(180deg, rgba(31, 35, 46, 0.95), rgba(18, 21, 30, 0.96));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18);
.balance-main {
display: flex;
align-items: center;
gap: 10px;
}
.balance-icon-wrap {
width: 32px;
height: 32px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(139, 124, 255, 0.22), rgba(246, 184, 106, 0.18));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.iconfont {
font-size: 16px;
color: #f6b86a;
}
.balance-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.balance-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
letter-spacing: 0.08em;
}
.balance-text {
font-weight: 700;
color: #fff;
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;
background: rgba(255, 255, 255, 0.08);
}
.recharge-text {
min-width: 72px;
height: 36px;
padding: 0 14px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 12px;
font-size: 13px;
color: #fff;
font-weight: 600;
background: linear-gradient(135deg, #6e5cff, #8b7cff);
box-shadow: 0 10px 24px rgba(110, 92, 255, 0.3);
}
&:hover {
transform: translateY(-1px);
border-color: rgba(139, 124, 255, 0.26);
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.24);
}
@media (max-width: 768px) {
height: 42px;
padding: 0 8px 0 10px;
font-size: 13px;
gap: 6px;
.balance-icon-wrap {
width: 28px;
height: 28px;
border-radius: 10px;
}
.balance-label {
display: none;
}
.combo-divider {
height: 18px;
}
.recharge-text {
min-width: 60px;
height: 30px;
padding: 0 10px;
font-size: 12px;
}
}
}
// 登录按钮
.btn {
min-width: 120px;
height: 48px;
font-size: 16px;
background-image: linear-gradient(135deg, #6e5cff, #8b7cff);
border-radius: 16px;
display: flex;
justify-content: center;
align-items: center;
font-weight: 600;
color: #fff;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 12px 28px rgba(110, 92, 255, 0.3);
&:hover {
background-image: linear-gradient(135deg, #7d6dff, #978bff);
transform: translateY(-2px);
box-shadow: 0 16px 36px rgba(110, 92, 255, 0.34);
}
@media (max-width: 768px) {
min-width: 100px;
height: 40px;
font-size: 14px;
}
}
// 用户个人信息区域
.user-profile {
position: relative;
display: inline-block;
}
.user-profile.dropdown-open .user-trigger .dropdown-icon {
transform: rotate(180deg);
color: #8b7cff;
}
.user-trigger {
display: flex;
align-items: center;
gap: 10px;
padding: 0 16px 0 12px;
height: 52px;
background: rgba(255, 255, 255, 0.04);
border-radius: 18px;
color: #fff;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.14);
&:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(139, 124, 255, 0.2);
transform: translateY(-1px);
}
.user-avatar {
width: 30px;
height: 30px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
// font-size: 50px;
.iconfont{
font-size: 28px;
}
// font-weight: 700;
// color: #fff;
// background: linear-gradient(135deg, rgba(139, 124, 255, 0.9), rgba(246, 184, 106, 0.72));
flex-shrink: 0;
}
.user-name {
font-size: 15px;
font-weight: 600;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-icon {
width: 16px;
height: 16px;
transition: transform 0.3s ease;
color: rgba(255, 255, 255, 0.56);
}
@media (max-width: 768px) {
height: 36px;
padding: 0 12px;
.user-avatar {
width: 24px;
height: 24px;
border-radius: 8px;
font-size: 11px;
}
.user-name {
max-width: 80px;
font-size: 13px;
}
}
}
// 用户下拉菜单
.user-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
width: 220px;
background: rgba(16, 19, 28, 0.98);
backdrop-filter: blur(14px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.3);
overflow: hidden;
z-index: 1000;
&::before {
content: '';
position: absolute;
top: -8px;
right: 20px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid rgba(16, 19, 28, 0.98);
}
}
// 下拉菜单项
.dropdown-item {
display: flex;
align-items: center;
padding: 12px 16px;
color: #fff;
transition: all 0.2s ease;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.05);
}
&.user-id {
justify-content: space-between;
font-size: 14px;
.item-label {
color: rgba(255, 255, 255, 0.7);
}
.item-value {
color: #8b7cff;
font-family: monospace;
font-size: 13px;
}
}
&.logout {
gap: 8px;
font-size: 14px;
.item-icon {
width: 16px;
height: 16px;
color: rgba(255, 255, 255, 0.8);
}
&:hover {
color: #ff6b6b;
.item-icon {
color: #ff6b6b;
}
}
}
}
// 下拉菜单分隔线
.dropdown-divider {
height: 1px;
background: rgba(255, 255, 255, 0.06);
margin: 4px 0;
}
// 下拉菜单动画
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.3s ease;
}
.dropdown-enter-from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}

View File

@ -0,0 +1,148 @@
.container {
width: 100%;
height: 100%;
}
.Topcard {
background-color: #1a1c20;
border-radius: 12px;
padding: 16px;
border: 0;
}
.Bottomcard {
background-color: #1a1c20;
border-radius: 12px;
padding: 16px;
border: 0;
margin-top: 12px;
}
.card-top {
display: flex;
justify-content: space-between;
}
.card-top img {
width: 163px;
}
.card-top .top-left h2 {
font-size: 24px;
font-weight: 600;
color: #fff;
}
.card-top .top-left .desc {
font-size: 14px;
margin-top: 4px;
color: #ffffff7a;
}
.card-top .top-left .iconfont {
font-size: 12px;
padding-right: 4px;
}
.card-top .top-left .add {
display: flex;
gap: 10px;
margin-top: 16px;
}
.card-top .top-left .add-item {
font-size: 14px;
gap: 4px;
border-radius: 8px;
color: #fff;
padding: 6px 12px;
background-color: #ffffff0a;
}
.card-bottom {
margin-top: 16px;
}
:deep(.el-textarea) .el-textarea__inner {
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 0 !important;
padding: 12px 0 !important;
font-size: 14px;
line-height: 1.5;
color: #fff !important;
resize: vertical;
outline: none !important;
border-radius: 10px !important;
color: #fff;
backdrop-filter: blur(30px);
padding: 14px 16px !important;
}
:deep(.el-textarea) .el-textarea__inner:focus {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
:deep(.el-textarea) .el-textarea__inner::placeholder {
color: #ffffff7a !important;
font-size: 14px;
}
:deep(.el-textarea) .el-input__count {
background: transparent;
color: #ffffff7a;
}
:deep(.el-select__wrapper) {
width: 100% !important;
height: 100%!important;
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 0 !important;
font-size: 14px;
color: #fff !important;
resize: vertical;
outline: none !important;
border-radius: 10px !important;
color: #fff;
}
:deep(.el-select__selected-item) {
color: #fff !important;
}
.select {
display: flex;
flex-direction: column;
}
.select .select-item {
width: 100%;
display: flex;
align-items: center;
}
.select .select-item .title {
width: 50%;
font-size: 14px;
color: #fff;
padding-right: 12px;
}
.select .select-item .content {
flex: 1;
}
.border {
height: 1px;
margin: 12px 0;
}
.quantity-buttons {
display: flex;
gap: 8px;
}
.quantity-buttons .quantity-btn {
flex: 1;
padding: 8px 0;
background-color: rgba(0, 0, 0, 0.32);
border: none;
border-radius: 10px;
color: #fff;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.quantity-buttons .quantity-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.quantity-buttons .quantity-btn.active {
background-color: rgba(255, 255, 255, 0.1);
color: #fff;
}
:deep(.el-upload--picture-card) {
width: 140px !important;
height: 140px !important;
}

View File

@ -0,0 +1,181 @@
.container {
width: 100%;
height: 100%;
}
.Topcard {
background-color: #1a1c20;
border-radius: 12px;
padding: 16px;
border: 0;
}
.Bottomcard {
background-color: #1a1c20;
border-radius: 12px;
padding: 16px;
border: 0;
margin-top: 12px;
}
.card-top {
display: flex;
justify-content: space-between;
img {
width: 163px;
}
.top-left {
h2 {
font-size: 24px;
font-weight: 600;
color: #fff;
}
.desc {
font-size: 14px;
margin-top: 4px;
color: #ffffff7a;
}
.iconfont {
font-size: 12px;
padding-right: 4px;
}
.add {
display: flex;
gap: 10px;
margin-top: 16px;
}
.add-item {
font-size: 14px;
gap: 4px;
border-radius: 8px;
color: #fff;
padding: 6px 12px;
background-color: #ffffff0a;
}
}
}
.card-bottom {
margin-top: 16px;
}
:deep(.el-textarea) {
.el-textarea__inner {
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 0 !important;
padding: 12px 0 !important;
font-size: 14px;
line-height: 1.5;
color: #fff !important;
resize: vertical;
outline: none !important;
border-radius: 10px !important;
color: #fff;
backdrop-filter: blur(30px);
padding: 14px 16px !important;
&:focus {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
&::placeholder {
color: #ffffff7a !important;
font-size: 14px;
}
}
// 确保没有任何附加的边框或装饰出现
.el-input__count {
background: transparent;
color: #ffffff7a;
}
}
:deep(.el-select__wrapper) {
width: 100% !important;
height: 100%!important;
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 0 !important;
font-size: 14px;
color: #fff !important;
resize: vertical;
outline: none !important;
border-radius: 10px !important;
color: #fff;
}
:deep(.el-select__selected-item) {
color: #fff !important;
}
.select {
display: flex;
flex-direction: column;
.select-item {
width: 100%;
display: flex;
align-items: center;
.title {
width: 50%;
font-size: 14px;
color: #fff;
padding-right: 12px;
}
.content {
flex: 1;
}
}
}
.border {
height: 1px;
margin: 12px 0;
}
.quantity-buttons {
display: flex;
gap: 8px;
.quantity-btn {
flex: 1;
padding: 8px 0;
background-color: rgba(0, 0, 0, 0.32);
border: none;
border-radius: 10px;
color: #fff;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
&.active {
background-color: rgba(255, 255, 255, 0.1);
color: #fff;
}
}
}
:deep(.el-upload--picture-card) {
width: 140px !important;
height: 140px !important;
}

View File

@ -0,0 +1,151 @@
.login {
width: 100%;
height: 100vh;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.background-video-stack {
position: absolute;
inset: 0;
z-index: 0;
}
.background-video {
position: absolute;
inset: 0;
height: 100%;
width: 100%;
object-fit: cover;
opacity: 1;
pointer-events: none;
transition: opacity 0.4s ease-in-out;
}
.form-card {
position: relative;
left: 25%;
z-index: 1;
width: 400px;
max-width: 90%;
padding: 40px 30px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
color: #fff;
}
.form-top {
text-align: center;
margin-bottom: 10%;
}
.form-title {
margin-bottom: 8px;
font-weight: 400;
letter-spacing: 2px;
color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.form-text {
font-size: 14px;
}
.login-form :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset;
border-radius: 12px;
transition: all 0.3s;
}
.login-form :deep(.el-input__wrapper):hover {
background: rgba(255, 255, 255, 0.3);
}
.login-form :deep(.el-input__wrapper).is-focus {
background: rgba(255, 255, 255, 0.35);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5) inset;
}
.login-form :deep(.el-input__inner) {
color: #fff;
}
.login-form :deep(.el-input__inner)::placeholder {
color: rgba(255, 255, 255, 0.6);
}
.login-form :deep(.el-input__prefix) {
color: rgba(255, 255, 255, 0.9);
}
.captcha-wrapper {
margin-top: 16px;
display: flex;
gap: 12px;
}
.captcha-wrapper .captcha-input {
flex: 1;
}
.glass-btn {
background: rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px) !important;
-webkit-backdrop-filter: blur(10px) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
color: #fff !important;
font-weight: 400;
border-radius: 12px;
transition: all 0.3s ease;
}
.glass-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.35) !important;
border-color: rgba(255, 255, 255, 0.5) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.glass-btn:active:not(:disabled) {
transform: translateY(0);
background: rgba(255, 255, 255, 0.45) !important;
}
.glass-btn:disabled {
background: rgba(255, 255, 255, 0.1) !important;
border-color: rgba(255, 255, 255, 0.15) !important;
color: rgba(255, 255, 255, 0.5) !important;
backdrop-filter: blur(5px) !important;
cursor: not-allowed;
}
.captcha-btn {
min-width: 160px;
font-size: 14px;
padding: 12px 16px;
height: 40px;
}
.submit-btn {
width: 100%;
margin-top: 30px;
background: rgba(255, 255, 255, 0.25) !important;
font-size: 16px;
padding: 14px 0;
}
.form-bottom {
text-align: center;
font-size: 12px;
margin-top: 20%;
}
/* 账号选择弹窗样式 */
.account-list .account-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.account-list .account-item:last-child {
border-bottom: none;
}
.account-list .account-item .account-info .account-name {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.account-list .account-item .account-info .account-phone {
font-size: 14px;
color: #909399;
}
.account-list .account-item:hover {
background-color: #f5f7fa;
}

View File

@ -0,0 +1,183 @@
.login {
width: 100%;
height: 100vh;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.background-video-stack {
position: absolute;
inset: 0;
z-index: 0;
}
.background-video {
position: absolute;
inset: 0;
height: 100%;
width: 100%;
object-fit: cover;
opacity: 1;
pointer-events: none;
transition: opacity 0.4s ease-in-out;
}
.form-card {
position: relative;
left: 25%;
z-index: 1;
width: 400px;
max-width: 90%;
padding: 40px 30px;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
color: #fff;
}
.form-top {
text-align: center;
margin-bottom: 10%;
}
.form-title {
margin-bottom: 8px;
font-weight: 400;
letter-spacing: 2px;
color: #fff;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.form-text {
font-size: 14px;
}
.login-form {
:deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.2);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset;
border-radius: 12px;
transition: all 0.3s;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
&.is-focus {
background: rgba(255, 255, 255, 0.35);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5) inset;
}
}
:deep(.el-input__inner) {
color: #fff;
&::placeholder {
color: rgba(255, 255, 255, 0.6);
}
}
:deep(.el-input__prefix) {
color: rgba(255, 255, 255, 0.9);
}
}
.captcha-wrapper {
margin-top: 16px;
display: flex;
gap: 12px;
.captcha-input {
flex: 1;
}
}
.glass-btn {
background: rgba(255, 255, 255, 0.2) !important;
backdrop-filter: blur(10px) !important;
-webkit-backdrop-filter: blur(10px) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
color: #fff !important;
font-weight: 400;
border-radius: 12px;
transition: all 0.3s ease;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.35) !important;
border-color: rgba(255, 255, 255, 0.5) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
&:active:not(:disabled) {
transform: translateY(0);
background: rgba(255, 255, 255, 0.45) !important;
}
&:disabled {
background: rgba(255, 255, 255, 0.1) !important;
border-color: rgba(255, 255, 255, 0.15) !important;
color: rgba(255, 255, 255, 0.5) !important;
backdrop-filter: blur(5px) !important;
cursor: not-allowed;
}
}
.captcha-btn {
min-width: 160px;
font-size: 14px;
padding: 12px 16px;
height: 40px;
}
.submit-btn {
width: 100%;
margin-top: 30px;
background: rgba(255, 255, 255, 0.25) !important;
font-size: 16px;
padding: 14px 0;
}
.form-bottom {
text-align: center;
font-size: 12px;
margin-top: 20%;
}
/* 账号选择弹窗样式 */
.account-list {
.account-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.account-info {
.account-name {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.account-phone {
font-size: 14px;
color: #909399;
}
}
&:hover {
background-color: #f5f7fa;
}
}
}

View File

@ -0,0 +1,547 @@
@primary: #8b7cff;
@primary-dark: #6e5cff;
@accent: #f6b86a;
@bg-dark: rgba(10, 12, 18, 0.94);
@glass-bg: linear-gradient(180deg, rgba(24, 28, 39, 0.96), rgba(15, 18, 27, 0.98));
@panel-bg: rgba(255, 255, 255, 0.04);
@panel-hover: rgba(255, 255, 255, 0.08);
@border-color: rgba(255, 255, 255, 0.08);
@text-light: #f7f8fb;
@text-secondary: rgba(255, 255, 255, 0.64);
@text-muted: rgba(255, 255, 255, 0.44);
// 通用动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
// 主弹窗遮罩层
.recharge-modal-mask {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
radial-gradient(circle at top, rgba(139, 124, 255, 0.2), transparent 35%),
rgba(0, 0, 0, 0.68);
backdrop-filter: blur(14px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
transition: all 0.3s;
}
// 主弹窗容器
.recharge-container {
width: 520px;
max-width: 90vw;
background: @glass-bg;
backdrop-filter: blur(20px);
border-radius: 28px;
padding: 30px 30px 24px;
transition: transform 0.2s ease;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.45);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 28px;
.header-copy {
display: flex;
flex-direction: column;
gap: 10px;
}
h2 {
font-size: 28px;
font-weight: 700;
color: @text-light;
margin: 0;
letter-spacing: 0.02em;
}
.header-desc {
margin: 0;
max-width: 360px;
font-size: 14px;
line-height: 1.65;
color: @text-secondary;
}
.close-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid @border-color;
font-size: 18px;
cursor: pointer;
color: @text-secondary;
width: 36px;
height: 36px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
transform: translateY(-1px);
}
}
}
.provider-section {
margin-bottom: 24px;
.section-title {
font-size: 13px;
letter-spacing: 0.08em;
color: @text-secondary;
margin-bottom: 16px;
font-weight: 500;
}
}
.provider-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.provider-btn {
background: @panel-bg;
border: 1px solid @border-color;
border-radius: 18px;
padding: 16px 18px;
font-size: 16px;
font-weight: 600;
color: @text-light;
cursor: pointer;
transition: all 0.25s ease;
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
.provider-name {
font-size: 16px;
}
.provider-tag {
margin-left: auto;
padding: 4px 10px;
border-radius: 999px;
background: rgba(246, 184, 106, 0.14);
color: @accent;
font-size: 12px;
font-weight: 600;
}
&:hover {
transform: translateY(-2px);
border-color: rgba(139, 124, 255, 0.4);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.2);
background: @panel-hover;
}
&.active {
background: linear-gradient(135deg, rgba(139, 124, 255, 0.18), rgba(246, 184, 106, 0.08));
border-color: rgba(139, 124, 255, 0.45);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
color: #fff;
}
.iconfont {
font-size: 22px;
color: #165dff;
}
}
.amount-section {
margin-bottom: 32px;
.section-title {
font-size: 13px;
letter-spacing: 0.08em;
color: @text-secondary;
margin-bottom: 16px;
font-weight: 500;
}
}
.amount-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 24px;
@media (min-width: 480px) {
grid-template-columns: repeat(5, 1fr);
}
}
.amount-btn {
min-height: 76px;
background: @panel-bg;
border: 1px solid @border-color;
border-radius: 18px;
padding: 12px 0;
font-size: 22px;
font-weight: 600;
color: @text-light;
cursor: pointer;
transition: all 0.25s ease;
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
.currency {
font-size: 15px;
font-weight: 400;
color: @text-secondary;
}
&:hover {
transform: translateY(-2px);
border-color: rgba(139, 124, 255, 0.4);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
background: @panel-hover;
}
&.active {
background: linear-gradient(180deg, rgba(139, 124, 255, 0.24), rgba(139, 124, 255, 0.12));
border-color: rgba(139, 124, 255, 0.48);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
color: #fff;
}
}
.custom-btn {
font-size: 16px;
flex-direction: column;
gap: 6px;
.currency {
font-size: 18px;
color: @accent;
}
}
.selected-info {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding-top: 2px;
.selected-info__item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 16px 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid @border-color;
}
.selected-info__item--highlight {
background: linear-gradient(135deg, rgba(139, 124, 255, 0.16), rgba(246, 184, 106, 0.08));
}
.selected-info__label {
font-size: 12px;
letter-spacing: 0.06em;
color: @text-muted;
}
.selected-info__value {
font-size: 18px;
font-weight: 600;
color: @text-light;
}
.selected-amount {
font-size: 28px;
font-weight: 700;
color: #fff;
}
}
.confirm-btn {
width: 100%;
background: linear-gradient(135deg, @primary-dark, @primary);
border: none;
border-radius: 18px;
padding: 16px 0;
font-size: 18px;
font-weight: 600;
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
transition: all 0.3s;
box-shadow: 0 16px 36px rgba(110, 92, 255, 0.32);
letter-spacing: 0.06em;
.btn-icon {
width: 20px;
height: 20px;
transition: transform 0.2s;
&.loading {
animation: spin 1s linear infinite;
}
}
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 20px 42px rgba(110, 92, 255, 0.38);
filter: brightness(1.06);
.btn-icon:not(.loading) {
transform: translateX(4px);
}
}
&:active:not(:disabled) {
transform: scale(0.98);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
.recharge-tips {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
font-size: 12px;
color: @text-muted;
span:last-child {
text-align: right;
}
}
.custom-modal-mask {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.76);
backdrop-filter: blur(16px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
}
.custom-dialog {
width: 100%;
max-width: 600px;
background: linear-gradient(180deg, rgba(23, 26, 37, 0.98), rgba(15, 18, 27, 0.99));
backdrop-filter: blur(18px);
border-radius: 24px;
border: 1px solid @border-color;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
overflow: hidden;
animation: slideUp 0.25s ease;
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin: 20px 30px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
h3 {
font-size: 22px;
font-weight: 600;
color: @text-light;
margin: 0;
}
.close-btn {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid @border-color;
background: rgba(255, 255, 255, 0.04);
font-size: 18px;
cursor: pointer;
color: @text-secondary;
transition: all 0.2s;
&:hover {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
}
}
.dialog-body {
padding:20px 30px;
.input-group {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.04);
border-radius: 18px;
border: 1px solid @border-color;
transition: all 0.2s;
&:focus-within {
border-color: rgba(139, 124, 255, 0.46);
box-shadow: 0 0 0 4px rgba(139, 124, 255, 0.12);
}
.input-currency {
font-size: 28px;
font-weight: 600;
padding: 0 16px;
color: @accent;
}
.custom-input {
flex: 1;
background: transparent;
border: none;
padding: 16px 16px 16px 0;
font-size: 26px;
font-weight: 600;
color: @text-light;
outline: none;
&::placeholder {
color: rgba(255, 255, 255, 0.28);
font-size: 20px;
font-weight: 400;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
-moz-appearance: textfield;
}
}
.error-msg {
color: #ff6b6b;
font-size: 13px;
margin-top: 12px;
padding-left: 4px;
}
}
.dialog-footer {
display: flex;
gap: 12px;
padding: 12px 24px 28px;
.dialog-btn {
flex: 1;
padding: 13px 0;
border-radius: 16px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
background: transparent;
&.cancel {
background: rgba(255, 255, 255, 0.05);
border: 1px solid @border-color;
color: @text-light;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
&.confirm {
background: linear-gradient(135deg, @primary-dark, @primary);
color: #fff;
box-shadow: 0 12px 28px rgba(110, 92, 255, 0.28);
&:hover {
transform: translateY(-1px);
filter: brightness(1.05);
}
}
}
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 640px) {
.recharge-container {
padding: 24px 20px 20px;
border-radius: 24px;
}
.modal-header {
.header-desc {
max-width: none;
font-size: 13px;
}
}
.amount-grid {
grid-template-columns: repeat(2, 1fr);
}
.selected-info {
grid-template-columns: 1fr;
}
.recharge-tips {
flex-direction: column;
span:last-child {
text-align: left;
}
}
}

View File

@ -0,0 +1,258 @@
.rig {
padding: 14px;
width: 100%;
height: calc(100vh - 100px);
border: 0;
background-color: #1a1c20;
color: #fff;
border-radius: 12px;
display: flex;
flex-direction: column;
}
.rig :deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
}
.top {
display: flex;
align-items: center;
gap: 20px;
padding: 0 0 12px 0;
}
.top :deep(.el-select__wrapper) {
border: none;
background-color: rgba(0, 0, 0, 0.32);
border-radius: 10px;
font-size: 14px;
color: #fff;
}
.top :deep(.el-checkbox__label) {
color: #fff;
}
.content {
flex: 1;
overflow-y: auto;
background-color: #1E1E22;
border-radius: 8px;
padding: 12px;
}
.content::-webkit-scrollbar {
width: 6px;
}
.content::-webkit-scrollbar-track {
background: #0F0F19;
border-radius: 3px;
}
.content::-webkit-scrollbar-thumb {
background: #2A2A32;
border-radius: 3px;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
color: rgba(255, 255, 255, 0.5);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 100%;
gap: 20px;
}
.empty img {
width: 30%;
opacity: 0.6;
}
.empty .title {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
}
/* 任务卡片样式 - 完全对齐参考 */
.task-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.task-item {
background: #1A1C20;
border-radius: 12px;
padding: 16px;
transition: all 0.2s;
}
.task-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.task-header .task-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #fff;
}
.task-header .task-title .title-icon {
width: 20px;
height: 20px;
color: rgba(255, 255, 255, 0.7);
}
.task-header .task-time {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
}
.task-prompt-wrapper {
margin-bottom: 12px;
}
.task-prompt-wrapper .prompt-content {
display: flex;
align-items: center;
gap: 12px;
}
.task-prompt-wrapper .video-preview {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.task-prompt-wrapper .video-preview .preview-bg {
width: 100%;
height: 100%;
border-radius: 8px;
overflow: hidden;
position: relative;
background: #343C48;
}
.task-prompt-wrapper .video-preview .preview-bg::before {
content: '';
position: absolute;
inset: 0;
background-image: var(--bg-image-url);
background-size: cover;
background-position: center;
opacity: 0.48;
filter: blur(30px);
z-index: 0;
}
.task-prompt-wrapper .video-preview .preview-bg .preview-video {
width: 100%;
height: 100%;
object-fit: contain;
position: relative;
z-index: 1;
}
.task-prompt-wrapper .prompt-text {
flex: 1;
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
gap: 8px;
}
.task-prompt-wrapper .prompt-text .tag {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
white-space: nowrap;
}
.task-prompt-wrapper .prompt-text .tag .tag-icon {
width: 12px;
height: 12px;
color: #AB8DFF;
}
.task-prompt-wrapper .model-info {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
display: flex;
align-items: center;
gap: 8px;
}
.task-prompt-wrapper .model-info .split {
color: rgba(255, 255, 255, 0.1);
}
.video-player {
margin-bottom: 12px;
}
.video-player .player-container {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 8px;
overflow: hidden;
}
.video-player .player-container .task-video {
width: 100%;
height: 100%;
object-fit: contain;
}
.task-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.task-actions .action-left {
display: flex;
gap: 8px;
}
.task-actions .action-left .action-btn {
height: 32px;
background: #2A2A32;
border: none;
color: #fff;
border-radius: 8px;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.task-actions .action-left .action-btn:hover {
background: #3A3A42;
}
.task-actions .action-left .action-btn .action-icon {
width: 16px;
height: 16px;
}
.task-actions .action-right {
display: flex;
align-items: center;
gap: 4px;
background: #2A2A32;
border-radius: 8px;
padding: 4px;
}
.task-actions .action-right .icon-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
color: rgba(255, 255, 255, 0.7);
transition: all 0.2s;
}
.task-actions .action-right .icon-btn:hover {
background: #3A3A42;
color: #fff;
}
.task-actions .action-right .icon-btn svg {
width: 18px;
height: 18px;
}
/* 未登录样式 */
.login-require {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}

View File

@ -0,0 +1,355 @@
.rig {
padding: 14px;
width: 100%;
height: calc(100vh - 100px);
border: 0;
background-color: #1a1c20;
color: #fff;
border-radius: 12px;
display: flex;
flex-direction: column;
:deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
}
}
.top {
display: flex;
align-items: center;
gap: 20px;
padding: 0 0 12px 0;
:deep(.el-select__wrapper) {
border: none;
background-color: rgba(0, 0, 0, 0.32);
border-radius: 10px;
font-size: 14px;
color: #fff;
}
:deep(.el-checkbox__label) {
color: #fff;
}
}
.content {
flex: 1;
overflow-y: auto;
background-color: #1E1E22;
border-radius: 8px;
padding: 12px;
&::-webkit-scrollbar { width: 6px; }
&::-webkit-scrollbar-track { background: #0F0F19; border-radius: 3px; }
&::-webkit-scrollbar-thumb { background: #2A2A32; border-radius: 3px; }
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
color: rgba(255, 255, 255, 0.5);
}
.empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
height: 100%;
gap: 20px;
img {
width: 30%;
opacity: 0.6;
}
.title {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
}
}
/* 任务卡片样式 - 完全对齐参考 */
.task-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.task-item {
background: #1A1C20;
border-radius: 12px;
padding: 16px;
transition: all 0.2s;
}
.task-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.task-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #fff;
.title-icon {
width: 20px;
height: 20px;
color: rgba(255,255,255,0.7);
}
}
.task-status {
display: flex;
align-items: center;
}
.task-time {
font-size: 12px;
color: rgba(255,255,255,0.4);
}
}
.task-prompt-wrapper {
margin-bottom: 12px;
.prompt-content {
display: flex;
align-items: center;
gap: 12px;
}
.video-preview {
width: 40px;
height: 40px;
flex-shrink: 0;
.preview-bg {
width: 100%;
height: 100%;
border-radius: 8px;
overflow: hidden;
position: relative;
background: #343C48;
&::before {
content: '';
position: absolute;
inset: 0;
background-image: var(--bg-image-url, none);
background-size: cover;
background-position: center;
opacity: 0.48;
filter: blur(30px);
z-index: 0;
}
.preview-video {
width: 100%;
height: 100%;
object-fit: contain;
position: relative;
z-index: 1;
}
}
}
.prompt-text {
flex: 1;
font-size: 14px;
color: rgba(255,255,255,0.8);
display: flex;
align-items: center;
gap: 8px;
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(0,0,0,0.4);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
white-space: nowrap;
.tag-icon {
width: 12px;
height: 12px;
color: #AB8DFF;
}
}
}
.model-info {
font-size: 12px;
color: rgba(255,255,255,0.4);
display: flex;
align-items: center;
gap: 8px;
.split {
color: rgba(255,255,255,0.1);
}
}
.task-meta {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
display: flex;
align-items: center;
gap: 8px;
.split {
color: rgba(255, 255, 255, 0.1);
}
.task-time {
color: rgba(255, 255, 255, 0.6);
}
}
}
.video-player {
margin-bottom: 12px;
.player-container {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 8px;
overflow: hidden;
.task-video {
width: 100%;
height: 100%;
object-fit: contain;
}
}
}
.task-actions {
display: flex;
align-items: center;
justify-content: space-between;
.action-left {
display: flex;
gap: 8px;
.action-btn {
height: 32px;
background: #2A2A32;
border: none;
color: #fff;
border-radius: 8px;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
&:hover {
background: #3A3A42;
}
.action-icon {
width: 16px;
height: 16px;
}
}
}
.action-right {
display: flex;
align-items: center;
gap: 4px;
background: #2A2A32;
border-radius: 8px;
padding: 4px;
.icon-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
color: rgba(255,255,255,0.7);
transition: all 0.2s;
&:hover {
background: #3A3A42;
color: #fff;
}
svg {
width: 18px;
height: 18px;
}
}
}
}
/* 未登录样式 */
.login-require {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
/* 状态徽章样式 */
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
&.status-created {
background-color: rgba(148, 216, 255, 0.2);
color: #94D8FF;
}
&.status-processing {
background-color: rgba(255, 193, 7, 0.2);
color: #FFC107;
animation: pulse 2s infinite;
}
&.status-queueing {
background-color: rgba(255, 255, 255, 0.1);
color: #FFFFFF;
}
&.status-succeeded,
&.status-completed {
background-color: rgba(76, 175, 80, 0.2);
color: #4CAF50;
}
&.status-failed {
background-color: rgba(244, 67, 54, 0.2);
color: #F44336;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

View File

@ -0,0 +1,206 @@
:deep(.el-select__wrapper) {
width: 100% !important;
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 10px !important;
font-size: 14px;
color: #fff !important;
resize: vertical;
outline: none !important;
}
:deep(.el-upload--picture-card) {
width: 140px !important;
height: 140px !important;
}
:deep(.el-select__selected-item) {
color: #fff !important;
}
:deep(.el-textarea) .el-textarea__inner {
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 10px !important;
padding: 14px 16px !important;
font-size: 14px;
line-height: 1.5;
color: #fff !important;
resize: none;
outline: none !important;
}
:deep(.el-textarea) .el-textarea__inner:focus {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
:deep(.el-textarea) .el-textarea__inner::placeholder {
color: rgba(255, 255, 255, 0.36) !important;
font-size: 14px;
}
:deep(.el-textarea) .el-input__count {
background: transparent;
color: rgba(255, 255, 255, 0.5);
}
.card {
width: 100%;
height: 100%;
background-color: #1a1c20;
border: none;
flex: 1;
padding: 16px;
border-radius: 12px;
}
.card :deep(.el-card__body) {
padding: 12px 16px;
height: 100%;
}
.reference-container {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
color: #fff;
}
.upload-section {
display: flex;
flex-wrap: wrap;
gap: 16px;
flex-shrink: 0;
}
.upload-item {
flex: 1;
min-width: 200px;
}
.upload-item .upload-label {
font-size: 14px;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.9);
}
.upload-item :deep(.el-upload--picture-card) {
background-color: rgba(0, 0, 0, 0.32);
border: 1px dashed rgba(255, 255, 255, 0.3);
border-radius: 10px;
width: 100%;
height: 100px;
line-height: 100px;
}
.upload-item :deep(.el-upload--picture-card):hover {
border-color: #409eff;
}
.upload-item :deep(.el-upload-list--picture-card) .el-upload-list__item {
width: 100%;
height: 100px;
background-color: rgba(0, 0, 0, 0.32);
border-radius: 10px;
}
.required-star {
color: #ff4d4f;
margin-left: 4px;
}
.input {
width: 100%;
flex: 1;
min-height: 0;
}
.btmcard {
width: 100%;
background-color: #1a1c20;
border: none;
margin-top: 16px;
padding: 16px;
border-radius: 12px;
flex-shrink: 0;
}
.btmcard :deep(.el-card__body) {
padding: 12px 16px;
}
.select {
display: flex;
flex-direction: column;
gap: 16px;
}
.select-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.select-title {
width: 50%;
font-size: 14px;
color: #fff;
padding-right: 12px;
}
.select-content {
width: 50%;
display: flex;
gap: 8px;
}
.select-content :deep(.el-input-number) {
width: 100%;
background-color: rgba(0, 0, 0, 0.32);
border-radius: 10px;
}
.select-content :deep(.el-input-number) .el-input-number__decrease,
.select-content :deep(.el-input-number) .el-input-number__increase {
background-color: transparent;
color: rgba(255, 255, 255, 0.6);
}
.select-content :deep(.el-input-number) .el-input-number__decrease:hover,
.select-content :deep(.el-input-number) .el-input-number__increase:hover {
color: #fff;
}
.select-content :deep(.el-input-number) .el-input__inner {
background-color: transparent;
color: #fff;
border: none;
}
.select-content :deep(.el-input) .el-input__inner {
background-color: rgba(0, 0, 0, 0.32);
border: none;
border-radius: 10px;
color: #fff;
}
.select-content :deep(.el-input) .el-input__inner::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.select-content :deep(.el-textarea) .el-textarea__inner {
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
border-radius: 10px !important;
color: #fff !important;
}
.audio-wrapper {
display: flex;
gap: 8px;
width: 100%;
}
.upload-btn {
flex-shrink: 0;
}
.custom-select-popup {
background-color: #2A2D34 !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
border: none !important;
}
.custom-select-popup .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !important;
}
.custom-select-popup .el-select-dropdown__item.selected,
.custom-select-popup .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-select .el-input__suffix-inner {
color: #fff !important;
}
:deep(.el-upload-list__item) {
transition: none !important;
}

View File

@ -0,0 +1,253 @@
:deep(.el-select__wrapper) {
width: 100% !important;
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 10px !important;
font-size: 14px;
color: #fff !important;
resize: vertical;
outline: none !important;
}
:deep(.el-upload--picture-card) {
width: 140px !important;
height: 140px !important;
}
:deep(.el-select__selected-item) {
color: #fff !important;
}
:deep(.el-textarea) {
.el-textarea__inner {
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 10px !important;
padding: 14px 16px !important;
font-size: 14px;
line-height: 1.5;
color: #fff !important;
resize: none;
outline: none !important;
&:focus {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
&::placeholder {
color: rgba(255, 255, 255, 0.36) !important;
font-size: 14px;
}
}
.el-input__count {
background: transparent;
color: rgba(255, 255, 255, 0.5);
}
}
.card {
width: 100%;
height: 100%;
background-color: #1a1c20;
border: none;
flex: 1;
padding: 16px;
border-radius: 12px;
:deep(.el-card__body) {
padding: 12px 16px;
height: 100%;
}
}
.reference-container {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
color: #fff;
}
.upload-section {
display: flex;
flex-wrap: wrap;
gap: 16px;
flex-shrink: 0;
}
.upload-item {
flex: 1;
min-width: 200px;
.upload-label {
font-size: 14px;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.9);
}
:deep(.el-upload--picture-card) {
background-color: rgba(0, 0, 0, 0.32);
border: 1px dashed rgba(255, 255, 255, 0.3);
border-radius: 10px;
width: 100%;
height: 100px;
line-height: 100px;
&:hover {
border-color: #409eff;
}
}
:deep(.el-upload-list--picture-card) {
.el-upload-list__item {
width: 100%;
height: 100px;
background-color: rgba(0, 0, 0, 0.32);
border-radius: 10px;
}
}
}
.required-star {
color: #ff4d4f;
margin-left: 4px;
}
.input {
width: 100%;
flex: 1;
min-height: 0;
}
.btmcard {
width: 100%;
background-color: #1a1c20;
border: none;
margin-top: 16px;
padding: 16px;
border-radius: 12px;
flex-shrink: 0;
:deep(.el-card__body) {
padding: 12px 16px;
}
}
.select {
display: flex;
flex-direction: column;
gap: 16px;
}
.select-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.select-title {
width: 50%;
font-size: 14px;
color: #fff;
padding-right: 12px;
}
.select-content {
width: 50%;
display: flex;
gap: 8px;
:deep(.el-input-number) {
width: 100%;
background-color: rgba(0, 0, 0, 0.32);
border-radius: 10px;
.el-input-number__decrease,
.el-input-number__increase {
background-color: transparent;
color: rgba(255, 255, 255, 0.6);
&:hover {
color: #fff;
}
}
.el-input__inner {
background-color: transparent;
color: #fff;
border: none;
}
}
:deep(.el-input) {
.el-input__inner {
background-color: rgba(0, 0, 0, 0.32);
border: none;
border-radius: 10px;
color: #fff;
&::placeholder {
color: rgba(255, 255, 255, 0.5);
}
}
}
:deep(.el-textarea) {
.el-textarea__inner {
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
border-radius: 10px !important;
color: #fff !important;
}
}
}
.audio-wrapper {
display: flex;
gap: 8px;
width: 100%;
}
.upload-btn {
flex-shrink: 0;
}
.custom-select-popup {
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;
}
}
:deep(.el-upload-list__item) {
transition: none !important;
}

View File

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

View File

@ -0,0 +1,222 @@
.reference-to-video {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
/* 卡片间距,可按需调整 */
}
/* 提示词卡片:自动填充剩余高度 */
.card {
flex: 1;
/* 关键:占据剩余空间 */
min-height: 0;
/* 允许收缩,防止溢出 */
background-color: #1a1c20;
border: none;
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 16px;
}
.card :deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
padding: 12px 16px;
height: 100%;
overflow: hidden;
}
.reference-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
color: #fff;
overflow: hidden;
}
.input {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
}
.input :deep(.el-textarea) {
flex: 1;
display: flex;
flex-direction: column;
}
.input :deep(.el-textarea__inner) {
flex: 1;
min-height: 150px;
resize: vertical;
}
/* 底部卡片:高度由内容决定,不伸缩 */
.btmcard {
flex-shrink: 0;
/* 不参与剩余空间分配 */
background-color: #1a1c20;
border: none;
border-radius: 12px;
padding: 16px;
margin-top: 0;
/* 间距已由 .reference-to-video 的 gap 控制 */
}
.btmcard :deep(.el-card__body) {
padding: 12px 16px;
}
/* 其余原有样式保持不变 */
.select {
display: flex;
flex-direction: column;
gap: 16px;
}
.select-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.select-title {
width: 50%;
font-size: 14px;
color: #fff;
padding-right: 12px;
}
.select-content {
width: 50%;
display: flex;
gap: 8px;
}
.required-star {
color: #f56c6c;
margin-left: 4px;
}
.audio-wrapper {
display: flex;
gap: 8px;
width: 100%;
}
.audio-wrapper .el-input {
flex: 1;
}
.audio-wrapper .upload-btn {
background-color: rgba(255, 255, 255, 0.08);
border: none;
color: #fff;
height: 32px;
padding: 0 12px;
}
.audio-wrapper .upload-btn:hover {
background-color: rgba(255, 255, 255, 0.16);
}
/* 全局深度样式 */
:deep(.el-select__wrapper) {
width: 100% !important;
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 10px !important;
font-size: 14px;
color: #fff !important;
outline: none !important;
}
:deep(.el-select__selected-item) {
color: #fff !important;
}
:deep(.el-textarea) .el-textarea__inner {
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 10px !important;
font-size: 14px;
line-height: 1.5;
color: #fff !important;
resize: vertical;
outline: none !important;
backdrop-filter: blur(30px);
padding: 14px 16px !important;
}
:deep(.el-textarea) .el-textarea__inner:focus {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
:deep(.el-textarea) .el-textarea__inner::placeholder {
color: #ffffff7a !important;
font-size: 14px;
}
:deep(.el-textarea) .el-input__count {
background: transparent;
color: #ffffff7a;
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-input-number) .el-input__wrapper {
background-color: rgba(0, 0, 0, 0.32) !important;
border: none !important;
box-shadow: none !important;
border-radius: 10px !important;
}
:deep(.el-input-number) .el-input__inner {
color: #fff !important;
background-color: transparent !important;
}
:deep(.el-input-number) .el-input-number__decrease,
:deep(.el-input-number) .el-input-number__increase {
background-color: rgba(255, 255, 255, 0.08);
color: #fff;
border: none;
}
:deep(.el-input__wrapper) {
width: 100% !important;
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 10px !important;
font-size: 14px;
color: #fff !important;
outline: none !important;
}
.offpeak-switch {
display: flex;
align-items: center;
height: 32px;
}
:deep(.el-upload--picture-card) {
width: 140px !important;
height: 140px !important;
}
.upload-section {
display: flex;
flex-wrap: wrap;
gap: 16px;
flex-shrink: 0;
}
.upload-item {
flex: 1;
min-width: 200px;
}
.upload-item .upload-label {
font-size: 14px;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.9);
}
.upload-item :deep(.el-upload--picture-card) {
background-color: rgba(0, 0, 0, 0.32);
border: 1px dashed rgba(255, 255, 255, 0.3);
border-radius: 10px;
width: 100%;
height: 100px;
line-height: 100px;
}
.upload-item :deep(.el-upload--picture-card):hover {
border-color: #409eff;
}
.upload-item :deep(.el-upload-list--picture-card) .el-upload-list__item {
width: 100%;
height: 100px;
background-color: rgba(0, 0, 0, 0.32);
border-radius: 10px;
}

View File

@ -0,0 +1,259 @@
.reference-to-video {
height: 100%;
display: flex;
flex-direction: column;
gap: 16px; /* 卡片间距,可按需调整 */
}
/* 提示词卡片:自动填充剩余高度 */
.card {
flex: 1; /* 关键:占据剩余空间 */
min-height: 0; /* 允许收缩,防止溢出 */
background-color: #1a1c20;
border: none;
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 16px;
}
.card :deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
padding: 12px 16px;
height: 100%;
overflow: hidden;
}
.reference-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 20px;
color: #fff;
overflow: hidden;
padding: 12px;
}
.input {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
}
.input :deep(.el-textarea) {
flex: 1;
display: flex;
flex-direction: column;
}
.input :deep(.el-textarea__inner) {
flex: 1;
min-height: 150px;
resize: vertical;
}
/* 底部卡片:高度由内容决定,不伸缩 */
.btmcard {
flex-shrink: 0; /* 不参与剩余空间分配 */
background-color: #1a1c20;
border: none;
border-radius: 12px;
padding: 16px;
margin-top: 0; /* 间距已由 .reference-to-video 的 gap 控制 */
}
.btmcard :deep(.el-card__body) {
padding: 12px 16px;
}
/* 其余原有样式保持不变 */
.select {
display: flex;
flex-direction: column;
gap: 16px;
padding: 12px;
}
.select-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.select-title {
width: 50%;
font-size: 14px;
color: #fff;
padding-right: 12px;
}
.select-content {
width: 50%;
display: flex;
gap: 8px;
}
.required-star {
color: #f56c6c;
margin-left: 4px;
}
.audio-wrapper {
display: flex;
gap: 8px;
width: 100%;
.el-input {
flex: 1;
}
.upload-btn {
background-color: rgba(255, 255, 255, 0.08);
border: none;
color: #fff;
height: 32px;
padding: 0 12px;
&:hover {
background-color: rgba(255, 255, 255, 0.16);
}
}
}
/* 全局深度样式 */
:deep(.el-select__wrapper) {
width: 100% !important;
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 10px !important;
font-size: 14px;
color: #fff !important;
outline: none !important;
}
:deep(.el-select__selected-item) {
color: #fff !important;
}
:deep(.el-textarea) {
.el-textarea__inner {
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 10px !important;
font-size: 14px;
line-height: 1.5;
color: #fff !important;
resize: vertical;
outline: none !important;
backdrop-filter: blur(30px);
padding: 14px 16px !important;
&:focus {
border: none !important;
box-shadow: none !important;
outline: none !important;
}
&::placeholder {
color: #ffffff7a !important;
font-size: 14px;
}
}
.el-input__count {
background: transparent;
color: #ffffff7a;
}
}
:deep(.el-input-number) {
width: 100%;
.el-input__wrapper {
background-color: rgba(0, 0, 0, 0.32) !important;
border: none !important;
box-shadow: none !important;
border-radius: 10px !important;
}
.el-input__inner {
color: #fff !important;
background-color: transparent !important;
}
.el-input-number__decrease,
.el-input-number__increase {
background-color: rgba(255, 255, 255, 0.08);
color: #fff;
border: none;
}
}
:deep(.el-input__wrapper) {
width: 100% !important;
border: none !important;
background-color: rgba(0, 0, 0, 0.32) !important;
box-shadow: none !important;
border-radius: 10px !important;
font-size: 14px;
color: #fff !important;
outline: none !important;
}
.offpeak-switch {
display: flex;
align-items: center;
height: 32px;
}
:deep(.el-upload--picture-card) {
width: 140px !important;
height: 140px !important;
}
.upload-section {
display: flex;
flex-wrap: wrap;
gap: 16px;
flex-shrink: 0;
}
.upload-item {
flex: 1;
min-width: 200px;
.upload-label {
font-size: 14px;
margin-bottom: 8px;
color: rgba(255, 255, 255, 0.9);
}
:deep(.el-upload--picture-card) {
background-color: rgba(0, 0, 0, 0.32);
border: 1px dashed rgba(255, 255, 255, 0.3);
border-radius: 10px;
width: 100%;
height: 100px;
line-height: 100px;
&:hover {
border-color: #409eff;
}
}
:deep(.el-upload-list--picture-card) {
.el-upload-list__item {
width: 100%;
height: 100px;
background-color: rgba(0, 0, 0, 0.32);
border-radius: 10px;
}
}
}

View File

@ -0,0 +1,307 @@
.apikey-page {
height: 100%;
padding-bottom: 40px;
box-sizing: border-box;
}
.apikey-card {
height: 100%;
background: #1e2024;
border: 1px solid #2a2d32;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}
.apikey-card :deep(.el-card__body) {
padding: 28px 32px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.page-header .header-main .title-row {
display: flex;
align-items: center;
gap: 12px;
font-size: 24px;
font-weight: 700;
color: #fff;
}
.page-header .header-main .title-row .iconfont {
font-size: 26px;
color: #00c4b3;
}
.page-header .header-main .header-desc {
margin: 10px 0 0;
color: rgba(255, 255, 255, 0.65);
font-size: 14px;
line-height: 1.6;
}
.page-header .header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.page-header .header-actions .refresh-btn {
background: #25282e;
border-color: #3a3e45;
color: rgba(255, 255, 255, 0.88);
}
.guide-card {
margin-bottom: 24px;
padding: 24px 28px;
border-radius: 14px;
background: #25282e;
border: 1px solid #32363d;
}
.guide-section + .guide-section {
margin-top: 24px;
}
.guide-title {
margin-bottom: 14px;
font-size: 18px;
font-weight: 700;
color: #ffffff;
}
.guide-list {
margin: 0;
padding-left: 24px;
color: rgba(255, 255, 255, 0.86);
line-height: 1.9;
font-size: 15px;
}
.guide-list li + li {
margin-top: 4px;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
flex-direction: column;
text-align: center;
}
.empty-state img {
width: 220px;
height: 220px;
object-fit: contain;
opacity: 0.55;
}
.empty-title {
font-size: 20px;
color: #fff;
font-weight: 600;
}
.login-desc,
.empty-desc {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.7;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
padding: 20px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(44, 255, 248, 0.08), rgba(44, 104, 255, 0.08));
border: 1px solid rgba(255, 255, 255, 0.08);
}
.stat-card .stat-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 12px;
}
.stat-card .stat-value {
font-size: 28px;
color: #fff;
font-weight: 700;
}
.stat-card .stat-value.success {
color: #3fe0b5;
}
.stat-card .stat-value.warning {
color: #f5b04c;
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.toolbar .toolbar-left {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.toolbar .toolbar-right {
display: flex;
align-items: center;
}
.toolbar .toolbar-tip {
font-size: 13px;
color: rgba(255, 255, 255, 0.45);
}
.search-input {
width: 320px;
}
.table-wrapper {
flex: 1;
overflow: auto;
border-radius: 12px;
}
.apikey-table {
width: 100%;
background: transparent;
}
.apikey-table :deep(.el-table__header-wrapper) .el-table__header th {
background-color: #1e2024;
color: rgba(255, 255, 255, 0.75);
border-bottom: 1px solid #2a2d32;
font-weight: 600;
}
.apikey-table :deep(.el-table__body-wrapper) .el-table__body tr {
background-color: #1e2024;
transition: background 0.2s;
}
.apikey-table :deep(.el-table__body-wrapper) .el-table__body tr:hover td {
background-color: #25282e !important;
}
.apikey-table :deep(.el-table__body-wrapper) .el-table__body tr td {
border-bottom: 1px solid #2a2d32;
color: rgba(255, 255, 255, 0.88);
}
.apikey-table :deep(.el-table__row--striped) td {
background-color: #1a1c20 !important;
}
.apikey-table .key-mask {
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
color: #87f5eb;
background: #14161a;
padding: 4px 8px;
border-radius: 6px;
letter-spacing: 0.3px;
}
.view-key-content .view-key-name,
.view-key-content .view-key-value {
margin-bottom: 20px;
}
.view-key-content .view-key-name .label,
.view-key-content .view-key-value .label {
font-weight: 600;
color: rgba(255, 255, 255, 0.75);
display: inline-block;
width: 90px;
}
.view-key-content .view-key-name .value,
.view-key-content .view-key-value .value {
color: #fff;
}
.view-key-content .key-box {
margin-top: 8px;
padding: 14px;
background: #14161a;
border-radius: 12px;
border: 1px solid #2a2d32;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.view-key-content .key-box code {
font-family: Consolas, 'Courier New', monospace;
color: #87f5eb;
font-size: 14px;
word-break: break-all;
flex: 1;
}
.view-key-content .view-key-tip {
margin-top: 16px;
padding: 10px 12px;
background: rgba(245, 176, 76, 0.12);
border-radius: 8px;
color: #f5b04c;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.view-key-content .view-key-tip i {
font-size: 16px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.full-width {
width: 100%;
}
:deep(.el-input__wrapper),
:deep(.el-textarea__inner),
:deep(.el-select__wrapper) {
background-color: #25282e !important;
border: 1px solid #3a3e45 !important;
box-shadow: none !important;
}
:deep(.el-input__inner),
:deep(.el-textarea__inner) {
color: rgba(255, 255, 255, 0.92) !important;
}
:deep(.el-radio) {
margin-right: 20px;
color: rgba(255, 255, 255, 0.85);
}
:deep(.el-dialog) {
background: #1e2024;
border: 1px solid #2a2d32;
border-radius: 16px;
}
:deep(.el-dialog__title),
:deep(.el-form-item__label) {
color: #fff !important;
}
:deep(.el-button--primary.is-link) {
color: #3fe0b5 !important;
}
:deep(.el-button--danger.is-link) {
color: #f56c6c !important;
}
:deep(.el-table__inner-wrapper) {
border: 0;
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.apikey-card :deep(.el-card__body) {
padding: 20px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.toolbar {
flex-direction: column;
}
.search-input {
width: 100%;
}
.table-wrapper {
overflow-x: auto;
}
}

View File

@ -0,0 +1,415 @@
.apikey-page {
height: 100%;
padding-bottom: 40px;
box-sizing: border-box;
}
.apikey-card {
height: 100%;
background: #1e2024;
border: 1px solid #2a2d32;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
:deep(.el-card__body) {
padding: 28px 32px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
.page-header {
display: flex;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
flex-wrap: wrap;
.header-main {
.title-row {
display: flex;
align-items: center;
gap: 12px;
font-size: 24px;
font-weight: 700;
color: #fff;
.iconfont {
font-size: 26px;
color: #00c4b3;
}
}
.header-desc {
margin: 10px 0 0;
color: rgba(255, 255, 255, 0.65);
font-size: 14px;
line-height: 1.6;
}
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
.refresh-btn {
background: #25282e;
border-color: #3a3e45;
color: rgba(255, 255, 255, 0.88);
}
}
}
.guide-card {
margin-bottom: 24px;
padding: 24px 28px;
border-radius: 14px;
background: #25282e;
border: 1px solid #32363d;
}
.guide-section+.guide-section {
margin-top: 24px;
}
.guide-title {
margin-bottom: 14px;
font-size: 18px;
font-weight: 700;
color: #ffffff;
}
.guide-list {
margin: 0;
padding-left: 24px;
color: rgba(255, 255, 255, 0.86);
line-height: 1.9;
font-size: 15px;
li+li {
margin-top: 4px;
}
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
flex-direction: column;
text-align: center;
img {
width: 220px;
height: 220px;
object-fit: contain;
opacity: 0.55;
}
}
.empty-title {
font-size: 20px;
color: #fff;
font-weight: 600;
}
.login-desc,
.empty-desc {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.7;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
padding: 20px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(44, 255, 248, 0.08), rgba(44, 104, 255, 0.08));
border: 1px solid rgba(255, 255, 255, 0.08);
.stat-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 12px;
}
.stat-value {
font-size: 28px;
color: #fff;
font-weight: 700;
&.success {
color: #3fe0b5;
}
&.warning {
color: #f5b04c;
}
}
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
.toolbar-left {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
}
.toolbar-tip {
font-size: 13px;
color: rgba(255, 255, 255, 0.45);
}
}
.search-input {
width: 320px;
}
.table-wrapper {
flex: 1;
overflow: auto;
border-radius: 12px;
}
.apikey-table {
width: 100%;
background: transparent;
:deep(.el-table) {
--el-table-bg-color: #1e2024;
--el-table-tr-bg-color: #1e2024;
--el-table-row-hover-bg-color: #25282e;
--el-table-current-row-bg-color: #25282e;
--el-table-header-bg-color: #1e2024;
--el-table-fixed-box-shadow: none;
--el-table-border-color: #2a2d32;
--el-table-text-color: rgba(255, 255, 255, 0.88);
--el-table-header-text-color: rgba(255, 255, 255, 0.75);
--el-bg-color: #1e2024;
--el-fill-color-lighter: #1a1c20;
background: transparent;
// Hide Element Plus default bottom divider on dark backgrounds.
&::before {
background-color: transparent;
}
}
:deep(.el-table__inner-wrapper),
:deep(.el-table__header-wrapper),
:deep(.el-table__body-wrapper),
:deep(.el-table__fixed),
:deep(.el-table__fixed-right),
:deep(.el-table__fixed-right-patch) {
background: #1e2024 !important;
}
:deep(.el-table__header-wrapper) {
.el-table__header th {
background-color: #1e2024;
color: rgba(255, 255, 255, 0.75);
border-bottom: 1px solid #2a2d32;
font-weight: 600;
}
}
:deep(.el-table__body-wrapper) {
.el-table__body tr {
background-color: #1e2024;
transition: background 0.2s;
&:hover td {
background-color: #25282e !important;
}
td {
border-bottom: 1px solid #2a2d32;
color: rgba(255, 255, 255, 0.88);
background-color: #1e2024;
}
}
}
:deep(.el-table__row--striped) {
td {
background-color: #1a1c20 !important;
}
}
:deep(.el-table__cell) {
background-color: transparent;
}
:deep(.el-table__fixed-right::before),
:deep(.el-table__fixed::before) {
background-color: #2a2d32 !important;
}
.key-mask {
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
color: #87f5eb;
background: #14161a;
padding: 4px 8px;
border-radius: 6px;
letter-spacing: 0.3px;
}
}
.view-key-content {
.view-key-name,
.view-key-value {
margin-bottom: 20px;
.label {
font-weight: 600;
color: rgba(255, 255, 255, 0.75);
display: inline-block;
width: 90px;
}
.value {
color: #fff;
}
}
.key-box {
margin-top: 8px;
padding: 14px;
background: #14161a;
border-radius: 12px;
border: 1px solid #2a2d32;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
code {
font-family: Consolas, 'Courier New', monospace;
color: #87f5eb;
font-size: 14px;
word-break: break-all;
flex: 1;
}
}
.view-key-tip {
margin-top: 16px;
padding: 10px 12px;
background: rgba(245, 176, 76, 0.12);
border-radius: 8px;
color: #f5b04c;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
i {
font-size: 16px;
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.full-width {
width: 100%;
}
:deep(.el-input__wrapper),
:deep(.el-textarea__inner),
:deep(.el-select__wrapper) {
background-color: #25282e !important;
border: 1px solid #3a3e45 !important;
box-shadow: none !important;
}
:deep(.el-input__inner),
:deep(.el-textarea__inner) {
color: rgba(255, 255, 255, 0.92) !important;
}
:deep(.el-radio) {
margin-right: 20px;
color: rgba(255, 255, 255, 0.85);
}
:deep(.el-dialog) {
background: #1e2024;
border: 1px solid #2a2d32;
border-radius: 16px;
}
:deep(.el-dialog__title),
:deep(.el-form-item__label) {
color: #fff !important;
}
:deep(.el-button--primary.is-link) {
color: #3fe0b5 !important;
}
:deep(.el-button--danger.is-link) {
color: #f56c6c !important;
}
:deep(.el-table__inner-wrapper) {
border: 0;
}
@media (max-width: 1200px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.apikey-card :deep(.el-card__body) {
padding: 20px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.toolbar {
flex-direction: column;
}
.search-input {
width: 100%;
}
.table-wrapper {
overflow-x: auto;
}
}

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,163 @@
<template>
<div class="header-container">
<div class="header">
<!-- 左侧Logo完全不动 -->
<div class="logo">
<img src="@/assets/images/favicon.png" alt="logo" />
<span class="header-title">元境</span>
</div>
<!-- 右侧操作区 -->
<div class="header-actions">
<!-- 登录后显示充值 + 余额 一体化模块 -->
<template v-if="loginStore.isLoggedIn">
<!-- 一体化余额 + 充值按钮 -->
<div class="balance-recharge-combo" @click="showRechargeModal = true">
<div class="balance-main">
<span class="balance-icon-wrap">
<i class="iconfont icon-yue"></i>
</span>
<div class="balance-copy">
<span class="balance-label">账户余额</span>
<span class="balance-text">
{{ formattedBalance }}
<span class="balance-unit"></span>
</span>
</div>
</div>
<span class="combo-divider"></span>
<span class="recharge-text">充值</span>
</div>
<!-- 用户信息 -->
<div class="user-profile" :class="{ 'dropdown-open': userDropdownVisible }">
<div class="user-trigger" @click="toggleUserDropdown">
<div class="user-avatar">
<i class="iconfont icon-a-414126695"></i>
</div>
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6" />
</svg>
</div>
<Transition name="dropdown">
<div v-if="userDropdownVisible" class="user-dropdown">
<div class="dropdown-item user-id">
<span class="item-label">用户名:</span>
<span class="item-value">{{ loginStore.userInfo?.user?.name || loginStore.userInfo?.user?.username }}</span>
</div>
<!-- <div class="dropdown-item user-id">
<span class="item-label">用户ID:</span>
<span class="item-value">{{ loginStore.userInfo?.user?.id }}</span>
</div> -->
<div class="dropdown-divider"></div>
<div class="dropdown-item logout" @click="handleLogout">
<svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
<span>退出登录</span>
</div>
</div>
</Transition>
</div>
</template>
<!-- 未登录登录按钮 -->
<!-- <div v-else class="btn" @click="$router.push('/login')">
登录
</div> -->
</div>
</div>
<Recharge v-if="showRechargeModal" @close="showRechargeModal = false" />
</div>
</template>
<script setup lang='ts'>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
import Recharge from '@/components/Recharge/Recharge.vue';
import { useLoginStore } from '@/store/LoginStore';
import { useRouter } from 'vue-router';
import { balance } from '@/apis/home';
import type { BalanceResult } from '@/types/home';
const router = useRouter();
const loginStore = useLoginStore();
//
const showRechargeModal = ref(false);
//
const userDropdownVisible = ref(false);
//
const balances = ref(0);
// const userDisplayName = computed(() => loginStore.userInfo?.user?.name || loginStore.userInfo?.user?.username || '');
// const userInitial = computed(() => userDisplayName.value.trim().charAt(0).toUpperCase() || 'U');
const formattedBalance = computed(() => Number(balances.value || 0).toLocaleString('zh-CN'));
//
const toggleUserDropdown = () => {
userDropdownVisible.value = !userDropdownVisible.value;
};
// 退
const handleLogout = () => {
loginStore.logout();
router.push('/login');
userDropdownVisible.value = false;
};
// ==============================================
//
// ==============================================
const getBalance = async () => {
try {
const res = await balance<BalanceResult>();
if (res.status === 'ok') {
balances.value = res.data[0].balance;
}
} catch (err) {
console.error('获取余额失败:', err);
}
};
// ==============================================
//
// ==============================================
watch(
() => loginStore.isLoggedIn,
(newVal) => {
if (newVal) {
getBalance(); //
} else {
balances.value = 0; // 退
}
},
{ immediate: true }
);
//
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.user-profile')) {
userDropdownVisible.value = false;
}
};
//
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<style scoped lang='less'>
@import url('../../assets/less/Header/header.less');
</style>

View File

@ -0,0 +1,96 @@
<template>
<div class="login-prompt" :class="{ 'is-full-height': fullHeight }">
<div class="login-prompt__content">
<div class="login-prompt__title">{{ title }}</div>
<div v-if="description" class="login-prompt__description">{{ description }}</div>
<el-button class="login-prompt__button" @click="handleLogin">
{{ buttonText }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
interface Props {
title?: string;
description?: string;
buttonText?: string;
fullHeight?: boolean;
}
withDefaults(defineProps<Props>(), {
title: '请先登录',
description: '登录后即可继续使用当前功能。',
buttonText: '立即登录',
fullHeight: false,
});
const router = useRouter();
const handleLogin = () => {
router.push('/login');
};
</script>
<style scoped lang="less">
.login-prompt {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
box-sizing: border-box;
&.is-full-height {
height: 100%;
}
}
.login-prompt__content {
width: min(460px, 100%);
display: flex;
flex-direction: column;
align-items: center;
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: 24px;
font-weight: 700;
color: rgba(255, 255, 255, 0.92);
}
.login-prompt__description {
max-width: 390px;
line-height: 1.75;
color: rgba(235, 244, 255, 0.58);
font-size: 16px;
}
.login-prompt__button {
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

@ -0,0 +1,289 @@
<template>
<div class="recharge-modal-mask" @click.self="closeModal">
<div class="recharge-container">
<!-- 头部 -->
<div class="modal-header">
<div class="header-copy">
<h2>充值账户</h2>
<p class="header-desc">选择金额后跳转到安全支付页面余额到账后可立即使用</p>
</div>
<button class="close-btn" @click="closeModal"></button>
</div>
<!-- 充值渠道选择 -->
<div class="provider-section">
<p class="section-title">选择充值渠道</p>
<div class="provider-grid">
<button
v-for="provider in providers"
:key="provider.value"
class="provider-btn"
:class="{ active: selectedProvider === provider.value }"
@click="selectProvider(provider.value)"
>
<span class="iconfont icon-zhifubaozhifu"></span>
<span class="provider-name">{{ provider.name }}</span>
<span class="provider-tag">推荐</span>
</button>
</div>
</div>
<!-- 金额选择区 -->
<div class="amount-section">
<p class="section-title">选择充值金额</p>
<div class="amount-grid">
<button
v-for="amount in presetAmounts"
:key="amount"
class="amount-btn"
:class="{ active: selectedAmount === amount }"
@click="selectPresetAmount(amount)"
>
<span class="currency">¥</span>{{ amount }}
</button>
<button
class="amount-btn custom-btn"
:class="{ active: isCustomSelected }"
@click="openCustomDialog"
>
<span class="currency"></span>
{{ customButtonText }}
</button>
</div>
<!-- 当前选中金额展示 -->
<div class="selected-info" v-if="selectedAmount">
<div class="selected-info__item">
<span class="selected-info__label">充值渠道</span>
<strong class="selected-info__value">支付宝</strong>
</div>
<div class="selected-info__item selected-info__item--highlight">
<span class="selected-info__label">到账金额</span>
<strong class="selected-amount">¥{{ selectedAmount }}</strong>
</div>
</div>
</div>
<!-- 确认充值按钮 -->
<button class="confirm-btn" @click="handleConfirm" :disabled="loading">
<span v-if="!loading">确认充值</span>
<span v-else>处理中...</span>
<svg v-if="!loading" class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
<svg v-else class="btn-icon loading" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
</button>
<div class="recharge-tips">
<span>支付由第三方渠道完成</span>
<span>如长时间未到账请刷新余额或联系客服</span>
</div>
</div>
<!-- 自定义金额弹窗 (独立遮罩) -->
<Teleport to="body">
<Transition name="fade">
<div v-if="customDialogVisible" class="custom-modal-mask" @click.self="closeCustomDialog">
<div class="custom-dialog">
<div class="dialog-header">
<h3>自定义金额</h3>
<button class="close-btn" @click="closeCustomDialog"></button>
</div>
<div class="dialog-body">
<div class="input-group">
<span class="input-currency">¥</span>
<input
ref="customInputRef"
v-model.number="customAmount"
type="number"
placeholder="请输入金额"
class="custom-input"
min="0.01"
step="0.01"
@keyup.enter="confirmCustomAmount"
/>
</div>
<div v-if="customError" class="error-msg">{{ customError }}</div>
</div>
<div class="dialog-footer">
<button class="dialog-btn cancel" @click="closeCustomDialog">取消</button>
<button class="dialog-btn confirm" @click="confirmCustomAmount">确定</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import { recharge } from '@/apis/home';
//
const emit = defineEmits<{
(e: 'close'): void;
(e: 'confirm', amount: number): void;
}>();
//
const providers = [
{ value: 'alipay', name: '支付宝', icon: '' },
];
//
const presetAmounts = [50, 200, 500, 1000];
//
const selectedAmount = ref<number | null>(50); // 50
//
const selectedProvider = ref<string>('alipay'); //
//
const loading = ref(false);
//
const customDialogVisible = ref(false);
const customAmount = ref<number | null>(null);
const customError = ref('');
const customInputRef = ref<HTMLInputElement | null>(null);
// ()
const isCustomSelected = computed(() => {
if (selectedAmount.value === null) return false;
return !presetAmounts.includes(selectedAmount.value);
});
//
const customButtonText = computed(() => {
if (isCustomSelected.value && selectedAmount.value !== null) {
return `自定义 (¥${selectedAmount.value})`;
}
return '自定义';
});
//
const selectPresetAmount = (amount: number) => {
selectedAmount.value = amount;
};
//
const selectProvider = (provider: string) => {
selectedProvider.value = provider;
};
//
const openCustomDialog = () => {
//
if (isCustomSelected.value && selectedAmount.value !== null) {
customAmount.value = selectedAmount.value;
} else {
customAmount.value = null;
}
customError.value = '';
customDialogVisible.value = true;
// DOM
nextTick(() => {
customInputRef.value?.focus();
});
};
//
const closeCustomDialog = () => {
customDialogVisible.value = false;
customError.value = '';
};
// ()
const confirmCustomAmount = () => {
//
let amount = customAmount.value;
if (amount === null || isNaN(amount)) {
customError.value = '请输入有效的金额';
return;
}
if (amount <= 0) {
customError.value = '金额必须大于0';
return;
}
if (amount > 999999) {
customError.value = '金额不能超过999999';
return;
}
//
const finalAmount = Number(amount.toFixed(2));
//
selectedAmount.value = finalAmount;
//
closeCustomDialog();
};
//
const handleConfirm = async () => {
if (selectedAmount.value === null) {
console.warn('未选择金额');
ElMessage.warning('请选择充值金额');
return;
}
loading.value = true;
try {
const amount = selectedAmount.value;
//
const rechargeParams = {
provider: 'alipay',
amount: amount,
currency: 'CNY'
};
//
interface RechargeResponse {
status: string;
message?: string;
data: { url: string } | null;
}
//
const res = await recharge<RechargeResponse, typeof rechargeParams>(rechargeParams);
//
if (res.status === 'ok') {
ElMessage.success('充值请求成功,正在跳转到支付页面');
//
if (res.data && res.data.url) {
//
window.open(res.data.url, '_blank', 'width=800,height=600');
} else {
ElMessage.error('缺少支付链接,请稍后重试');
}
//
emit('confirm', amount);
//
closeModal();
} else {
ElMessage.error(res.message || '充值请求失败,请稍后重试');
}
} catch (error) {
console.error('充值失败:', error);
ElMessage.error('充值失败,请稍后重试');
} finally {
loading.value = false;
}
};
// close
const closeModal = () => {
emit('close');
};
</script>
<style scoped lang="less">
@import url('@/assets/less/Recharge/Recharge.less');
</style>

View File

@ -0,0 +1,772 @@
<template>
<div>
<el-card class="rig">
<!-- 未登录状态 -->
<LoginPrompt
v-if="!isLoggedIn"
class="login-require"
title="请先登录"
description="登录后可查看生成任务和创作结果。"
/>
<!-- 已登录状态 -->
<template v-else>
<div class="top">
<el-checkbox v-model="showProcessing" @change="handleFilterChange" label="进行中" size="large" />
<el-select v-model="statusFilter" placeholder="全部状态" style="width: 240px" popper-class="custom-select-popup" @change="handleFilterChange">
<el-option label="全部状态" value="" />
<el-option label="已创建" value="CREATED" />
<el-option label="运行中" value="RUNNING" />
<el-option label="排队中" value="PENDING" />
<el-option label="成功" value="SUCCEEDED" />
<el-option label="失败" value="FAILED" />
</el-select>
</div>
<div class="content">
<!-- 加载中 -->
<div v-if="isLoading" class="loading-container">
<el-icon class="is-loading" :size="40" color="#ffffff">
<Loading />
</el-icon>
<div class="loading-text">加载中...</div>
</div>
<!-- 无任务 -->
<div v-else-if="!filteredTasks.length" class="empty">
<img src="../../assets/images/preparation.png" alt="">
<div class="title">开始创作您的第一个作品吧</div>
</div>
<!-- 任务列表 -->
<div v-else class="task-list">
<div class="task-item" v-for="task in filteredTasks" :key="task.taskid">
<!-- 顶部标题栏 -->
<div class="task-header">
<div class="task-title">
<svg width="1em" height="1em" fill="none" viewBox="0 0 24 24" class="title-icon">
<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>{{ getTaskTypeText(task) }}</span>
</div>
</div>
<!-- 提示词 + 模型信息 -->
<div class="task-prompt-wrapper">
<div class="prompt-content">
<div class="video-preview">
<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"
autoplay loop muted playsinline
></video>
</div>
</div>
<div class="prompt-text">
{{ task.prompt }}
</div>
<div class="task-meta">
<span class="task-time">{{ task.use_time }}</span>
<span class="split">|</span>
<span>{{ task.model || '未知模型' }}</span>
<span class="split">|</span>
<span>{{ task.resolution || '未知清晰度' }}</span>
</div>
</div>
</div>
<!-- 视频播放区域 -->
<div class="video-player">
<div class="player-container">
<!-- 视频生成中提示 -->
<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>
<!-- 视频播放器 -->
<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"
class="task-video"
controls
playsinline
></video>
</div>
</div>
<!-- 底部操作栏 -->
<div class="task-actions">
<div class="action-left">
<!-- <el-button class="action-btn">做同款</el-button> -->
</div>
<div class="action-right">
<!-- 下载按钮 -->
<div
class="icon-btn"
: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">
<Loading />
</el-icon>
</template>
<template v-else>
<svg width="1em" height="1em" fill="none" viewBox="0 0 24 24">
<path d="M3 14.0042V18.5C3 19.6046 3.89543 20.5 5 20.5H19C20.1046 20.5 21 19.6046 21 18.5V14" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"></path>
<path d="M17.5 9.5L12 15L6.5 9.5" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"></path>
<path d="M11.9961 3V15" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"></path>
</svg>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</el-card>
</div>
</template>
<script setup lang='ts'>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { ElLoading, ElMessage } from 'element-plus';
import { Loading } from '@element-plus/icons-vue';
import { useLoginStore } from '@/store/LoginStore';
import { getAsyncTaskStatus, getTodayTask } from '@/apis/Model';
import type { TaskItem, TaskResult } from '@/types/model';
import LoginPrompt from '@/components/LoginPrompt/LoginPrompt.vue';
// ==================== ====================
const loginStore = useLoginStore();
const props = defineProps<{
llmcatelogid?: string;
}>();
// ==================== ====================
/** 登录状态 */
const isLoggedIn = computed(() => loginStore.isLoggedIn);
/** 任务列表数据 */
const taskList = ref<TaskItem[]>([]);
/** 加载状态 */
const isLoading = ref(false);
/** 是否只显示进行中的任务 */
const showProcessing = ref(false);
/** 状态筛选器 */
const statusFilter = ref('');
/** 正在下载的任务ID集合 */
const downloadingTaskIds = ref<Set<string>>(new Set());
/** 轮询定时器 */
let pollTimer: ReturnType<typeof setInterval> | null = null;
/** 轮询间隔30秒 */
const POLL_INTERVAL = 30;
// ==================== ====================
/**
* 过滤和排序后的任务列表
* - 根据 showProcessing 筛选进行中的任务
* - 根据 statusFilter 筛选特定状态的任务
* - 按时间降序排序最新的在前
*/
const filteredTasks = computed(() => {
let tasks = [...taskList.value];
//
if (showProcessing.value) {
tasks = tasks.filter(task => isInProgressStatus(task.status));
}
//
if (statusFilter.value) {
tasks = tasks.filter(task => task.status === statusFilter.value);
}
//
tasks.sort((a, b) => {
const timeA = a.use_time || '';
const timeB = b.use_time || '';
return timeB.localeCompare(timeA);
});
return tasks;
});
// ==================== ====================
/**
* 处理筛选变化
*/
const handleFilterChange = () => {};
const normalizeTaskStatus = (status?: string): string => {
const upperStatus = String(status || '').toUpperCase();
const statusMap: Record<string, string> = {
CREATED: 'CREATED',
PENDING: 'PENDING',
QUEUEING: 'PENDING',
RUNNING: 'RUNNING',
PROCESSING: 'RUNNING',
SUCCEEDED: 'SUCCEEDED',
COMPLETED: 'SUCCEEDED',
FAILED: 'FAILED'
};
return statusMap[upperStatus] || upperStatus;
};
const isInProgressStatus = (status?: string) => {
const normalizedStatus = normalizeTaskStatus(status);
return normalizedStatus === 'CREATED' || normalizedStatus === 'PENDING' || normalizedStatus === 'RUNNING';
};
const 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 {};
}
return {
'--bg-image-url': `url("${coverUrl}")`
};
};
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 状态值
*/
const getStatusText = (status: string): string => {
const statusMap: Record<string, string> = {
'CREATED': '已创建',
'PENDING': '排队中',
'RUNNING': '运行中',
'SUCCEEDED': '成功',
'FAILED': '失败'
};
const normalizedStatus = normalizeTaskStatus(status);
return statusMap[normalizedStatus] || normalizedStatus;
};
/**
* 下载视频
* @param task 任务对象
*/
const downloadVideo = async (task: TaskItem) => {
//
if (!isLoggedIn.value) {
ElMessage.warning('请先登录');
return;
}
const mediaUrl = getTaskMediaUrl(task);
//
if (!mediaUrl) {
ElMessage.error('作品地址不存在,无法下载');
return;
}
const taskId = task.taskid;
//
if (downloadingTaskIds.value.has(taskId)) {
return;
}
downloadingTaskIds.value.add(taskId);
const loadingInstance = ElLoading.service({
text: '正在下载...',
background: 'rgba(0, 0, 0, 0.7)'
});
try {
// 1使 fetch CORS
const response = await fetch(mediaUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// _ID_.mp4
const timestamp = new Date().getTime();
const prompt = task.prompt?.substring(0, 15) || 'video';
const fileName = `${prompt}_${task.taskid}_${timestamp}.${isImageTask(task) ? 'png' : 'mp4'}`;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// URL
URL.revokeObjectURL(url);
// ElMessage.success('');
} catch (error) {
console.error('下载失败:', error);
// 2 -
try {
const link = document.createElement('a');
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();
document.body.removeChild(link);
ElMessage.success('已开始下载');
} catch (fallbackError) {
console.error('降级下载也失败:', fallbackError);
ElMessage.error('下载失败,请右键点击视频选择"另存为"');
}
} finally {
loadingInstance.close();
downloadingTaskIds.value.delete(taskId);
}
};
// ==================== ====================
/**
* 查询单个任务的状态
* @param taskId 任务ID
*/
const updateTaskStatus = async (taskId: string) => {
try {
interface Params {
taskid: string;
}
interface Response {
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;
}
interface Result {
status: string;
data: Data;
}
const res = await getAsyncTaskStatus<Result, Params>({ taskid: taskId });
if (res.status === 'ok' && res.data?.resposne) {
//
const taskIndex = taskList.value.findIndex(t => t.taskid === taskId);
if (taskIndex !== -1) {
// res.data.resposne.status
const newStatus = normalizeTaskStatus(res.data.resposne.status || taskList.value[taskIndex].status);
taskList.value[taskIndex].status = newStatus;
// / res.data.resposne
if (res.data.resposne.video) {
taskList.value[taskIndex].video_url = res.data.resposne.video;
}
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';
}
}
}
}
} catch (error) {
console.error(`查询任务状态失败 (${taskId}):`, error);
}
};
/**
* 轮询所有进行中的任务状态
*/
const pollTasksStatus = () => {
//
const processingTasks = taskList.value.filter(task => isInProgressStatus(task.status));
if (processingTasks.length === 0) {
//
stopPolling();
return;
}
//
processingTasks.forEach(task => {
updateTaskStatus(task.taskid);
});
};
/**
* 启动轮询
*/
const startPolling = () => {
stopPolling();
//
pollTasksStatus();
// 30
pollTimer = setInterval(() => {
pollTasksStatus();
}, POLL_INTERVAL * 1000);
};
/**
* 停止轮询
*/
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
};
/**
* 加载任务列表
* 从后端获取今日任务列表并转换为前端格式
*/
const loadTaskList = async () => {
if (!isLoggedIn.value) {
return;
}
isLoading.value = true;
try {
const taskParams = props.llmcatelogid ? { llmcatelogid: props.llmcatelogid } : undefined;
//
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 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: imageUrl || undefined,
image_url: imageUrl && !finalOutput.video ? imageUrl : undefined,
video_url: finalOutput.video || undefined,
duration: input.duration,
resolution: input.resolution || input.size || usageOutput?.resolution || '',
llmid: input.llmid,
model: input.model || usageOutput?.model || '',
use_time: task.use_time,
action,
} as TaskItem;
});
//
const hasProcessingTasks = taskList.value.some(task => isInProgressStatus(task.status));
if (hasProcessingTasks) {
startPolling();
}
} else {
taskList.value = [];
}
} catch (error) {
console.error('获取任务列表失败:', error);
taskList.value = [];
} finally {
isLoading.value = false;
}
};
// ==================== ====================
/**
* 组件挂载时加载任务列表
*/
onMounted(() => {
if (isLoggedIn.value) {
loadTaskList();
}
});
/**
* 组件卸载时停止轮询
*/
onUnmounted(() => {
stopPolling();
});
/**
* 监听登录状态变化
* 登录时加载任务列表登出时清空任务列表并停止轮询
*/
watch(() => loginStore.isLoggedIn, (newValue) => {
if (newValue) {
loadTaskList();
} else {
taskList.value = [];
stopPolling();
}
});
watch(
() => props.llmcatelogid,
() => {
if (isLoggedIn.value) {
stopPolling();
loadTaskList();
}
}
);
// ==================== ====================
/**
* 向父组件暴露的方法
*/
defineExpose({
refreshTaskList: loadTaskList //
});
</script>
<style lang="less" scoped>
@import url('../../assets/less/RightResult/RightResult.less');
/* 下载按钮禁用样式 */
.icon-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* 下载图标悬停效果 */
.icon-btn:not(.disabled):hover {
opacity: 0.8;
transform: scale(1.05);
transition: all 0.2s ease;
}
/* loading 图标动画 */
.is-loading {
animation: rotate 1.5s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 生成中状态样式 */
.generating-status {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #0F0F19;
gap: 16px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top: 3px solid #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.generating-text {
font-size: 16px;
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>
/* 全局样式(保留原有下拉框样式) */
.custom-select-popup {
background-color: #2A2D34 !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
border: none !important;
}
.custom-select-popup .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !important;
}
.custom-select-popup .el-select-dropdown__item.selected,
.custom-select-popup .el-select-dropdown__item:hover {
background-color: #3a3d46 !important;
}
</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

@ -0,0 +1,266 @@
<template>
<div>
<div class="left">
<div class="tab">
<ImgTap :activeTab="activeTab" @tabChange="handleTabChange" />
</div>
<div class="content">
<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 class="create-button-container">
<div
class="create-button"
:class="{ disabled: isCreateButtonDisabled }"
@click="handleCreate"
>
创作
<span v-if="currentPrice > 0" class="price-info">
消耗 {{ currentPrice }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang='ts'>
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) => {
activeTab.value = tab;
//
router.push(`/ImgGenerate/${tab}`);
}, 300);
// activeTab
const updateActiveTabFromRoute = () => {
const path = route.path;
if (path.includes('/ImgGenerate/reference')) {
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';
}
};
//
watch(() => route.path, () => {
updateActiveTabFromRoute();
});
//
onMounted(() => {
updateActiveTabFromRoute();
});
onUnmounted(() => {
handleTabChange.cancel();
handleCreate.cancel();
});
</script>
<style scoped lang='less'>
.left{
width: 100%;
height: calc(100vh - 80px);
// overflow-y: auto;
position: relative;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.create-button-container {
z-index: 999;
position: sticky;
bottom: 0;
left: 0;
width: 100%;
padding: 16px;
background: linear-gradient(180deg, rgba(2,11,19,0) 0%, #0F0F19 76.04%);
.create-button {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 48px;
border-radius: 12px !important;
background-image: linear-gradient(270deg, #94d8ff 11.11%, #e0ddff);
border: none !important;
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

@ -0,0 +1,89 @@
<template>
<div>
<div class="tabs-container ">
<div
class="tab-item"
:class="{ active: activeTab === 'reference' }"
@click="switchTab('reference')"
>
参考生图
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'text' }"
@click="switchTab('text')"
>
文生图
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'image' }"
@click="switchTab('image')"
>
图生图
</div>
</div>
</div>
</template>
<script setup lang='ts'>
// activeTab
defineProps<{
activeTab: string;
}>();
//
const emit = defineEmits<{
(e: 'tabChange', tab: string): void;
}>();
// tab
const switchTab = (tab: string) => {
emit('tabChange', tab);
};
</script>
<style scoped lang='less'>
.tabs-container {
width: 100%;
height: 44x;
display: flex;
background-color: #1a1c20;
border-radius: 12px;
overflow: hidden;
margin-bottom: 16px;
}
.tab-item {
flex: 1;
padding: 12px 0;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
text-align: center;
position: relative;
}
.tab-item:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.08);
}
.tab-item.active {
color: #fff;
background-color: rgba(255, 255, 255, 0.15);
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 20%;
width: 60%;
height: 2px;
border-radius: 2px;
}
</style>

View File

@ -0,0 +1,784 @@
<template>
<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 { 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">
.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;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
border: none !important;
}
.custom-select-popup .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !important;
}
.custom-select-popup .el-select-dropdown__item.selected,
.custom-select-popup .el-select-dropdown__item:hover {
background-color: #3a3d46 !important;
}
</style>

View File

@ -0,0 +1,595 @@
<template>
<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 { 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);
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">
.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;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
border: none !important;
}
.custom-select-popup .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !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

@ -0,0 +1,147 @@
<template>
<!-- AI视频功能选择栏 -->
<div class="ai-video-function-bar">
<!-- 功能按钮列表 - 横向滚动容器 -->
<div class="function-btn-scroll-container">
<!-- 功能按钮项 -->
<button
v-for="(item, index) in functionList"
:key="index"
class="function-btn-item"
type="button"
>
<!-- 按钮文本区域 -->
<div class="function-btn-text-wrapper">
<div class="function-btn-text">{{ item.name }}</div>
</div>
<!-- 按钮图片区域 -->
<img
:src="item.imageUrl"
:alt="item.name"
class="function-btn-image"
loading="lazy"
/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
//
interface FunctionItem {
name: string;
imageUrl: string;
}
//
const functionList = reactive<FunctionItem[]>([
{
name: '参考生视频',
imageUrl: 'https://image01.vidu.zone/vidu/media-asset/c2v-68811cfd.webp',
},
{
name: '图生视频',
imageUrl: 'https://image01.vidu.zone/vidu/media-asset/i2v-6e715ae0.webp',
},
{
name: 'AI模板',
imageUrl: 'https://image01.vidu.zone/vidu/media-asset/templates-bab5f63b.webp',
},
{
name: '视频换脸',
imageUrl: 'https://image01.vidu.zone/vidu/media-asset/face-7de6838a.webp',
},
{
name: '视频复刻',
imageUrl: 'https://image01.vidu.zone/vidu/media-asset/remake-09a26546.webp',
},
{
name: '一键成片',
imageUrl: 'https://image01.vidu.zone/vidu/media-asset/oneclick-beafcd9f.webp',
},
{
name: '视频编辑',
imageUrl: 'https://image01.vidu.zone/vidu/media-asset/videoedit-de4fb509.webp',
},
{
name: '视频增强',
imageUrl: 'https://image01.vidu.zone/vidu/media-asset/videoupscale-190d7537.webp',
},
]);
</script>
<style scoped lang="less">
// AI
.ai-video-function-bar {
padding-bottom: 0.25rem; // pb-1
margin-top: 1.5rem;
// 100%
}
//
.function-btn-scroll-container {
display: flex;
gap: 1rem; // gap-4
overflow-x: auto; //
padding-bottom: 0.25rem; //
//
&::-webkit-scrollbar {
height: 6px; //
}
&::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.3); //
border-radius: 10px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
}
//
.function-btn-item {
display: flex;
min-width: 13.75rem; // min-w-[220px]
flex: 1 0 13.75rem; // flex-[1_0_220px]
align-items: center;
justify-content: space-between;
gap: 0.75rem; // gap-3
overflow: hidden;
border-radius: 1rem; // rounded-1616px=1rem
border: 1px solid rgba(255, 255, 255, 0.12); // border-system-white12
background-color: rgba(255, 255, 255, 0.08); // bg-system-white08
padding-left: 1rem; // pl-4
text-align: left;
transition: background-color 0.3s ease;
cursor: pointer;
// hover
&:hover {
background-color: rgba(255, 255, 255, 0.12); // hover:bg-system-white12
}
}
//
.function-btn-text-wrapper {
min-width: 0;
flex: 1;
}
//
.function-btn-text {
font-weight: 600; // font-semibold
font-size: 1rem; // text-base
color: #ffffff; // text-white
line-height: 1.4; // leading-[1.4]
}
//
.function-btn-image {
height: 5.625rem; // h-[90px]
width: 6.8125rem; // w-[109px]
flex-shrink: 0;
object-fit: cover;
}
</style>

View File

@ -0,0 +1,115 @@
<template>
<div class="stencil-container">
<div class="stencil-top">
<div
class="stencil-item"
:class="{ active: activeTab === 'mixed' }"
@click="handleTabClick('mixed')"
>
<div class="item-title">推荐</div>
</div>
<div
class="stencil-item"
:class="{ active: activeTab === 'share_elements' }"
@click="handleTabClick('share_elements')"
>
<div class="item-title reference-title">
<img
src="https://sfzas1.vidu.cn/2.17.51-cn/_next/static/media/refhub_zh.4622df68.svg"
alt="reference"
class="reference-icon"
/>
<img
src="https://sfzas1.vidu.cn/2.17.51-cn/_next/static/media/reference_2.0.bbfb4cad.svg"
alt="reference_2.0"
class="reference-sub-icon"
/>
</div>
</div>
<div
class="stencil-item"
:class="{ active: activeTab === 'short_film' }"
@click="handleTabClick('short_film')"
>
<div class="item-title">短片</div>
</div>
<div
class="stencil-item"
:class="{ active: activeTab === 'tutorial' }"
@click="handleTabClick('tutorial')"
>
<div class="item-title">教程</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
activeTab: string
}>()
const emit = defineEmits<{
(e: 'tab-change', tabType: string): void
}>()
const handleTabClick = (type: string) => {
emit('tab-change', type)
}
</script>
<style scoped>
.stencil-container {
width: 100%;
}
.stencil-top {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 0;
}
.stencil-item {
cursor: pointer;
position: relative;
padding-bottom: 8px;
}
.stencil-item.active .item-title {
color: #fff;
font-weight: bold;
}
.stencil-item.active::after {
content: '';
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
width: 24px;
height: 2px;
border-radius: 999px;
background-color: #fff;
}
.item-title {
font-size: 16px;
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
}
.reference-title {
display: flex;
align-items: center;
gap: 4px;
}
.reference-icon,
.reference-sub-icon {
height: 20px;
width: auto;
object-fit: contain;
}
</style>

View File

@ -0,0 +1,592 @@
<!-- components/index/StencilWaterfall.vue -->
<template>
<div class="content">
<div ref="container" class="waterfall-container" :style="{
width: '100%',
height: '100%',
overflow: 'hidden auto',
position: 'relative',
willChange: 'transform',
}">
<!-- 加载遮罩层 -->
<div v-if="loading && items.length > 0" class="loading-overlay">
<div class="loading-overlay-spinner"></div>
<span class="loading-overlay-text">加载中...</span>
</div>
<div ref="innerScrollContainer" class="waterfall-inner" :style="{
width: '100%',
maxWidth: '100%',
overflow: 'hidden',
position: 'relative',
height: `${containerHeight}px`,
}">
<div v-for="(item, index) in itemsWithPosition" :key="item.id" :data-index="index" class="waterfall-item"
:style="{
width: `${cardWidth}px`,
position: 'absolute',
top: `${item.top}px`,
left: `${item.left}px`,
}">
<!-- 卡片内容保持不变 -->
<div class="card-wrapper" @click="openDetail(item)">
<div class="video-container" :style="{ aspectRatio: item.aspectRatio }"
@mouseenter="handleMouseEnter(index)" @mouseleave="handleMouseLeave(index)">
<video :ref="(el) => setVideoRef(index, el as HTMLVideoElement | null)" :src="item.src"
:poster="item.poster" preload="none" class="video-element" :class="{ playing: isVideoPlaying[index] }"
loop muted playsinline />
<div class="card-info-overlay">
<div class="user-info">
<img v-if="item.avatar" :src="item.avatar" class="user-avatar" alt="avatar" />
<span v-if="item.userName" class="user-name">{{ item.userName }}</span>
</div>
<div class="interaction-info">
<div v-if="item.likes > 0" class="interaction-item">
<span class="icon-heart"></span>
<span class="likes-count">{{ item.likes }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div ref="loadMoreTrigger" class="load-more-trigger">
<span v-if="loading" class="loading-spinner"></span>
<span v-else-if="hasMore">加载更多...</span>
<span v-else>已经到底啦~</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getVideos } from '@/apis/home'
import { transformFeedItem, transformShareElementItem } from '@/utlis/transform'
import type { FeedResponse, ShareElementsResponse } from '@/types/home'
import type { StencilItem } from '@/types/home'
const props = defineProps<{
activeTab: string // Tab
gap?: number
columnCount?: number
items?: StencilItem[]
}>()
const emit = defineEmits<{
(e: 'like', item: StencilItem): void
(e: 'makeSame', item: StencilItem): void
(e: 'loadMore'): void
}>()
const router = useRouter()
const DETAIL_STORAGE_KEY = 'material-square-detail'
// ==================== ====================
const items = ref<StencilItem[]>([])
const loading = ref(false)
const hasMore = ref(true)
const pageToken = ref<string | null>(null)
// ==================== ====================
const videoRefs = ref<(HTMLVideoElement | null)[]>([])
const isVideoPlaying = ref<Record<number, boolean>>({})
const setVideoRef = (index: number, el: HTMLVideoElement | null) => {
videoRefs.value[index] = el
}
const handleMouseEnter = (index: number) => {
const video = videoRefs.value[index]
if (video) {
video.play().catch(() => undefined)
isVideoPlaying.value[index] = true
}
}
const handleMouseLeave = (index: number) => {
const video = videoRefs.value[index]
if (video) {
video.pause()
video.currentTime = 0
isVideoPlaying.value[index] = false
}
}
const openDetail = (item: StencilItem) => {
sessionStorage.setItem(
DETAIL_STORAGE_KEY,
JSON.stringify({
selectedId: item.id,
items: items.value,
activeTab: props.activeTab,
})
)
router.push({
path: '/material-square/detail',
query: {
id: item.id,
tab: props.activeTab,
},
})
}
// ==================== ====================
const container = ref<HTMLElement>()
const innerScrollContainer = ref<HTMLElement>()
const loadMoreTrigger = ref<HTMLElement>()
const cardWidth = ref(0)
const containerHeight = ref(0)
const itemsWithPosition = ref<(StencilItem & { top: number; left: number; height: number })[]>([])
let columnHeights: number[] = []
let resizeObserver: ResizeObserver | null = null
let isLayoutPending = false
let intersectionObserver: IntersectionObserver | null = null
const getColumnCount = (): number => {
if (props.columnCount !== undefined) return props.columnCount
const width = window.innerWidth
if (width >= 1280) return 4
if (width >= 768) return 3
return 2
}
const layout = async () => {
// ... ...
if (!container.value || !innerScrollContainer.value || items.value.length === 0) return
if (isLayoutPending) return
isLayoutPending = true
try {
const containerWidth = container.value.clientWidth
const columnCount = getColumnCount()
const gap = 4
cardWidth.value = (containerWidth - gap * (columnCount - 1)) / columnCount
columnHeights = new Array(columnCount).fill(0)
const heights: number[] = []
for (const item of items.value) {
const [num, den] = item.aspectRatio.split('/').map(Number)
heights.push(cardWidth.value / (num / den))
}
const newItems = items.value.map((item, idx) => {
const height = heights[idx] || 0
const minHeight = Math.min(...columnHeights)
const minIndex = columnHeights.indexOf(minHeight)
const left = minIndex * (cardWidth.value + gap)
const top = columnHeights[minIndex]
columnHeights[minIndex] += height + gap
return { ...item, top, left, height }
})
itemsWithPosition.value = newItems
containerHeight.value = Math.max(...columnHeights) - gap
} finally {
isLayoutPending = false
}
}
// ==================== ====================
const loadFirstPage = async () => {
if (loading.value) return
loading.value = true
try {
const res: FeedResponse | ShareElementsResponse = await getVideos({
channel_type: props.activeTab
})
const cards: StencilItem[] = []
if (props.activeTab === 'share_elements') {
const shareRes = res as ShareElementsResponse
for (const item of shareRes.share_elements) {
const card = transformShareElementItem(item)
if (card) cards.push(card)
}
pageToken.value = shareRes.next_page_token ?? null
} else {
const feedRes = res as FeedResponse
for (const item of feedRes.list) {
const card = transformFeedItem(item)
if (card) cards.push(card)
}
pageToken.value = feedRes.next_page_token ?? null
}
items.value = cards
hasMore.value = !!pageToken.value
} catch (error) {
console.error('获取视频列表失败', error)
hasMore.value = false
} finally {
loading.value = false
nextTick(() => {
setupObserver()
})
}
}
const loadMore = async () => {
if (loading.value || !hasMore.value || !pageToken.value) return
loading.value = true
try {
const params: { channel_type: string; page_token?: string } = {
channel_type: props.activeTab,
}
if (pageToken.value) params.page_token = pageToken.value
const res: FeedResponse | ShareElementsResponse = await getVideos(params)
const newCards: StencilItem[] = []
if (props.activeTab === 'share_elements') {
const shareRes = res as ShareElementsResponse
for (const item of shareRes.share_elements) {
const card = transformShareElementItem(item)
if (card) newCards.push(card)
}
pageToken.value = shareRes.next_page_token ?? null
} else {
const feedRes = res as FeedResponse
for (const item of feedRes.list) {
const card = transformFeedItem(item)
if (card) newCards.push(card)
}
pageToken.value = feedRes.next_page_token ?? null
}
items.value = [...items.value, ...newCards]
hasMore.value = !!pageToken.value
} catch (error) {
console.error('加载更多失败', error)
hasMore.value = false
} finally {
loading.value = false
}
}
const setupObserver = () => {
if (intersectionObserver) intersectionObserver.disconnect()
if (!loadMoreTrigger.value) return
intersectionObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry.isIntersecting && !loading.value && hasMore.value) {
emit('loadMore')
loadMore()
}
},
{ root: container.value, threshold: 0.1 }
)
intersectionObserver.observe(loadMoreTrigger.value)
}
// ==================== ====================
// activeTab
watch(
() => props.activeTab,
() => {
items.value = []
pageToken.value = null
hasMore.value = true
containerHeight.value = 0
itemsWithPosition.value = []
loadFirstPage()
if (container.value) container.value.scrollTop = 0
}
)
watch(
() => items.value,
() => nextTick(() => layout()),
{ deep: true }
)
watch(
() => getColumnCount(),
() => layout()
)
let resizeTimer: number
const handleResize = () => {
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = window.setTimeout(() => layout(), 100)
}
onMounted(() => {
loadFirstPage()
window.addEventListener('resize', handleResize)
if (window.ResizeObserver && container.value) {
resizeObserver = new ResizeObserver(() => layout())
resizeObserver.observe(container.value)
}
nextTick(() => setupObserver())
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (resizeObserver) resizeObserver.disconnect()
if (intersectionObserver) intersectionObserver.disconnect()
if (resizeTimer) clearTimeout(resizeTimer)
})
defineExpose({ refreshLayout: layout })
</script>
<style scoped>
.content {
width: 100%;
height: 100%;
}
.user-avatar {
width: 20px !important;
height: 20px !important;
}
/* 把原来的 .tab 相关样式删掉,换成下面这些 */
.stencil-container {
width: 100%;
}
.stencil-top {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 0;
}
.stencil-item {
cursor: pointer;
}
.stencil-item.active .item-title {
color: #fff;
font-weight: bold;
}
.item-title {
font-size: 16px;
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
}
.reference-title {
display: flex;
align-items: center;
gap: 4px;
}
.reference-icon,
.reference-sub-icon {
height: 20px;
width: auto;
object-fit: contain;
}
/* 你原来的其他样式原样保留 */
.waterfall-item {
display: flex;
align-items: center;
justify-content: center;
}
.waterfall-item .relative {
width: 100%;
}
.waterfall-item img,
.waterfall-item video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-element {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 卡片容器 */
.card-wrapper {
width: 100%;
position: relative;
cursor: pointer;
}
/* 视频容器 */
.video-container {
width: 100%;
position: relative;
overflow: hidden;
border-radius: 4px;
background: #1a1a1a;
}
/* 底部信息遮罩 */
.card-info-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
/* background: rgba(74, 79, 90, 0.75); */
padding: 10px 12px;
display: flex;
justify-content: space-between;
align-items: center;
backdrop-filter: blur(2px);
transition: all 0.2s ease;
opacity: 0;
visibility: hidden;
}
/* 视频容器hover效果 */
.video-container:hover .card-info-overlay {
opacity: 1;
visibility: visible;
/* background: rgba(80, 85, 96, 0.85); */
backdrop-filter: blur(4px);
}
.video-container:hover .user-avatar {
width: 20px;
height: 20px;
/* border-color: rgba(255, 255, 255, 0.9); */
}
.video-container:hover .user-name {
color: #ffffff;
}
.video-container:hover .icon-heart {
color: #ffffff;
}
.video-container:hover .likes-count {
color: #ffffff;
}
/* 用户信息区 */
.user-info {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.user-name {
font-size: 14px;
color: rgba(255, 255, 255, 0.95);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 互动信息区 */
.interaction-info {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.interaction-item {
display: flex;
align-items: center;
gap: 4px;
}
.icon-heart {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
line-height: 1;
}
.likes-count {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
font-weight: 400;
line-height: 1;
}
.load-more-trigger {
text-align: center;
padding: 20px 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.loading-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: rgba(255, 255, 255, 0.8);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 加载遮罩层 */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
z-index: 10;
backdrop-filter: blur(2px);
}
.loading-overlay-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: rgba(255, 255, 255, 0.9);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-overlay-text {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.rounded-4 {
border-radius: 14px;
}
</style>

View File

@ -0,0 +1,523 @@
<template>
<div>
<div class="top">
<!-- 左侧菜单浮层半透明 -->
<div class="top-left">
<div
class="left-item"
:class="{ 'is-active': currentFeatureType === 'reference' }"
>
<div class="title">参考生视频</div>
<div class="detail">万物皆可自由组合搭配无限创意</div>
<div class="item-btn">
<div class="btn" @click="$router.push('/videoGenerate/reference')">立即使用</div>
</div>
</div>
<div
class="left-item"
:class="{ 'is-active': currentFeatureType === 'image' }"
>
<div class="title">图生视频</div>
<div class="detail">20s让图片动起来首尾帧无缝融合过渡丝滑自然</div>
<div class="item-btn">
<div class="btn" @click="$router.push('/videoGenerate/image')">立即使用</div>
</div>
</div>
</div>
<!-- 右侧轮播使用 Swiper -->
<div class="top-right" @mouseenter="pauseAutoPlay" @mouseleave="startAutoPlay">
<swiper :modules="modules" :slides-per-view="1" :space-between="0" :loop="true"
:autoplay="{ delay: AUTO_PLAY_INTERVAL, disableOnInteraction: false }"
:pagination="{ clickable: true, el: '.custom-pagination' }" @swiper="onSwiper" @slideChange="onSlideChange">
<swiper-slide v-for="(slide, index) in slides" :key="slide.id">
<div class="carousel-slide">
<!-- 背景层当前项显示视频其他项显示封面图片 -->
<div class="slide-background">
<video v-if="index === currentIndex && slide.videoSrc" :src="slide.videoSrc" autoplay loop muted
playsinline class="background-media"></video>
<img v-else :src="slide.poster" alt="cover" class="background-media" />
</div>
<!-- 右侧小图列表带按钮 -->
<div class="right-thumbnails">
<template v-for="(item, idx) in slide.rightItems" :key="idx">
<div v-if="item.type === 'image'" class="thumbnail-img" @click="goToSlide(index)">
<img :src="item.src" alt="thumb" />
</div>
<div v-else-if="item.type === 'button'" class="thumbnail-btn"
:class="{ 'play-btn': item.shape === 'play' }">
<svg v-if="item.shape === 'plus'" width="1em" height="1em" viewBox="0 0 25 25" fill="none">
<path d="M12.28 5.96875L12.2617 19.9688" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
<path d="M5.25 12.9688H19.25" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
<svg v-else-if="item.shape === 'play'" width="1em" height="1em" viewBox="0 0 24 24" fill="none">
<path
d="M6.25391 12V5.36603C6.25391 4.98113 6.67057 4.74056 7.00391 4.93301L12.7491 8.25L18.4942 11.567C18.8276 11.7594 18.8276 12.2406 18.4942 12.433L12.7491 15.75L7.00391 19.067C6.67057 19.2594 6.25391 19.0189 6.25391 18.634V12Z"
fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
</div>
</template>
</div>
<!-- 底部文字 -->
<div class="bottom-text">
<div class="subtitle">{{ slide.subtitle }}</div>
<div class="title">{{ slide.title }}</div>
</div>
</div>
</swiper-slide>
</swiper>
<!-- 全局自定义分页器容器放在轮播容器底部和左侧菜单对齐 -->
<div class="custom-pagination"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onBeforeUnmount } from 'vue'
import { Swiper, SwiperSlide } from 'swiper/vue'
import { Autoplay, Pagination } from 'swiper/modules'
import 'swiper/swiper-bundle.css'
/**
* 右侧缩略图项类型
*/
interface RightItem {
type: 'image' | 'button' //
src?: string //
shape?: 'plus' | 'play' //
}
/**
* 轮播幻灯片数据类型
*/
interface SlideData {
id: number //
poster: string //
videoSrc: string //
subtitle: string //
title: string //
rightItems: RightItem[] //
}
/**
* Swiper 实例类型定义
* 包含常用的 Swiper 方法和属性
*/
interface SwiperInstance {
autoplay: {
stop: () => void //
start: () => void //
}
slideToLoop: (index: number, speed?: number, runCallbacks?: boolean) => void //
}
//
const slides: SlideData[] = [
{
id: 1,
poster: 'https://image01.vidu.zone/vidu/media-asset/R2V1-poster-1-517a8d5f.webp',
videoSrc: 'https://image01.vidu.zone/vidu/media-asset/R2V1-218ca30f.mp4',
subtitle: '参考生视频',
title: '短片',
rightItems: [
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/R2V1-img-1-e607d496.webp' },
{ type: 'button', shape: 'plus' },
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/R2V1-img-2-1b75e6ed.webp' },
{ type: 'button', shape: 'plus' },
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/R2V1-img-3-89cf0518.webp' }
]
},
{
id: 2,
poster: 'https://image01.vidu.zone/vidu/media-asset/R2V2-poster-1-8a2c64f9.webp',
videoSrc: 'https://image01.vidu.zone/vidu/media-asset/R2V2-279a047f.mp4',
subtitle: '参考生视频',
title: '创意',
rightItems: [
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/R2V2-img-1-a636a740.webp' },
{ type: 'button', shape: 'plus' },
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/R2V2-img-2-c5dac1e5.webp' },
{ type: 'button', shape: 'plus' },
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/R2V2-img-3-908d7ec6.webp' }
]
},
{
id: 3,
poster: 'https://image01.vidu.zone/vidu/media-asset/R2V3-poster-1-7664cd7a.webp',
videoSrc: 'https://image01.vidu.zone/vidu/media-asset/R2V3-c79fd401.mp4',
subtitle: '参考生视频',
title: '广告 / 电商',
rightItems: [
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/R2V3-img-1-27138837.webp' },
{ type: 'button', shape: 'plus' },
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/R2V3-img-2-72f1929e.webp' },
{ type: 'button', shape: 'plus' },
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/R2V3-img-3-71250e7f.webp' }
]
},
{
id: 4,
poster: 'https://image01.vidu.zone/vidu/media-asset/I2V3-poster-1-b7a7f198.webp',
videoSrc: 'https://image01.vidu.zone/vidu/media-asset/I2V3-837996ba.mp4',
subtitle: '图生视频',
title: '故事',
rightItems: [
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/I2V3-img-1-dfff7f10.webp' },
{ type: 'button', shape: 'play' },
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/I2V3-img-2-507d7080.webp' }
]
},
{
id: 5,
poster: 'https://image01.vidu.zone/vidu/media-asset/I2V2-poster-1-1e73ffc8.webp',
videoSrc: 'https://image01.vidu.zone/vidu/media-asset/I2V2-74201655.mp4',
subtitle: '图生视频',
title: '广告 / 电商',
rightItems: [
{ type: 'image', src: 'https://image01.vidu.zone/vidu/media-asset/I2V2-img-1-3a3ff1a7.webp' }
]
}
]
// Swiper
const modules = [Autoplay, Pagination]
//
const AUTO_PLAY_INTERVAL = 5000
//
const currentIndex = ref(0)
const currentFeatureType = computed<'reference' | 'image'>(() => {
const currentSlide = slides[currentIndex.value]
return currentSlide?.subtitle.includes('图生视频') ? 'image' : 'reference'
})
// Swiper
const swiperInstance = ref<SwiperInstance | null>(null)
/**
* 保存 Swiper 实例
* @param swiper - Swiper 实例对象
*/
const onSwiper = (swiper: SwiperInstance) => {
swiperInstance.value = swiper
}
/**
* 监听幻灯片切换事件
* 更新当前索引使用 realIndex 以支持循环模式
* @param swiper - Swiper 实例对象包含 realIndex 属性
*/
const onSlideChange = (swiper: SwiperInstance & { realIndex: number }) => {
currentIndex.value = swiper.realIndex
}
/**
* 跳转到指定幻灯片
* @param index - 目标幻灯片索引
*/
const goToSlide = (index: number) => {
//
if (index === currentIndex.value) return
// 使 slideToLoop
swiperInstance.value?.slideToLoop(index)
}
/**
* 暂停自动播放
*/
const pauseAutoPlay = () => {
swiperInstance.value?.autoplay.stop()
}
/**
* 启动自动播放
*/
const startAutoPlay = () => {
swiperInstance.value?.autoplay.start()
}
/**
* 组件卸载前清理
* 停止自动播放避免内存泄漏
*/
onBeforeUnmount(() => {
pauseAutoPlay()
})
</script>
<style scoped lang="less">
.top {
position: relative;
width: 100%;
height: 260px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
overflow: hidden;
}
.top-left {
position: absolute;
left: 0;
top: 0;
width: 248px;
padding: 8px;
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(25px);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 2;
height: 100%;
}
.left-item {
height: 118px;
box-sizing: border-box;
padding: 16px 24px;
border-radius: 8px;
color: #fff;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: background-color 0.2s;
cursor: pointer;
background-color: transparent;
&:hover {
background-color: rgba(255, 255, 255, 0.08);
}
&.is-active {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.04) 100%);
}
&.is-active .detail {
opacity: 0.72;
}
.title {
font-size: 16px;
font-weight: 600;
line-height: 16px;
}
.detail {
font-size: 12px;
opacity: 0.4;
line-height: 1.4;
}
.item-btn {
width: 100%;
display: flex;
justify-content: flex-end;
.btn {
font-size: 12px;
padding: 2px 16px;
background-color: rgba(255, 255, 255, 0.16);
border-radius: 16px;
line-height: 24px;
display: inline-block;
transition: background-color 0.2s, color 0.2s;
}
}
}
.left-item.is-active {
.btn {
background-color: rgba(255, 255, 255, 0.28);
color: #fff;
}
}
.top-right {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: transparent;
z-index: 1;
// Swiper
.swiper {
width: 100%;
height: 100%;
}
.swiper-wrapper {
height: 100%;
}
}
.carousel-slide {
flex: 0 0 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.slide-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
.background-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.right-thumbnails {
position: absolute;
top: 0;
right: 8px;
bottom: 0;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
pointer-events: none;
.thumbnail-img {
pointer-events: auto;
width: 80px;
height: 48px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: opacity 0.2s, transform 0.2s;
position: relative;
z-index: 1;
&:hover {
opacity: 0.8;
transform: translateX(-2px);
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.thumbnail-btn {
width: 20px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.16);
backdrop-filter: blur(10px);
color: white;
font-size: 16px;
margin: -8px 0;
z-index: 2;
// border: 2px solid rgba(18, 20, 24, 0.72);
&.play-btn {
background-color: rgba(255, 255, 255, 0.24);
}
svg {
width: 16px;
height: 16px;
}
&.play-btn svg {
transform: rotate(90deg);
transform-origin: center;
}
}
}
.bottom-text {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 4px;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0) 100%);
padding-bottom: 24px;
padding-left: 248px;
color: white;
z-index: 5;
.subtitle {
font-size: 16px;
line-height: 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.64) 0.33%, rgba(255, 255, 255, 0.64) 99.67%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.title {
font-size: 20px;
font-weight: 600;
line-height: 22px;
background: linear-gradient(180deg, #fff 0.33%, #fff 99.67%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
//
.custom-pagination {
position: absolute;
left: 54%;
z-index: 20;
display: flex;
align-items: center;
gap: 8px;
}
</style>
<style lang="less">
/* 全局样式修改分页器外观 */
.swiper-pagination-bullet {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
opacity: 1;
transition: all 0.3s ease;
}
.swiper-pagination-bullet-active {
width: 24px;
height: 8px;
border-radius: 4px;
background: white;
}
</style>

View File

@ -0,0 +1,335 @@
<template>
<div class="left">
<!-- 可滚动菜单区域 -->
<div class="sidebar-scroll">
<!-- 主导航 -->
<div class="main-navigation mb-6">
<el-menu
:default-active="activeIndex"
class="custom-menu"
background-color="transparent"
text-color="#fff"
active-text-color="#00FFCC"
@select="handleSelect"
>
<el-menu-item index="index">
<div class="menu-item-content">
<i class="iconfont icon-shouye"></i>
<span class="menu-text">首页</span>
</div>
</el-menu-item>
</el-menu>
</div>
<!-- AI 创作分组 -->
<div class="ai-creation-menu mb-8">
<div class="menu-title">AI 创作</div>
<el-menu
:default-active="activeIndex"
class="custom-menu"
background-color="transparent"
text-color="#fff"
active-text-color="#00FFCC"
@select="handleSelect"
>
<el-menu-item index="/videoGenerate">
<div class="menu-item-content">
<i class="iconfont icon-img2video"></i>
<span class="menu-text">视频生成</span>
</div>
</el-menu-item>
<el-menu-item index="/ImgGenerate">
<div class="menu-item-content">
<i class="iconfont icon-tupianshengcheng"></i>
<span class="menu-text">图片生成</span>
</div>
</el-menu-item>
<el-menu-item index="/TextGenerate">
<div class="menu-item-content">
<i class="iconfont icon-xiaoxi"></i>
<span class="menu-text">文本生成</span>
</div>
</el-menu-item>
</el-menu>
</div>
<!-- 我的分组 -->
<div class="tool-navigation mb-8">
<div class="menu-title">我的</div>
<el-menu
:default-active="activeIndex"
class="custom-menu"
background-color="transparent"
text-color="#fff"
active-text-color="#00FFCC"
@select="handleSelect"
>
<!-- <el-menu-item index="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">
<div class="menu-item-content">
<i class="iconfont icon-apikey"></i>
<span class="menu-text">令牌管理</span>
</div>
</el-menu-item>
</el-menu>
</div>
<!-- 底部占位空白 -->
<div class="pb-20"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
//
const router = useRouter()
const route = useRoute()
//
const activeIndex = ref('index')
//
const handleSelect = (index: string) => {
activeIndex.value = index
//
switch (index) {
case 'index':
router.push('/index');
break;
case 'bill':
router.push('/bill');
break;
case 'history-creation':
router.push('/history-creation');
break;
case 'discover':
router.push('/discover');
break;
case 'profile':
router.push('/profile');
break;
case 'infinite-canvas':
router.push('/infinite-canvas');
break;
case '/videoGenerate':
router.push('/videoGenerate');
break;
case '/ImgGenerate':
router.push('/ImgGenerate');
break;
case '/TextGenerate':
router.push('/TextGenerate');
break;
case 'digital-human':
router.push('/digital-human');
break;
case 'material-square':
router.push('/material-square');
break;
case 'apikey':
router.push('/apikey');
break;
default:
break;
}
}
//
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')) {
activeIndex.value = 'discover';
} else if (path.includes('/profile')) {
activeIndex.value = 'profile';
} else if (path.includes('/infinite-canvas')) {
activeIndex.value = 'infinite-canvas';
} else if (path.includes('/videoGenerate')) {
activeIndex.value = '/videoGenerate';
} else if (path.includes('/ImgGenerate')) {
activeIndex.value = '/ImgGenerate';
} else if (path.includes('/TextGenerate')) {
activeIndex.value = '/TextGenerate';
} else if (path.includes('/digital-human')) {
activeIndex.value = 'digital-human';
} else if (path.includes('/material-square')) {
activeIndex.value = 'material-square';
} else if (path.includes('/apikey')) {
activeIndex.value = 'apikey';
}
else {
activeIndex.value = 'index';
}
}
//
onMounted(() => {
updateActiveIndex()
//
router.afterEach(() => {
updateActiveIndex()
})
})
</script>
<style scoped lang="less">
.left {
height: calc(100vh - 80px);
padding: 0 24px;
color: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
.sidebar-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
//
.menu-title {
margin-bottom: 16px;
padding-left: 16px;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
}
//
:deep(.custom-menu) {
border-right: none;
background-color: transparent;
.el-menu-item {
height: auto;
line-height: normal;
padding: 10px 12px;
margin-bottom: 4px;
border-radius: 8px;
transition: background-color 0.2s ease;
&:last-child {
margin-bottom: 0;
}
&:hover {
background-color: rgba(255, 255, 255, 0.12);
}
&.is-active {
background: linear-gradient(90deg, #2cfff84d, #2c68ff4d);
.menu-text {
color: #00FFCC;
}
.iconfont {
color: #00FFCC;
}
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.6;
&:hover {
background-color: transparent;
}
.menu-text {
color: rgba(255, 255, 255, 0.4);
}
.iconfont {
opacity: 0.4;
}
}
}
}
//
.menu-item-content {
display: flex;
align-items: center;
width: 100%;
.iconfont {
font-size: 20px;
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.menu-text {
flex: 1;
font-size: 14px;
}
}
//
.menu-tag {
margin-left: 8px;
padding: 2px 8px;
font-size: 10px;
border-radius: 12px;
background-color: #2563eb;
color: #fff;
white-space: nowrap;
&.new {
background-color: #2563eb;
}
&.disabled {
background-color: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.6);
}
}
//
.pb-20 {
padding-bottom: 80px;
}
//
.mb-6 {
margin-bottom: 24px;
}
.mb-8 {
margin-bottom: 32px;
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,307 @@
<template>
<div>
<div class="left">
<div class="tab">
<VideoTab :activeTab="activeTab" @tabChange="handleTabChange" />
</div>
<div class="content">
<Referencevideo
ref="referenceVideoRef"
v-if="activeTab === 'reference'"
@price-update="handleReferencePriceUpdate"
@prompt-change="handlePromptChange"
@task-created="handleTaskCreated"
/>
<ImgVideo
ref="imgVideoRef"
v-else-if="activeTab === 'image'"
@price-update="handleImagePriceUpdate"
@prompt-change="handleImagePromptChange"
@task-created="handleTaskCreated"
/>
<TextVideo
ref="textVideoRef"
v-else-if="activeTab === 'text'"
@price-update="handleTextPriceUpdate"
@prompt-change="handleTextPromptChange"
@task-created="handleTaskCreated"
/>
</div>
<div class="create-button-container">
<div
class="create-button"
:class="{ disabled: isCreateButtonDisabled }"
@click="handleCreate"
>
创作
<span v-if="currentPrice > 0" class="price-info">
{{ currentPrice }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import VideoTab from './VideoTab.vue';
import Referencevideo from './ReferenceVideo.vue';
import TextVideo from './TextVideo.vue';
import ImgVideo from './ImgVideo.vue';
import { debounce } from '@/utlis/debounce';
import { useLoginStore } from '@/store/LoginStore';
// ==================== ====================
/** 文生视频组件暴露的方法 */
interface TextVideoExposed {
generate: () => Promise<boolean>;
resetForm: () => void;
}
/** 图生视频组件暴露的方法 */
interface ImgVideoExposed {
generate: () => Promise<boolean>;
resetForm: () => void;
}
/** 参考生视频组件暴露的方法 */
interface ReferenceVideoExposed {
generate?: () => Promise<boolean>;
resetForm: () => void;
}
// ==================== 使 ====================
const referenceVideoRef = ref<ReferenceVideoExposed | null>(null);
const imgVideoRef = ref<ImgVideoExposed | null>(null);
const textVideoRef = ref<TextVideoExposed | null>(null);
// ==================== ====================
const route = useRoute();
const router = useRouter();
const loginStore = useLoginStore();
const activeTab = ref<'reference' | 'image' | 'text'>('reference');
const referencePrice = ref(0);
const textPrice = ref(0);
const imagePrice = ref(0);
const isPromptEmpty = ref(true);
const isTextPromptEmpty = ref(true);
const isImagePromptEmpty = ref(true);
const isCreating = ref(false);
//
const handleReferencePriceUpdate = (price: number) => { referencePrice.value = price; };
const handleTextPriceUpdate = (price: number) => { textPrice.value = price; };
const handleImagePriceUpdate = (price: number) => { imagePrice.value = price; };
//
const handlePromptChange = (isEmpty: boolean) => { isPromptEmpty.value = isEmpty; };
const handleTextPromptChange = (isEmpty: boolean) => { isTextPromptEmpty.value = isEmpty; };
const handleImagePromptChange = (isEmpty: boolean) => { isImagePromptEmpty.value = isEmpty; };
//
const currentPrice = computed(() => {
switch (activeTab.value) {
case 'reference': return referencePrice.value;
case 'text': return textPrice.value;
case 'image': return imagePrice.value;
default: return 0;
}
});
const isCreateButtonDisabled = computed(() => {
if (!loginStore.isLoggedIn || isCreating.value) {
return true;
}
switch (activeTab.value) {
case 'reference': return isPromptEmpty.value;
case 'text': return isTextPromptEmpty.value;
case 'image': return isImagePromptEmpty.value;
default: return true;
}
});
//
const handleTabChange = debounce((tab: 'reference' | 'image' | 'text') => {
activeTab.value = tab;
router.push(`/videoGenerate/${tab}`);
}, 300);
//
const handleCreate = debounce(async () => {
if (!loginStore.isLoggedIn) {
ElMessage.warning('请先登录后再创作');
return;
}
if (isCreating.value) {
return;
}
if (isCreateButtonDisabled.value) {
ElMessage.warning('请先填写必要信息');
return;
}
isCreating.value = true;
let result = false;
try {
switch (activeTab.value) {
case 'text':
if (textVideoRef.value?.generate) {
result = await textVideoRef.value.generate();
}
break;
case 'image':
if (imgVideoRef.value?.generate) {
result = await imgVideoRef.value.generate();
}
break;
case 'reference':
if (referenceVideoRef.value?.generate) {
result = await referenceVideoRef.value.generate();
} else {
ElMessage.info('参考生视频功能暂未开放');
}
break;
default:
ElMessage.info('该功能暂未开放');
break;
}
return result;
} finally {
isCreating.value = false;
}
}, 500);
const emit = defineEmits<{ (e: 'task-created', taskid: string): void }>();
const handleTaskCreated = (taskid: string) => {
emit('task-created', taskid);
switch (activeTab.value) {
case 'text':
textVideoRef.value?.resetForm?.();
break;
case 'image':
imgVideoRef.value?.resetForm?.();
break;
case 'reference':
referenceVideoRef.value?.resetForm?.();
break;
}
};
//
const updateActiveTabFromRoute = () => {
const path = route.path;
if (path.includes('/videoGenerate/reference')) activeTab.value = 'reference';
else if (path.includes('/videoGenerate/image')) activeTab.value = 'image';
else if (path.includes('/videoGenerate/text')) activeTab.value = 'text';
else activeTab.value = 'reference';
};
watch(() => route.path, () => updateActiveTabFromRoute());
onMounted(() => updateActiveTabFromRoute());
onUnmounted(() => {
handleTabChange.cancel();
handleCreate.cancel();
});
</script>
<style scoped lang="less">
.left {
width: 100%;
height: calc(100vh - 80px);
position: relative;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.create-button-container {
z-index: 999;
position: sticky;
bottom: 0;
left: 0;
width: 100%;
padding: 16px;
pointer-events: none;
// background: linear-gradient(180deg, rgba(2, 11, 19, 0) 0%, #0f0f19 100%);
// position: relative;
}
.create-button {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
width: 100%;
height: 48px;
border-radius: 12px !important;
background-image: linear-gradient(270deg, #94d8ff 11.11%, #e0ddff);
border: none !important;
color: #000 !important;
font-size: 18px !important;
font-weight: 600 !important;
cursor: pointer;
transition: all 0.2s;
pointer-events: auto;
}
.create-button .price-info {
font-size: 16px;
font-weight: normal;
}
.create-button:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.create-button:active {
transform: translateY(0);
}
.create-button.disabled {
background-image: linear-gradient(270deg, #cccccc 11.11%, #aaaaaa);
cursor: not-allowed;
opacity: 0.6;
}
.create-button.disabled:hover {
transform: none;
opacity: 0.6;
}
&::-webkit-scrollbar {
// /
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
//
background: #0F0F19;
//
border-radius: 3px;
}
// ========== ==========
&::-webkit-scrollbar-thumb {
//
background: #0F0F19;
//
border-radius: 3px;
//
&:hover {
background: #0F0F19;
}
}
</style>

View File

@ -0,0 +1,94 @@
<template>
<div>
<div class="tabs-container ">
<div
class="tab-item"
:class="{ active: activeTab === 'reference' }"
@click="switchTab('reference')"
>
参考生视频
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'image' }"
@click="switchTab('image')"
>
图生视频
</div>
<div
class="tab-item"
:class="{ active: activeTab === 'text' }"
@click="switchTab('text')"
>
文生视频
</div>
</div>
</div>
</template>
<script setup lang='ts'>
// Tab
type TabType = 'reference' | 'image' | 'text';
// activeTab使
defineProps<{
activeTab: TabType;
}>();
// tab 使
const emit = defineEmits<{
(e: 'tabChange', tab: TabType): void;
}>();
// tab
const switchTab = (tab: TabType) => {
emit('tabChange', tab);
};
</script>
<style scoped lang='less'>
.tabs-container {
width: 100%;
height: 44x;
display: flex;
background-color: #1a1c20;
border-radius: 12px;
overflow: hidden;
margin-bottom: 16px;
}
.tab-item {
width: 50%;
flex: 1;
padding: 12px 0;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
text-align: center;
position: relative;
}
.tab-item:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.08);
}
.tab-item.active {
color: #fff;
background-color: rgba(255, 255, 255, 0.15);
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 20%;
width: 60%;
height: 2px;
border-radius: 2px;
}
</style>

31
src/main.ts Normal file
View File

@ -0,0 +1,31 @@
import { createApp } from 'vue'
import App from './App.vue'
// 路由
import router from './router'
// element-ui
import ElementPlus from 'element-plus'
// element-ui css
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
// pinia持久化
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
// pinia
import { createPinia } from 'pinia'
// icon
import './assets/css/iconfont/iconfont.css'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
const app = createApp(App)
app.use(pinia)
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
router.isReady().then(() => {
app.mount('#app')
})

137
src/router/index.ts Normal file
View File

@ -0,0 +1,137 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import GetCookie from '@/views/Cookie/getCookie.vue'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
redirect: '/home'
},
{
path: '/home',
name: 'home',
component: () => import('@/views/Home.vue')
},
// 登录
{
path: '/login',
name: 'login',
component: () => import('@/views/login/Login.vue')
},
{
path: '/getCookie',
name: 'getCookie',
component: GetCookie
},
// 首页
{
path: '/Homeindex',
name: 'homeindex',
component: () => import('@/views/Homeindex.vue'),
children: [
{
path: '/index',
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',
name: 'ImgGenerate',
component: () => import('@/views/ImgGenerate/ImgGenerate.vue'),
children: [
{
path: 'reference',
name: 'ImgGenerateReference',
component: () => import('@/views/ImgGenerate/ImgGenerate.vue')
},
{
path: 'text',
name: 'ImgGenerateText',
component: () => import('@/views/ImgGenerate/ImgGenerate.vue')
},
{
path: 'image',
name: 'ImgGenerateImage',
component: () => import('@/views/ImgGenerate/ImgGenerate.vue')
}
]
},
{
path: '/TextGenerate',
name: 'TextGenerate',
component: () => import('@/views/TextGenerate/TextGenerate.vue')
},
// videoGenerate
{
path: '/videoGenerate',
name: 'videoGenerate',
component: () => import('@/views/videoGenerate/videoGenerate.vue'),
children: [
{
path: 'reference',
name: 'VideoGenerateReference',
component: () => import('@/views/videoGenerate/videoGenerate.vue')
},
{
path: 'image',
name: 'VideoGenerateImage',
component: () => import('@/views/videoGenerate/videoGenerate.vue')
},
{
path: 'text',
name: 'VideoGenerateText',
component: () => import('@/views/videoGenerate/videoGenerate.vue')
}
]
},
// 素材广场
{
path: '/material-square',
name: 'material-square',
component: () => import('@/views/videoGenerate/videoGenerate.vue')
},
// apikey
{
path: '/apikey',
name: 'apikey',
component: () => import('@/views/APiKey/apikey.vue')
}
]
},
{
path: '/material-square/detail',
name: 'material-square-detail',
component: () => import('@/views/materialSquare/Detail.vue')
},
]
})
export default router

211
src/store/LoginStore.ts Normal file
View File

@ -0,0 +1,211 @@
import { ref } from 'vue';
import { ElMessage } from 'element-plus';
import { defineStore } from 'pinia';
import router from '@/router';
import type { LoginResult, LoginParams, LoginUser } from '@/types/login';
import { LoginApi } from '@/apis/login';
import { openAccount } from '@/apis/home';
import { clearApiBaseURL } from '@/utlis/request';
export const useLoginStore = defineStore(
'loginStore',
() => {
// 登录状态
const isLoggedIn = ref(false);
// 当前用户信息
const userInfo = ref<{ user: LoginUser } | null>(null);
// 多账号列表
const accountList = ref<LoginUser[]>([]);
// 是否显示账号选择弹窗
const showAccountSelect = ref(false);
// 登录时间戳(用于精确计算剩余时间,可选)
const loginTimestamp = ref<number | null>(null);
// 过期前的当前页面路径
const redirectPath = ref<string | null>(null);
// 定时器 ID用于管理自动登出
let autoLogoutTimer: ReturnType<typeof setTimeout> | null = null;
// 开户
const openAccounts = async () => {
const res = await openAccount();
console.log('开户',res);
};
// 清除自动登出计时器
const clearAutoLogoutTimer = () => {
if (autoLogoutTimer) {
clearTimeout(autoLogoutTimer);
autoLogoutTimer = null;
}
};
// 启动自动登出计时器(基于剩余时间)
const startAutoLogoutTimer = () => {
clearAutoLogoutTimer();
if (!loginTimestamp.value) {
return;
}
const now = Date.now();
const elapsed = now - loginTimestamp.value;
const remaining = Math.max(0, 20 * 60 * 1000 - elapsed);
autoLogoutTimer = setTimeout(() => {
logoutS();
}, remaining);
};
// 核心登录方法(统一处理)
const doLogin = async (params: LoginParams) => {
const res = await LoginApi<LoginResult, LoginParams>(params);
if (res.status === 'ok') {
openAccounts();
// 登录成功
isLoggedIn.value = true;
userInfo.value = res.data as { user: LoginUser };
// 记录登录时间戳并启动计时器
loginTimestamp.value = Date.now();
startAutoLogoutTimer();
ElMessage.success('登录成功');
// 跳转到之前保存的路径或默认路径
const path = redirectPath.value || '/';
redirectPath.value = null;
router.push(path);
return res;
}
if (res.status === 'choose') {
// 需要选择账号
let users: LoginUser[] = [];
const data = res.data;
if (Array.isArray(data)) {
users = data;
} else if (data && 'users' in data) {
users = (data as { users: LoginUser[] }).users;
}
accountList.value = users;
showAccountSelect.value = true;
return res;
}
// 其他失败情况
ElMessage.error(res.message || '登录失败');
return res;
};
// 登录(首次)
const Login = (params: LoginParams) => doLogin(params);
// 选择账号后登录(携带 selected_id
const loginWithSelectedAccount = async (
selectedId: string,
params: Omit<LoginParams, 'selected_id'>
) => {
showAccountSelect.value = false;
return doLogin({ ...params, selected_id: selectedId });
};
// 第三方同步用户成功后,直接写入登录态
const syncLogin = (data: { user: LoginUser } | LoginUser) => {
const nextUserInfo = 'user' in data ? data : { user: data };
openAccounts();
isLoggedIn.value = true;
userInfo.value = nextUserInfo;
accountList.value = [];
showAccountSelect.value = false;
loginTimestamp.value = Date.now();
startAutoLogoutTimer();
const path = redirectPath.value || '/';
redirectPath.value = null;
router.push(path);
};
// 外部接口只同步 cookie 时,先恢复前端登录态
const syncCookieLogin = (user?: Partial<LoginUser>) => {
openAccounts();
isLoggedIn.value = true;
userInfo.value = {
user: {
id: user?.id || '',
username: user?.username || user?.name || '已登录用户',
password: '',
email: user?.email || '',
orgid: user?.orgid || '',
nick_name: user?.nick_name || null,
address: user?.address || null,
mobile: user?.mobile || user?.cellphone || '',
user_status: user?.user_status || '',
name: user?.name,
cellphone: user?.cellphone,
},
};
accountList.value = [];
showAccountSelect.value = false;
loginTimestamp.value = Date.now();
startAutoLogoutTimer();
const path = redirectPath.value || '/';
redirectPath.value = null;
router.push(path);
};
const logoutS = () => {
// 保存当前路径
redirectPath.value = router.currentRoute.value.path;
clearAutoLogoutTimer();
isLoggedIn.value = false;
userInfo.value = null;
accountList.value = [];
showAccountSelect.value = false;
loginTimestamp.value = null;
clearApiBaseURL();
router.push('/login');
ElMessage.error('身份验证过期请重新登录');
};
// 登出
const logout = () => {
clearAutoLogoutTimer();
isLoggedIn.value = false;
userInfo.value = null;
accountList.value = [];
showAccountSelect.value = false;
loginTimestamp.value = null;
clearApiBaseURL();
router.push('/login');
};
// 初始化自动登出(页面刷新后调用,恢复计时器)
const initAutoLogout = () => {
if (isLoggedIn.value) {
// 如果没有时间戳(可能因为持久化缺少),用当前时间作为基准
if (!loginTimestamp.value) {
loginTimestamp.value = Date.now();
}
startAutoLogoutTimer();
}
};
return {
isLoggedIn,
userInfo,
accountList,
showAccountSelect,
redirectPath,
Login,
loginWithSelectedAccount,
syncLogin,
syncCookieLogin,
logout,
logoutS,
initAutoLogout,
};
},
{
persist: true, // 持久化登录状态、userInfo、loginTimestamp 等
}
);

79
src/style.css Normal file
View File

@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

32
src/types/apiKey.ts Normal file
View File

@ -0,0 +1,32 @@
export interface ListApikeys {
id: string;
name: null;
apikeyid: string;
};
export interface ListResult {
apikeys: Array< ListApikeys >;
};
export interface AddParams {
appname: string;
description: string;
}
export interface AddResult{
status: string;
}
interface DetailData {
id: string;
name: null;
description: null;
secretkey: string;
allowedips: null;
orgid: string;
apikey: string;
};
export interface DetailResult {
status: string;
data: DetailData;
};

296
src/types/home.ts Normal file
View File

@ -0,0 +1,296 @@
// type/home.ts
// 创作者信息
export interface Creator {
id: string;
nick_name: string;
avatar: string;
user_medal?: string;
is_following: boolean;
}
// 尺寸信息
export interface Size {
width: number;
height: number;
ratio: string;
}
// 视频创作信息
export interface Creation {
uri: string;
cover_uri: string;
size: Size;
duration: number;
uris: Record<string, string>;
cover_uris: Record<string, string>;
origin_task_type: string;
}
// 互动信息
export interface ReactInfo {
voted: boolean;
vote_count: number;
play_count: string;
collected: boolean;
collect_count: number;
}
// 短剧类型
export interface ShortFilm {
id: string;
creation: Creation;
title: string;
detail: string;
creator: Creator;
created_at: string;
react_info: ReactInfo;
type: string;
episode_num: number;
phase: string;
latest_episode_num: number;
source: string;
award: Record<string, unknown> | null;
}
// 教程类型
export interface Tutorial {
id: string;
creation: Creation;
title: string;
detail: string;
creator: Creator;
created_at: string;
react_info: ReactInfo;
type: string;
episode_num: number;
phase: string;
latest_episode_num: number;
source: string;
award: Record<string, unknown> | null;
}
// 媒体资源类型(图生视频、文生视频等)
export interface MediaAsset {
id: string;
tag: string;
creation: Creation;
brief: string;
detail: string;
creator: Creator;
created_at: string;
react_info: ReactInfo;
creation_id: string;
type: string;
recreate_count: number;
award: Record<string, unknown> | null;
}
// 联合类型:列表中的每一项可能是短剧、教程或媒体资源
export interface FeedItem {
type: 'short_film' | 'tutorial' | 'media_asset';
short_film: ShortFilm | null;
tutorial: Tutorial | null;
media_asset: MediaAsset | null;
image_asset: Record<string, unknown> | null; // 根据实际情况细化
mini_video: Record<string, unknown> | null; // 根据实际情况细化
}
// 接口返回的完整数据结构
export interface FeedResponse {
list: FeedItem[];
next_page_token: string;
activities: Record<string, unknown>[] | null;
}
// ==================== Share Elements 数据格式 ====================
// Share Elements 创建者信息
export interface ShareElementCreator {
id: string;
nick_name: string;
avatar: string;
is_following: boolean;
}
// Share Elements 视频信息
export interface ShareElementVideo {
duration: number;
fps: number;
resolution: {
width: number;
height: number;
resolution: string;
};
ratio: string;
}
// Share Elements 创作信息
export interface ShareElementCreation {
id: string;
task_id: string;
type: string;
grade: string;
uri: string;
download_uri: string;
cover_uri: string;
resolution: {
width: number;
height: number;
resolution: string;
};
vote: string;
is_favor: boolean;
src_video_duration: number;
creator_id: string;
video: ShareElementVideo;
is_deleted: boolean;
err_code: string;
created_at: string;
origin_task_type: string;
style: string;
duration: number;
has_audio: boolean;
is_asset: boolean;
is_work: boolean;
asset_selected: boolean;
nomark_uri: string;
source_creation: Record<string, unknown> | null;
origin_creation: Record<string, unknown> | null;
err_info: {
err_code: string;
err_msg: string;
};
file_name: string;
is_posted: boolean;
has_copyright: boolean;
}
// Share Elements 互动信息
export interface ShareElementReactInfo {
voted: boolean;
vote_count: number;
play_count: string;
collected: boolean;
collect_count: number;
}
// Share Elements 分享信息
export interface ShareElementShare {
id: string;
element_id: string;
status: string;
category: string[];
series: string;
copy_count: number;
use_count: number;
creator: ShareElementCreator;
introduce: string;
creations: ShareElementCreation[];
allow_copy: boolean;
created_at: string;
react_info: ShareElementReactInfo;
cid_to_task: Record<string, unknown>;
operate_tag: string;
category_display: string[];
creator_rank: number;
element_labels: string[];
}
// Share Elements 元素信息
export interface ShareElement {
id: string;
name: string;
type: string;
components: Record<string, unknown>[];
version: string;
labels: Record<string, unknown>[];
creator_id: string;
recaption: {
style: string;
description: string;
};
created_at: string;
updated_at: string;
share_meta: {
id: string;
status: string;
creator: ShareElementCreator;
};
voice: {
id: string;
url?: string;
name?: string;
};
pay_info: {
pay_required: boolean;
};
modality: string;
creator: ShareElementCreator;
}
// Share Elements 列表项
export interface ShareElementItem {
element: ShareElement;
share: ShareElementShare;
}
// Share Elements 响应数据
export interface ShareElementsResponse {
share_elements: ShareElementItem[];
next_page_token: string;
categories: {
code: string;
display_name: string;
display_tag: string;
}[];
has_liked: boolean;
topics: {
code: string;
display_name: string;
display_tag: string;
}[];
has_collected: boolean;
tags: {
code: string;
type: string;
display_name: string;
display_tag: string;
extra: {
cover_uri: string;
flag: string;
};
}[];
}
// 转换后 Stencil 组件需要的卡片格式
export interface StencilItem {
id: string;
type: 'video';
src: string;
poster: string;
aspectRatio: string;
avatar: string;
userName: string;
likes: number;
title: string;
description: string;
createdAt: string;
contentType: string;
displayTag?: string;
collectCount?: number;
recreateCount?: number;
creatorId?: string;
averageColor?: string;
}
// 余额
interface BalanceData {
account: string;
balance: number;
};
export interface BalanceResult {
status: string;
data: Array< BalanceData >;
};

54
src/types/login.ts Normal file
View File

@ -0,0 +1,54 @@
// 验证码返回数据
export interface CodeData {
message: string;
key: string;
}
export interface CodeResult {
status: string;
data: CodeData;
}
// 用户信息
export interface LoginUser {
id: string;
username: string;
password: string;
email: string;
orgid: string;
nick_name: string | null;
address: string | null;
mobile: string;
user_status: string;
// 可选字段,用于显示
name?: string;
cellphone?: string;
}
// 登录成功时的数据
export interface LoginData {
user: LoginUser;
}
// 登录返回结果
// 注意:多账号选择时,后端可能直接返回 { status: 'choose', data: LoginUser[] }
// 或返回 { status: 'choose', data: { users: LoginUser[] } }
// 这里我们使用联合类型,并在 store 中根据实际情况处理
export interface LoginResult {
status: string;
data: LoginData | LoginUser[] | { users: LoginUser[] }; // 兼容多种结构
message?: string;
}
// 登录参数
export interface LoginParams {
cellphone: string;
key: string;
sms_code: string;
selected_id?: string; // 选择账号时传入
}
// 验证码参数
export interface CaptchaParams {
cellphone: string;
}

309
src/types/model.ts Normal file
View File

@ -0,0 +1,309 @@
interface Data {
value: string;
text: string;
};
interface Input_field {
name: string;
required?: boolean;
uitype: string;
label?: string;
defaultvalue?: number;
data?: Array< Data >;
lable?: string;
};
interface ModelData {
id: string;
name: string;
model: string;
description: string;
llmcatelogid: string;
iconid: string;
upappid: string;
apiname: string;
providerid: string;
ownerid: string;
enabled_date: string;
expired_date: string;
query_apiname: string;
query_period: number;
ppid: string;
input_fields: Array< Input_field >;
};
export interface ModelResult{
status: string;
data: Array< ModelData >;
};
export interface Model {
type: string;
}
/**
*
*/
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; // 下拉选项显示字段
}
/**
*
*/
export interface Model {
id: string;
name: string;
model: string;
description: string;
llmcatelogid: string;
iconid: string;
upappid: string;
apiname: string;
providerid: string;
ownerid: string;
enabled_date: string;
expired_date: string;
query_apiname: string;
query_period: number;
ppid: string;
input_fields: InputField[];
}
/**
*
*/
export interface AllModelResponse {
status: string
data: Model[];
}
/**
*
*/
export interface PriceQueryParams {
product_type: 'llm';
product_id: string;
config_data: {
action: string;
[key: string]: unknown;
};
}
/**
*
*/
export interface PriceQueryResponse {
status: string
data: {
amount: number; // 价格
original_amount?: number | null;
};
}
/**
*
*/
export interface TextVideoParams {
llmid: string;
prompt: string;
duration?: number;
ratio?: string;
resolution?: string;
audio?: boolean;
[key: string]: unknown;
}
/**
*
*/
export interface TextVideoResponse {
taskid: string | undefined;
status: string;
message?: string;
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;
}
/**
* -
*/
export interface TaskResult {
status: string;
data: ItemData;
}
export interface ItemData {
tasks: Array<Tasks>;
}
export interface Tasks {
id: string;
llmid: string;
use_date: string;
use_time: string;
userid: string;
usages: null;
ioinfo: Ioinfo;
transno: string;
responsed_seconds: number;
finish_seconds: number;
status: string;
taskid: string;
amount: number;
cost: number;
userorgid: string;
ownerid: null;
accounting_status: string;
}
export interface Ioinfo {
input: Input;
output: Array<Output>;
}
export interface Input {
llmid: string;
prompt: string;
catelogid?: string;
duration: number;
ratio: string;
resolution: string;
size?: string;
audio: boolean;
transno: string;
model: string;
callbackurl: string;
}
export interface Usage {
action: string;
credits: number;
type: string;
model: string;
resolution: string;
off_peak: boolean;
duration: number;
}
export interface Output {
taskid?: string;
status: string;
credits?: number;
usage?: Usage;
type?: string;
image?: string;
image_url?: string;
url?: string;
images?: string[];
video?: string;
}
/**
* - 使
*/
export interface TaskListResponse {
status: string;
data: TaskItem[];
}
/**
* -
*/
export interface TaskItem {
taskid: string;
status: string;
llmid?: string;
prompt?: string;
created_at?: string;
updated_at?: string;
video_url?: string;
image_url?: string;
cover_url?: string;
duration?: number;
resolution?: string;
error_msg?: string;
model?: string;
use_time?: string;
action?: string;
}
export interface ModelParams {
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;
}

53
src/utlis/cookie.ts Normal file
View File

@ -0,0 +1,53 @@
// utils/cookie.ts
/**
* Cookie
*/
export function getCookie(name: string): string | null {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
const raw = parts.pop()?.split(';').shift();
return raw ? decodeURIComponent(raw) : null;
}
return null;
}
/**
* Cookie
* @param name
* @param value
* @param days Cookie
*/
export function setCookie(name: string, value: string, days?: number): void {
let expires = '';
if (days) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
expires = `; expires=${date.toUTCString()}`;
}
// 注意:为了安全,实际项目中可根据需要加上 Secure / SameSite 等属性
document.cookie = `${name}=${encodeURIComponent(value)}${expires}; path=/`;
}
/**
* Cookie
*/
export function deleteCookie(name: string): void {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
/**
* Cookie HttpOnly
*/
export function logAllCookies(): void {
if (!document.cookie) return;
const cookies = document.cookie.split('; ').reduce((acc, item) => {
const eqIndex = item.indexOf('=');
const key = item.slice(0, eqIndex);
const value = item.slice(eqIndex + 1);
acc[key] = decodeURIComponent(value);
return acc;
}, {} as Record<string, string>);
console.table(cookies);
}

42
src/utlis/debounce.ts Normal file
View File

@ -0,0 +1,42 @@
export type DebouncedFunction<T extends (...args: any[]) => any> = ((
...args: Parameters<T>
) => void) & {
cancel: () => void;
};
export const debounce = <T extends (...args: any[]) => any>(
fn: T,
wait = 300,
immediate = true
): DebouncedFunction<T> => {
let timer: ReturnType<typeof setTimeout> | null = null;
const debounced = ((...args: Parameters<T>) => {
const shouldCallNow = immediate && timer === null;
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
timer = null;
if (!immediate) {
void fn(...args);
}
}, wait);
if (shouldCallNow) {
void fn(...args);
}
}) as DebouncedFunction<T>;
debounced.cancel = () => {
if (!timer) return;
clearTimeout(timer);
timer = null;
};
return debounced;
};

174
src/utlis/request.ts Normal file
View File

@ -0,0 +1,174 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
// 获取环境变量,添加默认值
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) => {
localStorage.setItem(apiBaseURLStorageKey, url)
}
export const clearApiBaseURL = () => {
localStorage.removeItem(apiBaseURLStorageKey)
}
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
try {
const apiBaseURL = getApiBaseURL()
const apiOrigin = new URL(apiBaseURL).origin
const requestOrigin = new URL(url, baseURL || apiBaseURL).origin
return requestOrigin === apiOrigin
} catch {
return true
}
}
// 请求拦截器
axios.interceptors.request.use(
function (config) {
const apiBaseURL = getApiBaseURL()
config.baseURL = apiBaseURL
config.withCredentials = shouldIncludeCredentials(config.url, config.baseURL)
return config
},
function (error) {
// 对请求错误做些什么
return Promise.reject(error)
},
)
// 响应拦截器
axios.interceptors.response.use(
function (response) {
// 放行的状态列表
const allowedStatuses = [
'ok',
'CREATED',
'PENDING',
'RUNNING',
'SUCCEEDED',
'FAILED',
'created',
'processing',
'queueing',
'running',
'succeeded',
'completed',
'failed',
'PENDING'
];
// 检查响应数据中的状态
const responseData = response.data;
const status = responseData?.status || responseData?.data?.resposne?.status;
// 如果状态在放行列表中,直接返回
if (status && allowedStatuses.includes(status)) {
return response;
}
// 如果状态不在放行列表中,也返回(兼容其他状态)
return response;
},
function (error) {
// 只有在确实有错误消息时才显示 ElMessage
if (error?.response?.data?.message) {
ElMessage({
message: error.response.data.message,
type: 'error',
duration: 2 * 1000
})
}
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({
method,
url,
data,
params,
})
.then((response) => {
resolve(response.data as T)
})
.catch(function (error) {
reject(error)
})
})
}
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')
}
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'是后端接收文件的字段名
// 如果需要附加其他数据
Object.keys(additionalData).forEach(key => {
formData.append(key, additionalData[key]);
});
try {
const response = await axios.post(uploadUrl, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data;
} catch (error) {
console.error('文件上传失败:', error);
throw error;
}
}

114
src/utlis/transform.ts Normal file
View File

@ -0,0 +1,114 @@
// utils/transform.ts
import type { FeedItem, StencilItem, ShareElementItem, ShortFilm, Tutorial, MediaAsset } from '../types/home';
type FeedItemData = ShortFilm | Tutorial | MediaAsset;
export function transformFeedItem(item: FeedItem): StencilItem | null {
// 根据 type 确定实际数据来源
let data: FeedItemData | null = null;
if (item.type === 'short_film' && item.short_film) {
data = item.short_film;
} else if (item.type === 'tutorial' && item.tutorial) {
data = item.tutorial;
} else if (item.type === 'media_asset' && item.media_asset) {
data = item.media_asset;
} else {
return null;
}
if (!data) return null;
const creation = data.creation;
if (!creation) return null;
// 构建宽高比字符串CSS 支持 "宽/高" 格式)
const { width, height } = creation.size;
const aspectRatio = `${width}/${height}`;
return {
id: data.id,
type: 'video', // 当前所有项都是视频,可根据实际情况调整
src: creation.uri,
poster: creation.cover_uri,
aspectRatio,
avatar: data.creator?.avatar ?? '',
userName: data.creator?.nick_name ?? '',
likes: data.react_info?.vote_count ?? 0,
title: 'title' in data ? data.title : data.tag || data.brief || '未命名作品',
description: data.detail || ('brief' in data ? data.brief : '') || '',
createdAt: data.created_at || '',
contentType: item.type,
displayTag: 'tag' in data ? data.tag : data.type,
collectCount: data.react_info?.collect_count ?? 0,
recreateCount: 'recreate_count' in data ? data.recreate_count : undefined,
creatorId: data.creator?.id ?? '',
};
}
/**
* Share Elements StencilItem
*/
export function transformShareElementItem(item: ShareElementItem): StencilItem | null {
const element = item.element;
const share = item.share;
if (!element || !share) return null;
// 尝试从 creations 中获取第一个视频/图片
const firstCreation = share.creations?.[0];
let src = '';
let poster = '';
let aspectRatio = '1/1';
if (firstCreation) {
// 从 creation 获取
src = firstCreation.uri;
poster = firstCreation.cover_uri;
// 计算宽高比
const width = firstCreation.resolution?.width || 1920;
const height = firstCreation.resolution?.height || 1080;
aspectRatio = `${width}/${height}`;
}
// else if (element.components?.[0]) {
// // 从 components 获取(降级方案)
// const component = element.components[0];
// src = component.content;
// poster = component.cover_uri || component.src_img;
// // 尝试从 selected_region 获取宽高
// if (component.selected_region) {
// const { x: x1, y: y1 } = component.selected_region.top_left;
// const { x: x2, y: y2 } = component.selected_region.bottom_right;
// const width = x2 - x1;
// const height = y2 - y1;
// if (width > 0 && height > 0) {
// aspectRatio = `${width}/${height}`;
// }
// }
// }
// 如果仍然没有数据,返回 null
if (!src) return null;
// 如果没有 poster使用 src 作为 poster
if (!poster) poster = src;
return {
id: element.id,
type: 'video',
src,
poster,
aspectRatio,
avatar: element.creator?.avatar ?? '',
userName: element.creator?.nick_name ?? '',
likes: share.react_info?.vote_count ?? 0,
title: element.name || '未命名作品',
description: share.introduce || element.recaption?.description || '',
createdAt: share.created_at || element.created_at || '',
contentType: 'share_elements',
displayTag: share.category_display?.[0] || element.type || '素材',
collectCount: share.react_info?.collect_count ?? 0,
creatorId: element.creator?.id ?? '',
};
}

364
src/views/APiKey/apikey.vue Normal file
View File

@ -0,0 +1,364 @@
<template>
<div class="apikey-page">
<LoginPrompt
v-if="!isLoggedIn"
class="login-fullscreen"
title="请先登录"
description="登录后可创建、复制、查看和删除自己的访问令牌。"
full-height
/>
<el-card v-else class="apikey-card">
<div class="page-header">
<div class="header-main">
<div class="title-row">
<i class="iconfont icon-apikey"></i>
<span>API Key 管理</span>
</div>
<p class="header-desc">创建和管理访问令牌用于在外部系统中安全调用你的能力接口</p>
</div>
<div class="header-actions">
<el-button class="refresh-btn" :loading="loading" @click="loadKeys">刷新</el-button>
<el-button type="primary" @click="openCreateDialog">创建新令牌</el-button>
</div>
</div>
<div class="guide-card">
<div class="guide-section">
<div class="guide-title">API 密钥安全指南</div>
<ul class="guide-list">
<li>请不要与他人共享您的 API Keys暴露 API Key 可能会破坏您的帐户安全和访问的服务</li>
<li>避免将 API Key 暴露在浏览器或其他客户端代码中</li>
<li>为了保护您的帐户安全一旦 API Keys 被发现泄露平台发现后可能会将其禁用</li>
</ul>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">令牌总数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<div class="stat-card">
<div class="stat-label">启用中</div>
<div class="stat-value success">{{ stats.active }}</div>
</div>
</div>
<div class="table-wrapper">
<el-table :data="keys" class="apikey-table" stripe v-loading="loading">
<el-table-column prop="name" label="令牌名称" min-width="150" />
<!-- <el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip /> -->
<el-table-column label="id" min-width="220">
<template #default="{ row }">
<code class="key-mask">{{ maskKey(row.key || '') }}</code>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openViewDialog(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<!-- 创建 API Key 对话框 -->
<el-dialog v-model="createDialogVisible" title="创建 API Key" width="560px" destroy-on-close
class="create-key-dialog">
<el-form ref="formRef" :model="createForm" :rules="formRules" label-position="top">
<el-form-item label="令牌名称" prop="appname">
<el-input v-model="createForm.appname" placeholder="例如:前端开发环境 / 自动化脚本" maxlength="30" show-word-limit />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="createForm.description" type="textarea" :rows="3" maxlength="100" show-word-limit
placeholder="填写使用场景,方便后续区分" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="createKey">确认创建</el-button>
</div>
</template>
</el-dialog>
<!-- 查看完整 Key 对话框 -->
<el-dialog v-model="viewDialogVisible" title="API Key 详情" width="560px" class="view-key-dialog" @close="handleViewDialogClose">
<div v-loading="detailLoading" class="dialog-content-wrapper">
<div class="view-key-content">
<div class="view-key-name">
<span class="label">令牌名称</span>
<span class="value">{{ viewingKey?.name || '未命名' }}</span>
</div>
<div class="view-key-value">
<span class="label">完整 Key</span>
<div class="key-box">
<code>{{ viewingKey?.key }}</code>
<el-button link type="primary" size="small" @click="copyViewingKey">
复制
</el-button>
</div>
</div>
<div class="view-key-tip">
<i class="el-icon-info"></i>
请妥善保管该密钥 切勿泄露
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang='ts'>
import { computed, reactive, ref, watch, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import { useLoginStore } from '@/store/LoginStore';
import { applyApiKey, getApiKeyList, getApiKeyDetail } from '@/apis/apiKey';
import type { AddParams, AddResult, DetailResult, ListApikeys } from '@/types/apiKey';
import LoginPrompt from '@/components/LoginPrompt/LoginPrompt.vue';
const loginStore = useLoginStore();
const isLoggedIn = computed(() => loginStore.isLoggedIn);
//
const keys = ref<ListApikeys[]>([]);
const loading = ref(false);
//
const stats = computed(() => {
const total = keys.value.length;
const active = keys.value.length; //
return { total, active };
});
//
const createDialogVisible = ref(false);
const createLoading = ref(false);
const formRef = ref<FormInstance>();
const createForm = reactive<AddParams>({
appname: '',
description: '',
});
const formRules: FormRules = {
appname: [
{ required: true, message: '请输入令牌名称', trigger: 'blur' },
{ max: 30, message: '名称不能超过30个字符', trigger: 'blur' }
]
};
//
const viewDialogVisible = ref(false);
const viewingKey = ref<any>(null);
const detailLoading = ref(false);
// API Key
const maskKey = (key: string) => {
if (!key || key.length <= 8) return '****';
const start = key.slice(0, 6);
const end = key.slice(-4);
return `${start}****${end}`;
};
const copyToClipboard = async (text: string, successMsg = '复制成功') => {
try {
await navigator.clipboard.writeText(text);
ElMessage.success(successMsg);
} catch (err) {
ElMessage.error('复制失败,请手动复制');
}
};
//
const loadKeys = async () => {
if (!isLoggedIn.value) return;
loading.value = true;
try {
const res = await getApiKeyList<any>();
// { status: 'ok', data: { apikeys: [...] } }
const apikeys = res.data?.apikeys || res.apikeys || [];
keys.value = apikeys.map((item: any) => ({
id: item.id,
name: item.name || '未命名',
note: item.description || '暂无备注', // description
key: item.apikeyid || '', // key
status: 'active',
createdAt: new Date().toISOString()
}));
} catch (error: any) {
ElMessage.error(error?.message || '获取 API Key 列表失败');
keys.value = [];
} finally {
loading.value = false;
}
};
//
const openCreateDialog = () => {
createForm.appname = '';
createForm.description = '';
formRef.value?.clearValidate();
createDialogVisible.value = true;
};
// API Key
const createKey = async () => {
if (!formRef.value) return;
await formRef.value.validate();
createLoading.value = true;
try {
const res = await applyApiKey<AddResult, AddParams>({
appname: createForm.appname,
description: createForm.description,
});
if (res.status === 'ok') {
createDialogVisible.value = false;
await loadKeys();
ElMessage.success('创建成功,点击查看可复制完整 Key');
}
} catch (error: any) {
ElMessage.error(error?.message || '创建失败');
} finally {
createLoading.value = false;
}
};
// Key
const openViewDialog = async (row: ListApikeys) => {
if (!row.id) {
ElMessage.error('令牌 ID 无效');
return;
}
detailLoading.value = true;
viewDialogVisible.value = false; //
try {
const res = await getApiKeyDetail<DetailResult, { id: string }>({ id: row.id });
if (res.status === 'ok' && res.data) {
// viewingKey apikey
viewingKey.value = {
...row,
key: res.data.apikey || '',
name: res.data.name || row.name || '未命名',
note: res.data.description || '暂无备注',
};
viewDialogVisible.value = true;
} else {
ElMessage.error('获取 API Key 详情失败');
}
} catch (error: any) {
console.error('获取详情异常:', error);
ElMessage.error(error?.message || '网络错误,请稍后重试');
} finally {
detailLoading.value = false;
}
};
// Key
const copyViewingKey = () => {
if (viewingKey.value?.key) {
copyToClipboard(viewingKey.value.key, '完整 API Key 已复制');
} else {
ElMessage.warning('无可复制的 Key');
}
};
//
const handleViewDialogClose = () => {
viewDialogVisible.value = false;
viewingKey.value = null;
};
//
watch(
() => loginStore.isLoggedIn,
(loggedIn) => {
if (loggedIn) {
loadKeys();
} else {
keys.value = [];
}
},
{ immediate: true }
);
onMounted(() => {
if (loginStore.isLoggedIn) {
loadKeys();
}
});
</script>
<style scoped lang='less'>
@import url('@/assets/less/apiKey/apikey.less');
.login-fullscreen {
height: 100%;
}
</style>
<style lang='less'>
.apikey-table.el-table {
--el-table-bg-color: #1e2024;
--el-table-tr-bg-color: #1e2024;
--el-table-row-hover-bg-color: #25282e;
--el-table-current-row-bg-color: #25282e;
--el-table-header-bg-color: #1e2024;
--el-table-border-color: #2a2d32;
--el-table-text-color: rgba(255, 255, 255, 0.88);
--el-table-header-text-color: rgba(255, 255, 255, 0.75);
--el-table-fixed-box-shadow: none;
background: #1e2024;
}
.apikey-table.el-table::before,
.apikey-table .el-table__inner-wrapper::before,
.apikey-table .el-table__fixed-right-patch {
background-color: transparent !important;
}
.apikey-table .el-table__header-wrapper,
.apikey-table .el-table__body-wrapper,
.apikey-table .el-table__inner-wrapper,
.apikey-table .el-table__fixed,
.apikey-table .el-table__fixed-right,
.apikey-table td.el-table__cell,
.apikey-table th.el-table__cell {
background: #1e2024 !important;
}
.apikey-table .el-table__row--striped td.el-table__cell {
background: #1a1c20 !important;
}
.apikey-table .el-table__row:hover td.el-table__cell {
background: #25282e !important;
}
.custom-select-popup {
background-color: #2A2D34 !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
border: none !important;
}
.custom-select-popup .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
}
.custom-select-popup .el-select-dropdown__item.selected,
.custom-select-popup .el-select-dropdown__item:hover {
background-color: #3a3d46 !important;
}
.el-input__count-inner{
background-color: #25282e !important;
}
.el-input__count{
background-color: #25282e !important;
}
</style>

822
src/views/Bill/Bill.vue Normal file
View File

@ -0,0 +1,822 @@
<template>
<div class="bill-container">
<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">
<i class="iconfont icon-xiaofeijilu"></i>
<span>消费记录</span>
</div>
<div class="header-filters">
<el-select
v-model="statusFilter"
placeholder="订单状态"
size="default"
@change="handleFilterChange"
popper-class="custom-select-popup"
class="status-select"
>
<el-option label="全部状态" value="" />
<el-option label="成功" value="SUCCEEDED" />
<el-option label="失败" value="FAILED" />
<el-option label="运行中" value="RUNNING" />
<el-option label="处理中" value="PROCESSING" />
<el-option label="已创建" value="CREATED" />
<el-option label="排队中" value="QUEUING" />
<el-option label="未知错误" value="UNKNOWN" />
</el-select>
</div>
</div>
<!-- 内容区 -->
<div class="bill-content">
<!-- 加载中 -->
<div v-if="isLoading" class="loading-container">
<div class="loading-spinner">
<div class="spinner-circle"></div>
</div>
<div class="loading-text">加载中...</div>
</div>
<!-- 空数据 -->
<div v-else-if="!orderList.length" class="empty-container">
<img src="../../assets/images/preparation.png" alt="">
<div class="empty-text">暂无消费记录</div>
</div>
<!-- 订单列表 -->
<div v-else class="order-list">
<div class="order-item" v-for="order in orderList" :key="order.row_num_">
<!-- 订单头部 -->
<div class="order-header">
<div class="order-model">
<span class="label">模型</span>
<span class="value">{{ order.model }}</span>
</div>
<div class="order-status">
<span :class="['status-badge', getStatusClass(order.status)]">
{{ getStatusText(order.status) }}
</span>
</div>
</div>
<!-- 订单内容 -->
<div class="order-body">
<div class="order-info">
<div class="info-row">
<span class="info-label">使用时间</span>
<span class="info-value">{{ order.use_time }}</span>
</div>
<div class="info-row">
<span class="info-label">模型ID</span>
<span class="info-value">{{ order.llmid }}</span>
</div>
<div class="info-row">
<span class="info-label">模型代码</span>
<span class="info-value">{{ order.model }}</span>
</div>
</div>
<!-- 金额信息 -->
<div class="order-amount">
<div class="amount-label">消费金额</div>
<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>
<!-- 订单底部 -->
<div class="order-footer">
<div class="footer-info">
<span class="info-item">使用日期{{ order.use_date }}</span>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="orderList.length" class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
: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"
/>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { consumptionRecord } from '@/apis/home';
import { useLoginStore } from '@/store/LoginStore';
import LoginPrompt from '@/components/LoginPrompt/LoginPrompt.vue';
// ==================== ====================
const loginStore = useLoginStore();
// ==================== ====================
/** 全部订单列表(原始数据) */
const allOrderList = ref<any[]>([]);
/** 当前页显示的订单列表(分页后) */
const orderList = ref<any[]>([]);
/** 加载状态 */
const isLoading = ref(false);
/** 当前页码 */
const currentPage = ref(1);
/** 每页条数 */
const pageSize = ref(5);
/** 总条数 */
const total = ref(0);
/** 状态筛选 */
const statusFilter = ref('');
// ==================== ====================
/** 登录状态 */
const isLoggedIn = computed(() => loginStore.isLoggedIn);
const resetOrderState = () => {
allOrderList.value = [];
orderList.value = [];
total.value = 0;
currentPage.value = 1;
statusFilter.value = '';
};
/**
* 更新当前页显示的数据前端分页
*/
const updatePaginatedList = () => {
//
let filteredList = allOrderList.value;
if (statusFilter.value) {
filteredList = allOrderList.value.filter(order => {
const status = (order.status || '').toUpperCase();
return status === statusFilter.value.toUpperCase();
});
}
//
total.value = filteredList.length;
//
const maxPage = Math.ceil(total.value / pageSize.value) || 1;
if (currentPage.value > maxPage) {
currentPage.value = 1;
}
//
const startIndex = (currentPage.value - 1) * pageSize.value;
const endIndex = startIndex + pageSize.value;
orderList.value = filteredList.slice(startIndex, endIndex);
};
// ==================== ====================
/**
* 获取状态显示文本
*/
const getStatusText = (status: string): string => {
const statusMap: Record<string, string> = {
'SUCCEEDED': '成功',
'SUCCESS': '成功',
'succeeded': '成功',
'success': '成功',
'FAILED': '失败',
'failed': '失败',
'ERROR': '失败',
'error': '失败',
'RUNNING': '运行中',
'running': '运行中',
'PROCESSING': '处理中',
'processing': '处理中',
'CREATED': '已创建',
'created': '已创建',
'PENDING': '排队中',
'pending': '排队中',
'QUEUING': '排队中',
'queueing': '排队中',
'UNKNOWN': '未知错误',
'unknown': '未知错误'
};
return statusMap[status] || '未知错误';
};
/**
* 获取状态样式类名
*/
const getStatusClass = (status: string): string => {
const classMap: Record<string, string> = {
'SUCCEEDED': 'status-succeeded',
'SUCCESS': 'status-succeeded',
'succeeded': 'status-succeeded',
'success': 'status-succeeded',
'FAILED': 'status-failed',
'failed': 'status-failed',
'ERROR': 'status-failed',
'error': 'status-failed',
'RUNNING': 'status-running',
'running': 'status-running',
'PROCESSING': 'status-processing',
'processing': 'status-processing',
'CREATED': 'status-created',
'created': 'status-created',
'PENDING': 'status-queueing',
'pending': 'status-queueing',
'QUEUING': 'status-queueing',
'queueing': 'status-queueing',
'UNKNOWN': 'status-unknown',
'unknown': 'status-unknown'
};
return classMap[status] || 'status-unknown';
};
/**
* 每页条数变化
*/
const handleSizeChange = (size: number) => {
pageSize.value = size;
currentPage.value = 1; //
updatePaginatedList(); //
};
/**
* 当前页码变化
*/
const handleCurrentChange = (page: number) => {
currentPage.value = page;
updatePaginatedList(); //
};
/**
* 筛选条件变化
*/
const handleFilterChange = () => {
currentPage.value = 1; //
updatePaginatedList(); //
};
// ==================== ====================
/**
* 加载订单列表
*/
const loadOrderList = async () => {
if (!isLoggedIn.value) {
resetOrderState();
return;
}
isLoading.value = true;
try {
//
const res = await consumptionRecord<any, any>({});
if (res.status === 'ok' && res.data) {
const rawData = res.data.rows || [];
allOrderList.value = rawData; //
total.value = rawData.length || 0; // 使
//
updatePaginatedList();
} else {
resetOrderState();
}
} catch (error) {
console.error('=== 获取消费记录失败 ===');
console.error('错误详情:', error);
ElMessage.error('获取消费记录失败');
resetOrderState();
} finally {
isLoading.value = false;
}
};
// ==================== ====================
onMounted(() => {
if (isLoggedIn.value) {
loadOrderList();
}
});
watch(
() => loginStore.isLoggedIn,
(loggedIn) => {
if (loggedIn) {
loadOrderList();
} else {
resetOrderState();
}
}
);
</script>
<style scoped lang="less">
:deep(.el-select__wrapper){
background-color: #1e2024!important;
border: none!important;
}
.bill-container {
height: 100%;
padding-bottom: 40px;
box-sizing: border-box;
// background-color: #1a1c20; //
}
.bill-card {
height: 100%;
background: #1e2024; //
border: 1px solid #2a2d32;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
:deep(.el-card__body) {
padding: 28px 32px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
.bill-login-prompt {
height: 100%;
}
//
.bill-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
flex-shrink: 0;
.header-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 22px;
font-weight: 700;
color: #fff;
letter-spacing: 1px;
.iconfont {
font-size: 26px;
color: #00c4b3; //
filter: drop-shadow(0 0 8px rgba(0, 196, 179, 0.5));
}
}
.header-filters {
.status-select {
width: 160px;
}
:deep(.el-input__wrapper) {
background-color: #25282e;
border: 1px solid #3a3e45;
border-radius: 8px;
box-shadow: none;
transition: all 0.3s ease;
&:hover {
border-color: #00c4b3;
box-shadow: 0 0 8px rgba(0, 196, 179, 0.15);
}
&.is-focus {
border-color: #00c4b3;
box-shadow: 0 0 12px rgba(0, 196, 179, 0.25);
}
}
:deep(.el-input__inner) {
color: #e0e0e0;
font-size: 14px;
}
:deep(.el-input__suffix) {
color: #00c4b3;
}
}
}
//
.bill-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
//
.loading-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.loading-spinner {
width: 52px;
height: 52px;
position: relative;
}
.spinner-circle {
width: 100%;
height: 100%;
border: 4px solid rgba(0, 196, 179, 0.1);
border-top-color: #00c4b3;
border-radius: 50%;
animation: spin 0.8s linear infinite;
box-shadow: 0 0 12px rgba(0, 196, 179, 0.3);
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 15px;
color: rgba(255, 255, 255, 0.7);
}
//
.empty-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
img {
width: 220px;
height: 220px;
object-fit: contain;
opacity: 0.5;
filter: grayscale(30%);
}
.empty-text {
font-size: 17px;
color: rgba(255, 255, 255, 0.6);
letter-spacing: 0.5px;
}
}
//
.order-list {
flex: 1;
overflow-y: auto;
padding-right: 12px;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #14161a;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: #3a3e45;
border-radius: 4px;
transition: all 0.3s;
&:hover {
background: #00c4b3;
}
}
}
.order-item {
background: #1e2024;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
border: 1px solid #2a2d32;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:last-child {
margin-bottom: 0;
}
}
//
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #2a2d32;
.order-model {
font-size: 15px;
color: rgba(255, 255, 255, 0.9);
.label {
color: rgba(255, 255, 255, 0.5);
margin-right: 8px;
}
.value {
font-weight: 600;
color: #fff;
}
}
.status-badge {
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
&.status-succeeded {
background-color: rgba(0, 196, 179, 0.12);
color: #7fddd3;
border: 1px solid rgba(0, 196, 179, 0.3);
}
&.status-processing {
background-color: rgba(33, 150, 243, 0.12);
color: #90caf9;
border: 1px solid rgba(33, 150, 243, 0.3);
}
&.status-running {
background-color: rgba(156, 39, 176, 0.12);
color: #ce93d8;
border: 1px solid rgba(156, 39, 176, 0.3);
}
&.status-created {
background-color: rgba(158, 158, 158, 0.12);
color: #e0e0e0;
border: 1px solid rgba(158, 158, 158, 0.3);
}
&.status-failed {
background-color: rgba(244, 67, 54, 0.12);
color: #ef9a9a;
border: 1px solid rgba(244, 67, 54, 0.3);
}
&.status-queueing {
background-color: rgba(255, 193, 7, 0.12);
color: #ffe082;
border: 1px solid rgba(255, 193, 7, 0.3);
}
&.status-unknown {
background-color: rgba(255, 87, 34, 0.12);
color: #ffab91;
border: 1px solid rgba(255, 87, 34, 0.3);
}
}
}
//
.order-body {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 32px;
}
.order-info {
flex: 1;
}
.info-row {
display: flex;
margin-bottom: 10px;
font-size: 14px;
align-items: center;
&:last-child {
margin-bottom: 0;
}
.info-label {
color: rgba(255, 255, 255, 0.55);
width: 80px;
flex-shrink: 0;
font-weight: 500;
}
.info-value {
color: rgba(255, 255, 255, 0.9);
flex: 1;
word-break: break-all;
}
}
.order-amount {
text-align: right;
flex-shrink: 0;
min-width: 140px;
.amount-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 6px;
font-weight: 500;
}
.amount-value {
font-size: 28px;
font-weight: 700;
color: #00c4b3;
text-shadow: 0 0 12px rgba(0, 196, 179, 0.4);
.currency {
font-size: 18px;
margin-right: 4px;
opacity: 0.9;
}
.number {
font-family: 'DIN Alternate', 'Courier New', monospace;
}
.unit {
margin-left: 4px;
font-size: 16px;
font-weight: 600;
}
}
}
//
.order-footer {
padding-top: 16px;
border-top: 1px solid #2a2d32;
.footer-info {
display: flex;
align-items: center;
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
.info-item {
margin-right: 12px;
}
.separator {
margin: 0 12px;
color: rgba(255, 255, 255, 0.3);
}
}
}
//
.pagination-container {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #2a2d32;
display: flex;
justify-content: center;
: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;
}
}
</style>
<style>
/* 全局样式(保留原有下拉框样式) */
.custom-select-popup {
background-color: #2A2D34 !important;
border-radius: 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
border: none !important;
}
.custom-select-popup .el-select-dropdown__item {
color: #ffffff !important;
background-color: transparent !important;
font-size: 14px !important;
}
.custom-select-popup .el-select-dropdown__item.selected,
.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

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

View File

@ -0,0 +1,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>

309
src/views/Home.vue Normal file
View File

@ -0,0 +1,309 @@
<template>
<div>
<div class="home">
<div class="background-video-stack">
<video v-for="(video, index) in videoList" :key="index" class="background-video"
:class="{ 'is-active': activeIndex === index }" autoplay muted loop :poster="video.poster">
<source :src="video.src" type="video/mp4" />
</video>
</div>
<header>
<div class="left">
<img src="../assets/images/favicon.png" alt="logo" />
<span class="header-title">元境</span>
</div>
<div class="right">
<div class="btn" @click="$router.push('/index')">立即体验</div>
</div>
</header>
<main>
<div class="container">
<h2 class="slogan">元启万象境由心生</h2>
<!-- 输入框容器 -->
<div class="input-container">
<!-- 头像区域使用 transition 实现淡入淡出 -->
<div class="avatar">
<!-- <transition name="fade" mode="out-in"> -->
<img :key="activeIndex" :src="videoList[activeIndex].poster" alt="avatar" />
<!-- </transition> -->
</div>
<!-- 输入框添加动态闪烁类提示占位符变化 -->
<input ref="inputRef" type="text" class="prompt-input" :placeholder="currentPlaceholder"
:class="{ 'placeholder-flash': isFlashing }" />
<div class="create-btn">创作</div>
</div>
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
//
const videoList = [
{
src: 'https://console-download.s3.cn-north-1.jdcloud-oss.com/-lingjing-only/pre_creation/first_page/01-%E5%A5%B3%E4%BA%BA%E4%B8%8E%E8%8A%B1.mp4',
poster: 'https://console-download.s3.cn-north-1.jdcloud-oss.com/-lingjing-only/pre_creation/first_page/%E5%A5%B3%E4%BA%BA%E4%B8%8E%E8%8A%B11.webp',
placeholder: '一位优雅的女性与花丛,午后阳光洒在花瓣上...'
},
{
src: 'https://console-download.s3.cn-north-1.jdcloud-oss.com/-lingjing-only/pre_creation/first_page/04-%E5%B0%8F%E5%A5%B3%E5%AD%A9%E4%B8%8E%E7%8C%AB.mp4',
poster: 'https://console-download.s3.cn-north-1.jdcloud-oss.com/-lingjing-only/pre_creation/first_page/%E5%B0%8F%E5%A5%B3%E5%AD%A9%E4%B8%8E%E7%8C%AB1.webp',
placeholder: '小女孩抱着猫,眼神温柔,毛茸茸的尾巴轻轻摆动...'
},
{
src: 'https://console-download.s3.cn-north-1.jdcloud-oss.com/-lingjing-only/pre_creation/first_page/03-%E8%A1%97%E5%A4%B4%E6%BB%91%E6%9D%BF.mp4',
poster: 'https://console-download.s3.cn-north-1.jdcloud-oss.com/-lingjing-only/pre_creation/first_page/%E8%A1%97%E5%A4%B4%E6%BB%91%E6%9D%BF1.webp',
placeholder: '街头滑板少年,腾空跃起,动作流畅充满活力...'
},
{
src: 'https://console-download.s3.cn-north-1.jdcloud-oss.com/-lingjing-only/pre_creation/first_page/05-%E4%B9%90%E9%AB%98%E9%A3%8E%E6%A0%BC%E6%9C%BA%E8%BD%A6.mp4',
poster: 'https://console-download.s3.cn-north-1.jdcloud-oss.com/-lingjing-only/pre_creation/first_page/%E4%B9%90%E9%AB%98%E9%A3%8E%E6%A0%BC%E6%9C%BA%E8%BD%A61.webp',
placeholder: '乐高风格的机车,色彩鲜艳,齿轮转动充满机械感...'
}
]
const activeIndex = ref(0) //
let timer: number | null = null //
//
const currentPlaceholder = computed(() => videoList[activeIndex.value].placeholder)
//
const inputRef = ref<HTMLInputElement | null>(null)
const isFlashing = ref(false)
// activeIndex
watch(activeIndex, () => {
isFlashing.value = true
setTimeout(() => {
isFlashing.value = false
}, 300) //
})
// 5
const startCarousel = () => {
timer = setInterval(() => {
activeIndex.value = (activeIndex.value + 1) % videoList.length
}, 5000) // 5
}
onMounted(() => {
startCarousel()
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped lang="less">
.home {
width: 100%;
height: 100vh;
overflow: hidden;
}
.background-video-stack {
position: absolute;
inset: 0;
// z-index: 0; //
}
.background-video {
position: absolute;
inset: 0;
height: 100%;
width: 100%;
object-fit: cover;
opacity: 0; //
pointer-events: none;
transition: opacity .4s ease-in-out;
}
.background-video.is-active {
opacity: 1; //
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
position: relative;
.left {
display: flex;
align-items: center;
gap: 8px;
img {
width: 50px;
height: 50px;
}
.header-title {
font-family: "Microsoft Yahei", "PingFang SC", sans-serif;
font-size: 30px;
font-weight: 550;
color: #fff;
transform: skew(-8deg);
-webkit-transform: skew(-12deg);
}
}
.right {
.btn {
background-color: #000;
color: #fff;
border: none;
border-radius: 40px;
padding: 10px 28px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
letter-spacing: 0.3px;
&:hover {
background-color: #2a2a2a;
}
&:active {
transform: scale(0.97);
}
}
}
}
main {
padding: 80px 0;
width: 100%;
position: absolute;
top: 50%;
left: 0%;
.container {
// z-index: 999;
.slogan {
font-size: 64px;
font-weight: 600;
color: #fff;
text-align: center;
margin-bottom: 40px;
}
.input-container {
display: flex;
align-items: center;
width: 90%;
max-width: 1000px;
height: 56px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 28px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
.avatar {
width: 40px;
height: 24px;
margin: 0 12px;
// border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.prompt-input {
flex: 1;
height: 100%;
padding: 0 8px;
background: transparent;
border: none;
outline: none;
font-size: 16px;
color: #333;
caret-color: #000;
transition: border-color 0.2s, background-color 0.2s;
&::placeholder {
color: #fff;
font-size: 15px;
line-height: 1.4;
}
}
.placeholder-flash {
border-color: rgba(255, 255, 255, 0.8) !important;
background-color: rgba(255, 255, 255, 0.2) !important;
animation: pulse 0.3s ease;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(255, 255, 255, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
}
}
.create-btn {
height: 44px;
min-width: 100px;
margin: 0 6px;
background-color: #000;
color: #fff;
border-radius: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
&:hover {
background-color: #333;
transform: translateY(-1px);
}
&:active {
transform: scale(0.98);
}
}
}
}
}
/* 头像淡入淡出动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

52
src/views/Homeindex.vue Normal file
View File

@ -0,0 +1,52 @@
<template>
<div>
<div class="common-layout">
<el-container>
<el-header>
<Header></Header>
</el-header>
<el-container>
<el-aside >
<Menu></Menu>
</el-aside>
<el-main>
<RouterView></RouterView>
</el-main>
</el-container>
</el-container>
</div>
</div>
</template>
<script setup lang='ts'>
import { RouterView } from 'vue-router';
import Header from '../components/Header/Header.vue';
import Menu from '@/components/left/Menu.vue';
// import { ref, reactive } from 'vue';
</script>
<style scoped lang='less'>
.el-aside {
width: 224px;
min-height: calc(100vh - 80px);
background-color: #0F0F19;
}
.el-header {
min-width: 100%;
height: 80px;
background-color: #0F0F19;
}
.el-main {
width: 100%;
padding: 0 32px;
height: calc(100vh - 80px);
background-color: #0F0F19;
}
</style>

View File

@ -0,0 +1,493 @@
<template>
<div class="app">
<div class="left-panel">
<ImgLeft
@generation-start="handleGenerationStart"
@image-generated="handleImageGenerated"
/>
</div>
<div class="right-panel">
<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 LoginPrompt from '@/components/LoginPrompt/LoginPrompt.vue';
import { useLoginStore } from '@/store/LoginStore';
import type { TextToImageResponse } from '@/types/model';
import { computed, ref, watch } 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){
padding: 0 !important;
}
/* 禁止页面滚动,并占满全屏 */
html, body {
height: 100%;
margin: 0;
overflow: hidden;
}
.app {
display: flex;
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 {
// /
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
//
background: #0F0F19;
//
border-radius: 3px;
}
// ========== ==========
&::-webkit-scrollbar-thumb {
//
background: #0F0F19;
//
border-radius: 3px;
//
&:hover {
background: #0F0F19;
}
}
}
.right-panel {
flex: 1;
height: 100%;
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

@ -0,0 +1,625 @@
<template>
<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>
</header>
<main class="welcome-panel">
<div class="aurora aurora-left"></div>
<div class="aurora aurora-right"></div>
<section class="welcome-content">
<LoginPrompt
v-if="!isLoggedIn"
class="text-login-prompt"
title="请先登录"
description="登录后可使用文本对话生成能力,并查看生成结果。"
full-height
/>
<template v-else-if="!messages.length && !isGenerating">
<div class="chat-bubble-icon">
<span></span>
<span></span>
<span></span>
</div>
<h1>开启智能对话</h1>
<p>
{{ selectedModel || '模型' }} 开始一场流畅的对话支持多轮上下文理解随时调整参数探索不同的输出效果
</p>
<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>
</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 { 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-generate-page {
height: calc(100vh - 80px);
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
color: #fff;
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%);
}
.page-header {
flex-shrink: 0;
height: 56px;
padding: 0 10px;
display: flex;
align-items: center;
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;
}
.params-empty {
height: 32px;
display: inline-flex;
align-items: center;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
color: rgba(255, 255, 255, 0.46);
background: rgba(255, 255, 255, 0.04);
font-size: 13px;
}
.welcome-panel {
flex: 1;
min-height: 0;
position: relative;
display: flex;
align-items: stretch;
justify-content: center;
padding: 20px 4px 150px;
overflow: hidden;
}
.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;
align-items: center;
justify-content: center;
text-align: center;
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 {
max-width: 560px;
margin: 0;
color: rgba(255, 255, 255, 0.62);
font-size: 14px;
line-height: 1.8;
}
}
.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;
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 {
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: 14px;
background: rgba(20, 22, 33, 0.92);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.36);
backdrop-filter: blur(18px);
}
.composer-input {
flex: 1;
}
.send-btn {
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) {
border: none;
box-shadow: none;
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>

105
src/views/index/index.vue Normal file
View File

@ -0,0 +1,105 @@
<template>
<div class="index" ref="indexContainer">
<div class="top"><Top /></div>
<div class="AI"><AI /></div>
<!-- Tab 后续通过添加 sticky 类实现吸顶 -->
<div class="sticky-tabs" :class="{ 'is-sticky': isTabSticky }">
<StencilTabs :activeTab="activeTab" @tab-change="handleTabChange" />
</div>
<!-- 瀑布流内容 -->
<div class="stencil">
<StencilWaterfall
ref="waterfallRef"
:activeTab="activeTab"
:gap="16"
@like="handleLike"
@make-same="handleMakeSame"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import Top from '@/components/index/top.vue'
import AI from '@/components/index/AIMode.vue'
import StencilTabs from '@/components/index/StencilTabs.vue'
import StencilWaterfall from '@/components/index/StencilWaterfall.vue'
// import type { StencilItem } from '@/types/home'
const activeTab = ref('mixed') //
const handleTabChange = (tabType: string) => {
activeTab.value = tabType
}
const handleLike = () => {
}
const handleMakeSame = () => {
}
// ===== =====
const indexContainer = ref<HTMLElement>()
const isTabSticky = ref(false)
const tabsOffsetTop = ref(0)
const handleScroll = () => {
if (!indexContainer.value) return
const scrollTop = indexContainer.value.scrollTop
// Tab sticky
isTabSticky.value = scrollTop >= tabsOffsetTop.value
}
onMounted(() => {
const container = indexContainer.value
if (container) {
container.addEventListener('scroll', handleScroll)
// Tab
const tabsEl = container.querySelector('.sticky-tabs') as HTMLElement
if (tabsEl) {
tabsOffsetTop.value = tabsEl.offsetTop
}
}
})
onUnmounted(() => {
indexContainer.value?.removeEventListener('scroll', handleScroll)
})
</script>
<style lang="less" scoped>
.index {
width: 100%;
height: 100%;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.3);
border-radius: 10px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
}
.sticky-tabs {
position: relative;
z-index: 10;
background-color: #0f0f0f; /* 与页面背景一致,吸顶时遮盖内容 */
transition: box-shadow 0.2s;
&.is-sticky {
position: sticky;
top: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
</style>

262
src/views/login/Login.vue Normal file
View File

@ -0,0 +1,262 @@
<template>
<div class="login">
<!-- 背景视频 -->
<div class="background-video-stack">
<video
class="background-video"
autoplay
muted
loop
playsinline
crossorigin="anonymous"
src="../../assets/images/loginBg.mp4"
></video>
</div>
<!-- 玻璃卡片表单 -->
<div class="form-card">
<div class="form-top">
<h2 class="form-title">欢迎来到元境</h2>
<span class="form-title"> 元启万象境由心生 </span>
</div>
<el-form
ref="formRef"
style="max-width: 400px"
:model="form"
:rules="rules"
label-width="0px"
class="login-form"
@submit.prevent="submitForm"
>
<!-- 手机号 -->
<el-form-item prop="cellphone">
<el-input
v-model="form.cellphone"
placeholder="请输入手机号"
:prefix-icon="Iphone"
size="large"
@keyup.enter="submitForm"
/>
</el-form-item>
<!-- 验证码 + 获取按钮 -->
<el-form-item prop="captcha">
<div class="captcha-wrapper">
<el-input
v-model="form.captcha"
placeholder="请输入验证码"
:prefix-icon="Key"
size="large"
class="captcha-input"
@keyup.enter="submitForm"
/>
<el-button
:disabled="countingDown || !validPhone"
class="captcha-btn glass-btn"
@click="handleGetCaptcha"
>
{{ countingDown ? `${countdown}秒后重试` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<!-- 操作按钮 -->
<el-form-item>
<el-button
type="primary"
size="large"
class="submit-btn glass-btn"
@click="submitForm"
:loading="loading"
>
/
</el-button>
</el-form-item>
</el-form>
<div class="form-bottom">
登录即表示您同意遵守用户协议隐私政策
</div>
</div>
<!-- 账号选择弹窗 -->
<el-dialog
v-model="loginStore.showAccountSelect"
title="选择账号"
width="400px"
:close-on-click-modal="false"
@close="handleDialogClose"
>
<div class="account-list">
<div
v-for="account in loginStore.accountList"
:key="account.id"
class="account-item"
@click="selectAccount(account.id)"
>
<div class="account-info">
<div class="account-name">{{ account.username }}</div>
<!-- 显示手机号优先使用 mobile 字段其次 orgid -->
<div class="account-phone">{{ account.mobile || account.orgid }}</div>
</div>
<el-button type="primary" size="small">选择</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onUnmounted } from 'vue';
import type { FormInstance, FormRules } from 'element-plus';
import { Iphone, Key } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { Code } from '@/apis/login';
import type { CodeResult, LoginParams, CaptchaParams } from '@/types/login';
import { useLoginStore } from '@/store/LoginStore';
import { debounce } from '@/utlis/debounce';
// Store
const loginStore = useLoginStore();
//
interface LoginForm {
cellphone: string;
captcha: string;
}
//
const form = reactive<LoginForm>({
cellphone: '',
captcha: '',
});
//
const formRef = ref<FormInstance>();
//
const loading = ref(false);
// key
const loginParams = ref<LoginParams>({
cellphone: '',
key: '',
sms_code: '',
});
//
const rules = reactive<FormRules<LoginForm>>({
cellphone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' },
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 6, message: '验证码应为6位数字', trigger: 'blur' },
],
});
//
const countdown = ref(60);
const countingDown = ref(false);
let timer: number | null = null;
//
const validPhone = computed(() => {
return /^1[3-9]\d{9}$/.test(form.cellphone);
});
//
const requestCaptcha = async () => {
if (countingDown.value) return;
try {
const res = await Code<CodeResult, CaptchaParams>({ cellphone: form.cellphone });
if (res.status === 'ok') {
ElMessage.success('发送成功');
// key
loginParams.value.key = res.data?.key || '';
} else {
ElMessage.error('发送失败');
}
} catch (error) {
console.error('发送验证码失败:', error);
ElMessage.error('发送失败,请稍后重试');
}
//
countingDown.value = true;
countdown.value = 60;
timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearInterval(timer!);
timer = null;
countingDown.value = false;
}
}, 1000);
};
const handleGetCaptcha = debounce(requestCaptcha, 500);
//
const doSubmitForm = async () => {
if (!formRef.value) return;
if (loading.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
//
loginParams.value.cellphone = form.cellphone;
loginParams.value.sms_code = form.captcha;
// store
await loginStore.Login(loginParams.value);
} catch (error) {
console.error('登录失败:', error);
ElMessage.error('登录失败,请稍后重试');
} finally {
loading.value = false;
}
} else {
ElMessage.error('请正确填写表单');
}
});
};
const submitForm = debounce(doSubmitForm, 500);
//
const handleSelectAccount = async (selectedId: string) => {
//
if (loading.value) return;
loading.value = true;
try {
await loginStore.loginWithSelectedAccount(selectedId, loginParams.value);
// store
} catch (error) {
console.error('选择账号登录失败:', error);
ElMessage.error('登录失败,请稍后重试');
} finally {
loading.value = false;
}
};
const selectAccount = debounce(handleSelectAccount, 500);
//
const handleDialogClose = () => {
loginStore.accountList = [];
};
//
onUnmounted(() => {
if (timer) clearInterval(timer);
handleGetCaptcha.cancel();
submitForm.cancel();
selectAccount.cancel();
});
</script>
<style scoped lang="less">
@import url('../../assets/less/Login/Login.less');
</style>

View File

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

View File

@ -0,0 +1,91 @@
<template>
<div class="app">
<div class="left-panel">
<VideoLeft ref="videoLeftRef" @task-created="handleTaskCreated" />
</div>
<div class="right-panel">
<RightResult ref="rightResultRef" :llmcatelogid="currentLlmcatelogid" />
</div>
</div>
</template>
<script setup lang='ts'>
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 = () => {
if (rightResultRef.value && typeof rightResultRef.value.refreshTaskList === 'function') {
rightResultRef.value.refreshTaskList();
} else {
console.error('refreshTaskList 方法不存在或不可用');
}
};
</script>
<style scoped lang="less">
:deep(.el-card__body){
padding: 0 !important;
}
/* 禁止页面滚动,并占满全屏 */
html, body {
height: 100%;
margin: 0;
overflow: hidden;
}
.app {
display: flex;
height: 100%;
width: 100%;
gap: 12px;
}
.left-panel {
width: 35%; /* 与 leftBox 宽度一致 */
height: 100%;
overflow-y: auto; /* 让左侧区域独立滚动 */
&::-webkit-scrollbar {
// /
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
//
background: #0F0F19;
//
border-radius: 3px;
}
// ========== ==========
&::-webkit-scrollbar-thumb {
//
background: #0F0F19;
//
border-radius: 3px;
//
&:hover {
background: #0F0F19;
}
}
}
.right-panel {
flex: 1;
height: 100%;
/* 如果右边不需要滚动,就不加 overflow */
}
</style>

7
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

24
tsconfig.app.json Normal file
View File

@ -0,0 +1,24 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"src/**/*.d.ts"
]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

42
vite.config.ts Normal file
View File

@ -0,0 +1,42 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
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))
}
},
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