869 lines
28 KiB
Vue
869 lines
28 KiB
Vue
<script lang="ts" setup>
|
||
import { computed, onMounted, reactive, ref } from 'vue';
|
||
|
||
import { Page } from '@vben/common-ui';
|
||
|
||
import {
|
||
Button,
|
||
Card,
|
||
Col,
|
||
DatePicker,
|
||
Empty,
|
||
Input,
|
||
message,
|
||
Modal,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Steps,
|
||
Tag,
|
||
} from 'ant-design-vue';
|
||
|
||
import {
|
||
createTraceabilityBatch,
|
||
deleteTraceabilityBatch,
|
||
getTraceabilityBatch,
|
||
getTraceabilityBatches,
|
||
getTraceabilityTemplates,
|
||
publishTraceabilityBatch,
|
||
uploadTraceabilityImage,
|
||
updateTraceabilityBatchStep,
|
||
} from '#/api';
|
||
import type { TraceabilityApi } from '#/api';
|
||
|
||
import { formatFieldValue, getFieldTypeLabel, normalizeFieldInput } from './shared';
|
||
|
||
const loading = ref(false);
|
||
const selectedBatchId = ref('');
|
||
const templates = ref<TraceabilityApi.TemplateSummary[]>([]);
|
||
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
|
||
const batchDetail = ref<null | TraceabilityApi.BatchDetail>(null);
|
||
const stepIndex = ref(0);
|
||
const editableStartIndex = ref(0);
|
||
const createBatchVisible = ref(false);
|
||
const formState = reactive({
|
||
batchCode: '',
|
||
batchName: '',
|
||
coverImage: '',
|
||
productName: '',
|
||
summary: '',
|
||
tagsText: '',
|
||
templateId: '',
|
||
});
|
||
const saving = ref(false);
|
||
const uploadingFieldKey = ref('');
|
||
const publishedTemplates = computed(() =>
|
||
templates.value.filter((item) => item.status === 'active'),
|
||
);
|
||
|
||
const currentStep = computed(
|
||
() => batchDetail.value?.steps?.[stepIndex.value] ?? null,
|
||
);
|
||
const isPublished = computed(() => batchDetail.value?.status === 'published');
|
||
const isLockedStep = computed(() => !!currentStep.value?.locked);
|
||
const actualCurrentStepIndex = computed(() =>
|
||
isPublished.value ? (batchDetail.value?.currentStep ?? 0) : editableStartIndex.value,
|
||
);
|
||
const isCurrentEditableStep = computed(() => !!batchDetail.value && !isPublished.value);
|
||
const isLastStep = computed(() => {
|
||
if (!batchDetail.value?.steps?.length) return false;
|
||
return actualCurrentStepIndex.value >= batchDetail.value.steps.length - 1;
|
||
});
|
||
const allStepsCompleted = computed(() =>
|
||
(batchDetail.value?.steps ?? []).every((item) => item.status === 'completed'),
|
||
);
|
||
const stepActionText = computed(() =>
|
||
isLastStep.value ? '保存并发布' : '保存并切换至下一节点',
|
||
);
|
||
|
||
async function loadLists() {
|
||
loading.value = true;
|
||
try {
|
||
templates.value = await getTraceabilityTemplates();
|
||
batches.value = await getTraceabilityBatches();
|
||
if (!selectedBatchId.value && batches.value[0]) {
|
||
await selectBatch(batches.value[0].id);
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function applyBatch(detail: TraceabilityApi.BatchDetail) {
|
||
batchDetail.value = structuredClone(detail);
|
||
formState.batchCode = detail.batchCode;
|
||
formState.batchName = detail.batchName;
|
||
formState.coverImage = detail.coverImage;
|
||
formState.productName = detail.productName;
|
||
formState.summary = detail.summary;
|
||
formState.tagsText = detail.tags.join(',');
|
||
formState.templateId = detail.templateId;
|
||
stepIndex.value = detail.currentStep ?? 0;
|
||
editableStartIndex.value = detail.currentStep ?? 0;
|
||
}
|
||
|
||
async function selectBatch(id: string) {
|
||
selectedBatchId.value = id;
|
||
const detail = await getTraceabilityBatch(id);
|
||
applyBatch(detail);
|
||
}
|
||
|
||
function openCreateBatchModal() {
|
||
if (publishedTemplates.value.length === 0) {
|
||
message.warning('请先在管理员页发布模板后,再新建批次');
|
||
return;
|
||
}
|
||
selectedBatchId.value = '';
|
||
batchDetail.value = null;
|
||
formState.batchCode = `TR-${new Date().getFullYear()}-${String(Date.now()).slice(-6)}`;
|
||
formState.batchName = '新建批次';
|
||
formState.coverImage = '';
|
||
formState.productName = '';
|
||
formState.summary = '';
|
||
formState.tagsText = '';
|
||
formState.templateId = publishedTemplates.value[0]?.id ?? '';
|
||
stepIndex.value = 0;
|
||
createBatchVisible.value = true;
|
||
}
|
||
|
||
async function createBatch() {
|
||
if (!formState.templateId) {
|
||
message.warning('请先选择模板');
|
||
return;
|
||
}
|
||
saving.value = true;
|
||
try {
|
||
const detail = await createTraceabilityBatch({
|
||
batchCode: formState.batchCode,
|
||
batchName: formState.batchName,
|
||
coverImage: formState.coverImage,
|
||
productName: formState.productName,
|
||
summary: formState.summary,
|
||
tags: formState.tagsText
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean),
|
||
templateId: formState.templateId,
|
||
});
|
||
message.success('批次已创建');
|
||
createBatchVisible.value = false;
|
||
await loadLists();
|
||
if (detail?.id) {
|
||
await selectBatch(detail.id);
|
||
} else if (batches.value[0]) {
|
||
await selectBatch(batches.value[0].id);
|
||
}
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
function getBatchStatusText(status: string) {
|
||
if (status === 'published') return '已发布';
|
||
if (status === 'draft') return '未发布';
|
||
return status || '进行中';
|
||
}
|
||
|
||
function getStepStatusText(index: number) {
|
||
if (allStepsCompleted.value) {
|
||
return '已完成';
|
||
}
|
||
return index < actualCurrentStepIndex.value ? '已完成' : '进行中';
|
||
}
|
||
|
||
function updateFieldValue(field: TraceabilityApi.FieldDefinition, value: any) {
|
||
if (!currentStep.value) return;
|
||
currentStep.value.values[field.key] = normalizeFieldInput(field, value);
|
||
}
|
||
|
||
function sanitizeIntegerInput(value: string) {
|
||
const cleaned = value.replaceAll(/[^\d-]/g, '');
|
||
const hasLeadingMinus = cleaned.startsWith('-');
|
||
const unsigned = hasLeadingMinus ? cleaned.slice(1).replaceAll('-', '') : cleaned.replaceAll('-', '');
|
||
return hasLeadingMinus ? `-${unsigned}` : unsigned;
|
||
}
|
||
|
||
function sanitizeDecimalInput(value: string) {
|
||
const cleaned = value.replaceAll(/[^\d.-]/g, '');
|
||
const firstDot = cleaned.indexOf('.');
|
||
const normalizedDot =
|
||
firstDot === -1
|
||
? cleaned
|
||
: `${cleaned.slice(0, firstDot + 1)}${cleaned
|
||
.slice(firstDot + 1)
|
||
.replaceAll('.', '')}`;
|
||
const firstMinus = normalizedDot.indexOf('-');
|
||
return firstMinus <= 0
|
||
? normalizedDot
|
||
: `-${normalizedDot.replaceAll('-', '')}`;
|
||
}
|
||
|
||
function getFieldUploadKey(field: TraceabilityApi.FieldDefinition) {
|
||
return `${currentStep.value?.id ?? 'step'}:${field.key}`;
|
||
}
|
||
|
||
function getFieldInputId(field: TraceabilityApi.FieldDefinition) {
|
||
return `traceability-upload-${getFieldUploadKey(field)}`;
|
||
}
|
||
|
||
function triggerImageSelect(field: TraceabilityApi.FieldDefinition) {
|
||
const input = document.getElementById(getFieldInputId(field));
|
||
input?.click();
|
||
}
|
||
|
||
function clearImageValue(field: TraceabilityApi.FieldDefinition) {
|
||
updateFieldValue(field, '');
|
||
}
|
||
|
||
async function handleImageUpload(field: TraceabilityApi.FieldDefinition, event: Event) {
|
||
const files = (event.target as HTMLInputElement).files;
|
||
const file = files?.[0];
|
||
if (!file || !currentStep.value || !selectedBatchId.value) return;
|
||
|
||
const uploadKey = getFieldUploadKey(field);
|
||
uploadingFieldKey.value = uploadKey;
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append(
|
||
'objectDir',
|
||
`traceability/${selectedBatchId.value}/${currentStep.value.id}/${field.key}`,
|
||
);
|
||
const result = await uploadTraceabilityImage(formData);
|
||
updateFieldValue(field, result.tempUrl || result.objectName);
|
||
message.success('图片上传成功');
|
||
} catch {
|
||
message.error('图片上传失败');
|
||
} finally {
|
||
uploadingFieldKey.value = '';
|
||
(event.target as HTMLInputElement).value = '';
|
||
}
|
||
}
|
||
|
||
function removeBatch(id: string) {
|
||
Modal.confirm({
|
||
title: '删除批次',
|
||
content: '删除后该批次的填报记录和发布信息都会一起清除,是否继续?',
|
||
async onOk() {
|
||
await deleteTraceabilityBatch(id);
|
||
message.success('批次已删除');
|
||
if (selectedBatchId.value === id) {
|
||
selectedBatchId.value = '';
|
||
batchDetail.value = null;
|
||
}
|
||
await loadLists();
|
||
},
|
||
});
|
||
}
|
||
|
||
async function saveStep() {
|
||
if (!selectedBatchId.value || !currentStep.value) return;
|
||
if (!isCurrentEditableStep.value || isPublished.value) {
|
||
message.warning('请先完成当前进行中的节点');
|
||
return;
|
||
}
|
||
saving.value = true;
|
||
try {
|
||
const detail = await updateTraceabilityBatchStep(
|
||
selectedBatchId.value,
|
||
currentStep.value.id,
|
||
{
|
||
completedAt: new Date().toISOString(),
|
||
operatorName: currentStep.value.operatorName,
|
||
status: 'completed',
|
||
values: currentStep.value.values,
|
||
},
|
||
);
|
||
applyBatch(detail);
|
||
if (detail.steps.every((item) => item.status === 'completed')) {
|
||
const published = await publishTraceabilityBatch(selectedBatchId.value);
|
||
applyBatch(published);
|
||
message.success('最后一个节点已保存并发布');
|
||
} else {
|
||
const nextIndex = detail.currentStep ?? 0;
|
||
editableStartIndex.value = nextIndex;
|
||
stepIndex.value = nextIndex;
|
||
message.success('当前节点已保存,已切换至下一节点');
|
||
}
|
||
await loadLists();
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadLists();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<Page auto-content-height>
|
||
<div class="trace-operator">
|
||
<Row :gutter="[16, 16]">
|
||
<Col :lg="7" :md="8" :sm="24" :xs="24">
|
||
<Card :loading="loading" class="panel-card batch-panel-card" title="批次流程">
|
||
<template #extra>
|
||
<Button type="primary" @click="openCreateBatchModal">新建批次</Button>
|
||
</template>
|
||
<div class="batch-list">
|
||
<button
|
||
v-for="item in batches"
|
||
:key="item.id"
|
||
class="batch-card"
|
||
:class="{ active: item.id === selectedBatchId }"
|
||
@click="selectBatch(item.id)"
|
||
>
|
||
<div class="batch-card__header">
|
||
<strong>{{ item.batchName }}</strong>
|
||
<div class="batch-card__actions">
|
||
<Tag>{{ getBatchStatusText(item.status) }}</Tag>
|
||
<Button danger size="small" type="link" @click.stop="removeBatch(item.id)">
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<p>{{ item.batchCode }}</p>
|
||
<div class="batch-card__meta">
|
||
<span>{{ item.templateName }}</span>
|
||
<span>{{ item.productName || '未设置产品名称' }}</span>
|
||
<span>扫码 {{ item.scanCount }} 次</span>
|
||
</div>
|
||
<small>{{ item.summary || '暂无批次概述' }}</small>
|
||
</button>
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
|
||
<Col :lg="17" :md="16" :sm="24" :xs="24">
|
||
<Space direction="vertical" size="middle" style="width: 100%">
|
||
<Card v-if="batchDetail?.publishedAt" class="panel-card" title="发布信息">
|
||
<div class="publish-panel">
|
||
<div>
|
||
<span>溯源码</span>
|
||
<strong>{{ batchDetail.batchCode }}</strong>
|
||
</div>
|
||
<div>
|
||
<span>消费者访问地址</span>
|
||
<strong>{{ batchDetail.publicUrl }}</strong>
|
||
</div>
|
||
<div>
|
||
<span>发布时间</span>
|
||
<strong>{{ batchDetail.publishedAt || '未发布' }}</strong>
|
||
</div>
|
||
<div>
|
||
<span>当前状态</span>
|
||
<strong>{{ getBatchStatusText(batchDetail.status) }}</strong>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card class="panel-card" title="节点填报">
|
||
<template #extra>
|
||
<Button
|
||
v-if="!isPublished"
|
||
type="primary"
|
||
:disabled="!currentStep || !isCurrentEditableStep"
|
||
:loading="saving"
|
||
@click="saveStep"
|
||
>
|
||
{{ stepActionText }}
|
||
</Button>
|
||
</template>
|
||
|
||
<template v-if="batchDetail">
|
||
<Steps
|
||
class="step-strip"
|
||
:class="{ 'step-strip--published': isPublished }"
|
||
:current="stepIndex"
|
||
size="small"
|
||
@change="
|
||
(value) => {
|
||
stepIndex = value;
|
||
if (!isPublished) {
|
||
editableStartIndex = value;
|
||
}
|
||
}
|
||
"
|
||
>
|
||
<Steps.Step
|
||
v-for="(item, index) in batchDetail.steps"
|
||
:key="item.id"
|
||
:title="item.name"
|
||
:description="getStepStatusText(index)"
|
||
/>
|
||
</Steps>
|
||
|
||
<div
|
||
v-if="currentStep"
|
||
class="step-editor"
|
||
:class="{ 'step-editor--published': isPublished }"
|
||
>
|
||
<div class="step-header">
|
||
<div>
|
||
<h3>{{ currentStep.name }}</h3>
|
||
<p>{{ currentStep.description || '请填报此环节的过程记录。' }}</p>
|
||
<small class="step-hint">
|
||
{{
|
||
isPublished
|
||
? '当前批次已发布,溯源链已锁定为只读。'
|
||
: isLockedStep
|
||
? '当前节点来自节点库,字段和值固定,不可修改。'
|
||
: isCurrentEditableStep
|
||
? '当前节点可填写并继续流转。'
|
||
: '当前查看的是非进行中节点,仅供浏览。'
|
||
}}
|
||
</small>
|
||
</div>
|
||
<Tag color="blue">
|
||
{{ currentStep.category === 'public' ? '公开节点' : '业务节点' }}
|
||
</Tag>
|
||
</div>
|
||
|
||
<Row :gutter="[16, 16]">
|
||
<Col :md="12" :xs="24">
|
||
<label class="field-label">操作人</label>
|
||
<Input v-model:value="currentStep.operatorName" :disabled="!isCurrentEditableStep" />
|
||
</Col>
|
||
<Col :md="12" :xs="24">
|
||
<label class="field-label">节点状态</label>
|
||
<div class="readonly-box">{{ getStepStatusText(stepIndex) }}</div>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Row :gutter="[16, 16]" class="dynamic-fields">
|
||
<Col
|
||
v-for="field in currentStep.fields"
|
||
:key="field.key"
|
||
:md="12"
|
||
:xs="24"
|
||
>
|
||
<div class="field-entry">
|
||
<div class="field-entry__head">
|
||
<label class="field-label">{{ field.label }}</label>
|
||
<span class="field-type-tag">{{ getFieldTypeLabel(field.type) }}</span>
|
||
</div>
|
||
<div class="field-entry__body">
|
||
<Select
|
||
v-if="field.type === 'select'"
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
|
||
:value="currentStep.values[field.key]"
|
||
style="width: 100%"
|
||
@update:value="(value) => updateFieldValue(field, value)"
|
||
/>
|
||
<Select
|
||
v-else-if="field.type === 'multi_select'"
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
:options="(field.options || []).map((item) => ({ label: item, value: item }))"
|
||
:value="currentStep.values[field.key]"
|
||
mode="multiple"
|
||
style="width: 100%"
|
||
@update:value="(value) => updateFieldValue(field, value)"
|
||
/>
|
||
<Input
|
||
v-else-if="field.type === 'integer'"
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
:placeholder="field.placeholder || '请输入整数'"
|
||
:value="String(currentStep.values[field.key] ?? '')"
|
||
style="width: 100%"
|
||
@update:value="
|
||
(value) =>
|
||
updateFieldValue(field, sanitizeIntegerInput(String(value ?? '')))
|
||
"
|
||
/>
|
||
<Input
|
||
v-else-if="field.type === 'decimal'"
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
:placeholder="field.placeholder || '请输入小数'"
|
||
:value="String(currentStep.values[field.key] ?? '')"
|
||
style="width: 100%"
|
||
@update:value="
|
||
(value) =>
|
||
updateFieldValue(field, sanitizeDecimalInput(String(value ?? '')))
|
||
"
|
||
/>
|
||
<DatePicker
|
||
v-else-if="field.type === 'date'"
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
:value="currentStep.values[field.key]"
|
||
style="width: 100%"
|
||
value-format="YYYY-MM-DD"
|
||
@update:value="(value) => updateFieldValue(field, value)"
|
||
/>
|
||
<DatePicker
|
||
v-else-if="field.type === 'datetime'"
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
:value="currentStep.values[field.key]"
|
||
format="YYYY-MM-DD HH:mm:ss"
|
||
show-time
|
||
style="width: 100%"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
@update:value="(value) => updateFieldValue(field, value)"
|
||
/>
|
||
<Input
|
||
v-else-if="field.type === 'link'"
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
:placeholder="field.placeholder || '请输入链接地址'"
|
||
:value="currentStep.values[field.key]"
|
||
@update:value="(value) => updateFieldValue(field, value)"
|
||
/>
|
||
<Input
|
||
v-else-if="field.type === 'string'"
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
:placeholder="field.placeholder || '请输入内容'"
|
||
:value="currentStep.values[field.key]"
|
||
@update:value="(value) => updateFieldValue(field, value)"
|
||
/>
|
||
<div
|
||
v-else-if="field.type === 'image'"
|
||
class="placeholder-uploader"
|
||
>
|
||
<strong>上传节点图片</strong>
|
||
<p>支持上传后直接回填图片地址,消费者端和预览页都可直接查看。</p>
|
||
<div
|
||
v-if="currentStep.values[field.key]"
|
||
class="image-preview-wrap"
|
||
>
|
||
<img
|
||
:src="String(currentStep.values[field.key])"
|
||
alt="节点图片"
|
||
class="image-preview"
|
||
/>
|
||
</div>
|
||
<div class="upload-trigger">
|
||
<input
|
||
:id="getFieldInputId(field)"
|
||
accept="image/*"
|
||
class="upload-input"
|
||
type="file"
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
@change="(event) => handleImageUpload(field, event)"
|
||
/>
|
||
<Button
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
:loading="uploadingFieldKey === getFieldUploadKey(field)"
|
||
size="small"
|
||
type="primary"
|
||
@click="triggerImageSelect(field)"
|
||
>
|
||
{{ currentStep.values[field.key] ? '重新上传' : '选择图片' }}
|
||
</Button>
|
||
<Button
|
||
v-if="currentStep.values[field.key]"
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
size="small"
|
||
@click="clearImageValue(field)"
|
||
>
|
||
移除图片
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-else-if="field.type === 'video_url'"
|
||
class="placeholder-uploader"
|
||
>
|
||
<strong>视频控件模板</strong>
|
||
<p>这里先预留视频控件位置,后续你可以补充视频上传或选择逻辑。</p>
|
||
</div>
|
||
<Input.TextArea
|
||
v-else
|
||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||
:disabled="!isCurrentEditableStep || isPublished || isLockedStep"
|
||
:placeholder="field.placeholder || '请输入内容'"
|
||
:value="currentStep.values[field.key]"
|
||
@update:value="(value) => updateFieldValue(field, value)"
|
||
/>
|
||
<div class="field-preview">
|
||
当前值:{{ formatFieldValue(currentStep.values[field.key]) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
</template>
|
||
|
||
<Empty v-else description="先新建批次或从左侧选择批次" />
|
||
</Card>
|
||
</Space>
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
|
||
<Modal
|
||
v-model:open="createBatchVisible"
|
||
title="新建批次"
|
||
ok-text="创建批次"
|
||
cancel-text="取消"
|
||
:confirm-loading="saving"
|
||
@ok="createBatch"
|
||
>
|
||
<Row :gutter="[16, 16]">
|
||
<Col :span="24">
|
||
<label class="field-label">模板</label>
|
||
<Select
|
||
v-model:value="formState.templateId"
|
||
:options="publishedTemplates.map((item) => ({ label: item.name, value: item.id }))"
|
||
style="width: 100%"
|
||
/>
|
||
</Col>
|
||
<Col :md="12" :xs="24">
|
||
<label class="field-label">批次名称</label>
|
||
<Input v-model:value="formState.batchName" />
|
||
</Col>
|
||
<Col :md="12" :xs="24">
|
||
<label class="field-label">批次编码</label>
|
||
<Input v-model:value="formState.batchCode" />
|
||
</Col>
|
||
<Col :md="12" :xs="24">
|
||
<label class="field-label">产品名称</label>
|
||
<Input v-model:value="formState.productName" />
|
||
</Col>
|
||
<Col :md="12" :xs="24">
|
||
<label class="field-label">封面图</label>
|
||
<Input v-model:value="formState.coverImage" />
|
||
</Col>
|
||
<Col :md="12" :xs="24">
|
||
<label class="field-label">标签</label>
|
||
<Input v-model:value="formState.tagsText" placeholder="用逗号分隔" />
|
||
</Col>
|
||
<Col :span="24">
|
||
<label class="field-label">批次概述</label>
|
||
<Input.TextArea
|
||
v-model:value="formState.summary"
|
||
:auto-size="{ minRows: 3, maxRows: 5 }"
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
</Modal>
|
||
|
||
</Page>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.trace-operator {
|
||
padding: 4px;
|
||
}
|
||
|
||
.panel-card {
|
||
border-radius: 18px;
|
||
}
|
||
|
||
.batch-panel-card {
|
||
height: 100%;
|
||
}
|
||
|
||
.batch-panel-card :deep(.ant-card-body) {
|
||
height: calc(100% - 57px);
|
||
}
|
||
|
||
.batch-list {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
|
||
.batch-card {
|
||
width: 100%;
|
||
border: 1px solid #edf1f7;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
padding: 16px;
|
||
text-align: left;
|
||
}
|
||
|
||
.batch-card.active {
|
||
border-color: #adc4ff;
|
||
background: #f5f8ff;
|
||
}
|
||
|
||
.batch-card__header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.batch-card__actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.batch-card p {
|
||
margin: 10px 0 8px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.batch-card__meta {
|
||
display: grid;
|
||
gap: 4px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.batch-card span,
|
||
.batch-card small {
|
||
color: #8b96a8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.field-label {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: #556070;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.step-strip {
|
||
margin-bottom: 20px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.step-editor {
|
||
border-top: 1px solid #f0f2f5;
|
||
padding-top: 20px;
|
||
}
|
||
|
||
.step-strip--published,
|
||
.step-editor--published {
|
||
opacity: 0.58;
|
||
}
|
||
|
||
.step-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.step-header h3 {
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
.step-header p {
|
||
margin: 0;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.step-hint {
|
||
display: block;
|
||
margin-top: 8px;
|
||
color: #1d4ed8;
|
||
}
|
||
|
||
.dynamic-fields {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.publish-panel {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.publish-panel div,
|
||
.field-entry__body,
|
||
.readonly-box,
|
||
.placeholder-uploader {
|
||
border: 1px solid #edf1f7;
|
||
border-radius: 16px;
|
||
padding: 16px;
|
||
background: #fafcff;
|
||
}
|
||
|
||
.publish-panel span,
|
||
.field-entry__body small,
|
||
.field-modal__meta {
|
||
color: #7d8899;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.publish-panel strong {
|
||
display: block;
|
||
margin-top: 8px;
|
||
font-size: 16px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.readonly-box {
|
||
min-height: 54px;
|
||
display: flex;
|
||
align-items: center;
|
||
color: #556070;
|
||
}
|
||
|
||
.field-entry {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.field-entry__head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.field-entry__body {
|
||
min-height: 120px;
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.field-entry__body strong {
|
||
display: block;
|
||
margin: 8px 0;
|
||
color: #1f2937;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.field-type-tag {
|
||
display: inline-flex;
|
||
padding: 4px 10px;
|
||
border-radius: 999px;
|
||
background: #edf3ff;
|
||
color: #1d4ed8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.placeholder-uploader strong {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.upload-trigger {
|
||
display: inline-flex;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.upload-input {
|
||
display: none;
|
||
}
|
||
|
||
.image-preview-wrap {
|
||
margin: 4px 0 2px;
|
||
}
|
||
|
||
.image-preview {
|
||
display: block;
|
||
max-width: 100%;
|
||
max-height: 220px;
|
||
border: 1px solid #dbe3f0;
|
||
border-radius: 14px;
|
||
object-fit: cover;
|
||
background: #fff;
|
||
}
|
||
|
||
.field-preview {
|
||
color: #7d8899;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.placeholder-uploader p {
|
||
margin: 0;
|
||
color: #6b7280;
|
||
line-height: 1.7;
|
||
}
|
||
</style>
|