feat: add report review publish flow

This commit is contained in:
wushumin
2026-06-04 12:08:16 +08:00
parent 9dfd5976ed
commit 55c357f2c2
14 changed files with 624 additions and 52 deletions

View File

@@ -515,6 +515,9 @@ export interface AdminReportListItem {
report_entry_admin_name: string;
report_entered_at: string;
trace_info_visible: boolean;
reject_reason: string;
rejected_by_name: string;
rejected_at: string;
product_name: string;
category_name: string;
brand_name: string;
@@ -542,6 +545,9 @@ export interface AdminReportDetail {
report_entry_admin_name: string;
report_entered_at: string;
trace_info_visible: boolean;
reject_reason: string;
rejected_by_name: string;
rejected_at: string;
};
product_info: Record<string, any>;
result_info: Record<string, any>;
@@ -565,6 +571,8 @@ export interface AdminReportDetail {
report_page_url: string;
verify_count: number;
};
audit_logs: AdminReportLog[];
change_logs: AdminReportLog[];
}
export interface AdminPublishReportResponse {
@@ -575,6 +583,18 @@ export interface AdminPublishReportResponse {
report_page_url: string;
}
export interface AdminReportLog {
id: number;
action: string;
action_text: string;
operator_id: number;
operator_name: string;
before_data: Record<string, any>;
after_data: Record<string, any>;
remark: string;
created_at: string;
}
export interface AdminManualInspectionPayload {
id?: number;
report_header: {
@@ -1864,6 +1884,16 @@ export const adminApi = {
data: AdminPublishReportResponse & { material_tag?: AdminMaterialTagCode | null };
}>;
},
rejectReport(id: number, reason: string) {
return request.post("/api/admin/report/reject", {
id,
reason,
}) as Promise<{
code: number;
message: string;
data: { id: number; report_status: string; reject_reason: string };
}>;
},
updateReportTraceVisibility(id: number, visible: boolean) {
return request.post("/api/admin/report/trace-visibility", {
id,

View File

@@ -256,6 +256,15 @@ const isTaskReadonly = computed(() => {
if (!detail.value) {
return false;
}
const reportStatus = detail.value.report_summary?.report_status || "";
if (["draft", "pending_publish", "updated", "rejected"].includes(reportStatus)) {
return false;
}
if (reportStatus === "published") {
return true;
}
return (
["submitted", "completed"].includes(detail.value.task_info.status) ||
(Boolean(detail.value.task_info.submitted_at) && Boolean(detail.value.report_summary))
@@ -799,7 +808,7 @@ async function submitResult(action: "save" | "submit") {
key_points: normalizedKeyPoints(),
...(qrInput ? { qr_input: qrInput } : {}),
});
ElMessage.success(response.message || (action === "submit" ? "验真吊牌已绑定,报告发布" : "结论已保存"));
ElMessage.success(response.message || (action === "submit" ? "验真吊牌已绑定,报告待管理员发布" : "结论已保存"));
await loadDetail(detail.value.task_info.id);
await fetchTasks();
} catch (error) {
@@ -817,7 +826,7 @@ async function publishCurrentTaskWithMaterialTag(qrInput: string) {
id: detail.value.task_info.id,
qr_input: qrInput,
});
ElMessage.success("验真吊牌已绑定,报告发布");
ElMessage.success("验真吊牌已绑定,报告待管理员发布");
await loadDetail(detail.value.task_info.id);
await fetchTasks();
}
@@ -835,7 +844,7 @@ async function bindMaterialTag() {
materialTagInput.value = "";
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || "验真吊牌绑定或报告发布失败");
ElMessage.error(error?.message || "验真吊牌绑定失败");
} finally {
materialTagBinding.value = false;
}
@@ -843,12 +852,12 @@ async function bindMaterialTag() {
async function promptPublishMaterialTagInput() {
try {
const result = await ElMessageBox.prompt("是否已鉴定完成并确定发布报告", "绑定验真吊牌并发布报告", {
const result = await ElMessageBox.prompt("是否已鉴定完成并提交报告待发布", "绑定验真吊牌并提交报告", {
type: "warning",
inputPlaceholder: "请扫描验真吊牌二维码",
inputPattern: /\S+/,
inputErrorMessage: "请扫描验真吊牌二维码",
confirmButtonText: "是的,绑定验真吊牌",
confirmButtonText: "是的,绑定并提交",
cancelButtonText: "取消",
closeOnClickModal: false,
});
@@ -904,7 +913,7 @@ async function submitZhongjianReport() {
report_files: zhongjianReportFiles.value,
qr_input: qrInput,
});
ElMessage.success(response.message || "验真吊牌已绑定,报告发布");
ElMessage.success(response.message || "验真吊牌已绑定,报告待管理员发布");
await loadDetail(detail.value.task_info.id);
await fetchTasks();
} catch (error: any) {
@@ -1124,7 +1133,7 @@ onMounted(async () => {
<el-button plain @click="openAssigneeDialog">分配处理人</el-button>
<el-button v-if="canClaimTask" plain type="primary" :loading="assigneeSubmitting" @click="claimTask">认领给我</el-button>
<el-button @click="resetZhongjianReportForm">重置内容</el-button>
<el-button type="primary" :loading="zhongjianReportSubmitting" @click="submitZhongjianReport">提交发布报告</el-button>
<el-button type="primary" :loading="zhongjianReportSubmitting" @click="submitZhongjianReport">提交发布报告</el-button>
</template>
<template v-else>
<el-button plain @click="returnToResultWorkbench">返回结论操作</el-button>
@@ -1487,7 +1496,7 @@ onMounted(async () => {
<div class="task-form-block">
<div class="task-form-block__title">吊牌绑定</div>
<div class="task-panel__desc">提交结论或中检报告时扫描平台验真吊牌绑定成功后发布报告</div>
<div class="task-panel__desc">提交结论或中检报告时扫描平台验真吊牌绑定成功后进入待发布</div>
<div v-if="detail.material_tag" class="task-material-tag-bound">
<div class="task-info-grid">
<div class="task-info-item task-info-item--full">
@@ -1672,7 +1681,7 @@ onMounted(async () => {
<el-tab-pane v-if="isZhongjianTask" label="中检报告录入" name="zhongjian">
<div :key="`zhongjian-${formRenderKey}`" class="task-form-stack">
<el-alert
title="请先在“填写结论”中补全物品信息、鉴定结论和模板项,再提交中检报告编号和文件;绑定吊牌成功后才会发布报告。"
title="请先在“填写结论”中补全物品信息、鉴定结论和模板项,再提交中检报告编号和文件;绑定吊牌成功后报告进入待发布。"
type="info"
:closable="false"
show-icon

View File

@@ -56,6 +56,7 @@ const drawerVisible = ref(false);
const inspectionDrawerVisible = ref(false);
const inspectionSubmitting = ref(false);
const publishingId = ref<number | null>(null);
const rejectingId = ref<number | null>(null);
const traceVisibilitySavingId = ref<number | null>(null);
const detailQrDataUrl = ref("");
@@ -69,6 +70,9 @@ const inspectionForm = ref<AdminManualInspectionPayload>(createInspectionPayload
const route = useRoute();
const canPublishCurrentReport = computed(() => detail.value?.report_header.report_status === "pending_publish");
const canRejectCurrentReport = computed(
() => detail.value?.report_header.report_type !== "inspection" && detail.value?.report_header.report_status === "pending_publish",
);
const canEditCurrentInspection = computed(
() => detail.value?.report_header.report_type === "inspection" && detail.value?.report_header.report_status !== "published",
);
@@ -84,6 +88,7 @@ const statusOptions = [
{ label: "全部状态", value: "" },
{ label: "已发布", value: "published" },
{ label: "待发布", value: "pending_publish" },
{ label: "已驳回", value: "rejected" },
{ label: "草稿中", value: "draft" },
{ label: "已更新", value: "updated" },
{ label: "已作废", value: "invalid" },
@@ -280,6 +285,11 @@ type PublishReportTarget = Pick<AdminReportListItem, "id" | "report_status" | "r
report_type: string;
material_tag_bound: boolean;
};
type RejectReportTarget = Pick<AdminReportListItem, "id" | "report_status" | "report_type"> | {
id: number;
report_status: string;
report_type: string;
};
type ReportTraceVisibilityTarget = Pick<AdminReportListItem, "id" | "trace_info_visible">;
async function promptReportMaterialTagInput() {
@@ -346,6 +356,62 @@ async function publishReport(row: PublishReportTarget) {
}
}
async function rejectReport(row: RejectReportTarget) {
if (row.report_status !== "pending_publish") {
ElMessage.warning("仅待发布报告可以驳回");
return;
}
if (row.report_type === "inspection") {
ElMessage.warning("补录检查单不支持驳回复鉴");
return;
}
let reason = "";
try {
const result = await ElMessageBox.prompt("请填写驳回原因,鉴定师将据此重新鉴定。", "驳回报告", {
type: "warning",
inputType: "textarea",
inputPlaceholder: "例如:结论说明不完整、图片附件不清晰",
inputPattern: /\S+/,
inputErrorMessage: "请填写驳回原因",
confirmButtonText: "确认驳回",
cancelButtonText: "取消",
closeOnClickModal: false,
});
reason = String(result.value || "").trim();
} catch {
return;
}
rejectingId.value = row.id;
try {
const response = await adminApi.rejectReport(row.id, reason);
if (response.code !== 0) {
ElMessage.error(response.message || "报告驳回失败");
return;
}
ElMessage.success(response.message || "报告已驳回");
await fetchReports();
if (drawerVisible.value && detail.value?.report_header.id === row.id) {
await loadDetail(row.id);
}
} catch (error: any) {
console.error(error);
ElMessage.error(error?.message || "报告驳回失败");
} finally {
rejectingId.value = null;
}
}
function logSummary(data: Record<string, any>) {
const status = data.report_status || "-";
const version = data.report_version ? `v${data.report_version}` : "";
const publishTime = data.publish_time ? ` · ${data.publish_time}` : "";
const rejectReason = data.reject_reason || data.invalid_reason ? ` · ${data.reject_reason || data.invalid_reason}` : "";
return [status, version].filter(Boolean).join(" / ") + publishTime + rejectReason;
}
function switchValueToBoolean(value: unknown) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value === 1;
@@ -522,7 +588,7 @@ watch(
</el-table-column>
<el-table-column prop="institution_name" label="出具机构" min-width="160" />
<el-table-column prop="publish_time" label="发布时间" min-width="170" />
<el-table-column label="操作" fixed="right" width="220">
<el-table-column label="操作" fixed="right" width="280">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">查看详情</el-button>
<el-button
@@ -534,7 +600,7 @@ watch(
编辑检查单
</el-button>
<el-button
v-if="row.report_status === 'pending_publish'"
v-if="row.report_type !== 'inspection' && row.report_status === 'pending_publish'"
link
type="warning"
:loading="publishingId === row.id"
@@ -542,6 +608,15 @@ watch(
>
发布报告
</el-button>
<el-button
v-if="row.report_type !== 'inspection' && row.report_status === 'pending_publish'"
link
type="danger"
:loading="rejectingId === row.id"
@click="rejectReport(row)"
>
驳回报告
</el-button>
</template>
</el-table-column>
</el-table>
@@ -566,6 +641,19 @@ watch(
>
发布报告
</el-button>
<el-button
v-if="canRejectCurrentReport"
type="danger"
plain
:loading="rejectingId === detail.report_header.id"
@click="rejectReport({
id: detail.report_header.id,
report_status: detail.report_header.report_status,
report_type: detail.report_header.report_type,
})"
>
驳回报告
</el-button>
</div>
<div class="detail-card">
@@ -588,6 +676,14 @@ watch(
<OrderStatusTag :status="detail.report_header.report_status_text" />
</div>
</div>
<div v-if="detail.report_header.reject_reason" class="detail-card__desc">
<div class="detail-label">驳回原因</div>
<div class="detail-value">{{ detail.report_header.reject_reason }}</div>
</div>
<div v-if="detail.report_header.rejected_at" class="detail-card__desc">
<div class="detail-label">驳回信息</div>
<div class="detail-value">{{ detail.report_header.rejected_by_name || "-" }} / {{ detail.report_header.rejected_at }}</div>
</div>
<div class="detail-card__desc">
<div class="detail-label">出具机构</div>
<div class="detail-value">{{ detail.report_header.institution_name }}</div>
@@ -634,6 +730,44 @@ watch(
</div>
</div>
<div class="detail-card detail-card--wide">
<div class="detail-card__title">审核记录</div>
<div v-if="detail.audit_logs.length" class="report-log-list">
<div v-for="log in detail.audit_logs" :key="`audit-${log.id}`" class="report-log-item">
<div class="report-log-main">
<span>{{ log.action_text }}</span>
<span>{{ log.operator_name || "-" }}</span>
<span>{{ log.created_at }}</span>
</div>
<div v-if="log.remark" class="report-log-remark">{{ log.remark }}</div>
<div class="report-log-snapshot">{{ logSummary(log.after_data) }}</div>
</div>
</div>
<div v-else class="detail-card__desc">
<div class="detail-value">暂无审核记录</div>
</div>
</div>
<div class="detail-card detail-card--wide">
<div class="detail-card__title">修改记录</div>
<div v-if="detail.change_logs.length" class="report-log-list">
<div v-for="log in detail.change_logs" :key="`change-${log.id}`" class="report-log-item">
<div class="report-log-main">
<span>{{ log.action_text }}</span>
<span>{{ log.operator_name || "-" }}</span>
<span>{{ log.created_at }}</span>
</div>
<div v-if="log.remark" class="report-log-remark">{{ log.remark }}</div>
<div class="report-log-snapshot">
{{ logSummary(log.before_data) }} -> {{ logSummary(log.after_data) }}
</div>
</div>
</div>
<div v-else class="detail-card__desc">
<div class="detail-value">暂无修改记录</div>
</div>
</div>
<div class="detail-card">
<div class="detail-card__title">商品信息</div>
<div class="detail-card__desc">
@@ -1068,4 +1202,39 @@ watch(
font-size: 13px;
font-weight: 500;
}
.detail-card--wide {
grid-column: 1 / -1;
}
.report-log-list {
display: grid;
gap: 10px;
}
.report-log-item {
display: grid;
gap: 6px;
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: #fffdfa;
}
.report-log-main {
display: flex;
flex-wrap: wrap;
gap: 12px;
color: var(--admin-text-main);
font-size: 13px;
font-weight: 700;
}
.report-log-remark,
.report-log-snapshot {
color: var(--admin-text-subtle);
font-size: 13px;
line-height: 1.5;
word-break: break-word;
}
</style>