溯源系统初版

This commit is contained in:
BBIT-Kai
2026-04-10 18:51:00 +08:00
parent 5971791038
commit 0a43f5e4b9
40 changed files with 7910 additions and 30 deletions
@@ -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>