更新树形结构

This commit is contained in:
2026-04-22 09:56:46 +08:00
parent 038e4d72d3
commit 0b352c2718
2 changed files with 287 additions and 116 deletions

View File

@@ -6,10 +6,15 @@ export interface CreationListParams {
keyword?: string;
}
export interface CreationImageItem {
name: string;
url: string;
}
export interface CreationTitleItem {
title: string;
htmlFileUrl: string;
jsonFileUrl: string[];
imageUrls: CreationImageItem[] | null;
}
export interface CreationThemeItem {
@@ -17,9 +22,14 @@ export interface CreationThemeItem {
titles: CreationTitleItem[];
}
export interface CreationContentTypeItem {
contentType: string;
themes: CreationThemeItem[];
}
export interface CreationTreeItem {
createdDate: string;
themes: CreationThemeItem[];
contentTypes: CreationContentTypeItem[];
}
export interface CreationListData {

View File

@@ -18,69 +18,63 @@
<div class="tree-node">
<div class="tree-node-main">
<el-icon v-if="data.nodeType === 'date'"><ele-Calendar /></el-icon>
<el-icon v-else-if="data.nodeType === 'contentType'"><ele-Collection /></el-icon>
<el-icon v-else-if="data.nodeType === 'theme'"><ele-CollectionTag /></el-icon>
<el-icon v-else-if="data.nodeType === 'title'"><ele-FolderOpened /></el-icon>
<el-icon v-else-if="data.nodeType === 'html'"><ele-Document /></el-icon>
<el-icon v-else><ele-Picture /></el-icon>
<span class="ellipsis">{{ data.label }}</span>
</div>
<el-button v-if="data.nodeType === 'html' || data.nodeType === 'image'" type="primary" link class="tree-download" @click.stop="downloadNode(data)">
<el-icon><ele-Download /></el-icon>
</el-button>
<el-button
v-if="data.nodeType === 'html' || data.nodeType === 'image'"
type="primary"
link
class="tree-download"
@click.stop="downloadNode(data)"
><el-icon><ele-Download /></el-icon
></el-button>
</div>
</template>
</el-tree>
</div>
</div>
<div class="panel center">
<div class="title">内容创建参数配置</div>
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top" class="compact-form">
<div class="form-grid">
<el-form-item label="1. 创作模式" prop="mode" class="span-1">
<el-select v-model="formData.mode">
<el-option v-for="item in modeOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="2. 内容类型" prop="content_type" class="span-1">
<el-select v-model="formData.content_type">
<el-option v-for="item in contentTypeOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="3. 主题(系列名)" prop="theme" class="span-1">
<el-input v-model="formData.theme" placeholder="例如:春季通勤穿搭、小个子显高技巧" />
</el-form-item>
<el-form-item label="4. 标题(具体标题)" prop="title" class="span-1">
<el-input v-model="formData.title" placeholder="例如通勤穿搭技巧、5个显高穿搭法则" />
</el-form-item>
<el-form-item label="5. 内容风格" prop="style" class="span-1">
<el-select v-model="formData.style">
<el-option v-for="item in styleOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="6. 生成条数" prop="count" class="span-1">
<el-input-number v-model="formData.count" :min="1" :max="20" controls-position="right" class="w100" />
</el-form-item>
<el-form-item label="7. 配图数量" prop="image_per_post" class="span-1">
<el-input-number v-model="formData.image_per_post" :min="1" :max="20" controls-position="right" class="w100" />
</el-form-item>
<el-form-item label="8. 图片比例" prop="image_ratio" class="span-1">
<el-select v-model="formData.image_ratio">
<el-option v-for="item in imageRatioOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item label="9. 描述" prop="description" class="span-2 description-item">
<el-input v-model="formData.description" type="textarea" :rows="4" placeholder="请输入内容补充描述、重点要求或限制条件" />
</el-form-item>
<el-form-item label="1. 创作模式" prop="mode" class="span-1"
><el-select v-model="formData.mode"><el-option v-for="item in modeOptions" :key="item" :label="item" :value="item" /></el-select
></el-form-item>
<el-form-item label="2. 内容类型" prop="content_type" class="span-1"
><el-select v-model="formData.content_type"
><el-option v-for="item in contentTypeOptions" :key="item" :label="item" :value="item" /></el-select
></el-form-item>
<el-form-item label="3. 主题(系列名)" prop="theme" class="span-1"
><el-input v-model="formData.theme" placeholder="例如:春季通勤穿搭、小个子显高技巧"
/></el-form-item>
<el-form-item label="4. 标题(具体标题)" prop="title" class="span-1"
><el-input v-model="formData.title" placeholder="例如通勤穿搭技巧、5个显高穿搭法则"
/></el-form-item>
<el-form-item label="5. 内容风格" prop="style" class="span-1"
><el-select v-model="formData.style"><el-option v-for="item in styleOptions" :key="item" :label="item" :value="item" /></el-select
></el-form-item>
<el-form-item label="6. 生成条数" prop="count" class="span-1"
><el-input-number v-model="formData.count" :min="1" :max="20" controls-position="right" class="w100"
/></el-form-item>
<el-form-item label="7. 配图数量" prop="image_per_post" class="span-1"
><el-input-number v-model="formData.image_per_post" :min="1" :max="20" controls-position="right" class="w100"
/></el-form-item>
<el-form-item label="8. 图片比例" prop="image_ratio" class="span-1"
><el-select v-model="formData.image_ratio"
><el-option v-for="item in imageRatioOptions" :key="item" :label="item" :value="item" /></el-select
></el-form-item>
<el-form-item label="9. 描述" prop="description" class="span-2 description-item"
><el-input v-model="formData.description" type="textarea" :rows="4" placeholder="请输入内容补充描述、重点要求或限制条件"
/></el-form-item>
</div>
<el-button type="primary" class="submit-btn" :loading="submitLoading" @click="handleSubmit">告诉我你的选择我马上开始创作</el-button>
</el-form>
</div>
<div class="panel right" v-loading="previewLoading">
<div class="title preview-title">预览区域</div>
<div class="preview-main">
@@ -97,15 +91,23 @@
<script setup lang="ts">
import { nextTick, onMounted, reactive, ref } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { createCreation, downloadToFile, getCreationList, type CreationListParams, type CreationSubmitParams, type CreationTreeItem } from '/@/api/digitalHuman/creation';
import {
createCreation,
downloadToFile,
getCreationList,
type CreationListParams,
type CreationSubmitParams,
type CreationTreeItem,
} from '/@/api/digitalHuman/creation';
type NodeType = 'date' | 'theme' | 'title' | 'html' | 'image';
type NodeType = 'date' | 'contentType' | 'theme' | 'title' | 'html' | 'image';
interface TreeNode {
id: string;
label: string;
nodeType: NodeType;
children?: TreeNode[];
createdDate?: string;
contentType?: string;
theme?: string;
creationTitle?: string;
fileUrl?: string;
@@ -114,7 +116,6 @@ interface PreviewState {
url: string;
nodeType: 'html' | 'image';
}
const formRef = ref<FormInstance>();
const treeLoading = ref(false);
const submitLoading = ref(false);
@@ -125,10 +126,25 @@ const selectedPreview = ref<PreviewState | null>(null);
const apiBaseUrl = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
const treeProps = { children: 'children', label: 'label' };
const queryParams = reactive<CreationListParams>({ keyword: '', pageNum: 1, pageSize: 10 });
const formData = reactive<CreationSubmitParams>({ mode: '混合模式(文案 + 图片)', content_type: '穿搭分享', theme: '', title: '', description: '', style: '生活分享 — 亲切自然,像朋友聊天', count: 3, image_per_post: 1, image_ratio: '3:4 — 小红书' });
const formData = reactive<CreationSubmitParams>({
mode: '混合模式(文案 + 图片)',
content_type: '穿搭分享',
theme: '',
title: '',
description: '',
style: '生活分享 — 亲切自然,像朋友聊天',
count: 3,
image_per_post: 1,
image_ratio: '3:4 — 小红书',
});
const modeOptions = ['混合模式(文案 + 图片)', '纯文案模式', '纯图片模式'];
const contentTypeOptions = ['穿搭分享', '好物推荐', '美妆护肤', '探店分享', '旅行日常', '美食分享'];
const styleOptions = ['生活分享 — 亲切自然,像朋友聊天', '专业测评 — 深度分析,数据支撑', '种草推荐 — 强调亮点,感染力强', '干货教学 — 条理清晰,步骤明确'];
const styleOptions = [
'生活分享 — 亲切自然,像朋友聊天',
'专业测评 — 深度分析,数据支撑',
'种草推荐 — 强调亮点,感染力强',
'干货教学 — 条理清晰,步骤明确',
];
const imageRatioOptions = ['3:4 — 小红书', '1:1 — 方图', '16:9 — 横版'];
const rules: FormRules = {
mode: [{ required: true, message: '请选择创作模式', trigger: 'change' }],
@@ -140,13 +156,7 @@ const rules: FormRules = {
image_per_post: [{ required: true, message: '请输入配图数量', trigger: 'change' }],
image_ratio: [{ required: true, message: '请选择图片比例', trigger: 'change' }],
};
const joinUrl = (base: string, path: string) => {
const cleanBase = base.replace(/\/$/, '');
const cleanPath = path.startsWith('/') ? path : `/${path}`;
return `${cleanBase}${cleanPath}`;
};
const joinUrl = (base: string, path: string) => `${base.replace(/\/$/, '')}${path.startsWith('/') ? path : `/${path}`}`;
const buildAssetUrl = (path?: string) => {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
@@ -155,33 +165,66 @@ const buildAssetUrl = (path?: string) => {
if (prefix) return joinUrl(joinUrl(apiBaseUrl, prefix), path);
return joinUrl(apiBaseUrl, path);
};
const buildTreeNodes = (tree: CreationTreeItem[]) => tree.map((dateGroup, dIndex) => ({
id: `date-${dIndex}`,
label: dateGroup.createdDate,
nodeType: 'date' as const,
children: (dateGroup.themes || []).map((themeGroup, tIndex) => ({
id: `theme-${dIndex}-${tIndex}`,
label: themeGroup.theme,
nodeType: 'theme' as const,
children: (themeGroup.titles || []).map((titleItem, i) => ({
id: `title-${dIndex}-${tIndex}-${i}`,
label: titleItem.title || `作品${i + 1}`,
nodeType: 'title' as const,
const buildTreeNodes = (tree: CreationTreeItem[]): TreeNode[] =>
tree.map((dateGroup, dIndex) => ({
id: `date-${dIndex}`,
label: dateGroup.createdDate,
nodeType: 'date' as const,
children: (dateGroup.contentTypes || []).map((contentTypeGroup, cIndex) => ({
id: `content-type-${dIndex}-${cIndex}`,
label: contentTypeGroup.contentType,
nodeType: 'contentType' as const,
createdDate: dateGroup.createdDate,
theme: themeGroup.theme,
creationTitle: titleItem.title || `作品${i + 1}`,
children: [
...(titleItem.htmlFileUrl ? [{ id: `html-${dIndex}-${tIndex}-${i}`, label: 'HTML', nodeType: 'html' as const, createdDate: dateGroup.createdDate, theme: themeGroup.theme, creationTitle: titleItem.title || `作品${i + 1}`, fileUrl: titleItem.htmlFileUrl }] : []),
...(titleItem.jsonFileUrl || []).map((img, imgIndex) => ({ id: `img-${dIndex}-${tIndex}-${i}-${imgIndex}`, label: `图片 ${imgIndex + 1}`, nodeType: 'image' as const, createdDate: dateGroup.createdDate, theme: themeGroup.theme, creationTitle: titleItem.title || `作品${i + 1}`, fileUrl: img })),
],
contentType: contentTypeGroup.contentType,
children: (contentTypeGroup.themes || []).map((themeGroup, tIndex) => ({
id: `theme-${dIndex}-${cIndex}-${tIndex}`,
label: themeGroup.theme,
nodeType: 'theme' as const,
createdDate: dateGroup.createdDate,
contentType: contentTypeGroup.contentType,
theme: themeGroup.theme,
children: (themeGroup.titles || []).map((titleItem, i) => ({
id: `title-${dIndex}-${cIndex}-${tIndex}-${i}`,
label: titleItem.title || `作品${i + 1}`,
nodeType: 'title' as const,
createdDate: dateGroup.createdDate,
contentType: contentTypeGroup.contentType,
theme: themeGroup.theme,
creationTitle: titleItem.title || `作品${i + 1}`,
children: [
...(titleItem.htmlFileUrl
? [
{
id: `html-${dIndex}-${cIndex}-${tIndex}-${i}`,
label: 'HTML',
nodeType: 'html' as const,
createdDate: dateGroup.createdDate,
contentType: contentTypeGroup.contentType,
theme: themeGroup.theme,
creationTitle: titleItem.title || `作品${i + 1}`,
fileUrl: titleItem.htmlFileUrl,
},
]
: []),
...(titleItem.imageUrls || []).map((img, imgIndex) => ({
id: `img-${dIndex}-${cIndex}-${tIndex}-${i}-${imgIndex}`,
label: img.name || `图片 ${imgIndex + 1}`,
nodeType: 'image' as const,
createdDate: dateGroup.createdDate,
contentType: contentTypeGroup.contentType,
theme: themeGroup.theme,
creationTitle: titleItem.title || `作品${i + 1}`,
fileUrl: img.url,
})),
],
})),
})),
})),
})),
}));
}));
const handleNodeClick = (data: TreeNode) => {
if (data.contentType) formData.content_type = data.contentType;
if (data.theme) formData.theme = data.theme;
if (data.nodeType === 'title') {
formData.theme = data.theme || formData.theme;
formData.title = data.creationTitle || data.label || formData.title;
return;
}
@@ -189,32 +232,37 @@ const handleNodeClick = (data: TreeNode) => {
const url = buildAssetUrl(data.fileUrl);
if (!url) return ElMessage.warning('当前节点没有可预览地址');
selectedPreview.value = { url, nodeType: data.nodeType };
formData.theme = data.theme || formData.theme;
formData.title = data.creationTitle || formData.title;
};
const downloadNode = async (data: TreeNode) => {
if (data.nodeType !== 'html' && data.nodeType !== 'image') return;
if (!data.fileUrl) return ElMessage.warning('当前节点没有可下载地址');
try {
await downloadToFile({
fileURL: data.fileUrl,
localPath: 'C:/Users/AI/Desktop',
});
await downloadToFile({ fileURL: data.fileUrl, localPath: 'C:/Users/AI/Desktop' });
ElMessage.success('已提交下载到本地');
} catch {
ElMessage.error('下载失败');
}
};
const findFirstPreviewNode = (nodes: TreeNode[]): TreeNode | null => {
for (const node of nodes) {
if (node.nodeType === 'html' || node.nodeType === 'image') return node;
if (node.children?.length) {
const matched = findFirstPreviewNode(node.children);
if (matched) return matched;
}
}
return null;
};
const getList = async () => {
treeLoading.value = true;
try {
const res = await getCreationList({ ...queryParams, keyword: queryParams.keyword || undefined });
imgAddressPrefix.value = res.data?.imgAddressPrefix || '';
treeNodes.value = buildTreeNodes(res.data?.Tree || []);
selectedPreview.value = null;
await nextTick();
const firstLeaf = treeNodes.value[0]?.children?.[0]?.children?.[0]?.children?.[0];
const firstLeaf = findFirstPreviewNode(treeNodes.value);
if (firstLeaf) handleNodeClick(firstLeaf);
} catch {
treeNodes.value = [];
@@ -225,7 +273,6 @@ const getList = async () => {
treeLoading.value = false;
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
@@ -245,34 +292,148 @@ const handleSubmit = async () => {
submitLoading.value = false;
}
};
onMounted(getList);
</script>
<style scoped lang="scss">
.creation-page { height: calc(100vh - 100px); display: grid; grid-template-columns: 292px minmax(470px, 1fr) minmax(500px, 1.02fr); gap: 14px; padding: 14px; background: #f6f8fb; box-sizing: border-box; }
.panel { background: #fff; border-radius: 10px; padding: 14px; box-shadow: 0 2px 10px rgba(15,23,42,.05); overflow: hidden; display: flex; flex-direction: column; }
.title { font-size: 18px; font-weight: 700; color: #303133; margin-bottom: 12px; }
.preview-title { margin-bottom: 0; }
.tree-wrap, .center, .preview-main { overflow: auto; }
.tree-node { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; }
.tree-node-main { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
.tree-download { flex-shrink: 0; padding: 2px; }
.ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.compact-form { flex: 1; display: flex; flex-direction: column; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }
.span-1 { grid-column: span 1; }
.span-2 { grid-column: span 2; }
.description-item { margin-bottom: 8px; }
.w100 { width: 100%; }
.submit-btn { width: 100%; height: 40px; margin-top: auto; border-radius: 8px; }
.preview-main { flex: 1; min-height: 0; background: #f8fafc; border: 1px solid #edf1f7; border-radius: 10px; padding: 10px; }
.iframe { width: 100%; height: 100%; min-height: 520px; border: 1px solid #ebeef5; border-radius: 8px; background: #fff; }
.img-wrap { height: 100%; min-height: 520px; border: 1px solid #ebeef5; border-radius: 8px; padding: 10px; display: flex; align-items: center; justify-content: center; background: #fff; }
.img { width: 100%; height: 100%; min-height: 480px; }
:deep(.el-form-item) { margin-bottom: 12px; }
:deep(.el-form-item__label) { padding-bottom: 4px; font-size: 13px; color: #606266; }
:deep(.el-input__wrapper), :deep(.el-select__wrapper), :deep(.el-textarea__inner), :deep(.el-input-number) { border-radius: 8px; }
:deep(.el-select), :deep(.el-input), :deep(.el-input-number), :deep(.el-textarea) { width: 100%; }
@media (max-width: 1800px) { .creation-page { grid-template-columns: 280px minmax(430px, 1fr) minmax(460px, 0.98fr); } }
.creation-page {
height: calc(100vh - 100px);
display: grid;
grid-template-columns: 292px minmax(470px, 1fr) minmax(500px, 1.02fr);
gap: 14px;
padding: 14px;
background: #f6f8fb;
box-sizing: border-box;
}
.panel {
background: #fff;
border-radius: 10px;
padding: 14px;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
overflow: hidden;
display: flex;
flex-direction: column;
}
.title {
font-size: 18px;
font-weight: 700;
color: #303133;
margin-bottom: 12px;
}
.preview-title {
margin-bottom: 0;
}
.tree-wrap,
.center,
.preview-main {
overflow: auto;
}
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.tree-node-main {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.tree-download {
flex-shrink: 0;
padding: 2px;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compact-form {
flex: 1;
display: flex;
flex-direction: column;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 12px;
}
.span-1 {
grid-column: span 1;
}
.span-2 {
grid-column: span 2;
}
.description-item {
margin-bottom: 8px;
}
.w100 {
width: 100%;
}
.submit-btn {
width: 100%;
height: 40px;
margin-top: auto;
border-radius: 8px;
}
.preview-main {
flex: 1;
min-height: 0;
background: #f8fafc;
border: 1px solid #edf1f7;
border-radius: 10px;
padding: 10px;
}
.iframe {
width: 100%;
height: 100%;
min-height: 520px;
border: 1px solid #ebeef5;
border-radius: 8px;
background: #fff;
}
.img-wrap {
height: 100%;
min-height: 520px;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 10px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.img {
width: 100%;
height: 100%;
min-height: 480px;
}
:deep(.el-form-item) {
margin-bottom: 12px;
}
:deep(.el-form-item__label) {
padding-bottom: 4px;
font-size: 13px;
color: #606266;
}
:deep(.el-input__wrapper),
:deep(.el-select__wrapper),
:deep(.el-textarea__inner),
:deep(.el-input-number) {
border-radius: 8px;
}
:deep(.el-select),
:deep(.el-input),
:deep(.el-input-number),
:deep(.el-textarea) {
width: 100%;
}
@media (max-width: 1800px) {
.creation-page {
grid-template-columns: 280px minmax(430px, 1fr) minmax(460px, 0.98fr);
}
}
</style>