Commit 048729dc authored by 黄同智's avatar 黄同智

基本完成agent 创作的模块内容

parent 4ebe9c71
......@@ -82,6 +82,12 @@ const routes = [
name: 'Agent',
component: Agent,
meta: { title: 'Agent创作', icon: 'audio', affix: true }
},
{
path: 'product-video',
name: 'AgentProductVideo',
component: () => import('@/views/agent/product-video.vue'),
meta: { title: '商品营销视频', hidden: true }
}
]
},
......
<template>
<el-drawer v-model="drawerVisible" direction="rtl" size="388px" :with-header="false" :modal="false" class="history-drawer">
<div class="task-drawer h-full flex-col bg-fff">
<header class="drawer-header flex-between h-56 px-18">
<div class="flex-items-center gap-8">
<el-icon class="fs-18 color-main">
<Refresh />
</el-icon>
<span class="fs-16 fw-700 color-000">任务队列</span>
<span class="count-tag h-20 lh-20 px-8 round-4 fs-12 color-888 fw-700">30</span>
</div>
<el-icon class="fs-18 color-888 pointer" @click="drawerVisible = false">
<Close />
</el-icon>
</header>
<div class="task-stats flex h-58">
<div v-for="item in taskStats" :key="item.label" class="stat-item flex-1 flex-col flex-center">
<span class="fs-12 color-888 lh-20">{{ item.label }}</span>
<span class="fs-12 fw-700" :class="item.valueClass">{{ item.value }}</span>
</div>
</div>
<div class="task-tabs flex-items-center h-40 px-12">
<div v-for="item in taskTabs" :key="item.value" class="task-tab h-40 lh-40 px-14 fs-14 fw-700 pointer"
:class="{ active: activeTaskTab === item.value }" @click="switchTaskTab(item.value)">
{{ item.label }}
</div>
</div>
<div class="task-filter px-12 py-12">
<div class="flex flex-wrap gap-8">
<span v-for="item in currentTaskFilters" :key="item.value"
class="filter-chip h-28 lh-28 px-12 round-6 fs-12 fw-700 pointer"
:class="{ active: activeTaskFilter === item.value }" @click="activeTaskFilter = item.value">
{{ item.label }}
</span>
</div>
</div>
<section class="task-list flex-1 over-y-auto hide-scroll px-12 py-8">
<template v-for="group in filteredTaskGroups" :key="group.date">
<div class="date-title fs-12 fw-700 color-888 mt-4 mb-8">{{ group.date }}</div>
<div v-for="item in group.items" :key="item.id" class="task-card bg-fff round-8 p-12 mb-12">
<div class="flex gap-12">
<img :src="item.cover" :alt="item.title" class="task-cover wh-64 round-6 cover" />
<div class="flex-1 hidden">
<div class="flex-between gap-8">
<div class="fs-14 fw-700 color-000 lh-22 ellipsis">{{ item.title }}</div>
<span class="status-tag h-24 lh-24 px-8 round-4 fs-12 fw-700"
:class="item.statusClass">
{{ item.status }}
</span>
</div>
<div class="fs-10 color-888 lh-20">{{ item.type }}</div>
<div class="flex-between">
<div class="fs-12 color-888">
消耗积分 <span class="cost-value fw-700">{{ item.cost }}</span>
</div>
<span class="fs-10 color-888">{{ item.time }}</span>
</div>
</div>
</div>
<div v-if="item.progressText" class="progress-box mt-12 p-10 round-6">
<div class="flex-between fs-10 color-888 mb-8">
<span class="flex-items-center gap-4">
<el-icon>
<Clock />
</el-icon>
{{ item.progressText }}
</span>
<span>{{ item.progress }}%</span>
</div>
<el-progress :percentage="item.progress" :show-text="false" :stroke-width="5" />
</div>
<div class="task-card-footer flex justify-end gap-8 mt-12 pt-12">
<el-button v-if="item.canCancel" class="h-28 px-12 fs-12">
<el-icon>
<Close />
</el-icon>
<span>取消任务</span>
</el-button>
<el-button type="primary" class="h-28 px-12 fs-12 border-none enter-btn">
<el-icon>
<View />
</el-icon>
<span>进入会话</span>
</el-button>
</div>
</div>
</template>
</section>
<footer class="drawer-footer flex-center h-40 fs-12 color-888">
<span v-if="activeTaskTab === 'recent'">
近期任务仅展示最近20条,更多请查看
<span class="color-main fw-700 pointer" @click="switchTaskTab('all')">全部任务</span>
</span>
<span v-else>共展示 {{ filteredTaskCount }} 个任务</span>
</footer>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Clock, Close, Refresh, View } from '@element-plus/icons-vue';
import productImg from '@/static/image/huaban.png';
import peopleImg from '@/static/image/meinv.png';
type TaskStatus = 'success' | 'queue' | 'failed';
type TaskItem = {
id: string;
title: string;
type: string;
category: string;
date: string;
time: string;
cost: string;
cover: string;
status: string;
statusClass: string;
statusKey: TaskStatus;
progress?: number;
progressText?: string;
canCancel?: boolean;
};
const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
}>();
const drawerVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const activeTaskTab = ref('recent');
const activeTaskFilter = ref('agent');
const taskTabs = [
{ label: '近期任务', value: 'recent' },
{ label: '全部任务', value: 'all' },
];
const recentTaskFilters = [
{ label: 'Agent创作', value: 'agent' },
{ label: '工具', value: 'tool' },
];
const allTaskFilters = [
{ label: '全部', value: 'all' },
{ label: 'Agent', value: 'agent' },
{ label: '快速创作', value: 'quick' },
{ label: '视频去水印', value: 'watermark' },
{ label: '字幕擦除', value: 'subtitle' },
{ label: '画质增强', value: 'quality' },
{ label: '数字人分身', value: 'digital' },
{ label: '模特换衣', value: 'tryOn' },
{ label: '爆款复制', value: 'copy' },
{ label: 'AI视频素材', value: 'videoMaterial' },
{ label: 'AI图片素材', value: 'imageMaterial' },
];
const taskStats = [
{ label: '排队中', value: 6, valueClass: 'color-333' },
{ label: '生成中', value: 1, valueClass: 'color-main' },
{ label: '生成失败', value: 2, valueClass: 'failed-text' },
];
const taskGroups = [
{
date: '9月1日',
items: [
{
id: 'task-1',
title: '流量',
type: 'Agent创作',
category: 'agent',
date: '9月1日',
time: '08:40',
cost: '-0.00',
cover: productImg,
status: '生成成功',
statusClass: 'success',
statusKey: 'success',
},
{
id: 'task-2',
title: '防晒霜SPF50权威检测图',
type: 'Agent创作',
category: 'agent',
date: '9月1日',
time: '08:16',
cost: '-15.00',
cover: productImg,
status: '生成成功',
statusClass: 'success',
statusKey: 'success',
},
{
id: 'task-3',
title: '看看卡萨斯',
type: 'Agent创作',
category: 'agent',
date: '9月1日',
time: '05:39',
cost: '-5.00',
cover: productImg,
status: '生成成功',
statusClass: 'success',
statusKey: 'success',
},
],
},
{
date: '8月31日',
items: [
{
id: 'task-4',
title: '请结合使用已投放素材和原料库为我生成一个短视频广告',
type: 'Agent创作',
category: 'agent',
date: '8月31日',
time: '07:32',
cost: '-0.00',
cover: peopleImg,
status: '生成成功',
statusClass: 'success',
statusKey: 'success',
},
{
id: 'task-5',
title: '用我的「商品」拍一条营销视频',
type: 'Agent创作',
category: 'agent',
date: '8月31日',
time: '07:31',
cost: '-0.00',
cover: productImg,
status: '排队中',
statusClass: 'queue',
statusKey: 'queue',
progress: 0,
progressText: '等待计算资源',
canCancel: true,
},
{
id: 'task-6',
title: '1',
type: 'Agent创作',
category: 'agent',
date: '8月31日',
time: '07:31',
cost: '-0.00',
cover: productImg,
status: '生成成功',
statusClass: 'success',
statusKey: 'success',
},
],
},
] satisfies Array<{ date: string; items: TaskItem[] }>;
const currentTaskFilters = computed(() => (activeTaskTab.value === 'recent' ? recentTaskFilters : allTaskFilters));
const filteredTaskGroups = computed(() => {
const filterValue = activeTaskFilter.value;
return taskGroups
.map((group) => ({
...group,
items: group.items.filter((item) => filterValue === 'all' || item.category === filterValue),
}))
.filter((group) => group.items.length);
});
const filteredTaskCount = computed(() => filteredTaskGroups.value.reduce((total, group) => total + group.items.length, 0));
const switchTaskTab = (value: string) => {
activeTaskTab.value = value;
activeTaskFilter.value = value === 'recent' ? 'agent' : 'all';
};
</script>
<style scoped lang="scss">
.drawer-header {
border-bottom: 1px solid #edf2f7;
}
.count-tag,
.progress-box {
background: #f3f6fb;
}
.task-stats,
.drawer-footer {
background: #f8fafc;
}
.stat-item + .stat-item {
border-left: 1px solid #dce6f2;
}
.failed-text,
.cost-value {
color: #ff5a00;
}
.task-tabs,
.task-filter,
.drawer-footer {
border-top: 1px solid #edf2f7;
}
.task-tabs {
border-bottom: 1px solid #edf2f7;
}
.task-tab {
color: #536883;
border-bottom: 2px solid transparent;
&.active {
color: rgba(var(--main-color), 1);
border-bottom-color: rgba(var(--main-color), 1);
}
}
.filter-chip {
color: #536883;
background: #f3f6fb;
&.active {
color: #fff;
background: #17243a;
}
}
.task-card {
border: 1px solid #dce6f2;
box-shadow: 0 2px 8px rgba(20, 38, 70, .06);
}
.task-cover {
flex: 0 0 64px;
background: #f3f6fb;
}
.status-tag.success {
color: #00a86b;
background: #e9fbf2;
}
.status-tag.queue {
color: #536883;
background: #f3f6fb;
}
.status-tag.failed {
color: #ff5a00;
background: #fff2e8;
}
.task-card-footer {
border-top: 1px solid #edf2f7;
}
.enter-btn {
background: #7d28ff;
}
:deep(.progress-box .el-progress-bar__outer) {
background: #dce6f2;
}
:deep(.progress-box .el-progress-bar__inner) {
background: #8aa0bf;
}
</style>
<style>
.history-drawer {
width: 380px !important;
box-shadow: 0 0 8px #bbb;
}
.history-drawer .el-drawer__body {
padding: 0 !important;
}
</style>
<template>
<main class="flex-1 flex-col">
<div class="flex-1 over-y-auto hide-scroll py-20 px-20">
<div>
<div class="mb-10">
1. 商品参考图
<span class="fs-12 color-888">(上传丰富的参考图有助于提升模型的生成质量)</span>
</div>
<UploadFile v-model="referenceFiles" :width="104" :show-file-list="false" />
</div>
<div class="mt-20">
<div class="fw-600">2. 商品详细信息</div>
<div class="flex gap-12 flex-wrap mt-10">
<div class="base-card px-16 py-10 round-10 bg-fff">
<div class="mb-10">商品名称</div>
<el-input v-model="product.name" class="plain-input" />
</div>
<div class="base-card px-16 py-10 round-10 bg-fff">
<div class="mb-10">商品行业</div>
<el-input v-model="product.industry" class="plain-input" />
</div>
<div class="base-card px-16 py-10 round-10 bg-fff">
<div class="mb-10">商品品类</div>
<el-input v-model="product.category" class="plain-input" />
</div>
</div>
<div class="flex flex-wrap gap-12 mt-20">
<div v-for="section in productSections" :key="section.key"
class="detail-card flex-col p-18 round-10 bg-fff"
:class="{ editing: isSectionEditing(section.key) }">
<div class="flex-between mb-14">
<div class="fs-14 fw-600">{{ section.title }}</div>
<el-icon class="edit-icon pointer" @click.stop="toggleSectionEdit(section.key)">
<Check v-if="isSectionEditing(section.key)" />
<Edit v-else />
</el-icon>
</div>
<template v-if="isSectionEditing(section.key)">
<template v-if="section.items.length">
<div v-for="(_, index) in section.items" :key="`${section.key}-${index}`"
class="editable-row flex-items-center gap-8 h-34 px-8 mb-6 round-4">
<span class="dot flex-center wh-6 round"></span>
<el-input v-model="section.items[index]" class="row-input flex-1" />
<el-icon class="delete-line color-999 pointer"
@click="removeSectionItem(section.key, index)">
<Delete />
</el-icon>
</div>
</template>
<div v-else class="fs-14 color-666 mb-12">暂无{{ section.title }}</div>
<div class="add-row flex-items-center gap-8 mt-8">
<el-input v-model="section.draft" class="add-input flex-1"
:placeholder="`添加${section.title}`" @keyup.enter="addSectionItem(section.key)" />
<el-button type="primary" class="add-btn wh-34 p-0 border-none"
@click="addSectionItem(section.key)">
<el-icon>
<Plus />
</el-icon>
</el-button>
</div>
</template>
<template v-else>
<div v-if="section.items.length" class="view-list flex-col gap-8">
<div v-for="item in section.items" :key="item"
class="view-row flex-items-center gap-8 fs-12 lh-22 color-333">
<span class="dot flex-center wh-6 round"></span>
<span>{{ item }}</span>
</div>
</div>
<div v-else class="fs-14 color-666">暂无{{ section.title }}</div>
</template>
</div>
</div>
</div>
</div>
<footer class="flex justify-end pt-12 pb-12 pr-20" style="border-top: 1px solid #eee;">
<el-button class="fs-12">一键成片</el-button>
<el-button type="primary" class="fs-12 border-none create-btn" @click="emit('createRecord')">
生成创意与分镜
</el-button>
</footer>
</main>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import type { UploadUserFile } from 'element-plus';
import { Check, Delete, Edit, Plus } from '@element-plus/icons-vue';
import UploadFile from '@/components/Upload/index.vue';
type ProductSection = {
key: string;
title: string;
draft: string;
items: string[];
};
const emit = defineEmits<{
createRecord: [];
}>();
const product = reactive({
name: '看看卡萨斯',
industry: '电商零售',
category: '待补充具体品类',
});
const referenceFiles = ref<UploadUserFile[]>([]);
const editingSectionKeys = ref<string[]>([]);
const productSections = reactive<ProductSection[]>([
{
key: 'selling',
title: '商品卖点',
draft: '',
items: ['核心功能突出', '使用简单', '效果直观', '适配日常场景'],
},
{
key: 'pain',
title: '商品痛点',
draft: '',
items: ['现有产品使用体验不佳', '难以直观看到使用效果', '同类商品选择成本高'],
},
{
key: 'target',
title: '目标人群',
draft: '',
items: ['有看看卡萨斯相关使用需求的消费者', '关注实际效果的人群', '重视性价比的用户'],
},
{
key: 'scene',
title: '适用人群和场景',
draft: '',
items: ['日常家庭使用', '需要快速解决问题时使用', '购买同类商品前对比选择'],
},
{
key: 'spec',
title: '商品规格',
draft: '',
items: ['核心功能配置', '便携易用设计', '适配常见使用环境'],
},
{
key: 'discount',
title: '优惠信息',
draft: '',
items: [],
},
]);
const findSection = (key: string) => productSections.find((section) => section.key === key);
const isSectionEditing = (key: string) => editingSectionKeys.value.includes(key);
const toggleSectionEdit = (key: string) => {
if (isSectionEditing(key)) {
editingSectionKeys.value = editingSectionKeys.value.filter((item) => item !== key);
return;
}
editingSectionKeys.value = [...editingSectionKeys.value, key];
};
const addSectionItem = (key: string) => {
const section = findSection(key);
const value = section?.draft.trim();
if (!section || !value) {
return;
}
section.items.push(value);
section.draft = '';
};
const removeSectionItem = (key: string, index: number) => {
const section = findSection(key);
section?.items.splice(index, 1);
};
</script>
<style scoped lang="scss">
.base-card,
.detail-card {
border: 1px solid #dce6f2;
}
.base-card {
flex: 0 0 calc((100% - 12px) / 2);
&:hover {
border-color: rgba(var(--main-color), .6);
}
}
.detail-card {
flex: 0 0 calc((100% - 12px) / 2);
min-height: 158px;
transition: border-color .2s, box-shadow .2s;
&:hover {
border-color: rgba(var(--main-color), .6);
}
&.editing {
border-color: #dce6f2;
}
}
.editable-row {
background: #f8fafc;
&:hover {
background: #f3f6fa;
}
}
.dot {
flex: 0 0 6px;
background: #c8d4e3;
}
.edit-icon {
color: #8aa0bf;
&:hover {
color: #7d28ff;
}
}
.delete-line {
flex: 0 0 18px;
}
.add-row {
border-top: 1px solid #edf2f7;
padding-top: 10px;
}
.add-btn,
.create-btn {
background: #7d28ff;
}
:deep(.plain-input .el-input__wrapper),
:deep(.row-input .el-input__wrapper),
:deep(.add-input .el-input__wrapper) {
padding: 0;
box-shadow: none;
background: transparent;
}
:deep(.plain-input .el-input__inner) {
height: 26px;
font-size: 14px;
color: #0a1b34;
}
:deep(.row-input .el-input__inner),
:deep(.add-input .el-input__inner) {
height: 30px;
font-size: 12px;
}
:deep(.add-input .el-input__wrapper) {
padding: 0 10px;
border: 1px solid #dce6f2;
border-radius: 4px;
background: #fff;
}
</style>
<template>
<main class="step2-main flex-1 flex hidden">
<aside class="creative-list flex-col gap-12 py-20 pr-16 pl-16">
<div v-for="item in creatives" :key="item.id" class="creative-card p-12 round-6 pointer"
:class="{ active: selectedCreativeId === item.id }" @click="selectedCreativeId = item.id">
<div class="flex-between mb-10">
<span class="fs-14 fw-700 color-000">{{ item.title }}</span>
<span class="tag h-24 lh-24 px-8 round-4 fs-12">{{ item.tag }}</span>
</div>
<div class="fs-14 fw-700 color-000 mb-8 ellipsis">{{ item.name }}</div>
<div class="fs-12 color-666 lh-20 ellipsis-2">{{ item.desc }}</div>
</div>
</aside>
<div class="flex-1 flex-col">
<section class="step2-content flex-1 over-y-auto hide-scroll py-20 pr-20">
<div class="creative-detail bg-fff round-8 hidden">
<div class="p-24">
<div class="flex-between mb-14">
<div class="flex-items-center gap-8">
<span class="color-main fs-12 fw-700">{{ selectedCreative.title }}</span>
<span class="tag h-22 lh-22 px-8 round-4 fs-12">{{ selectedCreative.tag }}</span>
</div>
<el-icon class="color-888 pointer">
<MoreFilled />
</el-icon>
</div>
<div class=" fw-700 color-000 mb-22">{{ selectedCreative.name }}</div>
<div class="summary-box p-16 round-8">
<div class="fs-14 fw-700 color-000 mb-10">创意概述</div>
<div class="fs-14 color-333 lh-24">{{ selectedCreative.desc }}</div>
</div>
<div class="flex-items-center gap-6 mt-18 pointer color-333 fs-12 fw-700"
@click="isCreativeExpanded = !isCreativeExpanded">
<span>{{ isCreativeExpanded ? '收起完整创意' : '查看完整创意' }}</span>
<el-icon>
<ArrowUp v-if="isCreativeExpanded" />
<ArrowRight v-else />
</el-icon>
</div>
<div v-if="isCreativeExpanded" class="full-creative mt-24 pt-24">
<div v-for="item in fullCreativeItems" :key="item.title" class="mb-24">
<div class="fs-14 fw-700 color-000 mb-12">{{ item.title }}</div>
<div class="fs-14 color-333 lh-24">{{ item.content }}</div>
</div>
</div>
</div>
<div class="script-panel p-24">
<h3 class="fs-14 fw-700 color-main mb-28">分镜脚本</h3>
<section class="mb-28">
<h4 class="fs-14 fw-700 color-000 mb-14">1. 主体设定</h4>
<div class="subject-list flex flex-wrap gap-12">
<div v-for="item in subjects" :key="item.id"
class="subject-card w-220 p-12 round-8 pointer relative" @click="openSubjectDialog(item.id)">
<div class="flex-between mb-8 ">
<div>
<div class="fs-14 fw-700 color-000 mb-6">{{ item.title }}</div>
<div class="fs-12 color-666 ellipsis">{{ item.desc }}</div>
</div>
<el-button class="wh-28 p-0 border-none bg-fff absolute top-10 right-10" @click.stop="openSubjectDialog(item.id)">
<el-icon>
<component :is="item.actionIcon" />
</el-icon>
</el-button>
</div>
<div class="subject-cover flex-center round-6 hidden">
<img v-if="item.image" :src="item.image" :alt="item.title"
class="w-full h-full cover" />
<el-icon v-else class="audio-icon">
<Mute />
</el-icon>
</div>
</div>
</div>
</section>
<section>
<h4 class="fs-14 fw-700 color-000 mb-18">2. 分镜描述</h4>
<div v-for="shot in shots" :key="shot.id" class="shot-block mb-28">
<div class="shot-title flex-items-center gap-8 mb-10 fs-12 fw-700 color-333">
<span class="shot-dot wh-6 round"></span>
<span>{{ shot.title }}</span>
</div>
<div class="shot-table round-8 hidden">
<div class="table-head flex fs-12 fw-700 color-000">
<div class="dialogue-cell p-14">人物台词</div>
<div class="picture-cell p-14">画面描述</div>
</div>
<div v-for="row in shot.rows" :key="row.id" class="table-row flex">
<div class="dialogue-cell p-14 fs-12 color-333 lh-24">
<span class="role-tag h-24 lh-24 px-8 round-4 fw-700 color-main mr-6">
{{ row.speaker }}
</span>
<span>{{ row.dialogue }}</span>
</div>
<div class="picture-cell p-14 fs-12 color-333 lh-24">
<span class="role-tag h-24 lh-24 px-8 round-4 fw-700 color-main mr-6">
{{ row.actor }}
</span>
<span>{{ row.picture }}</span>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</section>
<footer class="flex justify-end pt-12 pb-12 pr-20" style="border-top: 1px solid #eee;">
<el-button type="primary" class="fs-12 border-none create-btn" @click="emit('createPreview')">
生成视频预览
</el-button>
</footer>
</div>
</main>
<Step2SubjectDialog v-model="subjectDialogVisible" :subject-type="activeSubjectType" />
</template>
<script setup lang="ts">
import { computed, ref, type Component } from 'vue';
import { ArrowRight, ArrowUp, MoreFilled, Mute, Plus, VideoCamera } from '@element-plus/icons-vue';
import Step2SubjectDialog from './Step2SubjectDialog.vue';
import productImg from '@/static/image/huaban.png';
import peopleImg from '@/static/image/meinv.png';
type CreativeItem = {
id: string;
title: string;
tag: string;
name: string;
desc: string;
};
type SubjectType = 'product' | 'actor' | 'voice';
type SubjectItem = {
id: SubjectType;
title: string;
desc: string;
image?: string;
actionIcon: Component;
};
type ShotRow = {
id: string;
speaker: string;
actor: string;
dialogue: string;
picture: string;
};
type ShotItem = {
id: string;
title: string;
rows: ShotRow[];
};
const emit = defineEmits<{
createPreview: [];
}>();
const selectedCreativeId = ref('creative-1');
const isCreativeExpanded = ref(false);
const subjectDialogVisible = ref(false);
const activeSubjectType = ref<SubjectType>('product');
const creatives: CreativeItem[] = [
{
id: 'creative-1',
title: '创意 1',
tag: '痛点实测',
name: '雨天视线危机',
desc: '从雨天挡风玻璃油膜造成视线模糊的真实痛点切入,通过清洁前后的直观对比突出产品效果。',
},
{
id: 'creative-2',
title: '创意 2',
tag: '场景转化',
name: '夜间炫光对比',
desc: '聚焦夜间会车时油膜放大灯光炫光的问题,通过左右对比强化购买理由。',
},
{
id: 'creative-3',
title: '创意 3',
tag: '效率展示',
name: '30秒快速去膜',
desc: '用30秒计时挑战串联完整使用过程,以快节奏实操展示简单高效的清洁效果。',
},
];
const selectedCreative = computed(() => creatives.find((item) => item.id === selectedCreativeId.value) || creatives[0]);
const fullCreativeItems = [
{
title: '一、推荐理由',
content: '以“雨天视线模糊”这一高频驾驶安全隐患为切入点,先建立车主焦虑,再用清洁前后对比直接证明产品效果,购买理由清晰。',
},
{
title: '二、核心卖点',
content: '强力去油膜、擦拭无残留、视野通透、自带海绵擦头',
},
{
title: '三、卖点表现形式',
content: '雨天实景痛点 + 半边玻璃清洁对比 + 清洁后驾驶视角展示',
},
];
const subjects: SubjectItem[] = [
{
id: 'product',
title: '商品',
desc: '防晒霜SPF50权威检测图',
image: productImg,
actionIcon: Plus,
},
{
id: 'actor',
title: '人物形象 1',
desc: '一位注重生活品质和驾驶安全的车主',
image: peopleImg,
actionIcon: VideoCamera,
},
{
id: 'voice',
title: '旁白',
desc: '广告旁白音色,专业清晰,节奏沉稳',
actionIcon: VideoCamera,
},
];
const openSubjectDialog = (type: SubjectType) => {
activeSubjectType.value = type;
subjectDialogVisible.value = true;
};
const shots: ShotItem[] = [
{
id: 'shot-1',
title: '镜头 1:痛点前置,引发用户共鸣',
rows: [
{
id: 'shot-1-1',
speaker: '旁白',
actor: '人物形象 1',
dialogue: '一下雨,挡风玻璃就像蒙了一层雾,雨刮越刮反而越模糊。',
picture: '雨天车内第一视角,雨刮扫过后玻璃仍有大片油膜与光晕,驾驶员皱眉观察前方。',
},
{
id: 'shot-1-2',
speaker: '人物形象 1',
actor: '商品',
dialogue: '别急,用这款玻璃油膜清洁擦,自己就能快速处理。',
picture: '特写展示产品外观和实际使用过程,品牌与核心卖点清晰可见。',
},
],
},
{
id: 'shot-2',
title: '镜头 2:产品实测,展示去膜效果',
rows: [
{
id: 'shot-2-1',
speaker: '旁白',
actor: '人物形象 1',
dialogue: '用它在玻璃上轻轻擦几遍,冲水后油膜很快就被带走了。',
picture: '近景展示玻璃油膜清洁擦,完成涂抹、擦拭和冲水;画面保留一半未清洁区域形成直观对比。',
},
{
id: 'shot-2-2',
speaker: '人物形象 1',
actor: '商品',
dialogue: '操作简单,擦完以后玻璃清楚多了。',
picture: '特写展示产品外观和实际使用过程,品牌与核心卖点清晰可见。',
},
],
},
{
id: 'shot-3',
title: '镜头 3:效果收束,强化安全价值',
rows: [
{
id: 'shot-3-1',
speaker: '旁白',
actor: '人物形象 1',
dialogue: '玻璃重新透亮,雨天和夜间开车都安心多了。',
picture: '切回驾驶视角,挡风玻璃清晰通透,路面与车灯轮廓清楚,产品定格收尾。',
},
{
id: 'shot-3-2',
speaker: '人物形象 1',
actor: '商品',
dialogue: '操作简单,擦完以后玻璃清楚多了。',
picture: '特写展示产品外观和实际使用过程,品牌与核心卖点清晰可见。',
},
],
},
];
</script>
<style scoped lang="scss">
.step2-main {
min-width: 0;
}
.creative-list {
flex: 0 0 200px;
border-right: 1px solid #dce6f2;
}
.creative-card {
border: 1px solid #dce6f2;
background: #fff;
transition: border-color .2s, box-shadow .2s;
&:hover,
&.active {
border-color: rgba(var(--main-color), 1);
box-shadow: 0 0 0 1px rgba(var(--main-color), .35);
}
}
.tag,
.role-tag {
color: rgba(var(--main-color), 1);
background: rgba(var(--main-color), .08);
}
.step2-content {
min-width: 0;
padding-left: 24px;
}
.creative-detail {
border: 1px solid #dce6f2;
}
.summary-box {
background: #f8fafc;
}
.full-creative {
border-top: 1px solid #edf2f7;
}
.script-panel {
border-top: 1px solid #dce6f2;
}
.subject-card {
border: 1px solid #dce6f2;
background: #fbfdff;
}
.subject-cover {
height: 180px;
border: 1px dashed #dce6f2;
background: #fff;
}
.audio-icon {
font-size: 42px;
color: #c4cfdd;
}
.shot-dot {
flex: 0 0 6px;
background: rgba(var(--main-color), 1);
}
.shot-table {
border: 1px solid #dce6f2;
}
.table-head {
background: #f8fafc;
}
.table-row {
border-top: 1px solid #edf2f7;
}
.dialogue-cell,
.picture-cell {
flex: 1;
min-width: 0;
}
.dialogue-cell {
border-right: 1px solid #dce6f2;
}
</style>
<template>
<Dialog v-model="dialogVisible" bg-color="#fff" width="900">
<div class="subject-dialog flex-col bg-fff round-8 hidden">
<header class="dialog-header flex-items-center gap-16 px-26 py-10">
<el-icon class="fs-16 color-666 pointer" @click="dialogVisible = false">
<Back />
</el-icon>
<div>
<div class="fs-16 fw-700 color-000 lh-24">{{ currentTitle }}</div>
<div class="fs-12 color-888 lh-20">仅修改当前创意</div>
</div>
</header>
<section class="dialog-body px-26 py-20">
<template v-if="subjectType === 'product'">
<div class="flex-between mb-16">
<div>
<div class="fs-14 fw-700 color-000 lh-22">
商品参考图 <span class="color-888 fw-400">{{ productImages.length }}/6</span>
</div>
<div class="fs-12 color-888 lh-20">至少保留一张商品参考图</div>
</div>
<el-button type="primary" class="h-32 px-16 fs-12 border-none" @click="imageDialogVisible = true">
<el-icon>
<Plus />
</el-icon>
<span>添加参考图</span>
</el-button>
</div>
<div class="flex gap-10">
<div v-for="item in productImages" :key="item.id" class="reference-card relative w-178 h-178 round-6 hidden">
<img :src="item.image" :alt="item.name" class="w-full h-full cover" />
<div class="image-actions absolute top-8 right-8 flex-items-center gap-6">
<span class="action-btn flex-center wh-22 round-4 pointer">
<el-icon class="fs-12">
<Delete />
</el-icon>
</span>
</div>
</div>
</div>
</template>
<template v-else-if="subjectType === 'actor'">
<div class="flex gap-20">
<div class="actor-cover w-240 h-318 round-8 hidden">
<img :src="actorInfo.image" :alt="actorInfo.title" class="w-full h-full cover" />
</div>
<div class="flex-1 flex-col">
<div class="info-box p-16 round-6 mb-20">
<div class="fs-12 fw-700 color-000 mb-10">形象描述</div>
<div class="fs-12 color-333 lh-22">{{ actorInfo.desc }}</div>
</div>
<div class="flex-between mb-12">
<div class="fs-14 fw-700 color-000">音色</div>
<span class="fs-12 fw-700 color-main pointer" @click="toggleToneCreator">
{{ isToneCreatorVisible ? '取消' : '新增音色' }}
</span>
</div>
<div class="tone-list flex gap-8 mb-12">
<div v-for="item in actorTones" :key="item.id"
class="tone-item flex-center h-50 px-34 round-6 fs-12 fw-700 pointer"
:class="{ active: selectedToneId === item.id }" @click="selectedToneId = item.id">
{{ item.name }}
</div>
</div>
<div class="info-box p-16 round-6">
<div class="fs-12 fw-700 color-000 mb-10">音色描述</div>
<div class="fs-12 color-333 lh-22">{{ currentTone?.desc }}</div>
</div>
<div v-if="isToneCreatorVisible" class="tone-creator p-12 round-6 mt-12">
<el-input v-model="toneCreatorText" class="tone-textarea" type="textarea" resize="none"
:rows="4" placeholder="请输入音色描述" />
<div class="flex justify-end mt-14">
<el-button type="primary" class="h-32 px-16 fs-12 border-none"
:disabled="isGenerateToneDisabled" @click="generateTone">
生成音色
</el-button>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="info-box p-16 round-6 mb-20">
<div class="fs-12 fw-700 color-000 mb-10">旁白描述</div>
<div class="fs-12 color-333 lh-22">{{ voiceInfo.desc }}</div>
</div>
<div class="flex-between mb-12">
<div class="fs-14 fw-700 color-000">音色</div>
<span class="fs-12 fw-700 color-main pointer" @click="toggleToneCreator">
{{ isToneCreatorVisible ? '取消' : '新增音色' }}
</span>
</div>
<div class="tone-list flex gap-8 mb-12">
<div v-for="item in voiceTones" :key="item.id"
class="tone-item flex-center h-50 px-34 round-6 fs-12 fw-700 pointer"
:class="{ active: selectedToneId === item.id }" @click="selectedToneId = item.id">
{{ item.name }}
</div>
</div>
<div class="info-box p-16 round-6">
<div class="fs-12 fw-700 color-000 mb-10">音色描述</div>
<div class="fs-12 color-333 lh-22">{{ currentTone?.desc }}</div>
</div>
<div v-if="isToneCreatorVisible" class="tone-creator p-12 round-6 mt-12">
<el-input v-model="toneCreatorText" class="tone-textarea" type="textarea" resize="none"
:rows="4" placeholder="请输入音色描述" />
<div class="flex justify-end mt-14">
<el-button type="primary" class="h-32 px-16 fs-12 border-none"
:disabled="isGenerateToneDisabled" @click="generateTone">
生成音色
</el-button>
</div>
</div>
</template>
</section>
<footer class="dialog-footer flex justify-end px-26 py-16">
<el-button class="px-18 fs-12" @click="dialogVisible = false">取消</el-button>
<el-button type="primary" class="px-20 fs-12 border-none" @click="confirmSubject">
应用
</el-button>
</footer>
</div>
</Dialog>
<AgentProductImageDialog v-model="imageDialogVisible" data-type="goods" @confirm="handleReferenceImagesConfirm" />
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Back, Delete, Plus } from '@element-plus/icons-vue';
import AgentProductImageDialog from './AgentProductImageDialog.vue';
import Dialog from '@/components/Dialog/index.vue';
import productImg from '@/static/image/huaban.png';
import peopleImg from '@/static/image/meinv.png';
type SubjectType = 'product' | 'actor' | 'voice';
type ToneItem = {
id: string;
name: string;
desc: string;
};
const props = defineProps<{
modelValue: boolean;
subjectType: SubjectType;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
confirm: [value: { subjectType: SubjectType; toneId: string }];
}>();
const selectedToneId = ref('');
const imageDialogVisible = ref(false);
const isToneCreatorVisible = ref(false);
const toneCreatorText = ref('');
const productImages = ref([
{
id: 'product-1',
name: '商品参考图 1',
image: productImg,
},
{
id: 'product-2',
name: '商品参考图 2',
image: peopleImg,
},
]);
const actorInfo = {
title: '人物形象 1',
image: peopleImg,
desc: '一位注重生活品质和驾驶安全的年轻车主,表达自然可信。',
};
const voiceInfo = {
desc: '广告旁白音色,专业清晰,节奏稳健,重点信息有强调。',
};
const actorTones = ref<ToneItem[]>([
{
id: 'actor-tone-1',
name: '音色 1',
desc: '自然生活化口吻,语速适中,适合真实体验类内容。',
},
{
id: 'actor-tone-2',
name: '音色 2',
desc: '达到',
},
]);
const voiceTones = ref<ToneItem[]>([
{
id: 'voice-tone-1',
name: '音色 1',
desc: '青年男性,广告旁白风格,专业讲解口吻,音色清晰稳重',
},
]);
const titleMap: Record<SubjectType, string> = {
product: '防晒霜SPF50权威检测图',
actor: '人物形象 1设定',
voice: '旁白设定',
};
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const currentTitle = computed(() => titleMap[props.subjectType]);
const currentToneList = computed(() => (props.subjectType === 'voice' ? voiceTones.value : actorTones.value));
const currentTone = computed(() => currentToneList.value.find((item) => item.id === selectedToneId.value));
const isGenerateToneDisabled = computed(() => toneCreatorText.value.trim() === '');
watch(
() => props.subjectType,
(value) => {
selectedToneId.value = value === 'voice' ? 'voice-tone-1' : 'actor-tone-2';
isToneCreatorVisible.value = false;
toneCreatorText.value = '';
},
{ immediate: true },
);
const confirmSubject = () => {
emit('confirm', {
subjectType: props.subjectType,
toneId: selectedToneId.value,
});
dialogVisible.value = false;
};
const handleReferenceImagesConfirm = (ids: string[]) => {
const selectedImages = ids
.filter((id) => !productImages.value.some((item) => item.id === id))
.map((id, index) => ({
id,
name: `商品参考图 ${productImages.value.length + index + 1}`,
image: index % 2 === 0 ? productImg : peopleImg,
}));
productImages.value = [...productImages.value, ...selectedImages].slice(0, 6);
};
const toggleToneCreator = () => {
isToneCreatorVisible.value = !isToneCreatorVisible.value;
if (!isToneCreatorVisible.value) {
toneCreatorText.value = '';
}
};
const generateTone = () => {
if (isGenerateToneDisabled.value) {
return;
}
const list = props.subjectType === 'voice' ? voiceTones.value : actorTones.value;
const tone = {
id: `${props.subjectType}-tone-${Date.now()}`,
name: `音色 ${list.length + 1}`,
desc: toneCreatorText.value.trim(),
};
list.push(tone);
selectedToneId.value = tone.id;
isToneCreatorVisible.value = false;
toneCreatorText.value = '';
};
</script>
<style scoped lang="scss">
.subject-dialog {
border: 1px solid #dce6f2;
}
.dialog-header {
border-bottom: 1px solid #dce6f2;
}
.dialog-body {
min-height: 272px;
}
.dialog-footer {
border-top: 1px solid #dce6f2;
}
.reference-card,
.actor-cover {
background: #f3f6fb;
}
.image-actions {
opacity: 0;
transition: opacity .2s;
}
.reference-card:hover .image-actions {
opacity: 1;
}
.action-btn {
color: #fff;
background: rgba(0, 0, 0, .42);
}
.info-box {
border: 1px solid #dce6f2;
background: #f8fafc;
}
.tone-creator {
border: 1px solid #dce6f2;
background: #fbfdff;
}
.tone-item {
min-width: 108px;
border: 1px solid #dce6f2;
color: #333;
background: #fff;
&.active {
color: rgba(var(--main-color), 1);
border-color: rgba(var(--main-color), 1);
background: rgba(var(--main-color), .06);
}
}
:deep(.tone-textarea .el-textarea__inner) {
min-height: 96px !important;
border-radius: 6px;
font-size: 12px;
line-height: 22px;
box-shadow: 0 0 0 1px #dce6f2 inset;
}
</style>
<template>
<main class="step3-main flex-1 flex-col hidden">
<div class="preview-workspace flex-1 flex-col hidden">
<section class="flex-1 flex hidden">
<aside class="plan-tabs flex-col items-center gap-22 py-28">
<div v-for="item in plans" :key="item.id" class="plan-tab flex-col flex-center gap-8 py-10 round-6 pointer relative"
:class="{ active: selectedPlanId === item.id }" @click="selectPlan(item.id)">
<el-icon class="fs-18">
<VideoCamera />
</el-icon>
<span class="fs-12 fw-700">{{ item.name }}</span>
<span v-if="item.dot" class="notice-dot absolute wh-8 round"></span>
</div>
</aside>
<aside class="video-panel flex-col items-center px-16 py-18">
<div class="preview-cover relative w-188 h-334 round-8 hidden">
<img v-if="!previewCoverErrorMap[selectedPlan.id]" :src="selectedPlan.cover"
:alt="selectedPlan.name" class="w-full h-full cover" @error="handlePreviewCoverError" />
<div v-else class="preview-placeholder flex-col flex-center w-full h-full">
<el-icon class="fs-30 color-888 mb-8">
<VideoCamera />
</el-icon>
<span class="fs-12 color-888">视频加载中</span>
</div>
<span class="recommend-tag absolute top-8 left-8 h-24 lh-24 px-10 round-4 fs-12 fw-700 color-fff">
{{ selectedPlan.name }}
</span>
<span class="play-btn absolute flex-center wh-50 round pointer">
<el-icon class="fs-30 color-main">
<VideoPlay />
</el-icon>
</span>
</div>
<div class="flex-items-center gap-8 mt-16">
<el-button class="wh-32 p-0">
<el-icon>
<VideoPlay />
</el-icon>
</el-button>
<el-button class="wh-32 p-0">
<el-icon>
<Microphone />
</el-icon>
</el-button>
<span class="fs-12 color-000">00:00</span>
<span class="fs-12 color-888">/ 00:21</span>
</div>
</aside>
<section class="material-panel flex-1 px-16 py-18">
<h3 class="fs-16 fw-700 color-000 mb-14">适合“画面1”的原料</h3>
<div class="material-list flex gap-8 mb-16">
<div class="add-material flex-col flex-center w-66 h-66 round-6 pointer">
<el-icon class="fs-18 color-666 mb-4">
<Plus />
</el-icon>
<span class="fs-12 color-888">新增视频</span>
</div>
<div v-for="item in selectedPlan.materials" :key="item.id"
class="material-card relative w-66 h-66 round-6 hidden pointer"
:class="{ active: selectedMaterialId === item.id }" @click="selectedMaterialId = item.id">
<img :src="item.image" :alt="item.name" class="w-full h-full cover" />
<span class="ai-tag absolute top-0 left-0 h-20 lh-20 px-6 fs-10 fw-700 color-fff">AI</span>
</div>
</div>
<div class="description-box p-16 round-8">
<div class="flex-between mb-8">
<div>
<div class="fs-14 fw-700 color-000 mb-4">画面描述</div>
<div class="fs-12 color-888">原料时长:7秒 · 所用片段:0.0-7.0秒</div>
</div>
<div class="flex-items-center gap-22 color-666 fs-12">
<span class="flex-items-center gap-4 pointer">
<el-icon>
<Crop />
</el-icon>
裁剪
</span>
<span class="flex-items-center gap-4 pointer">
<el-icon>
<MagicStick />
</el-icon>
优化
</span>
</div>
</div>
<div class="section-line mb-14"></div>
<div class="script-scroll over-y-auto pr-8">
<div v-for="item in shotDescriptions" :key="item.time" class="mb-14">
<div class="fs-12 fw-700 color-333 lh-22">{{ item.time }} {{ item.scene }}</div>
<div class="fs-12 color-333 lh-22 mt-6">口播内容:{{ item.speech }}</div>
</div>
</div>
<div class="section-line mt-12 mb-12"></div>
<div class="flex-items-center gap-8">
<span class="fs-12 fw-700 color-333">参考主体:</span>
<span class="subject-icon flex-center wh-32 round-4">
<el-icon>
<Box />
</el-icon>
</span>
<span class="subject-icon flex-center wh-32 round-4 hidden">
<img :src="peopleImg" alt="人物形象" class="w-full h-full cover" />
</span>
<span class="subject-icon flex-center wh-32 round-4">
<el-icon>
<Microphone />
</el-icon>
</span>
</div>
</div>
</section>
</section>
<section class="timeline-panel flex-between px-10 py-10" >
<div class="timeline-inner flex-col">
<div class="flex-items-center gap-20 color-666 fs-16 mb-16">
<el-icon class="pointer">
<Refresh />
</el-icon>
<el-icon class="pointer">
<MagicStick />
</el-icon>
<el-icon class="pointer">
<Plus />
</el-icon>
</div>
<div class="flex gap-12">
<div v-for="item in timelineItems" :key="item.id" class="timeline-card relative w-112 h-62 round-4 hidden pointer"
:class="{ active: selectedTimelineId === item.id }" @click="selectedTimelineId = item.id">
<img :src="item.image" :alt="item.name" class="w-full h-full cover" />
<span class="timeline-name absolute top-4 left-4 h-18 lh-18 px-4 fs-10 color-fff fw-700">
{{ item.name }}
</span>
<span class="duration absolute right-4 bottom-4 h-18 lh-18 px-4 fs-10 color-fff round-4">7.0s</span>
</div>
</div>
</div>
<span class="fs-10 color-888">点击画面可查看对应分镜</span>
</section>
</div>
<footer class="flex justify-end pt-12 pb-12 pr-20">
<el-button type="primary" class="fs-12 border-none create-btn" @click="emit('createVideo')">
生成视频成片
</el-button>
</footer>
</main>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Box, Crop, MagicStick, Microphone, Plus, Refresh, VideoCamera, VideoPlay } from '@element-plus/icons-vue';
import productImg from '@/static/image/huaban.png';
import peopleImg from '@/static/image/meinv.png';
type PlanItem = {
id: string;
name: string;
cover: string;
dot?: boolean;
materials: MaterialItem[];
};
type MaterialItem = {
id: string;
name: string;
image: string;
};
type ShotDescription = {
time: string;
scene: string;
speech: string;
};
const emit = defineEmits<{
createVideo: [];
}>();
const plans: PlanItem[] = [
{
id: 'recommend',
name: '推荐',
cover: productImg,
materials: [
{ id: 'material-1', name: '商品原料', image: productImg },
{ id: 'material-2', name: '人物原料', image: peopleImg },
],
},
{
id: 'backup-1',
name: '备选1',
cover: peopleImg,
dot: true,
materials: [
{ id: 'material-3', name: '场景原料', image: peopleImg },
{ id: 'material-4', name: '商品原料', image: productImg },
],
},
];
const shotDescriptions: ShotDescription[] = [
{
time: '[00:00-00:07]',
scene: '雨天车内第一视角,雨刮扫过后玻璃仍有大片油膜与光晕,驾驶员皱眉观察前方。',
speech: '一下雨,挡风玻璃就像蒙了一层雾,雨刮越刮反而越模糊。第三代',
},
{
time: '[00:07-00:14]',
scene: '近景展示玻璃油膜清洁擦,完成涂抹、擦拭和冲水;画面保留一半未清洁区域形成直观对比。',
speech: '用它在玻璃上轻轻擦几遍,冲水后油膜很快就被带走了。',
},
{
time: '[00:14-00:21]',
scene: '切回驾驶视角,挡风玻璃清晰通透,路面与车灯轮廓清楚,产品定格收尾。',
speech: '玻璃重新透亮,雨天和夜间开车都安心多了。',
},
];
const timelineItems = [
{ id: 'shot-1', name: '画面1', image: productImg },
{ id: 'shot-2', name: '画面2', image: peopleImg },
{ id: 'shot-3', name: '画面3', image: productImg },
];
const selectedPlanId = ref('recommend');
const selectedMaterialId = ref('material-1');
const selectedTimelineId = ref('shot-1');
const previewCoverErrorMap = ref<Record<string, boolean>>({});
const selectedPlan = computed(() => plans.find((item) => item.id === selectedPlanId.value) || plans[0]);
const selectPlan = (id: string) => {
selectedPlanId.value = id;
selectedMaterialId.value = selectedPlan.value.materials[0]?.id || '';
};
const handlePreviewCoverError = () => {
previewCoverErrorMap.value = {
...previewCoverErrorMap.value,
[selectedPlan.value.id]: true,
};
};
</script>
<style scoped lang="scss">
.step3-main {
min-width: 0;
}
.timeline-panel {
border-bottom: 1px solid #dce6f2;
border-top: 1px solid #dce6f2;
}
.plan-tabs {
flex: 0 0 68px;
border-right: 1px solid #dce6f2;
background: #f8fafc;
}
.plan-tab {
width: 48px;
color: #0a1b34;
&.active {
color: rgba(var(--main-color), 1);
background: #fff;
}
}
.notice-dot {
top: -4px;
right: 6px;
background: #18d4e8;
}
.video-panel {
flex: 0 0 220px;
border-right: 1px solid #dce6f2;
}
.preview-cover,
.material-card,
.timeline-card {
background: #f3f6fb;
}
.preview-placeholder {
background: #f8fafc;
}
.recommend-tag {
background: #4f5b71;
}
.play-btn {
left: 50%;
top: 50%;
background: rgba(255, 255, 255, .9);
transform: translate(-50%, -50%);
}
.material-panel {
min-width: 0;
}
.add-material {
border: 1px dashed #b9c9dd;
background: #fff;
}
.material-card {
border: 2px solid transparent;
&.active {
border-color: rgba(var(--main-color), 1);
}
}
.ai-tag {
background: rgba(18, 24, 38, .72);
}
.description-box {
background: #f8fafc;
}
.section-line {
height: 1px;
background: #dce6f2;
}
.script-scroll {
min-height: 176px;
}
.subject-icon {
border: 1px solid #dce6f2;
background: #fff;
}
.timeline-panel {
flex: 0 0 132px;
}
.timeline-inner {
margin-left: 286px;
}
.timeline-card {
border: 2px solid transparent;
&.active {
border-color: rgba(var(--main-color), 1);
}
}
.timeline-name {
background: rgba(18, 24, 38, .62);
}
.duration {
background: rgba(18, 24, 38, .72);
}
.create-btn {
background: #7d28ff;
}
</style>
<template>
<main class="step4-main flex-1 flex-col hidden">
<section class="flex-1 over-y-auto hide-scroll py-20 px-20">
<div class="video-list flex flex-wrap gap-18">
<div v-for="item in videos" :key="item.id" class="video-card flex-col round-8 hidden bg-fff"
:class="{ active: selectedVideoIds.includes(item.id) }">
<div class="video-cover relative hidden">
<img :src="item.cover" :alt="item.name" class="w-full h-full cover" />
<el-checkbox :model-value="selectedVideoIds.includes(item.id)"
class="select-check absolute top-12 left-12" @change="toggleVideo(item.id)" />
<span v-if="item.recommend"
class="recommend-tag absolute top-10 right-10 h-24 lh-24 px-10 round-4 fs-12 fw-700 color-fff">
推荐
</span>
<span class="duration-tag absolute right-10 bottom-10 h-24 lh-24 px-8 round-4 fs-12 color-fff">
{{ item.duration }}
</span>
</div>
<div class="p-12">
<div class="fs-12 fw-700 color-000 lh-18 ellipsis mb-10">{{ item.name }}</div>
<div class="flex">
<el-button class="detail-btn flex-1 h-34 fs-12">
<el-icon>
<View />
</el-icon>
<span>详情</span>
</el-button>
<el-button class="icon-btn wh-34 p-0">
<el-icon>
<Upload />
</el-icon>
</el-button>
<el-button class="icon-btn wh-34 p-0">
<el-icon>
<Download />
</el-icon>
</el-button>
</div>
</div>
</div>
</div>
</section>
<footer class="flex-between items-center px-20 py-12" style="border-top: 1px solid #eee;">
<div class="flex justify-end">
<el-checkbox v-model="isAllSelected" :indeterminate="isIndeterminate" class="fs-12">全选</el-checkbox>
</div>
<div class="flex-items-center gap-10">
<div class="fs-12 color-888 text-right">
已选择 <span class="color-main fw-700">{{ selectedVideoIds.length }}</span> 个视频
</div>
<el-button type="primary" class="fs-12 border-none create-btn" @click="emit('uploadResource')">
上传资源库
</el-button>
</div>
</footer>
</main>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Download, Upload, View } from '@element-plus/icons-vue';
import productImg from '@/static/image/huaban.png';
import peopleImg from '@/static/image/meinv.png';
type VideoItem = {
id: string;
name: string;
cover: string;
duration: string;
recommend?: boolean;
};
const emit = defineEmits<{
uploadResource: [];
}>();
const videos: VideoItem[] = [
{
id: 'video-1',
name: '推荐版_玻璃油膜擦营销成片.mp4',
cover: productImg,
duration: '00:28',
recommend: true,
},
{
id: 'video-2',
name: '备选1版_玻璃油膜擦营销成片.mp4',
cover: peopleImg,
duration: '00:30',
},
];
const selectedVideoIds = ref<string[]>(['video-1']);
const isAllSelected = computed({
get: () => selectedVideoIds.value.length === videos.length,
set: (value: boolean) => {
selectedVideoIds.value = value ? videos.map((item) => item.id) : [];
},
});
const isIndeterminate = computed(() => selectedVideoIds.value.length > 0 && selectedVideoIds.value.length < videos.length);
const toggleVideo = (id: string) => {
if (selectedVideoIds.value.includes(id)) {
selectedVideoIds.value = selectedVideoIds.value.filter((item) => item !== id);
return;
}
selectedVideoIds.value = [...selectedVideoIds.value, id];
};
</script>
<style scoped lang="scss">
.step4-main {
min-width: 0;
}
.video-list {
align-content: flex-start;
}
.video-card {
flex: 0 0 260px;
border: 1px solid #dce6f2;
transition: border-color .2s, box-shadow .2s;
&.active {
border-color: rgba(var(--main-color), 1);
box-shadow: 0 0 0 1px rgba(var(--main-color), .35);
}
}
.video-cover {
height: 146px;
background: #f3f6fb;
}
.recommend-tag,
.create-btn {
background: #7d28ff;
}
.duration-tag {
background: rgba(18, 24, 38, .78);
}
.detail-btn,
.icon-btn {
border-color: #dce6f2;
}
:deep(.select-check .el-checkbox__label) {
display: none;
}
:deep(.select-check .el-checkbox__inner) {
width: 18px;
height: 18px;
border-radius: 4px;
}
:deep(.select-check .el-checkbox__inner::after) {
left: 8px;
top: 7px;
}
</style>
......@@ -6,6 +6,14 @@
</el-icon>
<span class="fs-12 fw-600">返回</span>
</div>
<div v-else class="fixed top-20 px-10 right-10 flex-items-center gap-6 pointer" @click="historyDrawerVisible = true">
<el-button>
<el-icon class="fs-16">
<AlarmClock />
</el-icon>
<span class="fs-12 fw-600">历史任务</span>
</el-button>
</div>
<section v-if="!hasMessages">
<div class="mt-60 flex-col items-center">
<div class="flex-center wh-44 round-8 color-fff bg-jb">
......@@ -19,7 +27,8 @@
<el-input v-model="prompt" :rows="10" type="textarea" resize="none" placeholder="发送给 Agent" />
<div class="flex-between pt-16">
<AgentPromptTools />
<div class="border-eee flex bg-fff round-100 p-6 gap-10 pl-16 minw-110 pointer hoverbth" @click="hasMessages = true">
<div class="border-eee flex bg-fff round-100 p-6 gap-10 pl-16 minw-110 pointer hoverbth"
@click="hasMessages = true">
开始创作
<div class="wh-24 color-fff round flex-center bg-jb">
<el-icon>
......@@ -31,9 +40,12 @@
</div>
<div class="flex-center mt-16">
<div class="flex-center gap-10">
<el-button v-for="item in suggestions" :key="item"
class="flex-center h-40 px-20 round-100 border-none boxshaw" @click="applySuggestion(item)">
{{ item }}
<el-button class="flex-center h-40 px-20 round-100 border-none boxshaw" @click="goProductVideo">
用商品拍一条营销视频
</el-button>
<el-button class="flex-center h-40 px-20 round-100 border-none boxshaw"
@click="applySuggestion('参考样片写分镜脚本')">
参考样片写分镜脚本
</el-button>
</div>
</div>
......@@ -104,26 +116,25 @@
</div>
</div>
</section>
<AgentHistoryDrawer v-model="historyDrawerVisible" />
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import type { Component } from 'vue';
import { ArrowDown, Back, Box, Coin, CopyDocument, Link, MagicStick, Position, Top } from '@element-plus/icons-vue';
import { useRouter } from 'vue-router';
import { AlarmClock, ArrowDown, Back, CopyDocument, MagicStick, Top } from '@element-plus/icons-vue';
import UploadFile from '@/components/Upload/index.vue'
import AgentHistoryDrawer from './components/AgentHistoryDrawer.vue';
import AgentPromptTools from './components/AgentPromptTools.vue';
type ToolItem = {
label: string;
icon: Component;
hasArrow?: boolean;
};
const prompt = ref('');
const router = useRouter();
const submittedTopic = ref('美颜');
const messagesCount = ref(0);
const hasMessages = ref(false)
const historyDrawerVisible = ref(false);
const chatScrollRef = ref<HTMLElement>();
const composerBoxRef = ref<HTMLElement>();
const isInputFocused = ref(false);
......@@ -131,17 +142,6 @@ const isComposerActive = ref(false);
const isNearBottom = ref(true);
const isComposerCompact = computed(() => !isNearBottom.value && !isInputFocused.value && !isComposerActive.value);
const agentControls: ToolItem[] = [
{ label: 'Agent 模式', icon: Position, hasArrow: true },
{ label: '智能模型', icon: Coin },
{ label: '生成参数', icon: MagicStick, hasArrow: true },
{ label: '引用', icon: Link },
{ label: '使用 Skill', icon: Box },
{ label: '优化', icon: MagicStick },
];
const suggestions = ['用商品拍一条营销视频', '参考样片写分镜脚本'];
const sendMessage = () => {
const value = prompt.value.trim();
submittedTopic.value = value || submittedTopic.value;
......@@ -155,6 +155,10 @@ const applySuggestion = (value: string) => {
sendMessage();
};
const goProductVideo = () => {
router.push('/agent/product-video');
};
const handleChatScroll = () => {
if (!chatScrollRef.value) {
return;
......@@ -339,4 +343,5 @@ onBeforeUnmount(() => {
opacity: 1;
}
}
</style>
<template>
<div class="p-20 vh100 hidden">
<el-card class="h-full round-10 content" shadow="never">
<template #header>
<div class="flex-items-center">
<div class="">
<span class="fw-600">{{title}}</span>
</div>
<div class="flex-1 flex-center gap-10 mr-100">
<div v-for="(item, index) in steps" :key="index" class="flex-items-center gap-10 default"
:class="{ finish: item.stepn < currentStep, active: item.stepn === currentStep, select: selectStep === item.stepn }" @click="lookStep(item)">
<div class="flex-items-center px-10 py-6 gap-6 round-6 pointer color-888 name">
<el-icon>
<component :is="item.icon" />
</el-icon>
<span>{{ item.name }}</span>
</div>
<div v-if="index < steps.length-1" class="w-40 line" style="height: 1px;"></div>
</div>
</div>
</div>
</template>
<div class="flex h-full">
<Step1 v-if="selectStep === 1" @create-record="addCreateRecord" />
<Step2 v-else-if="selectStep === 2" @create-preview="addPreviewRecord" />
<Step3 v-else-if="selectStep === 3" @create-video="addVideoRecord" />
<Step4 v-else-if="selectStep === 4" @upload-resource="addUploadRecord" />
<div v-else class="flex-1 flex-center color-888 fs-14">当前步骤内容待生成</div>
<aside class="record-side flex-col bg-fff">
<header class="record-header flex-items-center gap-8 h-48 px-16">
<el-icon class="color-main">
<ChatDotSquare />
</el-icon>
<span class="fs-14 fw-700 color-000">创作记录</span>
</header>
<div class="flex-1 over-y-auto px-18 py-16">
<div class="flex justify-end mb-16">
<div class="user-message color-fff fs-12 lh-20 px-16 py-10 round-8">
用我的「商品」拍一条营销视频
</div>
</div>
<div class="product-mini flex-items-center gap-12 p-12 round-8 mb-20">
<img :src="productImg" alt="看看卡萨斯" class="mini-cover wh-58 round-4 cover" />
<div>
<div class="fs-12 fw-700 color-000 mb-6">看看卡萨斯</div>
<div class="fs-12 color-888">商品 · 共 1 张参考图</div>
</div>
</div>
<div class="assistant-text fs-12 color-333 lh-22 mb-18">
为了帮您制作合适的电商营销视频,请补充具体商品信息,例如商品名称、所属品类、品牌或款式。
</div>
<div class="flex justify-end mb-18">
<div class="user-message color-fff fs-12 lh-20 px-16 py-10 round-8">看看卡萨斯</div>
</div>
<div class="assistant-text fs-12 color-333 lh-22 mb-14">
商品信息已确认,正在为您进行需求分析。
</div>
<div class="record-card flex-items-center gap-12 p-12 round-10 mb-20">
<el-icon class="color-main fs-18">
<Document />
</el-icon>
<div>
<div class="fs-12 fw-700 color-000 mb-4">看看卡萨斯</div>
<div class="fs-10 color-999">查看详情 | 09:12</div>
</div>
</div>
<template v-for="item in chatMessages" :key="item.id">
<div class="assistant-text fs-12 color-333 lh-22 mb-12">
已根据您的要求更新需求分析内容。
</div>
<div class="flex justify-end mb-18">
<div class="user-message color-fff fs-12 lh-20 px-16 py-10 round-8">{{ item.content }}
</div>
</div>
</template>
</div>
<div class="chat-input-wrap p-12">
<div class="chat-input flex-items-center gap-10 p-8 round-8">
<el-input v-model="chatInput" class="chat-text flex-1" type="textarea" resize="none"
:rows="2" placeholder="卡萨丁卡上课的" />
<el-button type="primary" class="send-btn wh-36 p-0 border-none" @click="sendChatMessage">
<el-icon>
<Top />
</el-icon>
</el-button>
</div>
</div>
</aside>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ChatDotSquare, Document, Edit, Top } from '@element-plus/icons-vue';
import productImg from '@/static/image/huaban.png';
import Step1 from './components/Step1.vue';
import Step2 from './components/Step2.vue';
import Step3 from './components/Step3.vue';
import Step4 from './components/Step4.vue';
type ChatMessage = {
id: number;
content: string;
};
const title = ref('商品分析信息')
const currentStep = ref(4)
const selectStep = ref(1)
const steps = ref([
{ name: '需求分析', icon: Edit, stepn: 1, desc: '商品分析信息' },
{ name: '创意与分镜', icon: Edit, stepn: 2, desc: '创意与分镜' },
{ name: '视频预览', icon: Edit, stepn: 3, desc: '视频预览' },
{ name: '视频成片', icon: Edit, stepn: 4, desc: '视频成片' },
])
const lookStep = (item:any)=>{
if(currentStep.value < item.stepn) return
selectStep.value = item.stepn
title.value = item.desc
}
const chatMessages = ref<ChatMessage[]>([
{ id: 1, content: '大活动哈师大' },
{ id: 2, content: '大萨达搬还打算大萨达' },
{ id: 3, content: '大萨达马上到凯撒' },
{ id: 4, content: '大萨达久啊圣诞节啊十八大手机登记登记啊萨' },
{ id: 5, content: '流量卡的角度讲撒娇大萨达' },
]);
const chatInput = ref('');
const sendChatMessage = () => {
const value = chatInput.value.trim();
if (!value) {
return;
}
chatMessages.value.push({
id: Date.now(),
content: value,
});
chatInput.value = '';
};
const addCreateRecord = () => {
currentStep.value = Math.max(currentStep.value, 2);
selectStep.value = 2;
title.value = '创意与分镜';
chatMessages.value.push({
id: Date.now(),
content: '生成创意与分镜',
});
};
const addPreviewRecord = () => {
currentStep.value = Math.max(currentStep.value, 3);
selectStep.value = 3;
title.value = '视频预览';
chatMessages.value.push({
id: Date.now(),
content: '生成视频预览',
});
};
const addVideoRecord = () => {
currentStep.value = Math.max(currentStep.value, 4);
selectStep.value = 4;
title.value = '视频成片';
chatMessages.value.push({
id: Date.now(),
content: '生成视频成片',
});
};
const addUploadRecord = () => {
chatMessages.value.push({
id: Date.now(),
content: '上传资源库',
});
};
</script>
<style scoped lang="scss">
.content {
:deep(.el-card__body) {
padding: 0;
}
}
.default {
.name {
&:hover {
background-color: #eee;
}
}
.line {
background: #ddd;
}
&.finish {
.name {
color: #01b95d;
}
.line {
background: #01b95d;
}
}
&.active {
.name {
color: rgba(var(--main-color), 1);
}
.line {
background: rgba(var(--main-color), 1);
}
}
&.select {
.name {
color: #fff;
background-color: rgba(var(--main-color), 1);
}
.line {
background: rgba(var(--main-color), 1);
}
}
}
.backhover {
&:hover {
color: rgba(var(--main-color), 1);
}
}
.record-card,
.product-mini {
border: 1px solid #dce6f2;
}
.send-btn,
.user-message {
background: #7d28ff;
}
.record-side {
flex: 0 0 30%;
border-left: 1px solid #dce6f2;
}
.record-header {
border-bottom: 1px solid #dce6f2;
}
.product-mini {
width: 250px;
margin-left: auto;
background: #f4f0ff;
}
.mini-cover {
background: #f8fafc;
}
.record-card {
width: 280px;
background: #fbfdff;
}
.chat-input-wrap {
border-top: 1px solid #dce6f2;
}
.chat-input {
border: 1px solid #dce6f2;
background: #fbfdff;
}
:deep(.chat-text .el-textarea__inner) {
padding: 0;
box-shadow: none;
background: transparent;
}
:deep(.chat-text .el-textarea__inner) {
font-size: 12px;
}
</style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment