溯源系统初版

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,278 @@
import { requestClient } from '#/api/request';
export namespace TraceabilityApi {
export interface Overview {
templateCount: number;
batchCount: number;
publishedCount: number;
feedbackCount: number;
totalScans: number;
}
export interface FieldDefinition {
key: string;
label: string;
type: string;
required: boolean;
visible: boolean;
placeholder?: string;
defaultValue?: any;
options?: string[];
}
export interface TemplateNode {
id?: string;
sort?: number;
category: 'business' | 'public' | string;
name: string;
description: string;
locked?: boolean;
consumerVisible: boolean;
fields: FieldDefinition[];
}
export interface TemplateSummary {
id: string;
name: string;
description: string;
productName: string;
industryName: string;
coverImage: string;
themeColor: string;
status: string;
nodeCount: number;
batchCount: number;
updatedAt: string;
}
export interface TemplateDetail extends TemplateSummary {
nodes: TemplateNode[];
}
export interface BatchStep {
id: string;
templateNodeId?: string;
sort: number;
category: string;
name: string;
description: string;
locked?: boolean;
consumerVisible: boolean;
status: string;
operatorName: string;
values: Record<string, any>;
completedAt: string;
fields: FieldDefinition[];
}
export interface BatchSummary {
id: string;
templateId: string;
templateName: string;
batchName: string;
batchCode: string;
productName: string;
summary: string;
coverImage: string;
tags: string[];
status: string;
currentStep: number;
scanCount: number;
publicUrl: string;
updatedAt: string;
}
export interface BatchDetail extends BatchSummary {
steps: BatchStep[];
publishedAt: string;
}
export interface PublicDetail {
batch: BatchDetail;
companySectionTitle: string;
publicSections: BatchStep[];
businessSections: BatchStep[];
}
export interface FeedbackItem {
id: string;
batchId: string;
batchCode: string;
batchName: string;
type: string;
contact: string;
content: string;
source: string;
rating: number;
createdAt: string;
}
export interface OssFileResult {
bucketName: string;
objectName: string;
uploadUrl?: string;
tempUrl?: string;
contentType?: string;
fileName?: string;
size?: number;
}
}
export function getTraceabilityOverview() {
return requestClient.get<TraceabilityApi.Overview>('/traceability/overview');
}
export function getTraceabilityTemplates() {
return requestClient.get<TraceabilityApi.TemplateSummary[]>(
'/traceability/templates',
);
}
export function getTraceabilityTemplate(id: string) {
return requestClient.get<TraceabilityApi.TemplateDetail>(
`/traceability/templates/${id}`,
);
}
export function createTraceabilityTemplate(
data: Omit<TraceabilityApi.TemplateDetail, 'batchCount' | 'id' | 'nodeCount' | 'updatedAt'>,
) {
return requestClient.post('/traceability/templates', data);
}
export function updateTraceabilityTemplate(
id: string,
data: Omit<TraceabilityApi.TemplateDetail, 'batchCount' | 'id' | 'nodeCount' | 'updatedAt'>,
) {
return requestClient.put(`/traceability/templates/${id}`, data);
}
export function deleteTraceabilityTemplate(id: string) {
return requestClient.delete(`/traceability/templates/${id}`);
}
export function getTraceabilityBatches() {
return requestClient.get<TraceabilityApi.BatchSummary[]>(
'/traceability/batches',
);
}
export function getTraceabilityBatch(id: string) {
return requestClient.get<TraceabilityApi.BatchDetail>(
`/traceability/batches/${id}`,
);
}
export function createTraceabilityBatch(data: {
templateId: string;
batchName: string;
batchCode: string;
productName?: string;
summary?: string;
coverImage?: string;
tags?: string[];
}) {
return requestClient.post('/traceability/batches', data);
}
export function deleteTraceabilityBatch(id: string) {
return requestClient.delete(`/traceability/batches/${id}`);
}
export function updateTraceabilityBatchBase(id: string, data: any) {
return requestClient.put(`/traceability/batches/${id}/base`, data);
}
export function updateTraceabilityBatchStep(
batchId: string,
stepId: string,
data: {
operatorName?: string;
status?: string;
values?: Record<string, any>;
completedAt?: string;
},
) {
return requestClient.put(`/traceability/batches/${batchId}/steps/${stepId}`, data);
}
export function publishTraceabilityBatch(id: string) {
return requestClient.post(`/traceability/batches/${id}/publish`);
}
export function getTraceabilityPublicDetail(code: string) {
return requestClient.get<TraceabilityApi.PublicDetail>(
`/traceability/public/by-code/${code}`,
);
}
export function getTraceabilityFeedbackList() {
return requestClient.get<TraceabilityApi.FeedbackItem[]>(
'/traceability/feedback',
);
}
export function submitTraceabilityFeedback(data: {
batchCode?: string;
batchId?: string;
type: string;
contact?: string;
content: string;
source?: string;
rating?: number;
}) {
return requestClient.post('/traceability/public/feedback', data);
}
export function uploadTraceabilityImage(data: FormData) {
return requestClient.post<TraceabilityApi.OssFileResult>(
'/traceability/files/upload-image',
data,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
}
export function getTraceabilityUploadToken(data: {
bucketName?: string;
objectName: string;
expiresMinutes?: number;
}) {
return requestClient.post<TraceabilityApi.OssFileResult>(
'/traceability/files/presigned-put',
data,
);
}
export function getTraceabilityTempUrl(data: {
bucketName?: string;
objectName?: string;
objectDir?: string;
expiresSeconds?: number;
}) {
return requestClient.post<TraceabilityApi.OssFileResult>(
'/traceability/files/temp-url',
data,
);
}
export function moveTraceabilityFile(data: {
bucketName?: string;
sourceObjectName: string;
targetObjectName: string;
}) {
return requestClient.post<TraceabilityApi.OssFileResult>(
'/traceability/files/move',
data,
);
}
export function deleteTraceabilityFile(data: {
bucketName?: string;
objectName: string;
}) {
return requestClient.post<boolean>('/traceability/files/delete', data);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,554 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useQRCode } from '@vueuse/integrations/useQRCode';
import { Page } from '@vben/common-ui';
import { Button, Card, Col, Empty, Input, message, Row, Tag } from 'ant-design-vue';
import { getTraceabilityBatches, getTraceabilityPublicDetail } from '#/api';
import type { TraceabilityApi } from '#/api';
import { formatFieldValue } from './shared';
const loading = ref(false);
const batches = ref<TraceabilityApi.BatchSummary[]>([]);
const queryCode = ref('');
const detail = ref<null | TraceabilityApi.PublicDetail>(null);
const publicLink = computed(() => detail.value?.batch.publicUrl ?? '');
const qrCode = useQRCode(publicLink, {
errorCorrectionLevel: 'M',
margin: 2,
width: 220,
});
async function loadBatches() {
batches.value = await getTraceabilityBatches();
if (!queryCode.value && batches.value[0]) {
queryCode.value = batches.value[0].batchCode;
await search();
}
}
async function search() {
if (!queryCode.value.trim()) {
message.warning('请输入批次编码');
return;
}
loading.value = true;
try {
detail.value = await getTraceabilityPublicDetail(queryCode.value.trim());
} finally {
loading.value = false;
}
}
function getStatusLabel(status: string) {
if (status === 'completed') return '已完成';
if (status === 'pending') return '进行中';
if (status === 'published') return '已发布';
if (status === 'draft') return '未发布';
return status || '进行中';
}
function getFieldLabel(
fields: TraceabilityApi.FieldDefinition[],
key: string,
) {
return fields.find((field) => field.key === key)?.label || key;
}
function getDisplayEntries(step: TraceabilityApi.BatchStep) {
return Object.entries(step.values).map(([key, value]) => ({
key,
label: getFieldLabel(step.fields, key),
type: step.fields.find((field) => field.key === key)?.type || 'string',
value,
}));
}
onMounted(loadBatches);
</script>
<template>
<Page auto-content-height>
<div class="trace-consumer">
<Card class="panel-card search-panel">
<div class="search-panel__meta">
<div>
<span class="panel-kicker">消费者页预览</span>
<h2>溯源信息预览</h2>
</div>
<div class="search-box">
<Input
v-model:value="queryCode"
placeholder="请输入批次编码,如:TR-2026-000001"
@press-enter="search"
/>
<Button type="primary" :loading="loading" @click="search">
查询
</Button>
</div>
</div>
</Card>
<template v-if="detail">
<Row :gutter="[16, 16]" class="metrics-row">
<Col :lg="6" :sm="12" :xs="24">
<Card class="panel-card stat-panel">
<span>批次名称</span>
<strong>{{ detail.batch.batchName }}</strong>
<small>{{ detail.batch.batchCode }}</small>
</Card>
</Col>
<Col :lg="6" :sm="12" :xs="24">
<Card class="panel-card stat-panel">
<span>当前状态</span>
<strong>{{ detail.batch.status }}</strong>
<small>{{ detail.batch.productName || '未设置产品名称' }}</small>
</Card>
</Col>
<Col :lg="6" :sm="12" :xs="24">
<Card class="panel-card stat-panel">
<span>扫码次数</span>
<strong>{{ detail.batch.scanCount }}</strong>
<small>{{ detail.batch.templateName }}</small>
</Card>
</Col>
<Col :lg="6" :sm="12" :xs="24">
<Card class="panel-card stat-panel">
<span>公开节点数</span>
<strong>{{ detail.publicSections.length }}</strong>
<small>业务节点 {{ detail.businessSections.length }} </small>
</Card>
</Col>
</Row>
<Row :gutter="[16, 16]" class="feature-row">
<Col :lg="8" :xs="24">
<Card class="panel-card qr-panel" title="二维码">
<div class="qr-wrap">
<img :src="qrCode" alt="溯源二维码" class="qr-image" />
<div class="qr-meta">
<strong>扫码查看溯源页</strong>
<p>{{ publicLink }}</p>
</div>
</div>
</Card>
</Col>
<Col :lg="16" :xs="24">
<Card class="panel-card access-panel" title="访问信息">
<div class="access-layout">
<div class="access-main">
<div class="access-main__head">
<span>消费者访问地址</span>
<strong>{{ publicLink }}</strong>
</div>
<p>{{ detail.batch.summary || '该批次已完成发布,可直接用于消费者扫码访问。' }}</p>
</div>
<div class="access-meta">
<div class="access-card">
<span>发布时间</span>
<strong>{{ detail.batch.publishedAt || '未发布' }}</strong>
</div>
<div class="access-card">
<span>产品名称</span>
<strong>{{ detail.batch.productName || '未设置产品名称' }}</strong>
</div>
<div class="access-card">
<span>所属模板</span>
<strong>{{ detail.batch.templateName }}</strong>
</div>
<div class="access-card">
<span>标签</span>
<strong>{{ detail.batch.tags.join('、') || '暂无标签' }}</strong>
</div>
</div>
</div>
</Card>
</Col>
</Row>
<Row :gutter="[16, 16]">
<Col :lg="9" :xs="24">
<Card class="panel-card" title="公开资料区">
<div class="section-stack">
<div
v-for="item in detail.publicSections"
:key="item.id"
class="section-card"
>
<div class="section-card__head">
<strong>{{ item.name }}</strong>
<Tag color="blue">公开</Tag>
</div>
<p>{{ item.description || '企业与资质信息展示区' }}</p>
<div class="kv-grid">
<div
v-for="entry in getDisplayEntries(item)"
:key="entry.key"
class="kv-card"
>
<span>{{ entry.label }}</span>
<img
v-if="entry.type === 'image' && entry.value"
:src="String(entry.value)"
:alt="entry.label"
class="consumer-image"
/>
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
</div>
</div>
</div>
</div>
</Card>
</Col>
<Col :lg="15" :xs="24">
<Card class="panel-card" title="溯源时间轴">
<div class="timeline">
<div
v-for="(item, index) in detail.businessSections"
:key="item.id"
class="timeline-item"
>
<div class="timeline-rail">
<span class="dot"></span>
<span
v-if="index !== detail.businessSections.length - 1"
class="line"
></span>
</div>
<div class="timeline-card">
<div class="timeline-card__head">
<div>
<h3>{{ item.name }}</h3>
<p>{{ item.description || '流程记录' }}</p>
</div>
<Tag>{{ getStatusLabel(item.status) }}</Tag>
</div>
<div class="kv-grid">
<div
v-for="entry in getDisplayEntries(item)"
:key="entry.key"
class="kv-card"
>
<span>{{ entry.label }}</span>
<img
v-if="entry.type === 'image' && entry.value"
:src="String(entry.value)"
:alt="entry.label"
class="consumer-image"
/>
<strong v-else>{{ formatFieldValue(entry.value) }}</strong>
</div>
</div>
</div>
</div>
</div>
</Card>
</Col>
</Row>
</template>
<Card v-else class="panel-card empty-wrap">
<Empty description="输入批次编码后查看消费者端预览" />
</Card>
</div>
</Page>
</template>
<style scoped>
.trace-consumer {
display: grid;
gap: 16px;
padding: 4px;
}
.panel-card {
border-radius: 22px;
}
.search-panel {
background: linear-gradient(135deg, #ffffff, #f8fbff);
}
.search-panel__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.panel-kicker {
display: inline-flex;
padding: 6px 12px;
border-radius: 999px;
background: #edf3ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 600;
}
.search-panel h2 {
margin: 12px 0 0;
font-size: 28px;
}
.search-box {
display: grid;
grid-template-columns: minmax(0, 360px) auto;
gap: 12px;
width: min(100%, 520px);
}
.stat-panel span,
.kv-card span,
.access-card span {
color: #7d8899;
font-size: 12px;
}
.stat-panel strong,
.access-card strong {
display: block;
margin-top: 8px;
font-size: 18px;
word-break: break-word;
}
.stat-panel small {
display: block;
margin-top: 8px;
color: #98a2b3;
}
.metrics-row :deep(.ant-col),
.feature-row :deep(.ant-col) {
display: flex;
}
.metrics-row .panel-card,
.feature-row .panel-card {
width: 100%;
height: 100%;
}
.stat-panel :deep(.ant-card-body),
.access-panel :deep(.ant-card-body),
.qr-panel :deep(.ant-card-body) {
height: 100%;
}
.stat-panel {
min-height: 138px;
}
.qr-wrap {
display: grid;
align-content: center;
justify-items: center;
gap: 16px;
min-height: 100%;
}
.qr-image {
width: 220px;
height: 220px;
border-radius: 18px;
border: 1px solid #edf1f7;
background: #fff;
padding: 12px;
}
.qr-meta {
text-align: center;
}
.qr-meta strong {
display: block;
font-size: 18px;
}
.qr-meta p {
margin: 10px 0 0;
color: #667085;
word-break: break-all;
}
.access-layout {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.access-main {
border: 1px solid #dfe9fb;
border-radius: 20px;
background: linear-gradient(135deg, #f7faff, #eef4ff);
padding: 18px;
min-width: 0;
}
.access-main__head {
display: grid;
gap: 8px;
}
.access-main span {
color: #7d8899;
font-size: 12px;
}
.access-main strong {
display: block;
color: #1d4ed8;
font-size: 18px;
line-height: 1.6;
word-break: break-all;
}
.access-main p {
margin: 14px 0 0;
color: #5f6b7c;
line-height: 1.7;
word-break: break-word;
}
.access-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-width: 0;
}
.access-card {
border: 1px solid #edf1f7;
border-radius: 18px;
background: #fafcff;
padding: 16px;
min-height: 108px;
min-width: 0;
}
.access-card strong {
line-height: 1.6;
word-break: break-word;
}
.section-stack {
display: grid;
gap: 12px;
}
.section-card,
.timeline-card {
border: 1px solid #edf1f7;
border-radius: 18px;
background: #fff;
padding: 16px;
}
.section-card__head,
.timeline-card__head {
display: flex;
justify-content: space-between;
align-items: start;
gap: 12px;
}
.section-card p,
.timeline-card p {
margin: 8px 0 0;
color: #6b7280;
}
.kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.kv-card {
border: 1px solid #eef2f7;
border-radius: 14px;
background: #fafcff;
padding: 12px;
}
.kv-card strong {
display: block;
margin-top: 8px;
white-space: pre-wrap;
word-break: break-word;
}
.consumer-image {
display: block;
width: 100%;
max-height: 220px;
margin-top: 10px;
border: 1px solid #e2e8f0;
border-radius: 14px;
object-fit: cover;
background: #fff;
}
.timeline {
display: grid;
gap: 16px;
}
.timeline-item {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 16px;
}
.timeline-rail {
display: flex;
flex-direction: column;
align-items: center;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #1d4ed8;
box-shadow: 0 0 0 5px rgba(29, 78, 216, 0.1);
}
.line {
width: 2px;
flex: 1;
min-height: 60px;
margin-top: 8px;
background: linear-gradient(180deg, #c9d8ff, transparent);
}
.timeline-card h3 {
margin: 0;
}
.section-card__head strong,
.timeline-card__head h3 {
line-height: 1.4;
}
.empty-wrap {
padding: 48px 12px;
}
@media (max-width: 992px) {
.search-box,
.access-layout,
.access-meta,
.kv-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -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>
@@ -0,0 +1,225 @@
import type { TraceabilityApi } from '#/api';
export interface TraceabilityNodeLibraryItem {
id: string;
category: 'business' | 'public';
name: string;
description: string;
consumerVisible: boolean;
fields: TraceabilityApi.FieldDefinition[];
}
export const fieldTypeOptions = [
{ label: '字符串', value: 'string' },
{ label: '整数', value: 'integer' },
{ label: '小数', value: 'decimal' },
{ label: '日期', value: 'date' },
{ label: '日期时间', value: 'datetime' },
{ label: '单选', value: 'select' },
{ label: '多选', value: 'multi_select' },
{ label: '图片', value: 'image' },
{ label: '链接', value: 'link' },
{ label: '视频', value: 'video_url' },
{ label: 'JSON', value: 'json' },
];
export const nodeLibraryPresets: TraceabilityNodeLibraryItem[] = [
{
id: 'business-production',
category: 'business',
name: '生产加工节点',
description: '记录原料、工艺、加工批次等业务过程信息。',
consumerVisible: true,
fields: [
createField('process_name', '工艺名称'),
createField('operator', '负责人'),
createField('production_date', '生产日期', 'date'),
createField('remark', '备注'),
],
},
{
id: 'business-quality',
category: 'business',
name: '质检检验节点',
description: '记录质检结果、检验员和检验时间。',
consumerVisible: true,
fields: [
createField('inspector', '检验员'),
createField('inspection_date', '检验日期', 'date'),
createField('inspection_result', '检验结果', 'select', {
options: ['合格', '不合格', '复检中'],
}),
createField('inspection_note', '检验说明'),
],
},
{
id: 'public-company',
category: 'public',
name: '企业信息节点',
description: '面向消费者展示企业名称、产地和联系方式等信息。',
consumerVisible: true,
fields: [
createField('company_name', '企业名称'),
createField('origin', '产地'),
createField('contact_phone', '联系电话'),
createField('company_intro', '企业简介'),
],
},
{
id: 'public-certification',
category: 'public',
name: '资质证书节点',
description: '展示认证证书、证书编号和有效期。',
consumerVisible: true,
fields: [
createField('certificate_name', '证书名称'),
createField('certificate_no', '证书编号'),
createField('valid_until', '有效期', 'date'),
createField('certificate_image', '证书图片', 'image'),
],
},
];
export function createField(
key: string,
label: string,
type: string = 'string',
extra: Partial<TraceabilityApi.FieldDefinition> = {},
): TraceabilityApi.FieldDefinition {
return {
key,
label,
type,
required: false,
visible: true,
placeholder: '',
defaultValue: '',
options: [],
...extra,
};
}
export function createEmptyField(): TraceabilityApi.FieldDefinition {
return {
key: `field_${Date.now()}`,
label: '新字段',
type: 'string',
required: false,
visible: true,
placeholder: '',
defaultValue: '',
options: [],
};
}
export function createEmptyNode(
category: TraceabilityApi.TemplateNode['category'] = 'business',
): TraceabilityApi.TemplateNode {
return {
category,
name: category === 'public' ? '公开资料节点' : '业务流程节点',
description: '',
consumerVisible: true,
fields: [createEmptyField()],
};
}
export function cloneNodeFromLibrary(
preset: TraceabilityNodeLibraryItem,
): TraceabilityApi.TemplateNode {
return {
category: preset.category,
name: preset.name,
description: preset.description,
locked: true,
consumerVisible: preset.consumerVisible,
fields: preset.fields.map((field) => ({
...field,
options: [...(field.options ?? [])],
})),
};
}
export function cloneTemplateForSave(
template: Partial<TraceabilityApi.TemplateDetail>,
) {
return {
name: template.name ?? '',
description: template.description ?? '',
productName: template.productName ?? '',
industryName: template.industryName ?? '',
coverImage: template.coverImage ?? '',
themeColor: template.themeColor ?? '#1f4fd6',
status: template.status ?? 'draft',
nodes: (template.nodes ?? []).map((node) => ({
category: node.category ?? 'business',
name: node.name ?? '',
description: node.description ?? '',
locked: node.locked ?? false,
consumerVisible: node.consumerVisible ?? true,
fields: (node.fields ?? []).map((field) => ({
key: field.key,
label: field.label,
type: field.type ?? 'string',
required: field.required ?? false,
visible: field.visible ?? true,
placeholder: field.placeholder ?? '',
defaultValue: field.defaultValue ?? '',
options: field.options ?? [],
})),
})),
};
}
export function formatFieldValue(value: any) {
if (value === null || value === undefined || value === '') {
return '未填写';
}
if (Array.isArray(value)) {
return value.join('、');
}
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
return String(value);
}
export function normalizeFieldInput(
field: TraceabilityApi.FieldDefinition,
value: any,
) {
if (field.type === 'integer') {
if (value === '' || value === null || value === undefined) {
return null;
}
return Number.isInteger(value) ? value : Math.trunc(Number(value));
}
if (field.type === 'decimal') {
if (value === '' || value === null || value === undefined) {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
if (field.type === 'multi_select') {
return Array.isArray(value) ? value : [];
}
return value;
}
export function getFieldTypeLabel(type: string) {
return fieldTypeOptions.find((item) => item.value === type)?.label ?? type;
}
export const groupedNodeLibrary = {
business: nodeLibraryPresets
.filter((item) => item.category === 'business')
.map((item) => item),
public: nodeLibraryPresets
.filter((item) => item.category === 'public')
.map((item) => item),
};