Files
AILab/vue2/apps/web-antd/src/views/traceability/operator.vue
T
2026-04-10 18:51:00 +08:00

869 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>