1023 lines
23 KiB
Vue
1023 lines
23 KiB
Vue
<template>
|
||
<FullWidthLayout :show-padding="false">
|
||
<div class="notion-page">
|
||
<!-- 主内容区域 -->
|
||
<div class="main-content">
|
||
<!-- 左侧:视频模块 -->
|
||
<section class="content-block">
|
||
<div class="block-header">
|
||
<span class="block-emoji">📹</span>
|
||
<h3 class="block-title">视频素材</h3>
|
||
</div>
|
||
|
||
<!-- 视频来源选项 -->
|
||
<div class="source-toggle">
|
||
<button
|
||
class="toggle-btn"
|
||
:class="{ active: store.videoSource === 'upload' }"
|
||
@click="store.selectUploadMode"
|
||
>
|
||
<CloudUploadOutlined />
|
||
<span>上传视频</span>
|
||
</button>
|
||
<button
|
||
class="toggle-btn"
|
||
:class="{ active: store.videoSource === 'select' }"
|
||
@click="store.selectLibraryMode"
|
||
>
|
||
<FolderOutlined />
|
||
<span>素材库</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 上传区域 -->
|
||
<div
|
||
v-if="store.videoSource === 'upload'"
|
||
class="upload-area"
|
||
:class="{ dragover: dragOver, 'has-video': store.videoPreviewUrl }"
|
||
@drop.prevent="handleDrop"
|
||
@dragover.prevent="dragOver = true"
|
||
@dragleave.prevent="dragOver = false"
|
||
>
|
||
<input
|
||
ref="fileInput"
|
||
type="file"
|
||
accept=".mp4,.mov"
|
||
class="hidden-input"
|
||
@change="handleFileSelect"
|
||
/>
|
||
|
||
<div v-if="!store.videoPreviewUrl" class="upload-empty" @click="triggerFileSelect">
|
||
<div class="upload-icon-wrapper">
|
||
<CloudUploadOutlined class="upload-icon" />
|
||
</div>
|
||
<div class="upload-text">点击上传或拖拽文件到此处</div>
|
||
<div class="upload-hint">支持 MP4、MOV 格式,视频需大于 3 秒</div>
|
||
</div>
|
||
|
||
<div v-else class="upload-preview">
|
||
<video
|
||
:src="store.videoPreviewUrl"
|
||
controls
|
||
playsinline
|
||
preload="metadata"
|
||
class="preview-video"
|
||
></video>
|
||
<button class="replace-btn" @click="clearVideo">
|
||
<ReloadOutlined />
|
||
<span>更换视频</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 已选视频预览(素材库) -->
|
||
<div v-if="store.selectedVideo && store.videoSource === 'select'" class="library-preview">
|
||
<video
|
||
v-if="store.videoPreviewUrl"
|
||
:src="store.videoPreviewUrl"
|
||
controls
|
||
playsinline
|
||
preload="metadata"
|
||
class="preview-video"
|
||
></video>
|
||
<div class="video-meta">
|
||
<div class="video-name" :title="store.selectedVideo.fileName">{{ store.selectedVideo.fileName }}</div>
|
||
<div class="video-info">
|
||
{{ formatDuration(store.selectedVideo.duration) }} · {{ formatFileSize(store.selectedVideo.fileSize) }}
|
||
</div>
|
||
</div>
|
||
<button class="replace-btn" @click="clearVideo">
|
||
<ReloadOutlined />
|
||
<span>更换</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 识别状态 -->
|
||
<div v-if="store.videoStep !== 'idle'" class="process-status" :class="store.videoStep">
|
||
<div v-if="store.videoStep === 'uploading'" class="status-row">
|
||
<a-spin size="small" />
|
||
<span>正在上传视频...</span>
|
||
</div>
|
||
<div v-else-if="store.videoStep === 'recognizing'" class="status-row">
|
||
<a-spin size="small" />
|
||
<span>正在识别人脸...</span>
|
||
</div>
|
||
<div v-else-if="store.videoStep === 'recognized'" class="status-row success">
|
||
<CheckCircleOutlined />
|
||
<span>识别成功 · 人脸时长 {{ formatDurationMs(store.faceDurationMs) }}</span>
|
||
</div>
|
||
<div v-else-if="store.videoStep === 'error'" class="status-row error">
|
||
<ExclamationCircleOutlined />
|
||
<span>{{ store.error }}</span>
|
||
<button class="link-btn" @click="store.retry">重试</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 生成按钮 - 放在左侧面板底部 -->
|
||
<div class="generate-action">
|
||
<!-- 成功状态 -->
|
||
<div v-if="store.isDone" class="result-inline success">
|
||
<CheckCircleFilled />
|
||
<span>任务已提交</span>
|
||
<button class="inline-btn" @click="store.reset">重新生成</button>
|
||
</div>
|
||
|
||
<!-- 错误状态 -->
|
||
<div v-else-if="store.createStep === 'error'" class="result-inline error">
|
||
<ExclamationCircleFilled />
|
||
<span>{{ store.error }}</span>
|
||
<button class="link-btn" @click="store.retry">重试</button>
|
||
</div>
|
||
|
||
<!-- 生成按钮 -->
|
||
<button
|
||
v-else
|
||
class="generate-btn"
|
||
:class="{ disabled: !store.canGenerate }"
|
||
:disabled="!store.canGenerate"
|
||
@click="store.createTask"
|
||
>
|
||
<div class="btn-glow"></div>
|
||
<div class="btn-left">
|
||
<template v-if="store.createStep === 'creating'">
|
||
<LoadingOutlined class="btn-spin" spin />
|
||
<span>正在创建...</span>
|
||
</template>
|
||
<template v-else>
|
||
<PlayCircleOutlined class="btn-icon" />
|
||
<span>生成视频</span>
|
||
</template>
|
||
</div>
|
||
<div class="btn-right">
|
||
<span class="cost-num">{{ estimatedPoints }}</span>
|
||
<span class="cost-label">积分</span>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 右侧:配音文案模块 -->
|
||
<section class="content-block">
|
||
<div class="block-header">
|
||
<span class="block-emoji">🎙️</span>
|
||
<h3 class="block-title">配音文案</h3>
|
||
</div>
|
||
|
||
<!-- 文案输入 -->
|
||
<div class="input-section">
|
||
<div class="label-row">
|
||
<label class="input-label">播报文案</label>
|
||
<button class="generate-text-btn" @click="openTextGeneratePopup">
|
||
<EditOutlined />
|
||
<span>AI 生成</span>
|
||
</button>
|
||
</div>
|
||
<a-textarea
|
||
v-model:value="store.text"
|
||
:placeholder="placeholder"
|
||
:rows="6"
|
||
:maxlength="4000"
|
||
:show-count="true"
|
||
class="notion-textarea"
|
||
:bordered="false"
|
||
/>
|
||
<div class="input-footer">
|
||
<span>{{ store.text?.length || 0 }} 字</span>
|
||
<span v-if="store.faceDurationMs > 0">· 建议 {{ suggestedChars }} 字</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文案生成浮窗 -->
|
||
<TextGeneratePopup
|
||
v-model:visible="textGenerateVisible"
|
||
@success="handleTextGenerated"
|
||
/>
|
||
|
||
<!-- 音色选择 -->
|
||
<div class="input-section">
|
||
<label class="input-label">选择音色</label>
|
||
<VoiceSelector
|
||
:synth-text="store.text"
|
||
:speech-rate="store.speechRate"
|
||
@select="store.setVoice"
|
||
@audio-generated="handleAudioGenerated"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 语速调节 -->
|
||
<div class="input-section">
|
||
<label class="input-label">语速调节</label>
|
||
<div class="rate-control">
|
||
<a-slider
|
||
v-model:value="store.speechRate"
|
||
:min="0.5"
|
||
:max="2.0"
|
||
:step="0.1"
|
||
:marks="rateMarks"
|
||
class="rate-slider"
|
||
/>
|
||
<span class="rate-value">{{ store.speechRate.toFixed(1) }}x</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 时间轴对比 -->
|
||
<TimelinePanel
|
||
v-if="store.timeline"
|
||
:face-duration-ms="store.timeline.videoDurationMs"
|
||
:audio-duration-ms="store.timeline.audioDurationMs"
|
||
:face-start-time="store.timeline.faceStartTime"
|
||
:face-end-time="store.timeline.faceEndTime"
|
||
/>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 视频选择器弹窗 -->
|
||
<VideoSelector
|
||
v-model:open="store.videoSelectorVisible"
|
||
@select="store.selectVideo"
|
||
/>
|
||
</FullWidthLayout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue'
|
||
import VideoSelector from '@/components/VideoSelector.vue'
|
||
import VoiceSelector from '@/components/VoiceSelector.vue'
|
||
import TimelinePanel from './components/TimelinePanel.vue'
|
||
import TextGeneratePopup from './components/TextGeneratePopup.vue'
|
||
import {
|
||
CloudUploadOutlined,
|
||
FolderOutlined,
|
||
CheckCircleOutlined,
|
||
ExclamationCircleOutlined,
|
||
ReloadOutlined,
|
||
PlayCircleOutlined,
|
||
CheckCircleFilled,
|
||
ExclamationCircleFilled,
|
||
LoadingOutlined,
|
||
EditOutlined,
|
||
} from '@ant-design/icons-vue'
|
||
import { useDigitalHumanStore } from './stores/useDigitalHumanStore'
|
||
import { useUserStore } from '@/stores/user'
|
||
import { usePointsConfigStore } from '@/stores/pointsConfig'
|
||
import { useVoiceCopyStore } from '@/stores/voiceCopy'
|
||
import { formatDuration, formatDurationMs, formatFileSize } from './utils/format'
|
||
|
||
const store = useDigitalHumanStore()
|
||
const userStore = useUserStore()
|
||
const pointsConfigStore = usePointsConfigStore()
|
||
const voiceStore = useVoiceCopyStore()
|
||
|
||
const dragOver = ref(false)
|
||
const fileInput = ref<HTMLInputElement | null>(null)
|
||
const textGenerateVisible = ref(false)
|
||
|
||
const estimatedPoints = computed(() => {
|
||
const points = pointsConfigStore.getConsumePoints('kling')
|
||
return points ?? 150
|
||
})
|
||
|
||
const suggestedChars = computed(() => {
|
||
return Math.floor((store.faceDurationMs || 10000) / 1000 * 4)
|
||
})
|
||
|
||
const placeholder = computed(() =>
|
||
store.faceDurationMs > 0
|
||
? `请输入播报文案,建议不超过 ${suggestedChars.value} 字以确保与视频匹配`
|
||
: '请输入你想让角色说话的内容'
|
||
)
|
||
|
||
const rateMarks = {
|
||
0.5: '0.5x',
|
||
1.0: '1.0x',
|
||
1.5: '1.5x',
|
||
2.0: '2.0x',
|
||
}
|
||
|
||
function triggerFileSelect() {
|
||
fileInput.value?.click()
|
||
}
|
||
|
||
function handleFileSelect(e: Event) {
|
||
const file = (e.target as HTMLInputElement).files?.[0]
|
||
if (file) {
|
||
store.handleFileUpload(file)
|
||
}
|
||
}
|
||
|
||
function handleDrop(e: DragEvent) {
|
||
dragOver.value = false
|
||
const file = e.dataTransfer?.files[0]
|
||
if (file) {
|
||
store.handleFileUpload(file)
|
||
}
|
||
}
|
||
|
||
function clearVideo() {
|
||
if (store.videoPreviewUrl?.startsWith('blob:')) {
|
||
URL.revokeObjectURL(store.videoPreviewUrl)
|
||
}
|
||
store.videoFile = null
|
||
store.selectedVideo = null
|
||
store.videoPreviewUrl = ''
|
||
store.videoSource = null
|
||
store.resetProcess()
|
||
}
|
||
|
||
function handleAudioGenerated(data: { durationMs: number; audioBase64: string }) {
|
||
if (store.timeline && data.durationMs > 0) {
|
||
store.timeline.audioDurationMs = data.durationMs
|
||
}
|
||
if (data.audioBase64) {
|
||
store.audioData = {
|
||
audioBase64: data.audioBase64,
|
||
format: 'mp3',
|
||
durationMs: data.durationMs
|
||
}
|
||
store.audioStep = 'generated'
|
||
}
|
||
}
|
||
|
||
function openTextGeneratePopup() {
|
||
textGenerateVisible.value = true
|
||
}
|
||
|
||
function handleTextGenerated(text: string) {
|
||
store.text = text
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([
|
||
voiceStore.refresh(),
|
||
userStore.fetchUserProfile(),
|
||
pointsConfigStore.loadConfig(),
|
||
])
|
||
})
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
// ========================================
|
||
// Notion + VoiceSelector 蓝紫主题
|
||
// ========================================
|
||
|
||
// Color Palette - 与 VoiceSelector 协调
|
||
@bg-page: #fafbfc;
|
||
@bg-block: #ffffff;
|
||
@text-primary: #1e293b;
|
||
@text-secondary: #64748b;
|
||
@text-tertiary: #94a3b8;
|
||
@border-light: rgba(59, 130, 246, 0.1);
|
||
@border-medium: rgba(59, 130, 246, 0.2);
|
||
|
||
// 蓝紫渐变主题色 (与 VoiceSelector 一致)
|
||
@accent-blue: #3b82f6;
|
||
@accent-purple: #8b5cf6;
|
||
@accent-gradient: linear-gradient(135deg, @accent-blue 0%, @accent-purple 100%);
|
||
@accent-green: #10b981;
|
||
@accent-red: #ef4444;
|
||
@accent-orange: #f59e0b;
|
||
|
||
// Shadows - 柔和蓝调
|
||
@shadow-sm: 0 1px 2px rgba(59, 130, 246, 0.04);
|
||
@shadow-md: 0 4px 12px rgba(59, 130, 246, 0.08);
|
||
@shadow-lg: 0 8px 24px rgba(59, 130, 246, 0.12);
|
||
|
||
// Typography
|
||
@font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, 'Apple Color Emoji', Arial, sans-serif;
|
||
|
||
.notion-page {
|
||
min-height: 100vh;
|
||
background: @bg-page;
|
||
padding: 48px 64px;
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
|
||
@media (max-width: 1024px) {
|
||
padding: 32px 24px;
|
||
}
|
||
}
|
||
|
||
// Page Header
|
||
.page-header {
|
||
margin-bottom: 40px;
|
||
|
||
.header-icon {
|
||
font-size: 56px;
|
||
margin-bottom: 16px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.header-title {
|
||
font-family: @font-sans;
|
||
font-size: 40px;
|
||
font-weight: 700;
|
||
color: @text-primary;
|
||
margin: 0 0 8px 0;
|
||
letter-spacing: -0.5px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.header-desc {
|
||
font-size: 16px;
|
||
color: @text-secondary;
|
||
margin: 0;
|
||
line-height: 1.5;
|
||
}
|
||
}
|
||
|
||
// Main Content Layout
|
||
.main-content {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 24px;
|
||
margin-bottom: 32px;
|
||
|
||
@media (max-width: 1024px) {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
// Content Block
|
||
.content-block {
|
||
background: @bg-block;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
box-shadow: @shadow-md;
|
||
border: 1px solid @border-light;
|
||
transition: all 0.3s ease;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
&:hover {
|
||
box-shadow: @shadow-lg;
|
||
border-color: rgba(59, 130, 246, 0.2);
|
||
}
|
||
}
|
||
|
||
// Block Header
|
||
.block-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 14px;
|
||
border-bottom: 1px solid rgba(59, 130, 246, 0.08);
|
||
|
||
.block-emoji {
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.block-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: @text-primary;
|
||
margin: 0;
|
||
}
|
||
}
|
||
|
||
// Source Toggle
|
||
.source-toggle {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 20px;
|
||
background: @bg-page;
|
||
padding: 4px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.toggle-btn {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 10px 16px;
|
||
border: none;
|
||
background: transparent;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: @text-secondary;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
|
||
&:hover {
|
||
color: @text-primary;
|
||
background: rgba(59, 130, 246, 0.05);
|
||
}
|
||
|
||
&.active {
|
||
background: @bg-block;
|
||
color: @accent-blue;
|
||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.12);
|
||
}
|
||
}
|
||
|
||
// Upload Area
|
||
.upload-area {
|
||
min-height: 200px;
|
||
border: 1.5px dashed rgba(59, 130, 246, 0.2);
|
||
border-radius: 10px;
|
||
background: rgba(59, 130, 246, 0.02);
|
||
transition: all 0.25s ease;
|
||
|
||
&.dragover {
|
||
border-color: @accent-blue;
|
||
background: rgba(59, 130, 246, 0.06);
|
||
}
|
||
|
||
&.has-video {
|
||
border-style: solid;
|
||
border-color: rgba(59, 130, 246, 0.1);
|
||
background: @bg-block;
|
||
}
|
||
}
|
||
|
||
.hidden-input {
|
||
display: none;
|
||
}
|
||
|
||
.upload-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 200px;
|
||
cursor: pointer;
|
||
padding: 24px;
|
||
}
|
||
|
||
.upload-icon-wrapper {
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: 50%;
|
||
background: rgba(59, 130, 246, 0.08);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 14px;
|
||
transition: all 0.25s ease;
|
||
|
||
.upload-icon {
|
||
font-size: 24px;
|
||
color: @accent-blue;
|
||
}
|
||
}
|
||
|
||
.upload-empty:hover .upload-icon-wrapper {
|
||
background: rgba(59, 130, 246, 0.12);
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.upload-text {
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
color: @text-primary;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.upload-hint {
|
||
font-size: 13px;
|
||
color: @text-tertiary;
|
||
}
|
||
|
||
.upload-preview {
|
||
padding: 16px;
|
||
}
|
||
|
||
.preview-video {
|
||
width: 100%;
|
||
max-height: 260px;
|
||
border-radius: 4px;
|
||
background: #191919;
|
||
}
|
||
|
||
.replace-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-top: 12px;
|
||
padding: 8px 14px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: @text-secondary;
|
||
background: transparent;
|
||
border: 1px solid rgba(59, 130, 246, 0.15);
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
|
||
&:hover {
|
||
color: @accent-blue;
|
||
border-color: @accent-blue;
|
||
background: rgba(59, 130, 246, 0.05);
|
||
}
|
||
}
|
||
|
||
// Library Video Preview
|
||
.library-preview {
|
||
padding: 16px;
|
||
background: rgba(59, 130, 246, 0.03);
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(59, 130, 246, 0.1);
|
||
|
||
.preview-video {
|
||
width: 100%;
|
||
max-height: 260px;
|
||
border-radius: 4px;
|
||
background: #191919;
|
||
}
|
||
|
||
.video-meta {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.replace-btn {
|
||
margin-top: 8px;
|
||
}
|
||
}
|
||
|
||
.video-thumbnail {
|
||
width: 120px;
|
||
height: 68px;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
background: #191919;
|
||
flex-shrink: 0;
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
}
|
||
|
||
.video-meta {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.video-name {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: @text-primary;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
margin-bottom: 4px;
|
||
max-width: 180px;
|
||
}
|
||
|
||
.video-info {
|
||
font-size: 12px;
|
||
color: @text-tertiary;
|
||
}
|
||
|
||
// Process Status
|
||
.process-status {
|
||
margin-top: 14px;
|
||
padding: 12px 16px;
|
||
background: rgba(59, 130, 246, 0.04);
|
||
border-radius: 8px;
|
||
|
||
&.recognized {
|
||
background: rgba(16, 185, 129, 0.08);
|
||
}
|
||
|
||
&.error {
|
||
background: rgba(239, 68, 68, 0.08);
|
||
}
|
||
}
|
||
|
||
.status-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 13px;
|
||
color: @text-secondary;
|
||
|
||
&.success {
|
||
color: @accent-green;
|
||
}
|
||
|
||
&.error {
|
||
color: @accent-red;
|
||
}
|
||
}
|
||
|
||
.link-btn {
|
||
margin-left: auto;
|
||
padding: 0;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: @accent-blue;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
|
||
// Input Section
|
||
.input-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.input-label {
|
||
display: block;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: @text-secondary;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.label-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.notion-textarea {
|
||
width: 100%;
|
||
|
||
:deep(.ant-input) {
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
line-height: 1.6;
|
||
padding: 14px 16px;
|
||
background: rgba(59, 130, 246, 0.03);
|
||
color: @text-primary;
|
||
|
||
&::placeholder {
|
||
color: @text-tertiary;
|
||
}
|
||
|
||
&:focus {
|
||
background: @bg-block;
|
||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
|
||
}
|
||
}
|
||
|
||
:deep(.ant-input-data-count) {
|
||
color: @text-tertiary;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
.input-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 12px;
|
||
color: @text-tertiary;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.generate-text-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 10px;
|
||
background: transparent;
|
||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: @accent-blue;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
|
||
&:hover {
|
||
background: rgba(59, 130, 246, 0.06);
|
||
border-color: rgba(59, 130, 246, 0.3);
|
||
}
|
||
}
|
||
|
||
// Rate Control
|
||
.rate-control {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
}
|
||
|
||
.rate-slider {
|
||
flex: 1;
|
||
|
||
:deep(.ant-slider-rail) {
|
||
background: rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
:deep(.ant-slider-track) {
|
||
background: @accent-gradient;
|
||
}
|
||
|
||
:deep(.ant-slider-handle) {
|
||
border-color: @accent-blue;
|
||
|
||
&:hover, &:focus {
|
||
border-color: @accent-blue;
|
||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15);
|
||
}
|
||
}
|
||
|
||
:deep(.ant-slider-mark-text) {
|
||
font-size: 11px;
|
||
color: @text-tertiary;
|
||
}
|
||
}
|
||
|
||
.rate-value {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: @text-primary;
|
||
min-width: 40px;
|
||
text-align: right;
|
||
}
|
||
|
||
// 生成按钮 - 左右布局,右下角
|
||
.generate-action {
|
||
margin-top: auto;
|
||
padding-top: 16px;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.result-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
|
||
&.success {
|
||
background: rgba(16, 185, 129, 0.08);
|
||
color: @accent-green;
|
||
}
|
||
|
||
&.error {
|
||
background: rgba(239, 68, 68, 0.08);
|
||
color: @accent-red;
|
||
}
|
||
|
||
.banner-icon {
|
||
font-size: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.banner-text {
|
||
flex: 1;
|
||
text-align: right;
|
||
}
|
||
|
||
.retry-btn {
|
||
padding: 4px 12px;
|
||
border-radius: 6px;
|
||
border: 1px solid currentColor;
|
||
background: transparent;
|
||
color: inherit;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
|
||
&:hover {
|
||
background: rgba(16, 185, 129, 0.1);
|
||
}
|
||
}
|
||
}
|
||
|
||
.generate-btn {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 14px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: @accent-gradient;
|
||
color: white;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
transition: all 0.25s ease;
|
||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.25);
|
||
|
||
&:hover:not(.disabled) {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.35);
|
||
}
|
||
|
||
&:active:not(.disabled) {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
&.disabled {
|
||
background: linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%);
|
||
cursor: not-allowed;
|
||
box-shadow: 0 2px 8px rgba(148, 163, 184, 0.25);
|
||
}
|
||
|
||
.btn-glow {
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(
|
||
90deg,
|
||
transparent,
|
||
rgba(255, 255, 255, 0.15),
|
||
transparent
|
||
);
|
||
animation: glow-slide 3s ease-in-out infinite;
|
||
}
|
||
|
||
.btn-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.btn-icon {
|
||
font-size: 15px;
|
||
}
|
||
|
||
.btn-spin {
|
||
font-size: 13px;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
.btn-right {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 2px;
|
||
padding: 2px 8px;
|
||
background: rgba(255, 255, 255, 0.15);
|
||
border-radius: 10px;
|
||
font-size: 10px;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.cost-num {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.cost-label {
|
||
opacity: 0.9;
|
||
}
|
||
}
|
||
|
||
@keyframes glow-slide {
|
||
0% {
|
||
left: -100%;
|
||
}
|
||
50%, 100% {
|
||
left: 100%;
|
||
}
|
||
}
|
||
|
||
@keyframes spin {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.link-btn {
|
||
padding: 0;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: inherit;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
text-decoration: underline;
|
||
text-underline-offset: 2px;
|
||
|
||
&:hover {
|
||
opacity: 0.8;
|
||
}
|
||
}
|
||
</style>
|