溯源系统初版
This commit is contained in:
@@ -0,0 +1,868 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user