Compare commits
No commits in common. "main" and "master" have entirely different histories.
1
.env.development
Normal file
1
.env.development
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=https://token.opencomputing.cn
|
||||
1
.env.production
Normal file
1
.env.production
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=https://token.opencomputing.cn
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
46
index.html
Normal file
46
index.html
Normal 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
3655
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
30
src/App.vue
Normal file
30
src/App.vue
Normal 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
204
src/apis/Model.ts
Normal 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
10
src/apis/apiKey.ts
Normal 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
16
src/apis/home.ts
Normal 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
5
src/apis/login.ts
Normal 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)
|
||||
539
src/assets/css/iconfont/demo.css
Normal file
539
src/assets/css/iconfont/demo.css
Normal 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;
|
||||
}
|
||||
832
src/assets/css/iconfont/demo_index.html
Normal file
832
src/assets/css/iconfont/demo_index.html
Normal 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"></span>
|
||||
<div class="name">下载</div>
|
||||
<div class="code-name">&#xe621;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">箭头_上下切换_o</div>
|
||||
<div class="code-name">&#xeb90;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">搜索</div>
|
||||
<div class="code-name">&#xe619;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">我的创作</div>
|
||||
<div class="code-name">&#xe61e;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">上</div>
|
||||
<div class="code-name">&#xe62f;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">下</div>
|
||||
<div class="code-name">&#xe611;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">消息</div>
|
||||
<div class="code-name">&#xe8bd;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">17A发送</div>
|
||||
<div class="code-name">&#xe67d;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">414126695</div>
|
||||
<div class="code-name">&#xe606;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">apikey</div>
|
||||
<div class="code-name">&#xe617;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">消费记录</div>
|
||||
<div class="code-name">&#xe654;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">余额</div>
|
||||
<div class="code-name">&#xe615;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">支付宝支付</div>
|
||||
<div class="code-name">&#xe634;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">警告</div>
|
||||
<div class="code-name">&#xe84f;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">加</div>
|
||||
<div class="code-name">&#xe716;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">验证码</div>
|
||||
<div class="code-name">&#xe623;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">003_手机号-53</div>
|
||||
<div class="code-name">&#xe61c;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">数字人</div>
|
||||
<div class="code-name">&#xe603;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">音频生成</div>
|
||||
<div class="code-name">&#xe604;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">左</div>
|
||||
<div class="code-name">&#xe622;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">右</div>
|
||||
<div class="code-name">&#xe60f;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">img2video</div>
|
||||
<div class="code-name">&#xe600;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">image-edit</div>
|
||||
<div class="code-name">&#xe601;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">无限画布</div>
|
||||
<div class="code-name">&#xe602;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">图片生成</div>
|
||||
<div class="code-name">&#xe636;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">首页</div>
|
||||
<div class="code-name">&#xe62e;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">个人中心</div>
|
||||
<div class="code-name">&#xe63c;</div>
|
||||
</li>
|
||||
|
||||
<li class="dib">
|
||||
<span class="icon iconfont"></span>
|
||||
<div class="name">发现</div>
|
||||
<div class="code-name">&#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"
|
||||
><span class="iconfont">&#x33;</span>
|
||||
</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"><link rel="stylesheet" href="./iconfont.css">
|
||||
</code></pre>
|
||||
<h3 id="-">第二步:挑选相应图标并获取类名,应用于页面:</h3>
|
||||
<pre><code class="language-html"><span class="iconfont icon-xxx"></span>
|
||||
</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"><script src="./iconfont.js"></script>
|
||||
</code></pre>
|
||||
<h3 id="-css-">第二步:加入通用 CSS 代码(引入一次就行):</h3>
|
||||
<pre><code class="language-html"><style>
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</code></pre>
|
||||
<h3 id="-">第三步:挑选相应图标并获取类名,应用于页面:</h3>
|
||||
<pre><code class="language-html"><svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-xxx"></use>
|
||||
</svg>
|
||||
</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>
|
||||
127
src/assets/css/iconfont/iconfont.css
Normal file
127
src/assets/css/iconfont/iconfont.css
Normal 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";
|
||||
}
|
||||
|
||||
1
src/assets/css/iconfont/iconfont.js
Normal file
1
src/assets/css/iconfont/iconfont.js
Normal file
File diff suppressed because one or more lines are too long
205
src/assets/css/iconfont/iconfont.json
Normal file
205
src/assets/css/iconfont/iconfont.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
src/assets/css/iconfont/iconfont.ttf
Normal file
BIN
src/assets/css/iconfont/iconfont.ttf
Normal file
Binary file not shown.
BIN
src/assets/css/iconfont/iconfont.woff
Normal file
BIN
src/assets/css/iconfont/iconfont.woff
Normal file
Binary file not shown.
BIN
src/assets/css/iconfont/iconfont.woff2
Normal file
BIN
src/assets/css/iconfont/iconfont.woff2
Normal file
Binary file not shown.
BIN
src/assets/images/favicon.png
Normal file
BIN
src/assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src/assets/images/idfile.mp4
Normal file
BIN
src/assets/images/idfile.mp4
Normal file
Binary file not shown.
BIN
src/assets/images/idfile1.mp4
Normal file
BIN
src/assets/images/idfile1.mp4
Normal file
Binary file not shown.
BIN
src/assets/images/login.mp4
Normal file
BIN
src/assets/images/login.mp4
Normal file
Binary file not shown.
BIN
src/assets/images/loginBg.mp4
Normal file
BIN
src/assets/images/loginBg.mp4
Normal file
Binary file not shown.
BIN
src/assets/images/preparation.png
Normal file
BIN
src/assets/images/preparation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
262
src/assets/less/Header/header.css
Normal file
262
src/assets/less/Header/header.css
Normal 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);
|
||||
}
|
||||
393
src/assets/less/Header/header.less
Normal file
393
src/assets/less/Header/header.less
Normal 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);
|
||||
}
|
||||
148
src/assets/less/Img/ImgBox.css
Normal file
148
src/assets/less/Img/ImgBox.css
Normal 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;
|
||||
}
|
||||
181
src/assets/less/Img/ImgBox.less
Normal file
181
src/assets/less/Img/ImgBox.less
Normal 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;
|
||||
}
|
||||
151
src/assets/less/Login/Login.css
Normal file
151
src/assets/less/Login/Login.css
Normal 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;
|
||||
}
|
||||
183
src/assets/less/Login/Login.less
Normal file
183
src/assets/less/Login/Login.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
547
src/assets/less/Recharge/Recharge.less
Normal file
547
src/assets/less/Recharge/Recharge.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
258
src/assets/less/RightResult/RightResult.css
Normal file
258
src/assets/less/RightResult/RightResult.css
Normal 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;
|
||||
}
|
||||
355
src/assets/less/RightResult/RightResult.less
Normal file
355
src/assets/less/RightResult/RightResult.less
Normal 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;
|
||||
}
|
||||
}
|
||||
206
src/assets/less/Video/img/img.css
Normal file
206
src/assets/less/Video/img/img.css
Normal 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;
|
||||
}
|
||||
253
src/assets/less/Video/img/img.less
Normal file
253
src/assets/less/Video/img/img.less
Normal 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;
|
||||
}
|
||||
429
src/assets/less/Video/refernce/refernce.less
Normal file
429
src/assets/less/Video/refernce/refernce.less
Normal 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;
|
||||
}
|
||||
}
|
||||
222
src/assets/less/Video/text/text.css
Normal file
222
src/assets/less/Video/text/text.css
Normal 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;
|
||||
}
|
||||
259
src/assets/less/Video/text/text.less
Normal file
259
src/assets/less/Video/text/text.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
307
src/assets/less/apiKey/apikey.css
Normal file
307
src/assets/less/apiKey/apikey.css
Normal 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;
|
||||
}
|
||||
}
|
||||
415
src/assets/less/apiKey/apikey.less
Normal file
415
src/assets/less/apiKey/apikey.less
Normal 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
1
src/assets/vue.svg
Normal 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 |
163
src/components/Header/Header.vue
Normal file
163
src/components/Header/Header.vue
Normal 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>
|
||||
96
src/components/LoginPrompt/LoginPrompt.vue
Normal file
96
src/components/LoginPrompt/LoginPrompt.vue
Normal 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>
|
||||
289
src/components/Recharge/Recharge.vue
Normal file
289
src/components/Recharge/Recharge.vue
Normal 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>
|
||||
772
src/components/RightResult/RightResult.vue
Normal file
772
src/components/RightResult/RightResult.vue
Normal 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>
|
||||
665
src/components/img/ImgImg.vue
Normal file
665
src/components/img/ImgImg.vue
Normal 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>
|
||||
266
src/components/img/ImgLeft.vue
Normal file
266
src/components/img/ImgLeft.vue
Normal 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>
|
||||
89
src/components/img/ImgTap.vue
Normal file
89
src/components/img/ImgTap.vue
Normal 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>
|
||||
784
src/components/img/ReferenceImg.vue
Normal file
784
src/components/img/ReferenceImg.vue
Normal 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>
|
||||
595
src/components/img/TextImg.vue
Normal file
595
src/components/img/TextImg.vue
Normal 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>
|
||||
147
src/components/index/AIMode.vue
Normal file
147
src/components/index/AIMode.vue
Normal 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-16(假设16px=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>
|
||||
115
src/components/index/StencilTabs.vue
Normal file
115
src/components/index/StencilTabs.vue
Normal 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>
|
||||
592
src/components/index/StencilWaterfall.vue
Normal file
592
src/components/index/StencilWaterfall.vue
Normal 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>
|
||||
523
src/components/index/top.vue
Normal file
523
src/components/index/top.vue
Normal 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>
|
||||
335
src/components/left/Menu.vue
Normal file
335
src/components/left/Menu.vue
Normal 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>
|
||||
1339
src/components/video/ImgVideo.vue
Normal file
1339
src/components/video/ImgVideo.vue
Normal file
File diff suppressed because it is too large
Load Diff
1534
src/components/video/ReferenceVideo.vue
Normal file
1534
src/components/video/ReferenceVideo.vue
Normal file
File diff suppressed because it is too large
Load Diff
1068
src/components/video/TextVideo.vue
Normal file
1068
src/components/video/TextVideo.vue
Normal file
File diff suppressed because it is too large
Load Diff
307
src/components/video/VideoLeft.vue
Normal file
307
src/components/video/VideoLeft.vue
Normal 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>
|
||||
94
src/components/video/VideoTab.vue
Normal file
94
src/components/video/VideoTab.vue
Normal 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
31
src/main.ts
Normal 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
137
src/router/index.ts
Normal 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
211
src/store/LoginStore.ts
Normal 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
79
src/style.css
Normal 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
32
src/types/apiKey.ts
Normal 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
296
src/types/home.ts
Normal 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
54
src/types/login.ts
Normal 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
309
src/types/model.ts
Normal 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
53
src/utlis/cookie.ts
Normal 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
42
src/utlis/debounce.ts
Normal 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
174
src/utlis/request.ts
Normal 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
114
src/utlis/transform.ts
Normal 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
364
src/views/APiKey/apikey.vue
Normal 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
822
src/views/Bill/Bill.vue
Normal 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>
|
||||
307
src/views/Cookie/getCookie.vue
Normal file
307
src/views/Cookie/getCookie.vue
Normal file
@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="sync-page">
|
||||
<div class="sync-card">
|
||||
<div class="sync-icon" :class="status">
|
||||
<el-icon v-if="status === 'loading'" class="is-loading">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<el-icon v-else-if="status === 'success'">
|
||||
<CircleCheckFilled />
|
||||
</el-icon>
|
||||
<el-icon v-else>
|
||||
<CircleCloseFilled />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ message }}</p>
|
||||
|
||||
<div v-if="status === 'loading'" class="sync-progress">
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<el-button v-if="status === 'error'" type="primary" @click="router.replace('/login')">
|
||||
返回登录
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang='ts'>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { CircleCheckFilled, CircleCloseFilled, Loading } from '@element-plus/icons-vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useLoginStore } from '@/store/LoginStore';
|
||||
import { setApiBaseURL } from '@/utlis/request';
|
||||
import type { LoginUser } from '@/types/login';
|
||||
|
||||
interface SyncUserResponse {
|
||||
status?: string;
|
||||
message?: string;
|
||||
cookie?: string | string[] | Record<string, string>;
|
||||
cookies?: string | string[] | Record<string, string>;
|
||||
user?: Partial<LoginUser>;
|
||||
data?: {
|
||||
user?: Partial<LoginUser>;
|
||||
cookie?: string | string[] | Record<string, string>;
|
||||
cookies?: string | string[] | Record<string, string>;
|
||||
} | Partial<LoginUser>;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loginStore = useLoginStore();
|
||||
const cookieApiBaseURL = 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>
|
||||
784
src/views/HistoryCreation.vue
Normal file
784
src/views/HistoryCreation.vue
Normal 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>
|
||||
491
src/views/HistoryCreationDetail.vue
Normal file
491
src/views/HistoryCreationDetail.vue
Normal 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
309
src/views/Home.vue
Normal 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
52
src/views/Homeindex.vue
Normal 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>
|
||||
493
src/views/ImgGenerate/ImgGenerate.vue
Normal file
493
src/views/ImgGenerate/ImgGenerate.vue
Normal 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>
|
||||
|
||||
625
src/views/TextGenerate/TextGenerate.vue
Normal file
625
src/views/TextGenerate/TextGenerate.vue
Normal 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
105
src/views/index/index.vue
Normal 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
262
src/views/login/Login.vue
Normal 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>
|
||||
529
src/views/materialSquare/Detail.vue
Normal file
529
src/views/materialSquare/Detail.vue
Normal file
@ -0,0 +1,529 @@
|
||||
<template>
|
||||
<div class="material-detail">
|
||||
<div v-if="currentItem" class="detail-layout">
|
||||
<section class="player-panel">
|
||||
<button class="back-btn" type="button" @click="goBack">
|
||||
<i class="iconfont icon-zuo"></i>
|
||||
返回
|
||||
</button>
|
||||
|
||||
<div class="video-card">
|
||||
<video
|
||||
:key="currentItem.id"
|
||||
:src="currentItem.src"
|
||||
:poster="currentItem.poster"
|
||||
class="main-video"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="side-panel">
|
||||
<div class="info-card">
|
||||
<div class="meta-row">
|
||||
<!-- <span class="type-tag">{{ currentItem.displayTag || getTypeLabel(currentItem.contentType) }}</span> -->
|
||||
<span class="publish-time">{{ formatDate(currentItem.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="creator">
|
||||
<img
|
||||
v-if="currentItem.avatar"
|
||||
:src="currentItem.avatar"
|
||||
:alt="currentItem.userName"
|
||||
class="creator-avatar"
|
||||
/>
|
||||
<div class="creator-info">
|
||||
<div class="creator-name">{{ currentItem.userName || '匿名创作者' }}</div>
|
||||
<div class="creator-subtitle">{{ getTypeLabel(currentItem.contentType) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="title">{{ currentItem.title || '未命名作品' }}</h1>
|
||||
|
||||
|
||||
|
||||
<p class="description">
|
||||
{{ currentItem.description || '暂无作品描述' }}
|
||||
</p>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">点赞</span>
|
||||
<span class="stat-value">{{ currentItem.likes }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">收藏</span>
|
||||
<span class="stat-value">{{ currentItem.collectCount ?? 0 }}</span>
|
||||
</div>
|
||||
<div v-if="currentItem.recreateCount !== undefined" class="stat-item">
|
||||
<span class="stat-label">复用</span>
|
||||
<span class="stat-value">{{ currentItem.recreateCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="relatedItems.length" class="recommend-card">
|
||||
<div class="recommend-title">相关内容</div>
|
||||
|
||||
<button
|
||||
v-for="item in relatedItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="recommend-item"
|
||||
:class="{ active: item.id === selectedId }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<img :src="item.poster" :alt="item.title" class="recommend-cover" />
|
||||
<div class="recommend-info">
|
||||
<div class="recommend-name">{{ item.title || item.userName }}</div>
|
||||
<div class="recommend-author">{{ item.userName || '匿名创作者' }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-title">暂无详情数据</div>
|
||||
<div class="empty-desc">请从素材瀑布流列表中点击任意卡片进入详情页。</div>
|
||||
<button class="back-home-btn" type="button" @click="goBack">返回列表</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { StencilItem } from '@/types/home'
|
||||
|
||||
interface DetailCache {
|
||||
selectedId?: string
|
||||
items?: StencilItem[]
|
||||
activeTab?: string
|
||||
}
|
||||
|
||||
const DETAIL_STORAGE_KEY = 'material-square-detail'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const items = ref<StencilItem[]>([])
|
||||
const selectedId = ref('')
|
||||
|
||||
const readCache = (): DetailCache | null => {
|
||||
const raw = sessionStorage.getItem(DETAIL_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as DetailCache
|
||||
} catch (error) {
|
||||
console.error('读取详情缓存失败', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const writeCache = () => {
|
||||
sessionStorage.setItem(
|
||||
DETAIL_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
selectedId: selectedId.value,
|
||||
items: items.value,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const syncFromCache = () => {
|
||||
const cache = readCache()
|
||||
const routeId = typeof route.query.id === 'string' ? route.query.id : ''
|
||||
|
||||
items.value = cache?.items ?? []
|
||||
selectedId.value = routeId || cache?.selectedId || items.value[0]?.id || ''
|
||||
}
|
||||
|
||||
const currentItem = computed(() => {
|
||||
return items.value.find((item) => item.id === selectedId.value) ?? null
|
||||
})
|
||||
|
||||
const relatedItems = computed(() => {
|
||||
return items.value.filter((item) => item.id !== selectedId.value).slice(0, 12)
|
||||
})
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
short_film: '短片',
|
||||
tutorial: '教程',
|
||||
media_asset: '素材',
|
||||
share_elements: '参考素材',
|
||||
}
|
||||
|
||||
return typeMap[type] || '作品'
|
||||
}
|
||||
|
||||
const formatDate = (value: string) => {
|
||||
if (!value) {
|
||||
return '未知时间'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const selectItem = (item: StencilItem) => {
|
||||
selectedId.value = item.id
|
||||
writeCache()
|
||||
|
||||
router.replace({
|
||||
path: '/material-square/detail',
|
||||
query: {
|
||||
...route.query,
|
||||
id: item.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/index')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.id,
|
||||
() => {
|
||||
syncFromCache()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.material-detail {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
background: #0f0f19;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
.player-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.back-btn,
|
||||
.back-home-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
justify-content: center;
|
||||
.iconfont{
|
||||
font-size: 12px;
|
||||
}
|
||||
height: 40px;
|
||||
padding: 0 18px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-card,
|
||||
.info-card,
|
||||
.recommend-card {
|
||||
background: linear-gradient(180deg, rgba(26, 28, 35, 0.98), rgba(13, 14, 19, 0.98));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
height: calc(100% - 56px);
|
||||
min-height: 640px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.main-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 18px;
|
||||
background: #000;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
width: 500px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(71, 117, 255, 0.22);
|
||||
color: #b9ccff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.publish-time {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.creator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.creator-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.creator-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.creator-subtitle {
|
||||
margin-top: 4px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recommend-card {
|
||||
width: 120px;
|
||||
height: 100%;
|
||||
padding: 12px 10px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
right: -4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(122, 144, 214, 0.18) transparent;
|
||||
}
|
||||
|
||||
.recommend-card::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.recommend-card::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.recommend-card::-webkit-scrollbar-thumb {
|
||||
background: rgba(132, 158, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.recommend-card:hover::-webkit-scrollbar {
|
||||
margin-left: 8px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.recommend-card:hover::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.recommend-card:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(132, 158, 255, 0.38);
|
||||
}
|
||||
|
||||
.recommend-card::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(154, 178, 255, 0.58);
|
||||
}
|
||||
|
||||
.recommend-title {
|
||||
margin-bottom: 12px;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recommend-item {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding: 0;
|
||||
margin-bottom: 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recommend-item.active .recommend-cover,
|
||||
.recommend-item:hover .recommend-cover {
|
||||
border-color: rgba(130, 161, 255, 0.9);
|
||||
}
|
||||
|
||||
.recommend-cover {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
// aspect-ratio: 9 / 16;
|
||||
display: block;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
background: #111;
|
||||
img{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.recommend-info {
|
||||
margin-top: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.recommend-name,
|
||||
.recommend-author {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recommend-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.recommend-author {
|
||||
margin-top: 2px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.detail-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recommend-card {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recommend-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
91
src/views/videoGenerate/videoGenerate.vue
Normal file
91
src/views/videoGenerate/videoGenerate.vue
Normal 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
7
src/vite-env.d.ts
vendored
Normal 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
24
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal 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
42
vite.config.ts
Normal 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
1
yuanjing
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit ef717c6c46a0c261e885443d6878e34f046adfbe
|
||||
Loading…
x
Reference in New Issue
Block a user