Files
anxinyan/work-app/src/pages/task/detail.vue
2026-05-22 13:31:02 +08:00

1222 lines
42 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 setup lang="ts">
import { computed, reactive, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { adminApi, type AdminAppraisalTaskDetail, type AdminFileAsset } from "../../api/admin";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
const loading = ref(false);
const submitting = ref(false);
const supplementSubmitting = ref(false);
const uploading = ref(false);
const pageReady = ref(false);
const loadError = ref("");
const detail = ref<AdminAppraisalTaskDetail | null>(null);
const taskId = ref(0);
const activeSection = ref<"result" | "supplement" | "zhongjian">("result");
const resultText = ref("");
const resultDesc = ref("");
const conditionGrade = ref("");
const conditionDesc = ref("");
const valuationMin = ref("");
const valuationMax = ref("");
const valuationDesc = ref("");
const externalRemark = ref("");
const internalRemark = ref("");
const zhongjianReportNo = ref("");
const productName = ref("");
const categoryName = ref("");
const brandName = ref("");
const color = ref("");
const sizeSpec = ref("");
const serialNo = ref("");
const zhongjianFiles = ref<AdminFileAsset[]>([]);
const evidenceFiles = ref<AdminFileAsset[]>([]);
const activePreviewVideo = ref<AdminFileAsset | null>(null);
const supplementForm = reactive({
reason: "",
deadline: "",
items: [{ item_name: "", guide_text: "", is_required: true }],
});
const isZhongjian = computed(() => detail.value?.task_info.service_provider === "zhongjian");
const isTaskReadonly = computed(() => {
const status = detail.value?.task_info.status || "";
return status === "submitted" || status === "completed";
});
const internalTagNo = computed(() => detail.value?.task_info.internal_tag_no || "");
const resultSummary = computed(() => detail.value?.result_info.result_text || "暂未填写");
const reportSummary = computed(() => detail.value?.report_summary?.report_no || "");
type AppraisalTemplate = NonNullable<AdminAppraisalTaskDetail["appraisal_template"]>;
function hasConditionFields(template?: AppraisalTemplate | null) {
return (template?.condition_options?.length || 0) > 0;
}
function hasValuationFields(template?: AppraisalTemplate | null) {
return Boolean((template?.valuation_hint || "").trim());
}
const showConditionFields = computed(() => hasConditionFields(detail.value?.appraisal_template));
const showValuationFields = computed(() => hasValuationFields(detail.value?.appraisal_template));
function formatMoneyInput(value: string | number) {
const num = Number(value || 0);
return Number.isFinite(num) ? num : 0;
}
function hydrate(detailData: AdminAppraisalTaskDetail) {
detail.value = detailData;
activeSection.value = detailData.task_info.service_provider === "zhongjian"
? "zhongjian"
: (detailData.supplement_task ? "supplement" : "result");
resultText.value = detailData.result_info.result_text || "";
resultDesc.value = detailData.result_info.result_desc || "";
if (hasConditionFields(detailData.appraisal_template)) {
conditionGrade.value = detailData.result_info.condition_grade || "";
conditionDesc.value = detailData.result_info.condition_desc || "";
} else {
conditionGrade.value = "";
conditionDesc.value = "";
}
if (hasValuationFields(detailData.appraisal_template)) {
valuationMin.value = detailData.result_info.valuation_min ? String(detailData.result_info.valuation_min) : "";
valuationMax.value = detailData.result_info.valuation_max ? String(detailData.result_info.valuation_max) : "";
valuationDesc.value = detailData.result_info.valuation_desc || "";
} else {
valuationMin.value = "";
valuationMax.value = "";
valuationDesc.value = "";
}
externalRemark.value = detailData.result_info.external_remark || "";
internalRemark.value = detailData.result_info.internal_remark || "";
zhongjianReportNo.value = detailData.zhongjian_report?.report_no || "";
productName.value = detailData.product_info.product_name || "";
categoryName.value = detailData.product_info.category_name || "";
brandName.value = detailData.product_info.brand_name || "";
color.value = detailData.product_info.color || "";
sizeSpec.value = detailData.product_info.size_spec || "";
serialNo.value = detailData.product_info.serial_no || "";
zhongjianFiles.value = [...(detailData.zhongjian_report?.files || [])];
evidenceFiles.value = [...(detailData.result_info.attachments || [])];
if (detailData.supplement_task) {
supplementForm.reason = detailData.supplement_task.reason || "";
supplementForm.deadline = detailData.supplement_task.deadline || "";
supplementForm.items.splice(
0,
supplementForm.items.length,
...(detailData.supplement_task.items.length
? detailData.supplement_task.items.map((item) => ({
item_name: item.item_name,
guide_text: item.guide_text,
is_required: item.is_required,
}))
: [{ item_name: "", guide_text: "", is_required: true }]),
);
} else {
supplementForm.reason = "";
supplementForm.deadline = "";
supplementForm.items.splice(0, supplementForm.items.length, {
item_name: "",
guide_text: "",
is_required: true,
});
}
}
async function fetchDetail() {
if (!taskId.value) return;
loading.value = true;
if (!pageReady.value) {
loadError.value = "";
}
try {
const data = await adminApi.getAppraisalTaskDetail(taskId.value);
hydrate(data);
pageReady.value = true;
} catch (error) {
if (!pageReady.value) {
loadError.value = "工单详情加载失败,请稍后重试。";
}
showErrorToast(error, "工单详情加载失败");
} finally {
loading.value = false;
}
}
function addSupplementItem() {
if (isTaskReadonly.value) {
showInfoToast("当前任务已完成,不能再编辑");
return;
}
supplementForm.items.push({ item_name: "", guide_text: "", is_required: true });
}
function removeSupplementItem(index: number) {
if (isTaskReadonly.value) {
showInfoToast("当前任务已完成,不能再编辑");
return;
}
if (supplementForm.items.length === 1) {
supplementForm.items[0].item_name = "";
supplementForm.items[0].guide_text = "";
supplementForm.items[0].is_required = true;
return;
}
supplementForm.items.splice(index, 1);
}
async function removeEvidenceFile(fileUrl: string) {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再删除附件");
return;
}
try {
await adminApi.deleteAppraisalEvidenceFile(fileUrl, detail.value.task_info.id);
evidenceFiles.value = evidenceFiles.value.filter((item) => item.file_url !== fileUrl);
showInfoToast("附件已删除");
} catch (error) {
showErrorToast(error, "附件删除失败");
}
}
async function removeZhongjianFile(fileUrl: string) {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再删除文件");
return;
}
try {
await adminApi.deleteAppraisalEvidenceFile(fileUrl, detail.value.task_info.id);
zhongjianFiles.value = zhongjianFiles.value.filter((item) => item.file_url !== fileUrl);
showInfoToast("文件已删除");
} catch (error) {
showErrorToast(error, "文件删除失败");
}
}
function isImageAsset(item: AdminFileAsset) {
return item.file_type === "image" || item.mime_type?.startsWith("image/");
}
function isVideoAsset(item: AdminFileAsset) {
return item.file_type === "video" || item.mime_type?.startsWith("video/");
}
function attachmentTypeLabel(item: AdminFileAsset) {
if (isImageAsset(item)) return "图片";
if (isVideoAsset(item)) return "视频";
return "附件";
}
function previewAttachment(files: AdminFileAsset[], item: AdminFileAsset) {
if (isImageAsset(item)) {
const urls = files.filter(isImageAsset).map((asset) => asset.file_url);
uni.previewImage({ urls, current: item.file_url });
return;
}
if (isVideoAsset(item)) {
activePreviewVideo.value = item;
return;
}
showInfoToast("当前附件暂不支持预览");
}
function closePreviewVideo() {
activePreviewVideo.value = null;
}
function updateTemplatePoint(index: number, key: "point_value" | "point_remark", value: string) {
if (isTaskReadonly.value) return;
const template = detail.value?.appraisal_template;
if (!template) return;
const current = template.key_points[index];
if (!current) return;
current[key] = value;
}
function updateTemplatePointFromInput(
index: number,
key: "point_value" | "point_remark",
event: Event,
) {
const target = event.target as HTMLInputElement | HTMLTextAreaElement | null;
updateTemplatePoint(index, key, target?.value || "");
}
function templateKeyPointsPayload() {
return detail.value?.appraisal_template?.key_points?.map((item) => ({
point_code: item.point_code,
point_name: item.point_name,
point_value: item.point_value || "",
point_remark: item.point_remark || "",
})) || [];
}
function returnToWorkOrders(message: string) {
showInfoToast(message);
setTimeout(() => {
uni.switchTab({ url: "/pages/work-order/index" });
}, 700);
}
function confirmPublishReport() {
return new Promise<boolean>((resolve) => {
uni.showModal({
title: "提交确认",
content: "是否已鉴定完成并确定发布报告?",
cancelText: "取消",
confirmText: "去绑定",
success: (result) => resolve(Boolean(result.confirm)),
fail: () => resolve(false),
});
});
}
function promptMaterialTagQrInput() {
return new Promise<string>((resolve, reject) => {
uni.showModal({
title: "绑定验真吊牌",
content: "本地预览无法直接扫码,请输入或粘贴吊牌二维码内容。",
editable: true,
placeholderText: "二维码内容 / 验真吊牌编号",
cancelText: "取消",
confirmText: "绑定",
success: (result) => {
if (!result.confirm) {
reject(new Error("已取消绑定验真吊牌"));
return;
}
const qrInput = String(result.content || "").trim();
if (!qrInput) {
reject(new Error("请输入验真吊牌二维码内容"));
return;
}
resolve(qrInput);
},
fail: () => reject(new Error("已取消绑定验真吊牌")),
});
});
}
function scanMaterialTagQr() {
return new Promise<string>((resolve, reject) => {
uni.scanCode({
scanType: ["barCode", "qrCode"],
success: (result) => {
const qrInput = String(result.result || "").trim();
if (!qrInput) {
reject(new Error("未识别到验真吊牌二维码"));
return;
}
resolve(qrInput);
},
fail: () => {
// #ifdef H5
promptMaterialTagQrInput().then(resolve).catch(reject);
// #endif
// #ifndef H5
reject(new Error("已取消绑定验真吊牌"));
// #endif
},
});
});
}
async function confirmAndScanMaterialTag() {
const confirmed = await confirmPublishReport();
if (!confirmed) {
return "";
}
try {
return await scanMaterialTagQr();
} catch (error) {
showErrorToast(error, "验真吊牌扫码失败");
return "";
}
}
async function chooseEvidenceImage() {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再上传附件");
return;
}
try {
const result = await uni.chooseImage({
count: 9,
sizeType: ["compressed"],
sourceType: ["album", "camera"],
});
if (!result.tempFilePaths?.length) return;
uploading.value = true;
for (const filePath of result.tempFilePaths) {
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
evidenceFiles.value.push(asset);
}
showInfoToast("图片上传成功");
} catch (error) {
showErrorToast(error, "图片上传失败");
} finally {
uploading.value = false;
}
}
async function chooseEvidenceVideo() {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再上传附件");
return;
}
try {
const result = await uni.chooseVideo({
sourceType: ["album", "camera"],
});
const filePath = result.tempFilePath;
if (!filePath) return;
uploading.value = true;
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
evidenceFiles.value.push(asset);
showInfoToast("视频上传成功");
} catch (error) {
showErrorToast(error, "视频上传失败");
} finally {
uploading.value = false;
}
}
async function chooseZhongjianImage() {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再上传文件");
return;
}
try {
const result = await uni.chooseImage({
count: 9,
sizeType: ["compressed"],
sourceType: ["album", "camera"],
});
if (!result.tempFilePaths?.length) return;
uploading.value = true;
for (const filePath of result.tempFilePaths) {
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
zhongjianFiles.value.push(asset);
}
showInfoToast("图片上传成功");
} catch (error) {
showErrorToast(error, "图片上传失败");
} finally {
uploading.value = false;
}
}
async function chooseZhongjianVideo() {
if (isTaskReadonly.value || !detail.value) {
showInfoToast("当前任务已完成,不能再上传文件");
return;
}
try {
const result = await uni.chooseVideo({
sourceType: ["album", "camera"],
});
const filePath = result.tempFilePath;
if (!filePath) return;
uploading.value = true;
const asset = await adminApi.uploadAppraisalEvidenceFile(filePath, detail.value.task_info.id);
zhongjianFiles.value.push(asset);
showInfoToast("视频上传成功");
} catch (error) {
showErrorToast(error, "视频上传失败");
} finally {
uploading.value = false;
}
}
async function submitResult(action: "save" | "submit") {
if (!detail.value) return;
if (isTaskReadonly.value) {
showInfoToast("当前任务已完成,不能再提交");
return;
}
if (isZhongjian.value) {
showInfoToast("中检订单请切换到中检报告区");
activeSection.value = "zhongjian";
return;
}
if (action === "submit" && !resultText.value.trim()) {
showInfoToast("请先填写鉴定结论");
return;
}
const qrInput = action === "submit" ? await confirmAndScanMaterialTag() : "";
if (action === "submit" && !qrInput) {
return;
}
submitting.value = true;
try {
const conditionPayload = showConditionFields.value
? {
condition_grade: conditionGrade.value.trim(),
condition_desc: conditionDesc.value.trim(),
}
: {
condition_grade: "",
condition_desc: "",
};
const valuationPayload = showValuationFields.value
? {
valuation_min: formatMoneyInput(valuationMin.value),
valuation_max: formatMoneyInput(valuationMax.value),
valuation_desc: valuationDesc.value.trim(),
}
: {
valuation_min: 0,
valuation_max: 0,
valuation_desc: "",
};
await withLoading(action === "submit" ? "正在提交鉴定" : "正在保存鉴定", () =>
adminApi.saveAppraisalTaskResult({
id: detail.value!.task_info.id,
action,
product_info: {
category_id: detail.value!.product_info.category_id,
product_name: detail.value!.product_info.product_name,
category_name: detail.value!.product_info.category_name,
brand_name: detail.value!.product_info.brand_name,
color: detail.value!.product_info.color,
size_spec: detail.value!.product_info.size_spec,
serial_no: detail.value!.product_info.serial_no,
},
result_text: resultText.value.trim(),
result_desc: resultDesc.value.trim(),
...conditionPayload,
...valuationPayload,
external_remark: externalRemark.value.trim(),
internal_remark: internalRemark.value.trim(),
attachments: evidenceFiles.value,
key_points: templateKeyPointsPayload(),
...(qrInput ? { qr_input: qrInput } : {}),
}),
);
if (action === "submit") {
returnToWorkOrders("验真吊牌已绑定,报告已发布");
return;
}
showInfoToast("鉴定已保存");
await fetchDetail();
} catch (error) {
showErrorToast(error, action === "submit" ? "鉴定提交失败" : "鉴定保存失败");
} finally {
submitting.value = false;
}
}
async function submitSupplement() {
if (!detail.value) return;
if (isTaskReadonly.value) {
showInfoToast("当前任务已完成,不能再发起补资料");
return;
}
const items = supplementForm.items.filter((item) => item.item_name.trim());
if (!supplementForm.reason.trim()) {
showInfoToast("请先填写补资料原因");
return;
}
if (!items.length) {
showInfoToast("请至少填写一项补资料要求");
return;
}
supplementSubmitting.value = true;
try {
await adminApi.requestAppraisalTaskSupplement({
id: detail.value.task_info.id,
reason: supplementForm.reason.trim(),
deadline: supplementForm.deadline.trim(),
items: items.map((item) => ({
item_name: item.item_name.trim(),
guide_text: item.guide_text.trim(),
is_required: item.is_required,
})),
});
showInfoToast("已发起补资料要求");
await fetchDetail();
} catch (error) {
showErrorToast(error, "发起补资料失败");
} finally {
supplementSubmitting.value = false;
}
}
async function submitZhongjianReport() {
if (!detail.value) return;
if (isTaskReadonly.value) {
showInfoToast("当前任务已完成,不能再提交");
return;
}
if (!zhongjianReportNo.value.trim()) {
showInfoToast("请填写中检报告编号");
return;
}
if (!resultText.value.trim()) {
showInfoToast("请填写鉴定结论");
return;
}
if (!productName.value.trim() && !categoryName.value.trim() && !brandName.value.trim()) {
showInfoToast("请先完善物品信息");
return;
}
if (!zhongjianFiles.value.length) {
showInfoToast("请至少上传 1 个中检报告文件");
return;
}
const qrInput = await confirmAndScanMaterialTag();
if (!qrInput) {
return;
}
submitting.value = true;
try {
await adminApi.saveZhongjianAppraisalReport({
id: detail.value.task_info.id,
zhongjian_report_no: zhongjianReportNo.value.trim(),
product_info: {
category_id: detail.value.product_info.category_id,
product_name: productName.value.trim(),
category_name: categoryName.value.trim(),
brand_name: brandName.value.trim(),
color: color.value.trim(),
size_spec: sizeSpec.value.trim(),
serial_no: serialNo.value.trim(),
},
result_text: resultText.value.trim(),
result_desc: resultDesc.value.trim(),
attachments: evidenceFiles.value,
key_points: templateKeyPointsPayload(),
report_files: zhongjianFiles.value,
qr_input: qrInput,
});
returnToWorkOrders("验真吊牌已绑定,报告已发布");
} catch (error) {
showErrorToast(error, "中检报告录入失败");
} finally {
submitting.value = false;
}
}
function openReportDetail() {
const reportId = Number(detail.value?.report_summary?.id || 0);
if (!reportId) return;
uni.navigateTo({ url: `/pages/report/detail?id=${reportId}` });
}
onLoad((options) => {
taskId.value = Number(options?.id || 0);
if (!taskId.value) {
loadError.value = "缺少工单编号,无法查看详情。";
}
});
onShow(() => {
if (taskId.value && !pageReady.value) {
void fetchDetail();
}
});
</script>
<template>
<view class="page">
<view v-if="!pageReady && loading" class="empty">正在加载工单详情</view>
<view v-else-if="!pageReady && loadError" class="empty">{{ loadError }}</view>
<template v-else-if="detail">
<view class="hero">
<view class="eyebrow">鉴定工单</view>
<view class="title">{{ detail.product_info.product_name || "待完善物品信息" }}</view>
<view class="subtitle">{{ detail.task_info.order_no }} / {{ detail.task_info.appraisal_no }}</view>
</view>
<view class="card">
<view class="row">
<view>
<view class="card-title">{{ detail.task_info.task_stage_text }} · {{ detail.task_info.status_text }}</view>
<view class="card-desc">{{ detail.task_info.service_provider_text }} / {{ detail.task_info.assignee_name }}</view>
</view>
<text class="tag">{{ resultSummary }}</text>
</view>
<view class="meta-grid">
<view class="meta-item">
<view class="meta-label">SLA 截止</view>
<view class="meta-value">{{ detail.task_info.sla_deadline || "-" }}</view>
</view>
<view class="meta-item">
<view class="meta-label">开始时间</view>
<view class="meta-value">{{ detail.task_info.started_at || "-" }}</view>
</view>
<view class="meta-item">
<view class="meta-label">提交时间</view>
<view class="meta-value">{{ detail.task_info.submitted_at || "-" }}</view>
</view>
<view class="meta-item">
<view class="meta-label">报告摘要</view>
<view class="meta-value">{{ reportSummary || "-" }}</view>
</view>
<view v-if="internalTagNo" class="meta-item meta-item--wide">
<view class="meta-label">流转码编号</view>
<view class="meta-value transfer-code-value">{{ internalTagNo }}</view>
</view>
</view>
</view>
<view v-if="isTaskReadonly" class="readonly-notice">
当前工单已完成鉴定内容和附件仅可查看
</view>
<view class="card">
<view class="segmented">
<view :class="['segment', activeSection === 'result' ? 'segment--active' : '']" @click="activeSection = 'result'">鉴定结论</view>
<view :class="['segment', activeSection === 'supplement' ? 'segment--active' : '']" @click="activeSection = 'supplement'">补资料</view>
<view :class="['segment', activeSection === 'zhongjian' ? 'segment--active' : '']" @click="activeSection = 'zhongjian'">中检报告</view>
</view>
</view>
<view v-if="activeSection === 'result' && !isZhongjian" class="card">
<view class="card-title">鉴定结论</view>
<view class="stack" style="margin-top: 18rpx">
<input v-model="resultText" class="field" :disabled="isTaskReadonly" placeholder="结论,例如:正品 / 存疑" />
<textarea v-model="resultDesc" class="textarea" :disabled="isTaskReadonly" placeholder="结论说明" />
<template v-if="showConditionFields">
<input v-model="conditionGrade" class="field" :disabled="isTaskReadonly" placeholder="成色评级" />
<textarea v-model="conditionDesc" class="textarea" :disabled="isTaskReadonly" placeholder="成色说明" />
</template>
<template v-if="showValuationFields">
<view class="meta-grid">
<input v-model="valuationMin" class="field" :disabled="isTaskReadonly" placeholder="最低估值" />
<input v-model="valuationMax" class="field" :disabled="isTaskReadonly" placeholder="最高估值" />
</view>
<textarea v-model="valuationDesc" class="textarea" :disabled="isTaskReadonly" placeholder="估值说明" />
</template>
<textarea v-model="externalRemark" class="textarea" :disabled="isTaskReadonly" placeholder="对外备注" />
<textarea v-model="internalRemark" class="textarea" :disabled="isTaskReadonly" placeholder="内部备注" />
</view>
<view v-if="detail.appraisal_template?.key_points?.length" class="stack" style="margin-top: 20rpx">
<view class="card-desc">模板项</view>
<view v-for="(item, index) in detail.appraisal_template.key_points" :key="item.point_code" class="stack">
<view class="meta-item">
<view class="meta-label">{{ item.point_name }}</view>
<view class="meta-value">{{ item.point_type }}{{ item.is_required ? " · 必填" : "" }}</view>
</view>
<input
:value="item.point_value"
class="field"
:disabled="isTaskReadonly"
:placeholder="`${item.point_name} 值`"
@input="updateTemplatePointFromInput(index, 'point_value', $event)"
/>
<textarea
:value="item.point_remark"
class="textarea"
:disabled="isTaskReadonly"
:placeholder="`${item.point_name} 说明`"
@input="updateTemplatePointFromInput(index, 'point_remark', $event)"
/>
</view>
</view>
<view class="card-desc evidence-title">证据附件</view>
<view v-if="evidenceFiles.length" class="attachment-grid">
<view v-for="item in evidenceFiles" :key="item.file_url" class="attachment-tile">
<view class="attachment-preview" @click="previewAttachment(evidenceFiles, item)">
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
<template v-else-if="isVideoAsset(item)">
<image v-if="item.thumbnail_url" class="attachment-thumb" :src="item.thumbnail_url" mode="aspectFill" />
<view v-else class="attachment-video-thumb">
<text class="attachment-video-label">视频</text>
</view>
</template>
<view v-else class="attachment-file-thumb">附件</view>
<view v-if="isVideoAsset(item)" class="attachment-play" @click.stop="previewAttachment(evidenceFiles, item)"></view>
</view>
<view class="attachment-meta">
<view class="attachment-name">{{ item.name || item.file_id }}</view>
<view class="attachment-actions">
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
<text v-if="!isTaskReadonly" class="attachment-remove" @click.stop="removeEvidenceFile(item.file_url)">删除</text>
</view>
</view>
</view>
</view>
<view v-if="!isTaskReadonly" class="upload-actions">
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceImage">
<text class="action-symbol">+</text>
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
</button>
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceVideo">
<text class="action-symbol">+</text>
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
</button>
</view>
<view v-if="!isTaskReadonly" class="form-actions">
<button class="form-action form-action--secondary" :disabled="submitting" @click="submitResult('save')">保存</button>
<button class="form-action form-action--primary" :disabled="submitting" @click="submitResult('submit')">提交</button>
</view>
</view>
<view v-else-if="activeSection === 'supplement'" class="card">
<view class="card-title">补资料</view>
<view class="stack" style="margin-top: 18rpx">
<textarea v-model="supplementForm.reason" class="textarea" :disabled="isTaskReadonly" placeholder="补资料原因" />
<input v-model="supplementForm.deadline" class="field" :disabled="isTaskReadonly" placeholder="截止时间(可选)" />
<view v-for="(item, index) in supplementForm.items" :key="index" class="stack">
<input v-model="item.item_name" class="field" :disabled="isTaskReadonly" placeholder="补资料项名称" />
<textarea v-model="item.guide_text" class="textarea" :disabled="isTaskReadonly" placeholder="补资料说明" />
<view v-if="!isTaskReadonly" class="row">
<text class="tag" :class="item.is_required ? 'tag--warning' : ''" @click="item.is_required = !item.is_required">
{{ item.is_required ? "必传" : "选传" }}
</text>
<text class="tag tag--danger" @click="removeSupplementItem(index)">删除</text>
</view>
</view>
<view v-if="!isTaskReadonly" class="form-actions">
<button class="form-action form-action--secondary" @click="addSupplementItem">添加一项</button>
<button class="form-action form-action--primary" :disabled="supplementSubmitting" @click="submitSupplement">发起补资料</button>
</view>
</view>
</view>
<view v-else class="card">
<view class="card-title">中检报告</view>
<view class="stack" style="margin-top: 18rpx">
<view class="card-desc">报告展示信息</view>
<input v-model="productName" class="field" :disabled="isTaskReadonly" placeholder="产品名称" />
<input v-model="categoryName" class="field" :disabled="isTaskReadonly" placeholder="品类" />
<input v-model="brandName" class="field" :disabled="isTaskReadonly" placeholder="品牌" />
<view class="meta-grid">
<input v-model="color" class="field" :disabled="isTaskReadonly" placeholder="颜色" />
<input v-model="sizeSpec" class="field" :disabled="isTaskReadonly" placeholder="规格 / 尺寸" />
</view>
<input v-model="serialNo" class="field" :disabled="isTaskReadonly" placeholder="序列号 / 编码" />
<textarea v-model="resultText" class="textarea" :disabled="isTaskReadonly" placeholder="鉴定结论" />
<textarea v-model="resultDesc" class="textarea" :disabled="isTaskReadonly" placeholder="结论说明" />
<view v-if="detail.appraisal_template?.key_points?.length" class="stack">
<view class="card-desc">模板项</view>
<view v-for="(item, index) in detail.appraisal_template.key_points" :key="`zhongjian-${item.point_code}`" class="stack">
<view class="meta-item">
<view class="meta-label">{{ item.point_name }}</view>
<view class="meta-value">{{ item.point_type }}{{ item.is_required ? " · 必填" : "" }}</view>
</view>
<input
:value="item.point_value"
class="field"
:disabled="isTaskReadonly"
:placeholder="`${item.point_name} 值`"
@input="updateTemplatePointFromInput(index, 'point_value', $event)"
/>
<textarea
:value="item.point_remark"
class="textarea"
:disabled="isTaskReadonly"
:placeholder="`${item.point_name} 说明`"
@input="updateTemplatePointFromInput(index, 'point_remark', $event)"
/>
</view>
</view>
<view class="card-desc evidence-title">鉴定图片 / 视频</view>
<view v-if="evidenceFiles.length" class="attachment-grid">
<view v-for="item in evidenceFiles" :key="`zj-evidence-${item.file_url}`" class="attachment-tile">
<view class="attachment-preview" @click="previewAttachment(evidenceFiles, item)">
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
<template v-else-if="isVideoAsset(item)">
<image v-if="item.thumbnail_url" class="attachment-thumb" :src="item.thumbnail_url" mode="aspectFill" />
<view v-else class="attachment-video-thumb">
<text class="attachment-video-label">视频</text>
</view>
</template>
<view v-else class="attachment-file-thumb">附件</view>
<view v-if="isVideoAsset(item)" class="attachment-play" @click.stop="previewAttachment(evidenceFiles, item)"></view>
</view>
<view class="attachment-meta">
<view class="attachment-name">{{ item.name || item.file_id }}</view>
<view class="attachment-actions">
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
<text v-if="!isTaskReadonly" class="attachment-remove" @click.stop="removeEvidenceFile(item.file_url)">删除</text>
</view>
</view>
</view>
</view>
<view v-if="!isTaskReadonly" class="upload-actions">
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceImage">
<text class="action-symbol">+</text>
<text>{{ uploading ? "上传中" : "添加鉴定图片" }}</text>
</button>
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseEvidenceVideo">
<text class="action-symbol">+</text>
<text>{{ uploading ? "上传中" : "添加鉴定视频" }}</text>
</button>
</view>
<view class="card-desc">中检报告文件</view>
<input v-model="zhongjianReportNo" class="field" :disabled="isTaskReadonly" placeholder="中检报告编号" />
<view v-if="zhongjianFiles.length" class="attachment-grid">
<view v-for="item in zhongjianFiles" :key="item.file_url" class="attachment-tile">
<view class="attachment-preview" @click="previewAttachment(zhongjianFiles, item)">
<image v-if="isImageAsset(item)" class="attachment-thumb" :src="item.thumbnail_url || item.file_url" mode="aspectFill" />
<template v-else-if="isVideoAsset(item)">
<image v-if="item.thumbnail_url" class="attachment-thumb" :src="item.thumbnail_url" mode="aspectFill" />
<view v-else class="attachment-video-thumb">
<text class="attachment-video-label">视频</text>
</view>
</template>
<view v-else class="attachment-file-thumb">附件</view>
<view v-if="isVideoAsset(item)" class="attachment-play" @click.stop="previewAttachment(zhongjianFiles, item)"></view>
</view>
<view class="attachment-meta">
<view class="attachment-name">{{ item.name || item.file_id }}</view>
<view class="attachment-actions">
<text class="attachment-type">{{ attachmentTypeLabel(item) }}</text>
<text v-if="!isTaskReadonly" class="attachment-remove" @click.stop="removeZhongjianFile(item.file_url)">删除</text>
</view>
</view>
</view>
</view>
<view v-if="!isTaskReadonly" class="upload-actions">
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseZhongjianImage">
<text class="action-symbol">+</text>
<text>{{ uploading ? "上传中" : "添加图片" }}</text>
</button>
<button class="action-button action-button--secondary" :disabled="uploading" @click="chooseZhongjianVideo">
<text class="action-symbol">+</text>
<text>{{ uploading ? "上传中" : "添加视频" }}</text>
</button>
</view>
<view v-if="!isTaskReadonly || detail.report_summary?.id" class="form-actions" :class="detail.report_summary?.id && !isTaskReadonly ? '' : 'form-actions--single'">
<button v-if="!isTaskReadonly" class="form-action form-action--primary" :disabled="submitting" @click="submitZhongjianReport">提交并发布</button>
<button v-if="detail.report_summary?.id" class="form-action form-action--secondary" @click="openReportDetail">查看报告</button>
</view>
</view>
</view>
<view v-if="activePreviewVideo" class="video-preview-mask" @click="closePreviewVideo">
<view class="video-preview-panel" @click.stop>
<view class="video-preview-head">
<text class="video-preview-title">{{ activePreviewVideo.name || "附件视频" }}</text>
<text class="video-preview-close" @click="closePreviewVideo">关闭</text>
</view>
<video class="video-preview-player" :src="activePreviewVideo.file_url" :poster="activePreviewVideo.thumbnail_url || ''" controls autoplay />
</view>
</view>
<view class="card">
<view class="card-title">任务信息</view>
<view class="meta-grid">
<view class="meta-item">
<view class="meta-label">订单号</view>
<view class="meta-value">{{ detail.task_info.order_no }}</view>
</view>
<view class="meta-item">
<view class="meta-label">鉴定单号</view>
<view class="meta-value">{{ detail.task_info.appraisal_no }}</view>
</view>
<view class="meta-item">
<view class="meta-label">服务类型</view>
<view class="meta-value">{{ detail.task_info.service_provider_text }}</view>
</view>
<view class="meta-item">
<view class="meta-label">处理人</view>
<view class="meta-value">{{ detail.task_info.assignee_name }}</view>
</view>
<view v-if="internalTagNo" class="meta-item meta-item--wide">
<view class="meta-label">流转码编号</view>
<view class="meta-value transfer-code-value">{{ internalTagNo }}</view>
</view>
</view>
</view>
</template>
</view>
</template>
<style scoped lang="scss">
.list {
display: grid;
gap: 14rpx;
}
.readonly-notice {
margin: -6rpx 0 18rpx;
padding: 18rpx 22rpx;
border: 1px solid var(--work-border);
border-radius: var(--work-radius-sm);
background: var(--work-card-muted);
color: var(--work-text-soft);
font-size: 24rpx;
font-weight: 700;
line-height: 1.5;
}
.meta-item--wide {
grid-column: 1 / -1;
}
.transfer-code-value {
color: var(--work-warning);
font-weight: 900;
word-break: break-all;
}
.evidence-title {
margin-top: 24rpx;
color: var(--work-text);
font-weight: 800;
}
.attachment-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14rpx;
margin-top: 14rpx;
}
.attachment-tile {
min-width: 0;
}
.attachment-preview {
position: relative;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--work-border);
border-radius: var(--work-radius-sm);
background: var(--work-card-muted);
}
.attachment-thumb {
display: block;
width: 100%;
height: 100%;
}
.attachment-video-thumb,
.attachment-file-thumb {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.attachment-video-thumb {
background: linear-gradient(135deg, #f8fafc 0%, #e8edf3 100%);
color: var(--work-text);
}
.attachment-video-label {
font-size: 24rpx;
font-weight: 900;
}
.attachment-file-thumb {
color: var(--work-text-soft);
font-size: 24rpx;
font-weight: 800;
}
.attachment-play {
position: absolute;
left: 50%;
top: 50%;
width: 54rpx;
height: 54rpx;
margin-left: -27rpx;
margin-top: -27rpx;
border-radius: 50%;
background: rgba(32, 33, 36, 0.72);
color: #ffffff;
font-size: 28rpx;
line-height: 54rpx;
text-align: center;
}
.attachment-meta {
min-width: 0;
margin-top: 8rpx;
}
.attachment-name {
min-width: 0;
overflow: hidden;
color: var(--work-text);
font-size: 22rpx;
font-weight: 700;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-actions {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 6rpx;
}
.attachment-type,
.attachment-remove {
flex: 0 0 auto;
min-height: 34rpx;
padding: 0 10rpx;
border-radius: var(--work-radius-pill);
font-size: 20rpx;
font-weight: 700;
line-height: 34rpx;
white-space: nowrap;
}
.attachment-type {
background: var(--work-info-soft);
color: var(--work-info);
}
.attachment-remove {
background: var(--work-danger-soft);
color: var(--work-danger);
}
.upload-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 16rpx;
}
.action-button,
.form-action {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 88rpx;
padding: 0 22rpx;
border: 1px solid transparent;
border-radius: var(--work-radius-sm);
font-size: 28rpx;
font-weight: 800;
line-height: 1;
}
.action-button::after,
.form-action::after {
border: 0;
}
.action-button[disabled],
.form-action[disabled] {
opacity: 0.56;
}
.action-button--secondary {
justify-content: flex-start;
gap: 12rpx;
border-color: var(--work-border);
background: var(--work-card-muted);
color: var(--work-text);
}
.action-symbol {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: #ffffff;
color: var(--work-accent-deep);
font-size: 30rpx;
font-weight: 800;
line-height: 34rpx;
text-align: center;
}
.form-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr);
gap: 16rpx;
margin-top: 24rpx;
}
.form-actions--single {
grid-template-columns: 1fr;
}
.form-action--secondary {
border-color: var(--work-border);
background: #ffffff;
color: var(--work-text);
}
.form-action--primary {
border-color: var(--work-accent);
background: var(--work-accent);
color: #ffffff;
}
.video-preview-mask {
position: fixed;
z-index: 20;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(0, 0, 0, 0.58);
}
.video-preview-panel {
width: 100%;
overflow: hidden;
border-radius: var(--work-radius);
background: #ffffff;
}
.video-preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 22rpx 24rpx;
}
.video-preview-title {
min-width: 0;
overflow: hidden;
color: var(--work-text);
font-size: 28rpx;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-preview-close {
flex: 0 0 auto;
color: var(--work-info);
font-size: 26rpx;
font-weight: 800;
}
.video-preview-player {
display: block;
width: 100%;
height: 58vh;
background: #000000;
}
</style>