677 lines
23 KiB
Vue
677 lines
23 KiB
Vue
<template>
|
||
<div class="main-content">
|
||
<!-- 工作流动态表单 -->
|
||
<div v-if="workflowDetail" class="content-body workflow-form-body">
|
||
<div class="workflow-form-card">
|
||
<div class="workflow-form-header">
|
||
<div>
|
||
<h3>{{ workflowDetail.flowName || '工作流表单' }}</h3>
|
||
<div class="workflow-form-sub">{{ workflowDetail.description || '请填写以下参数' }}</div>
|
||
</div>
|
||
<el-button size="small" @click="$emit('close-workflow')">返回对话</el-button>
|
||
</div>
|
||
<div class="workflow-form-scroll">
|
||
<el-form label-position="top" class="workflow-form">
|
||
<template v-if="workflowDetail.nodeInputParams">
|
||
<template v-for="node in workflowDetail.nodeInputParams" :key="node.id || node.nodeCode">
|
||
<template v-if="node.nodeCode !== '__start__' && hasFormConfig(node)">
|
||
|
||
<el-form-item
|
||
v-for="field in getVisibleFields(node)"
|
||
:key="getFieldKey(node, field)"
|
||
:label="field.label"
|
||
:required="field.required"
|
||
>
|
||
<!-- 文本输入 -->
|
||
<el-input
|
||
v-if="field.type === 'input' || field.type === 'string'"
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
:placeholder="field.required ? '必填' : '选填'"
|
||
clearable
|
||
/>
|
||
|
||
<!-- 数字输入 -->
|
||
<el-input-number
|
||
v-else-if="field.type === 'number' || field.type === 'inputNumber'"
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
class="w100"
|
||
:controls="true"
|
||
:min="field.fieldConstraint?.minValue ?? undefined"
|
||
:max="field.fieldConstraint?.maxValue ?? undefined"
|
||
/>
|
||
|
||
<!-- 多行文本 -->
|
||
<el-input
|
||
v-else-if="field.type === 'textarea'"
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
type="textarea"
|
||
:rows="3"
|
||
:placeholder="field.required ? '必填' : '选填'"
|
||
show-word-limit
|
||
:maxlength="500"
|
||
/>
|
||
|
||
<!-- 开关 -->
|
||
<el-switch
|
||
v-else-if="field.type === 'switch'"
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
active-text="开启"
|
||
inactive-text="关闭"
|
||
/>
|
||
|
||
<!-- 下拉选择 -->
|
||
<el-select
|
||
v-else-if="field.type === 'select' && field.options"
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
:placeholder="field.required ? '必填' : '选填'"
|
||
class="w100"
|
||
>
|
||
<el-option v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||
</el-select>
|
||
|
||
<!-- 文件上传 -->
|
||
<div v-else-if="isFileField(field)" class="field-upload-wrapper">
|
||
<el-upload
|
||
:key="`upload-${getFieldKey(node, field)}`"
|
||
:auto-upload="false"
|
||
:multiple="field.type === 'uploadMultiple'"
|
||
:show-file-list="false"
|
||
:accept="getFileAccept(field)"
|
||
:on-change="(file: any) => handleFileUpload(node, field, file)"
|
||
>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
:loading="uploadingFields[getFieldKey(node, field)]"
|
||
:disabled="uploadingFields[getFieldKey(node, field)]"
|
||
>
|
||
{{ uploadingFields[getFieldKey(node, field)] ? '上传中...' : '选择文件' }}
|
||
</el-button>
|
||
</el-upload>
|
||
<div class="upload-rules">
|
||
<span>{{ getFileRuleText(field) }}</span>
|
||
<span v-if="getFieldFileList(node, field).length > 0" class="upload-count">
|
||
已上传 {{ getFieldFileCountText(node, field) }}
|
||
</span>
|
||
</div>
|
||
<div v-if="getFieldFileList(node, field).length > 0" class="uploaded-files-list">
|
||
<div
|
||
v-for="(f, fileIdx) in getFieldFileList(node, field)"
|
||
:key="fileIdx"
|
||
class="uploaded-file-item"
|
||
>
|
||
<span class="file-name">{{ f.name }}</span>
|
||
<el-button type="danger" link size="small" @click="removeFieldFile(node, field, fileIdx)">
|
||
删除
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 兜底文本输入 -->
|
||
<el-input
|
||
v-else
|
||
v-model="formValues[getFieldKey(node, field)]"
|
||
:placeholder="field.required ? '必填' : '选填'"
|
||
/>
|
||
</el-form-item>
|
||
</template>
|
||
</template>
|
||
</template>
|
||
</el-form>
|
||
|
||
<!-- 贴片模板编辑器 -->
|
||
<div v-if="currentWorkflowHasPatchLayout" class="patch-template-section">
|
||
<PatchTemplateEditor v-model="templates" />
|
||
</div>
|
||
|
||
<el-empty v-if="!hasFormFields && !currentWorkflowHasPatchLayout" description="该工作流无需填写参数" :image-size="60" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 默认占位 — 无工作流时显示引导 -->
|
||
<div v-else class="content-body placeholder-body">
|
||
<div class="placeholder-content">
|
||
<div class="placeholder-icon">
|
||
<div class="icon-ring ring-outer">
|
||
<div class="icon-ring ring-inner">
|
||
<div class="icon-ring ring-core">
|
||
<svg class="icon-sparkle" width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M12 2l1.09 6.26L18 4.5l-3.41 4.74L20 10l-5.41.76L18 15.5l-4.91-2.76L12 19l-1.09-6.26L6 15.5l3.41-4.74L4 10l5.41-.76L6 4.5l4.91 2.74L12 2z" fill="currentColor"/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<h2 class="placeholder-title">请选择下方工作流进行创作</h2>
|
||
<p class="placeholder-desc">点击工作流胶囊快速切换,填写参数后一键生成内容</p>
|
||
|
||
<div class="placeholder-steps">
|
||
<div class="step-item">
|
||
<div class="step-num">1</div>
|
||
<div class="step-text">
|
||
<span class="step-label">选择工作流</span>
|
||
<span class="step-hint">点击胶囊切换不同创作模式</span>
|
||
</div>
|
||
</div>
|
||
<div class="step-arrow">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
|
||
</div>
|
||
<div class="step-item">
|
||
<div class="step-num">2</div>
|
||
<div class="step-text">
|
||
<span class="step-label">填写参数</span>
|
||
<span class="step-hint">按需配置内容与样式选项</span>
|
||
</div>
|
||
</div>
|
||
<div class="step-arrow">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
|
||
</div>
|
||
<div class="step-item">
|
||
<div class="step-num">3</div>
|
||
<div class="step-text">
|
||
<span class="step-label">一键生成</span>
|
||
<span class="step-hint">AI 自动产出,结果同步到工作空间</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="placeholder-footer">
|
||
<span class="footer-hint">还可从左侧「对话记录」查看历史或「工作空间」管理作品</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, reactive, ref, watch } from 'vue';
|
||
import { ElMessage } from 'element-plus';
|
||
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
|
||
import { uploadFile } from '/@/api/common/upload';
|
||
|
||
interface Props {
|
||
activeMenu: string;
|
||
workflowDetail: any;
|
||
activeHistoryId?: string | null;
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
activeHistoryId: null,
|
||
});
|
||
defineEmits<{
|
||
(e: 'close-workflow'): void;
|
||
}>();
|
||
|
||
const formValues = reactive<Record<string, any>>({});
|
||
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
|
||
const uploadingFields = reactive<Record<string, boolean>>({});
|
||
const templates = ref<any[]>([]);
|
||
const getFieldKey = (node: any, field: any): string => {
|
||
if (field?.__isHttpBodyChild) {
|
||
return `${node.id}_body_${field.bodyKey}`;
|
||
}
|
||
return (node.id || node.nodeCode) + '_' + (field.field || field.label);
|
||
};
|
||
|
||
const getVisibleFields = (node: any): any[] => {
|
||
const fields = Array.isArray(node?.formConfig) ? node.formConfig : [];
|
||
const result: any[] = [];
|
||
fields.forEach((field: any) => {
|
||
if (!field) return;
|
||
if (field.expand && typeof field.expand === 'object' && field.expand.editable === false) return;
|
||
|
||
if (String(node?.nodeCode || '').toLowerCase() === 'http') {
|
||
if (field.field !== 'body') return;
|
||
const bodyVal = field.value;
|
||
if (!bodyVal || typeof bodyVal !== 'object' || Array.isArray(bodyVal)) return;
|
||
Object.entries(bodyVal).forEach(([bodyKey, bodyItem]: [string, any]) => {
|
||
if (!bodyItem || bodyItem.showInForm !== true) return;
|
||
result.push({
|
||
__isHttpBodyChild: true,
|
||
bodyKey,
|
||
field: `body.${bodyKey}`,
|
||
label: bodyItem.key || bodyKey,
|
||
required: false,
|
||
type: bodyItem.fieldType || 'input',
|
||
fieldType: bodyItem.fieldType || 'string',
|
||
fieldConstraint: bodyItem.fieldConstraint || {},
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
|
||
result.push(field);
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const isFileField = (field: any): boolean => {
|
||
return field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload';
|
||
};
|
||
|
||
const getFileAccept = (field: any): string => {
|
||
return field.fieldConstraint?.fileTypes ? '.' + field.fieldConstraint.fileTypes.replace(/,/g, ',.') : '*';
|
||
};
|
||
|
||
const getFileRuleText = (field: any): string => {
|
||
const rules: string[] = [];
|
||
if (field.fieldConstraint?.fileTypes) rules.push('格式: ' + field.fieldConstraint.fileTypes);
|
||
if (field.fieldConstraint?.maxFileSize) rules.push('最大: ' + field.fieldConstraint.maxFileSize + 'MB');
|
||
if (field.fieldConstraint?.maxFileCount) rules.push('最多: ' + field.fieldConstraint.maxFileCount + '个');
|
||
return rules.join(' | ');
|
||
};
|
||
|
||
const getFieldFileList = (node: any, field: any): { name: string; url: string }[] => {
|
||
return fieldFiles[getFieldKey(node, field)] || [];
|
||
};
|
||
|
||
const getFieldConstraint = (field: any): any => {
|
||
return field?.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : {};
|
||
};
|
||
|
||
const getFieldFileCountText = (node: any, field: any): string => {
|
||
const current = getFieldFileList(node, field).length;
|
||
const max = Number(getFieldConstraint(field).maxFileCount);
|
||
if (!Number.isNaN(max) && max > 0) {
|
||
return `${current} / ${max}`;
|
||
}
|
||
return `${current}`;
|
||
};
|
||
|
||
const handleFileUpload = async (node: any, field: any, file: any) => {
|
||
const raw = file.raw;
|
||
if (!raw) return;
|
||
|
||
const key = getFieldKey(node, field);
|
||
const fc = getFieldConstraint(field);
|
||
|
||
const typeRules = String(fc.fileTypes || '')
|
||
.split(',')
|
||
.map((t: string) => t.trim().toLowerCase().replace(/^\./, ''))
|
||
.filter(Boolean);
|
||
if (typeRules.length > 0) {
|
||
const ext = (raw.name?.split('.').pop() || '').toLowerCase();
|
||
if (!typeRules.includes(ext)) {
|
||
ElMessage.warning(`文件格式不符合要求,仅支持:${typeRules.join(', ')}`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const maxFileSize = Number(fc.maxFileSize);
|
||
if (!Number.isNaN(maxFileSize) && maxFileSize > 0) {
|
||
const sizeMB = raw.size / 1024 / 1024;
|
||
if (sizeMB > maxFileSize) {
|
||
ElMessage.warning(`文件大小超限,最大 ${maxFileSize}MB`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const maxFileCount = Number(fc.maxFileCount);
|
||
if (!Number.isNaN(maxFileCount) && maxFileCount > 0) {
|
||
const currentCount = fieldFiles[key]?.length || 0;
|
||
if (currentCount >= maxFileCount) {
|
||
ElMessage.warning(`文件数量超限,最多 ${maxFileCount} 个`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
uploadingFields[key] = true;
|
||
|
||
try {
|
||
const uploadRes = await uploadFile(raw, { timeout: 0 });
|
||
if (!uploadRes?.data?.fileURL) throw new Error('上传失败:未返回文件URL');
|
||
|
||
const fileUrl = uploadRes.data.fileAddressPrefix
|
||
? `${uploadRes.data.fileAddressPrefix}${uploadRes.data.fileURL}`
|
||
: uploadRes.data.fileURL;
|
||
|
||
if (!fieldFiles[key]) fieldFiles[key] = [];
|
||
fieldFiles[key].push({ name: raw.name, url: fileUrl });
|
||
|
||
if (field.type === 'upload') {
|
||
formValues[key] = fileUrl;
|
||
} else {
|
||
formValues[key] = fieldFiles[key].map((f: any) => f.url);
|
||
}
|
||
} catch (error: any) {
|
||
ElMessage.error(error?.message || '文件上传失败');
|
||
} finally {
|
||
uploadingFields[key] = false;
|
||
}
|
||
};
|
||
|
||
const removeFieldFile = (node: any, field: any, fileIdx: number) => {
|
||
const key = getFieldKey(node, field);
|
||
if (!fieldFiles[key]) return;
|
||
fieldFiles[key].splice(fileIdx, 1);
|
||
if (field.type === 'upload') {
|
||
formValues[key] = '';
|
||
} else {
|
||
formValues[key] = fieldFiles[key].map((f) => f.url);
|
||
}
|
||
};
|
||
|
||
const currentWorkflowHasPatchLayout = computed(() => {
|
||
const nodes = props.workflowDetail?.nodeInputParams || [];
|
||
return nodes.some((node: any) => node.patchLayout === true);
|
||
});
|
||
|
||
const hasFormConfig = (node: any): boolean => {
|
||
return node.nodeCode !== '__start__' && node.formConfig && node.formConfig.length > 0;
|
||
};
|
||
|
||
const hasFormFields = computed(() => {
|
||
if (!props.workflowDetail?.nodeInputParams) return false;
|
||
return props.workflowDetail.nodeInputParams.some(
|
||
(node: any) => node.nodeCode !== '__start__' && node.formConfig?.length > 0
|
||
);
|
||
});
|
||
|
||
const validateFormFields = (): boolean => {
|
||
if (!props.workflowDetail?.nodeInputParams) return true;
|
||
for (const node of props.workflowDetail.nodeInputParams as any[]) {
|
||
if (node.nodeCode === '__start__') continue;
|
||
const fields = getVisibleFields(node);
|
||
for (const field of fields) {
|
||
if (!field.required) continue;
|
||
const key = getFieldKey(node, field);
|
||
const value = formValues[key];
|
||
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
|
||
ElMessage.warning(`请填写必填项:${field.label}`);
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
|
||
// 初始化或重置表单
|
||
watch(
|
||
() => props.workflowDetail,
|
||
(detail) => {
|
||
Object.keys(formValues).forEach((key) => delete formValues[key]);
|
||
Object.keys(fieldFiles).forEach((key) => delete fieldFiles[key]);
|
||
Object.keys(uploadingFields).forEach((key) => delete uploadingFields[key]);
|
||
// 尝试从执行详情恢复贴片模板
|
||
const ext = (detail as any)?.extension;
|
||
const restoredTemplates = ext?.templates || (detail as any)?.templates || detail?.flowContent?.templates || [];
|
||
templates.value = Array.isArray(restoredTemplates) ? restoredTemplates : [];
|
||
|
||
if (!detail?.nodeInputParams) return;
|
||
detail.nodeInputParams.forEach((node: any) => {
|
||
if (!node.formConfig) return;
|
||
node.formConfig.forEach((field: any) => {
|
||
if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') {
|
||
Object.entries(field.value).forEach(([bodyKey, bodyItem]: [string, any]) => {
|
||
if (!bodyItem || bodyItem.showInForm !== true) return;
|
||
const bodyFieldKey = `${node.id}_body_${bodyKey}`;
|
||
if (bodyItem.fieldType === 'number') {
|
||
formValues[bodyFieldKey] = (bodyItem.value !== undefined && bodyItem.value !== null && bodyItem.value !== '')
|
||
? Number(bodyItem.value) : null;
|
||
} else if (bodyItem.fieldType === 'fileUpload') {
|
||
formValues[bodyFieldKey] = Array.isArray(bodyItem.value) ? bodyItem.value
|
||
: bodyItem.value ? [bodyItem.value] : [];
|
||
} else {
|
||
formValues[bodyFieldKey] = bodyItem.value || '';
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
const key = getFieldKey(node, field);
|
||
const hasValue = field.value !== undefined && field.value !== null;
|
||
if (field.type === 'number' || field.type === 'inputNumber') {
|
||
formValues[key] = hasValue ? Number(field.value) : (field.default ?? null);
|
||
} else if (field.type === 'switch') {
|
||
formValues[key] = hasValue ? Boolean(field.value) : (field.default ?? false);
|
||
} else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') {
|
||
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
|
||
} else {
|
||
formValues[key] = hasValue ? field.value : (field.default ?? '');
|
||
}
|
||
});
|
||
});
|
||
|
||
detail.nodeInputParams.forEach((node: any) => {
|
||
if (!node.formConfig) return;
|
||
node.formConfig.forEach((field: any) => {
|
||
if (!isFileField(field)) return;
|
||
const key = getFieldKey(node, field);
|
||
const rawValue = formValues[key];
|
||
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
|
||
if (urls.length === 0) return;
|
||
fieldFiles[key] = urls.map((url: string) => ({
|
||
name: String(url || '').split('/').pop() || 'file-' + Math.random().toString(36).slice(2, 8),
|
||
url,
|
||
}));
|
||
});
|
||
});
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||
</script>
|
||
|
||
<style scoped lang="scss">.main-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.content-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
scrollbar-width: none;
|
||
-ms-overflow-style: none;
|
||
&::-webkit-scrollbar { display: none; }
|
||
}
|
||
|
||
/* ===== 工作流表单 ===== */
|
||
.workflow-form-body {
|
||
width: min(820px, 100%);
|
||
margin: 0 auto;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 16px 20px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.workflow-form-card {
|
||
background: #fff;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: 100%;
|
||
overflow: hidden;
|
||
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
|
||
}
|
||
|
||
.workflow-form-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 20px;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
flex-shrink: 0;
|
||
h3 { margin: 0; font-size: 14px; font-weight: 600; color: #0f172a; }
|
||
.workflow-form-sub { font-size: 12px; color: #94a3b8; margin-top: 2px; }
|
||
.el-button { flex-shrink: 0; }
|
||
}
|
||
|
||
.workflow-form-scroll {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
&::-webkit-scrollbar { width: 4px; }
|
||
&::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||
&::-webkit-scrollbar-track { background: transparent; }
|
||
}
|
||
|
||
.form-node-group {
|
||
background: #f8fafc;
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 10px;
|
||
padding: 16px 20px;
|
||
margin-bottom: 16px;
|
||
&:last-child { margin-bottom: 0; }
|
||
}
|
||
|
||
.form-node-group-title {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #64748b;
|
||
margin-bottom: 14px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
letter-spacing: 0.3px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
:deep(.el-form-item) {
|
||
margin-bottom: 14px;
|
||
&:last-child { margin-bottom: 0; }
|
||
.el-form-item__label { font-size: 13px; font-weight: 500; color: #334155; padding-bottom: 4px; line-height: 1.5; }
|
||
.el-form-item__label::before { color: #ef4444; margin-right: 4px; }
|
||
.el-input-number { width: 100%; }
|
||
.el-select { width: 100%; }
|
||
.el-textarea__inner { font-size: 13px; }
|
||
}
|
||
|
||
:deep(.el-divider) {
|
||
border-top-color: #e2e8f0;
|
||
margin-top: 6px;
|
||
margin-bottom: 12px;
|
||
.el-divider__text { font-weight: 600; font-size: 12px; color: #64748b; background: transparent; padding-left: 0; }
|
||
}
|
||
|
||
:deep(.upload-area) {
|
||
width: 100%;
|
||
.el-upload-dragger {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 100%; height: auto; padding: 20px;
|
||
border: 2px dashed #e2e8f0; border-radius: 10px;
|
||
background: #fff; cursor: pointer;
|
||
transition: all 0.2s;
|
||
&:hover { border-color: #93c5fd; background: #f8faff; }
|
||
}
|
||
.upload-trigger { display: flex; align-items: center; gap: 12px; }
|
||
.upload-icon { font-size: 28px; color: #93c5fd; }
|
||
.upload-text { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #475569; text-align: left; }
|
||
.upload-rule { font-size: 11px; color: #94a3b8; }
|
||
&.is-filled .el-upload-dragger { border-color: #bfdbfe; background: #eff6ff; }
|
||
}
|
||
|
||
.uploaded-files-list { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||
|
||
.uploaded-file-item {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 6px 12px; background: #fff;
|
||
border: 1px solid #e2e8f0; border-radius: 8px;
|
||
transition: background 0.15s;
|
||
&:hover { background: #f1f5f9; }
|
||
.file-type-icon { font-size: 16px; color: #3b82f6; flex-shrink: 0; }
|
||
.file-name { flex: 1; font-size: 12px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.el-button { flex-shrink: 0; }
|
||
}
|
||
|
||
.patch-template-section {
|
||
margin-top: 16px;
|
||
:deep(.patch-template-editor) {
|
||
> .el-divider { margin-top: 4px; margin-bottom: 12px; border-top-color: #e2e8f0;
|
||
.el-divider__text { font-weight: 600; font-size: 13px; color: #475569; background: transparent; padding-left: 0; }
|
||
}
|
||
.template-card { border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 10px; }
|
||
}
|
||
}
|
||
|
||
.w100 { width: 100%; }
|
||
.chat-container { width: min(1060px, 82%); margin: 0 auto; padding: 20px 0 130px; }
|
||
|
||
.placeholder-body { display: flex; align-items: center; justify-content: center; }
|
||
.placeholder-content { text-align: center; padding: 20px; max-width: 480px; }
|
||
|
||
/* ===== 图标:层叠圆环 + 星形 ===== */
|
||
.placeholder-icon { margin-bottom: 28px; display: flex; justify-content: center; }
|
||
.icon-ring {
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: transform 0.3s ease;
|
||
}
|
||
.ring-outer {
|
||
width: 88px; height: 88px;
|
||
background: radial-gradient(circle at 35% 30%, rgba(59, 130, 246, 0.10) 0%, rgba(139, 92, 246, 0.06) 100%);
|
||
border: 1.5px solid rgba(59, 130, 246, 0.15);
|
||
&:hover { transform: scale(1.05); }
|
||
}
|
||
.ring-inner {
|
||
width: 62px; height: 62px;
|
||
background: radial-gradient(circle at 35% 30%, rgba(59, 130, 246, 0.14) 0%, rgba(139, 92, 246, 0.08) 100%);
|
||
border: 1.5px solid rgba(59, 130, 246, 0.20);
|
||
}
|
||
.ring-core {
|
||
width: 42px; height: 42px;
|
||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.35);
|
||
}
|
||
.icon-sparkle { color: #fff; }
|
||
|
||
/* ===== 标题与描述 ===== */
|
||
.placeholder-title { font-size: 18px; font-weight: 700; color: #0f172a; margin: 0 0 6px; letter-spacing: -0.2px; line-height: 1.4; }
|
||
.placeholder-desc { font-size: 14px; color: #94a3b8; margin: 0 0 32px; line-height: 1.5; }
|
||
|
||
/* ===== 步骤引导 ===== */
|
||
.placeholder-steps {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0;
|
||
margin-bottom: 28px;
|
||
}
|
||
.step-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
text-align: left;
|
||
}
|
||
.step-num {
|
||
width: 28px; height: 28px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #eff6ff 0%, #f0f0ff 100%);
|
||
border: 1.5px solid #bfdbfe;
|
||
color: #3b82f6;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
transition: all 0.2s;
|
||
.step-item:hover & {
|
||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||
color: #fff;
|
||
border-color: transparent;
|
||
}
|
||
}
|
||
.step-text { display: flex; flex-direction: column; gap: 1px; }
|
||
.step-label { font-size: 13px; font-weight: 600; color: #334155; line-height: 1.3; white-space: nowrap; }
|
||
.step-hint { font-size: 11px; color: #94a3b8; line-height: 1.3; white-space: nowrap; }
|
||
.step-arrow {
|
||
color: #cbd5e1;
|
||
display: flex;
|
||
align-items: center;
|
||
margin: 0 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ===== 底部辅助提示 ===== */
|
||
.placeholder-footer { margin-top: 4px; }
|
||
.footer-hint { font-size: 12px; color: #cbd5e1; line-height: 1.5; }
|
||
</style> |