This commit is contained in:
wushumin
2026-05-11 15:28:27 +08:00
commit 9aac78b8da
289 changed files with 67193 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,500 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { appApi, type OrderListItem } from "../../api/app";
import { isLoggedIn, redirectToLogin } from "../../utils/auth";
import { showErrorToast } from "../../utils/feedback";
import { getPrivacyMode, maskOrderNo } from "../../utils/privacy";
const orders = ref<OrderListItem[]>([]);
const privacyMode = ref(getPrivacyMode());
const orderHeroBackground = ref("");
const defaultOrderHeroBackground = "/static/order/order-reference.jpg";
const orderHeroStyle = computed(() => ({
backgroundImage: `url("${orderHeroBackground.value || defaultOrderHeroBackground}")`,
}));
const heroStats = computed(() => {
const stats = {
pending: 0,
processing: 0,
completed: 0,
};
for (const item of orders.value) {
if (["report_published", "completed"].includes(item.order_status)) {
stats.completed += 1;
continue;
}
if (item.order_status === "pending_supplement") {
stats.pending += 1;
continue;
}
stats.processing += 1;
}
return stats;
});
const emptyState = computed(() => ({
title: "还没有鉴定订单",
desc: "发起第一笔鉴定后,订单进度、补资料提醒和报告状态都会集中展示在这里。",
}));
function openOrder(id: number) {
uni.navigateTo({ url: `/pages/order/detail?id=${id}` });
}
function goStartAppraisal() {
uni.navigateTo({ url: "/pages/appraisal/service" });
}
function goHome() {
uni.switchTab({ url: "/pages/home/index" });
}
function goHelp() {
uni.navigateTo({ url: "/pages/help/index" });
}
async function fetchPageVisuals() {
try {
const data = await appApi.getPageVisuals();
orderHeroBackground.value = data.order_background_image_url || "";
} catch (error) {
console.warn("order page visuals fallback", error);
}
}
onShow(async () => {
privacyMode.value = getPrivacyMode();
void fetchPageVisuals();
if (!isLoggedIn()) {
orders.value = [];
redirectToLogin("/pages/order/index");
return;
}
try {
const data = await appApi.getOrders();
orders.value = data.list;
} catch (error) {
orders.value = [];
showErrorToast(error, "订单加载失败");
}
});
</script>
<template>
<view class="order-page">
<view :class="['order-hero', orders.length === 0 ? 'order-hero--empty' : 'order-hero--list']">
<view class="order-hero__bg" :style="orderHeroStyle"></view>
<view class="order-nav">
<view class="order-nav__home" @click="goHome">
<view class="order-nav__home-roof"></view>
<view class="order-nav__home-body"></view>
</view>
<view class="order-nav__title">订单中心</view>
<view class="order-nav__capsule">
<text class="order-nav__dots"></text>
<view class="order-nav__divider"></view>
<view class="order-nav__circle"></view>
</view>
</view>
<view class="order-hero__content">
<view class="order-hero__title">订单中心</view>
<view class="order-hero__desc">查看每一笔鉴定从下单寄送补资料到出报告的完整进度</view>
<view class="order-stat-grid">
<view class="order-stat-card">
<view class="order-stat-card__value">{{ heroStats.pending }}</view>
<view class="order-stat-card__label">待处理</view>
</view>
<view class="order-stat-card">
<view class="order-stat-card__value">{{ heroStats.processing }}</view>
<view class="order-stat-card__label">进行中</view>
</view>
<view class="order-stat-card">
<view class="order-stat-card__value">{{ heroStats.completed }}</view>
<view class="order-stat-card__label">已完成</view>
</view>
</view>
</view>
</view>
<view v-if="orders.length === 0" class="order-empty-state">
<view class="order-empty-state__title">{{ emptyState.title }}</view>
<view class="order-empty-state__desc">{{ emptyState.desc }}</view>
<view class="order-empty-state__actions">
<view class="order-empty-state__button order-empty-state__button--primary" @click="goStartAppraisal">发起鉴定</view>
<view class="order-empty-state__button order-empty-state__button--secondary" @click="goHelp">查看帮助</view>
</view>
</view>
<view v-else class="order-list">
<view
v-for="item in orders"
:key="item.order_id"
class="order-card"
@click="openOrder(item.order_id)"
>
<view class="order-card__top">
<view>
<view class="order-card__title">{{ item.product_name }}</view>
<view class="order-card__no">订单号{{ maskOrderNo(item.order_no, privacyMode) }}</view>
</view>
<text
class="order-card__status"
:class="item.order_status === 'pending_supplement' ? 'order-card__status--warning' : ['report_published', 'completed'].includes(item.order_status) ? 'order-card__status--success' : 'order-card__status--info'"
>
{{ item.display_status }}
</text>
</view>
<view class="order-card__desc">{{ item.status_desc }}</view>
<view v-if="item.order_status === 'report_published'" class="order-card__desc">平台待安排寄回请先确认寄回地址</view>
<view v-if="item.order_status === 'completed' && item.display_status === '物品已寄回'" class="order-card__desc">平台已回寄商品请留意签收物流</view>
<view class="order-card__footer">
<view class="order-card__provider">{{ item.service_provider === "zhongjian" ? "中检鉴定" : "安心验鉴定" }}</view>
<view class="order-card__action">{{ item.primary_action }}</view>
</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.order-page {
width: 100vw;
min-height: 100vh;
overflow-x: hidden;
padding-bottom: calc(48rpx + env(safe-area-inset-bottom));
background: #f2f2f4;
color: #2d2d2f;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
}
.order-page,
.order-page view,
.order-page text {
box-sizing: border-box;
}
.order-hero {
position: relative;
overflow: hidden;
width: 100vw;
padding: 88rpx 32rpx 0;
}
.order-hero--empty {
height: 900rpx;
}
.order-hero--list {
height: 790rpx;
}
.order-hero__bg {
position: absolute;
inset: -18rpx;
background-size: 100vw auto;
background-position: top center;
background-repeat: no-repeat;
filter: blur(8rpx) saturate(1.08);
transform: scale(1.04);
opacity: 0.82;
pointer-events: none;
}
.order-hero::after {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(245, 239, 232, 0.08) 0%, rgba(243, 243, 245, 0.22) 42%, #f2f2f4 88%),
linear-gradient(0deg, rgba(242, 242, 244, 0.36), rgba(255, 255, 255, 0.04));
pointer-events: none;
}
.order-nav,
.order-hero__content {
position: relative;
z-index: 1;
}
.order-nav {
display: flex;
align-items: center;
justify-content: space-between;
height: 76rpx;
}
.order-nav__title {
position: absolute;
left: 50%;
top: 50%;
color: #272729;
font-size: 34rpx;
line-height: 1;
font-weight: 700;
transform: translate(-50%, -50%);
white-space: nowrap;
}
.order-nav__home {
position: relative;
width: 56rpx;
height: 56rpx;
}
.order-nav__home-roof {
position: absolute;
left: 10rpx;
top: 7rpx;
width: 36rpx;
height: 36rpx;
border-top: 4rpx solid #2a2a2c;
border-left: 4rpx solid #2a2a2c;
transform: rotate(45deg);
}
.order-nav__home-body {
position: absolute;
left: 12rpx;
bottom: 8rpx;
width: 34rpx;
height: 30rpx;
border: 4rpx solid #2a2a2c;
border-top: 0;
border-radius: 0 0 8rpx 8rpx;
background: linear-gradient(135deg, transparent 0 36%, #f0c000 36% 100%);
}
.order-nav__capsule {
display: flex;
align-items: center;
justify-content: space-around;
width: 174rpx;
height: 64rpx;
padding: 0 20rpx;
border-radius: 34rpx;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.05);
backdrop-filter: blur(12rpx);
}
.order-nav__dots {
color: #050505;
font-size: 36rpx;
line-height: 1;
font-weight: 800;
}
.order-nav__divider {
width: 1rpx;
height: 34rpx;
background: rgba(20, 20, 20, 0.68);
}
.order-nav__circle {
width: 38rpx;
height: 38rpx;
border: 7rpx solid #070707;
border-radius: 50%;
}
.order-hero__content {
margin-top: 214rpx;
}
.order-hero__title {
color: #262628;
font-size: 70rpx;
line-height: 1.08;
font-weight: 800;
letter-spacing: 0;
}
.order-hero__desc {
width: 650rpx;
max-width: 100%;
margin-top: 28rpx;
color: #666;
font-size: 29rpx;
line-height: 1.7;
}
.order-stat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 42rpx;
margin: 26rpx 22rpx 0;
}
.order-stat-card {
height: 176rpx;
border-radius: 10rpx;
background: rgba(255, 255, 255, 0.9);
text-align: center;
box-shadow: 0 10rpx 24rpx rgba(0, 0, 0, 0.02);
}
.order-stat-card__value {
margin-top: 35rpx;
color: #2b2b2d;
font-size: 68rpx;
line-height: 1;
font-weight: 500;
}
.order-stat-card__label {
margin-top: 18rpx;
color: #686868;
font-size: 25rpx;
line-height: 1;
}
.order-empty-state {
margin: 190rpx 32rpx 0;
text-align: center;
}
.order-empty-state__title {
color: #202022;
font-size: 40rpx;
line-height: 1.2;
font-weight: 800;
}
.order-empty-state__desc {
width: 560rpx;
max-width: 100%;
margin: 28rpx auto 0;
color: #818181;
font-size: 29rpx;
line-height: 1.55;
}
.order-empty-state__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 38rpx;
margin-top: 50rpx;
}
.order-empty-state__button {
height: 68rpx;
border-radius: 36rpx;
font-size: 25rpx;
font-weight: 700;
line-height: 68rpx;
text-align: center;
}
.order-empty-state__button--primary {
background: #edbd00;
color: #fff;
}
.order-empty-state__button--secondary {
background: #fff;
color: #29292b;
}
.order-list {
display: grid;
gap: 22rpx;
margin: -22rpx 32rpx 0;
padding-bottom: 36rpx;
}
.order-card {
padding: 28rpx;
border-radius: 16rpx;
background: #fff;
box-shadow: 0 12rpx 26rpx rgba(0, 0, 0, 0.035);
}
.order-card__top {
display: flex;
justify-content: space-between;
gap: 20rpx;
}
.order-card__title {
color: #242426;
font-size: 31rpx;
line-height: 1.25;
font-weight: 800;
}
.order-card__no,
.order-card__desc {
margin-top: 12rpx;
color: #777;
font-size: 24rpx;
line-height: 1.55;
}
.order-card__status {
flex-shrink: 0;
height: 42rpx;
padding: 0 16rpx;
border-radius: 22rpx;
font-size: 22rpx;
line-height: 42rpx;
}
.order-card__status--warning {
background: #fff4d9;
color: #a97700;
}
.order-card__status--success {
background: #eaf7ef;
color: #2d7652;
}
.order-card__status--info {
background: #edf4ff;
color: #28669f;
}
.order-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 22rpx;
}
.order-card__provider {
color: #9a9a9a;
font-size: 23rpx;
}
.order-card__action {
min-width: 126rpx;
height: 48rpx;
padding: 0 20rpx;
border-radius: 25rpx;
background: #f1bd00;
color: #fff;
font-size: 23rpx;
font-weight: 700;
line-height: 48rpx;
text-align: center;
}
@media (max-width: 360px) {
.order-stat-grid {
gap: 22rpx;
margin-left: 12rpx;
margin-right: 12rpx;
}
.order-empty-state__actions {
gap: 22rpx;
}
}
</style>

View File

@@ -0,0 +1,627 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import { appApi, type ShippingDetailData } from "../../api/app";
import { shippingDetailFallback } from "../../mocks/app";
import { resolveErrorMessage, showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
import { getPrivacyMode, maskOrderNo } from "../../utils/privacy";
const detail = ref<ShippingDetailData>(shippingDetailFallback);
const orderId = ref(0);
const saving = ref(false);
const expressCompany = ref("");
const trackingNo = ref("");
const privacyMode = ref(getPrivacyMode());
const selectedWarehouseId = ref(0);
const warehouseSheetVisible = ref(false);
const loading = ref(false);
const pageReady = ref(false);
const loadError = ref("");
const submitted = computed(() => detail.value.logistics_info.is_submitted);
const canEditTracking = computed(() => !submitted.value && detail.value.can_submit_tracking);
const hasWarehouseChoices = computed(
() => detail.value.shipping_options.can_select_warehouse && detail.value.shipping_options.list.length > 1,
);
const warehouseOptions = computed(() =>
[...detail.value.shipping_options.list].sort((left, right) => {
const leftSelected = left.id === selectedWarehouseId.value ? 1 : 0;
const rightSelected = right.id === selectedWarehouseId.value ? 1 : 0;
if (leftSelected !== rightSelected) {
return rightSelected - leftSelected;
}
const leftRecommended = left.is_recommended ? 1 : 0;
const rightRecommended = right.is_recommended ? 1 : 0;
if (leftRecommended !== rightRecommended) {
return rightRecommended - leftRecommended;
}
return left.sort_order - right.sort_order;
}),
);
const selectedWarehouse = computed(() => {
const matched = warehouseOptions.value.find((item) => item.id === selectedWarehouseId.value);
return matched || null;
});
const alternativeWarehouses = computed(() => warehouseOptions.value.filter((item) => item.id !== selectedWarehouseId.value));
const activeShippingAddress = computed(() => selectedWarehouse.value || detail.value.shipping_address);
const selectionChanged = computed(() => {
const initialWarehouseId = detail.value.shipping_options.current_warehouse_id || detail.value.shipping_address.warehouse_id || 0;
return Boolean(initialWarehouseId && selectedWarehouseId.value && initialWarehouseId !== selectedWarehouseId.value);
});
const activeWarehouseServiceText = computed(
() => selectedWarehouse.value?.service_provider_text || detail.value.order_info.service_provider || "当前鉴定服务",
);
const activeWarehouseHint = computed(() => {
if (!hasWarehouseChoices.value) {
return "该订单当前仅匹配这一家检测中心,可直接按下方收件信息寄送。";
}
if (selectionChanged.value) {
return "你已切换检测中心,提交运单后将按当前选择的仓库收货。";
}
return "寄送前可重新选择检测中心,避免商品寄错仓库。";
});
function goBack() {
uni.navigateBack();
}
function openWarehouseSheet() {
if (!hasWarehouseChoices.value) return;
warehouseSheetVisible.value = true;
}
function closeWarehouseSheet() {
warehouseSheetVisible.value = false;
}
function selectWarehouse(warehouseId: number) {
const target = warehouseOptions.value.find((item) => item.id === warehouseId);
if (!target) return;
const changed = selectedWarehouseId.value !== warehouseId;
selectedWarehouseId.value = warehouseId;
closeWarehouseSheet();
if (changed) {
showInfoToast(`已切换至${target.warehouse_name}`);
}
}
function copyAddress() {
const current = activeShippingAddress.value;
const address = `${current.receiver_name} ${current.receiver_mobile} ${current.province}${current.city}${current.district}${current.detail_address}`;
uni.setClipboardData({
data: address,
success: () => showInfoToast("收件信息已复制"),
});
}
function useCompany(name: string) {
expressCompany.value = name;
}
async function fetchDetail() {
if (!orderId.value) return;
loading.value = true;
privacyMode.value = getPrivacyMode();
if (!pageReady.value) {
loadError.value = "";
}
try {
detail.value = await appApi.getOrderShippingDetail(orderId.value);
expressCompany.value = detail.value.logistics_info.express_company || expressCompany.value;
trackingNo.value = detail.value.logistics_info.tracking_no || trackingNo.value;
selectedWarehouseId.value = detail.value.shipping_options.current_warehouse_id || detail.value.shipping_address.warehouse_id || 0;
pageReady.value = true;
} catch (error) {
console.warn("shipping detail fallback", error);
if (!pageReady.value) {
loadError.value = resolveErrorMessage(error, "寄送信息加载失败,请稍后重试。");
} else {
showErrorToast(error, "寄送信息刷新失败");
}
} finally {
loading.value = false;
}
}
async function submitTracking() {
if (submitted.value) {
showInfoToast("运单已提交,如需更正请联系客服处理");
return;
}
if (!detail.value.can_submit_tracking) {
showInfoToast("当前订单暂不需要提交运单");
return;
}
if (!expressCompany.value.trim() || !trackingNo.value.trim()) {
showInfoToast("请填写快递公司和运单号");
return;
}
saving.value = true;
try {
await withLoading("正在提交运单", async () =>
appApi.saveOrderShipping({
order_id: detail.value.order_info.order_id,
express_company: expressCompany.value.trim(),
tracking_no: trackingNo.value.trim(),
warehouse_id: selectedWarehouseId.value || undefined,
}),
);
showInfoToast("运单已提交");
await fetchDetail();
} catch (error) {
showErrorToast(error, "运单提交失败");
} finally {
saving.value = false;
}
}
onLoad((options) => {
orderId.value = Number(options?.order_id || 0);
if (!orderId.value) {
loadError.value = "缺少订单编号,无法查看寄送信息。";
}
});
onShow(fetchDetail);
</script>
<template>
<view class="app-page app-page--tight">
<view v-if="!pageReady && loading" class="section notice-card">
<view class="notice-card__title">正在加载寄送信息</view>
<view class="notice-card__desc">请稍候我们正在同步收货仓库物流状态与可选检测中心</view>
</view>
<view v-else-if="!pageReady && loadError" class="section notice-card">
<view class="notice-card__title">寄送信息加载失败</view>
<view class="notice-card__desc">{{ loadError }}</view>
<view style="margin-top: 20rpx">
<text class="btn btn--ghost" @click="fetchDetail">重新加载</text>
</view>
</view>
<template v-else>
<view class="section-card">
<view class="tag" :class="submitted ? 'tag--info' : 'tag--warning'">{{ detail.order_info.display_status }}</view>
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
查看寄送与提交运单
</view>
<view class="section__desc">请先寄出商品再回到本页填写快递公司和运单号我们会同步更新鉴定进度</view>
<view class="certificate-header__meta">
<text class="certificate-meta-chip">订单号 {{ maskOrderNo(detail.order_info.order_no, privacyMode) }}</text>
<text class="certificate-meta-chip">鉴定单号 {{ maskOrderNo(detail.order_info.appraisal_no, privacyMode) }}</text>
</view>
</view>
<view class="section section-card section-card--soft">
<view class="section__title">收件信息</view>
<view v-if="detail.shipping_options.list.length" class="warehouse-picker">
<view class="warehouse-current">
<view class="warehouse-current__header">
<view>
<view class="warehouse-current__eyebrow">当前检测中心</view>
<view class="warehouse-current__name">{{ activeShippingAddress.warehouse_name || "检测中心待确认" }}</view>
</view>
<text class="warehouse-current__badge">{{ selectionChanged ? "已切换待生效" : "当前选择" }}</text>
</view>
<view class="warehouse-current__meta-row">
<text class="warehouse-current__meta-pill">{{ activeWarehouseServiceText }}</text>
<text v-if="hasWarehouseChoices" class="warehouse-current__meta-pill">{{ alternativeWarehouses.length }} 个可切换</text>
</view>
<view class="warehouse-current__notice">{{ activeWarehouseHint }}</view>
<view v-if="hasWarehouseChoices" class="warehouse-current__action" @click="openWarehouseSheet">
<text>更换检测中心</text>
<text class="warehouse-current__action-arrow">去选择</text>
</view>
</view>
</view>
<view class="shipping-detail__header">
<view class="shipping-detail__title">详细收件资料</view>
<view class="shipping-detail__desc">请按以下联系人地址与签收时间完成寄送</view>
</view>
<view class="report-meta__row">
<text class="report-meta__label">联系人</text>
<text class="report-meta__value">{{ activeShippingAddress.receiver_name }}</text>
</view>
<view class="report-meta__row">
<text class="report-meta__label">联系电话</text>
<text class="report-meta__value">{{ activeShippingAddress.receiver_mobile }}</text>
</view>
<view class="report-meta__row report-meta__row--stacked">
<text class="report-meta__label">详细地址</text>
<text class="report-meta__value">{{ activeShippingAddress.province }}{{ activeShippingAddress.city }}{{ activeShippingAddress.district }}{{ activeShippingAddress.detail_address }}</text>
</view>
<view class="report-meta__row">
<text class="report-meta__label">签收时间</text>
<text class="report-meta__value">{{ activeShippingAddress.service_time }}</text>
</view>
<view class="section__desc">{{ activeShippingAddress.notice }}</view>
<view style="margin-top: 20rpx">
<text class="btn btn--ghost" @click="copyAddress">复制收件信息</text>
</view>
</view>
<view class="section grid-2">
<view class="metric-card">
<view class="metric-card__value">{{ detail.order_info.product_name || "待确认商品" }}</view>
<view class="metric-card__label">当前寄送商品请确保与订单信息一致</view>
</view>
<view class="metric-card">
<view class="metric-card__value">{{ submitted ? detail.logistics_info.tracking_status_text : "待提交" }}</view>
<view class="metric-card__label">寄送状态提交运单后我们会继续同步节点</view>
</view>
</view>
<view class="section section-card">
<view class="section__title">寄送提醒</view>
<view v-for="(item, index) in detail.shipping_notice.tips" :key="item" class="report-meta__row report-meta__row--stacked">
<text class="report-meta__label">{{ index + 1 }}. {{ item }}</text>
</view>
<view class="chip-list">
<view v-for="company in detail.shipping_notice.express_recommendations" :key="company" class="choice-chip" @click="useCompany(company)">
{{ company }}
</view>
</view>
</view>
<view :class="['section', 'form-panel', submitted ? 'form-panel--readonly' : '']">
<view class="form-panel__title">{{ submitted ? "运单信息" : "填写运单信息" }}</view>
<view v-if="submitted" class="form-panel__readonly-tip">
运单已提交当前页面仅展示已登记信息避免误触修改
</view>
<view class="form-group">
<view class="form-group__label">快递公司</view>
<view :class="['field-box', submitted ? 'field-box--readonly' : '']">
<input v-model="expressCompany" class="field-input" maxlength="30" placeholder="例如:顺丰速运" :disabled="submitted" />
</view>
</view>
<view class="form-group">
<view class="form-group__label">运单号</view>
<view :class="['field-box', submitted ? 'field-box--readonly' : '']">
<input v-model="trackingNo" class="field-input" maxlength="40" placeholder="请输入快递单号" :disabled="submitted" />
</view>
</view>
<view class="form-group__hint">
{{ submitted ? "如物流信息存在异常,请联系平台客服协助处理。" : "提交后将进入待签收跟踪状态,请确认信息无误后再提交。" }}
</view>
</view>
<view v-if="submitted" class="section timeline-panel">
<view class="section__title">寄送轨迹</view>
<view class="section__desc">{{ detail.logistics_info.latest_desc }}</view>
<view class="timeline" style="margin-top: 24rpx">
<view
v-for="item in detail.logistics_nodes"
:key="`${item.node_time}-${item.node_desc}`"
class="timeline__item timeline__item--active"
>
<view class="timeline-card__title">{{ item.node_desc }}</view>
<view class="timeline__time">{{ item.node_time }}</view>
<view v-if="item.node_location" class="timeline-card__desc">{{ item.node_location }}</view>
</view>
</view>
</view>
<view class="fixed-action-bar">
<view :class="['btn', 'btn--primary', submitted || saving || !canEditTracking ? 'btn--disabled' : '']" @click="submitTracking">
{{ saving ? "提交中..." : submitted ? "运单已提交" : "提交运单" }}
</view>
</view>
<view v-if="warehouseSheetVisible" class="warehouse-sheet">
<view class="warehouse-sheet__mask" @click="closeWarehouseSheet"></view>
<view class="warehouse-sheet__panel">
<view class="warehouse-sheet__header">
<view>
<view class="warehouse-sheet__title">选择检测中心</view>
<view class="warehouse-sheet__desc">切换后下方收件资料会同步更新提交运单时自动保存当前选择</view>
</view>
<view class="warehouse-sheet__close" @click="closeWarehouseSheet">关闭</view>
</view>
<scroll-view scroll-y class="warehouse-sheet__list">
<view
v-for="item in warehouseOptions"
:key="item.id"
:class="['warehouse-sheet__option', selectedWarehouseId === item.id ? 'warehouse-sheet__option--selected' : '']"
@click="selectWarehouse(item.id)"
>
<view class="warehouse-sheet__top">
<view class="warehouse-sheet__head">
<view class="warehouse-sheet__name">{{ item.warehouse_name }}</view>
<view class="warehouse-sheet__badges">
<text v-if="item.is_recommended" class="warehouse-sheet__badge warehouse-sheet__badge--accent">推荐</text>
<text v-if="item.is_default" class="warehouse-sheet__badge">默认</text>
</view>
</view>
<text class="warehouse-sheet__state">{{ selectedWarehouseId === item.id ? "已选中心" : "选择" }}</text>
</view>
<view class="warehouse-sheet__meta">{{ item.service_provider_text }} / {{ item.service_time }}</view>
<view class="warehouse-sheet__meta">{{ item.full_address }}</view>
<view v-if="item.is_recommended && item.recommended_reason" class="warehouse-sheet__reason">
{{ item.recommended_reason }}
</view>
</view>
</scroll-view>
</view>
</view>
</template>
</view>
</template>
<style scoped>
.warehouse-picker {
margin-bottom: 24rpx;
}
.warehouse-current {
padding: 26rpx;
border-radius: 32rpx;
background:
linear-gradient(150deg, rgba(29, 29, 29, 0.98) 0%, rgba(52, 44, 31, 0.98) 100%);
box-shadow: 0 14rpx 32rpx rgba(26, 20, 12, 0.16);
}
.warehouse-current__header {
display: flex;
justify-content: space-between;
gap: 20rpx;
align-items: flex-start;
}
.warehouse-current__eyebrow {
color: rgba(244, 215, 157, 0.82);
font-size: 22rpx;
letter-spacing: 1rpx;
}
.warehouse-current__name {
margin-top: 10rpx;
color: #fffaf0;
font-size: 34rpx;
font-weight: 600;
line-height: 1.5;
}
.warehouse-current__badge {
flex-shrink: 0;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(244, 215, 157, 0.14);
color: #f4d79d;
font-size: 22rpx;
font-weight: 600;
}
.warehouse-current__meta-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.warehouse-current__meta-pill {
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 250, 240, 0.88);
font-size: 22rpx;
}
.warehouse-current__notice {
margin-top: 18rpx;
color: #f7e6bc;
font-size: var(--font-size-xs);
line-height: 1.7;
}
.warehouse-current__action {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16rpx;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1px solid rgba(255, 255, 255, 0.12);
color: #fff6e3;
font-size: var(--font-size-sm);
font-weight: 600;
}
.warehouse-current__action-arrow {
color: #f4d79d;
font-size: 24rpx;
}
.shipping-detail__header {
margin-top: 28rpx;
padding-top: 24rpx;
border-top: 1px solid var(--card-border);
}
.shipping-detail__title {
color: var(--color-heading);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
}
.shipping-detail__desc {
margin-top: 8rpx;
color: var(--color-body);
font-size: var(--font-size-xs);
line-height: 1.7;
}
.form-panel--readonly {
background: #ffffff;
}
.form-panel__readonly-tip {
margin-top: 12rpx;
padding: 16rpx 18rpx;
border-radius: 18rpx;
background: rgba(237, 189, 0, 0.1);
color: #c89b00;
font-size: var(--font-size-xs);
line-height: 1.7;
}
.field-box--readonly {
background: #f6f6f7;
border-color: var(--card-border);
}
.warehouse-sheet {
position: fixed;
inset: 0;
z-index: 180;
}
.warehouse-sheet__mask {
position: absolute;
inset: 0;
background: rgba(20, 20, 18, 0.38);
}
.warehouse-sheet__panel {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 28rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
border-radius: 36rpx 36rpx 0 0;
background: #ffffff;
box-shadow: 0 -12rpx 36rpx rgba(0, 0, 0, 0.08);
}
.warehouse-sheet__header {
display: flex;
justify-content: space-between;
gap: 20rpx;
align-items: flex-start;
margin-bottom: 20rpx;
}
.warehouse-sheet__title {
color: var(--color-heading);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
}
.warehouse-sheet__desc {
margin-top: 8rpx;
color: var(--color-body);
font-size: var(--font-size-xs);
line-height: 1.7;
}
.warehouse-sheet__close {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 104rpx;
padding: 12rpx 20rpx;
border-radius: 999rpx;
background: rgba(237, 189, 0, 0.12);
color: var(--color-accent);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
line-height: 1;
white-space: nowrap;
text-align: center;
}
.warehouse-sheet__list {
max-height: 58vh;
}
.warehouse-sheet__option {
padding: 22rpx 22rpx 24rpx;
border-radius: 24rpx;
border: 2rpx solid rgba(219, 206, 183, 0.9);
background: rgba(255, 255, 255, 0.92);
}
.warehouse-sheet__option + .warehouse-sheet__option {
margin-top: 16rpx;
}
.warehouse-sheet__option--selected {
border-color: rgba(237, 189, 0, 0.38);
background: #ffffff;
}
.warehouse-sheet__top {
display: flex;
justify-content: space-between;
gap: 16rpx;
align-items: flex-start;
}
.warehouse-sheet__head {
flex: 1;
}
.warehouse-sheet__name {
color: var(--color-heading);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
line-height: 1.6;
}
.warehouse-sheet__badges {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 10rpx;
}
.warehouse-sheet__badge {
padding: 6rpx 14rpx;
border-radius: 999rpx;
background: #f3efe6;
color: #7d6540;
font-size: 20rpx;
font-weight: 600;
}
.warehouse-sheet__badge--accent {
background: rgba(237, 189, 0, 0.12);
color: #a36816;
}
.warehouse-sheet__state {
flex-shrink: 0;
padding: 10rpx 16rpx;
border-radius: 999rpx;
background: rgba(237, 189, 0, 0.12);
color: var(--color-accent);
font-size: 22rpx;
font-weight: var(--font-weight-semibold);
}
.warehouse-sheet__meta {
margin-top: 8rpx;
color: var(--color-body);
font-size: var(--font-size-xs);
line-height: 1.7;
}
.warehouse-sheet__reason {
margin-top: 12rpx;
padding: 14rpx 16rpx;
border-radius: 18rpx;
background: rgba(237, 189, 0, 0.1);
color: #9a6218;
font-size: var(--font-size-xs);
line-height: 1.7;
}
</style>

View File

@@ -0,0 +1,217 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { appApi, type SupplementDetailData } from "../../api/app";
import { showErrorToast, showInfoToast, withLoading } from "../../utils/feedback";
const detail = ref<SupplementDetailData | null>(null);
const loading = ref(true);
const submitting = ref(false);
const uploadingItemId = ref(0);
const completedCount = computed(
() => detail.value?.items.filter((item) => item.files.length > 0).length || 0,
);
function goBack() {
uni.navigateBack();
}
async function fetchDetail(orderId: number) {
loading.value = true;
try {
detail.value = await appApi.getSupplementDetail(orderId);
} catch (error) {
showErrorToast(error, "补资料任务加载失败");
} finally {
loading.value = false;
}
}
function previewFiles(files: Array<{ file_url: string }>, current: string) {
if (!files.length) return;
uni.previewImage({
urls: files.map((item) => item.file_url),
current,
});
}
async function chooseAndUpload(uploadItemId: number) {
try {
const result = await uni.chooseImage({
count: 1,
sizeType: ["compressed"],
sourceType: ["album", "camera"],
});
const filePath = result.tempFilePaths?.[0];
if (!filePath) {
return;
}
uploadingItemId.value = uploadItemId;
const file = await appApi.uploadSupplementFile({
uploadItemId,
filePath,
});
const item = detail.value?.items.find((entry) => entry.upload_item_id === uploadItemId);
if (item) {
item.files.push(file);
item.status = "uploaded";
}
showInfoToast("补资料上传成功");
} catch (error) {
showErrorToast(error, "补资料上传失败");
} finally {
uploadingItemId.value = 0;
}
}
async function removeFile(uploadItemId: number, fileId: string) {
try {
await appApi.deleteSupplementFile(fileId);
const item = detail.value?.items.find((entry) => entry.upload_item_id === uploadItemId);
if (item) {
item.files = item.files.filter((file) => file.file_id !== fileId);
item.status = item.files.length > 0 ? "uploaded" : "pending";
}
showInfoToast("已删除上传文件");
} catch (error) {
showErrorToast(error, "删除文件失败");
}
}
async function submitSupplement() {
if (!detail.value) return;
const missingItem = detail.value.items.find((item) => item.is_required && item.files.length === 0);
if (missingItem) {
showInfoToast(`请先上传${missingItem.item_name}`);
return;
}
submitting.value = true;
try {
await withLoading("正在提交补资料", async () => {
await appApi.submitSupplement(detail.value!.order_id);
});
showInfoToast("补资料已提交");
uni.redirectTo({
url: `/pages/order/detail?id=${detail.value.order_id}`,
});
} catch (error) {
showErrorToast(error, "补资料提交失败");
} finally {
submitting.value = false;
}
}
onLoad(async (options) => {
const orderId = Number(options?.order_id || 0);
if (!orderId) {
showInfoToast("订单信息缺失");
return;
}
await fetchDetail(orderId);
});
</script>
<template>
<view class="app-page app-page--tight">
<view v-if="detail" class="section-card section-card--soft">
<view class="tag tag--warning">补资料任务</view>
<view class="page-title" style="margin-top: 20rpx; font-size: var(--font-size-2xl); color: var(--color-heading);">
请补充鉴定资料
</view>
<view class="section__desc">{{ detail.reason }}</view>
<view class="certificate-header__meta">
<text class="certificate-meta-chip">订单号 {{ detail.order_no }}</text>
<text class="certificate-meta-chip">截止 {{ detail.deadline || "尽快补充" }}</text>
</view>
</view>
<view v-if="detail" class="section section-card section-card--soft">
<view class="section__title">补充任务说明</view>
<view class="section__desc">建议先把必传资料传完再视情况补充辅助资料提交后订单会重新进入鉴定流程系统会继续同步新进展</view>
<view class="chip-list" style="margin-top: 18rpx;">
<text class="choice-chip choice-chip--selected">{{ completedCount }} 项已完成</text>
<text class="choice-chip">{{ detail.items.length }} 项资料任务</text>
<text class="choice-chip">提交后重新进入鉴定</text>
</view>
</view>
<view v-if="detail" class="section progress-panel">
<view class="progress-panel__top">
<view class="section__title">当前完成进度</view>
<view class="progress-panel__value">{{ completedCount }} / {{ detail.items.length }}</view>
</view>
<view class="progress-panel__hint">请优先完成所有必传资料上传提交后订单会重新进入鉴定流程</view>
<view class="progress-panel__bar">
<view
class="progress-panel__fill"
:style="{ width: `${detail.items.length ? Math.max(12, (completedCount / detail.items.length) * 100) : 0}%` }"
></view>
</view>
</view>
<view v-if="loading" class="notice-card">
<view class="notice-card__title">正在加载补资料任务</view>
<view class="notice-card__desc">请稍候我们正在读取鉴定师给您的补充要求</view>
</view>
<view v-if="!loading && !detail" class="notice-card">
<view class="notice-card__title">暂无待补资料任务</view>
<view class="notice-card__desc">当前订单没有可补交的资料项您可以返回订单页稍后查看</view>
</view>
<view v-if="detail" class="section">
<view
v-for="item in detail.items"
:key="item.upload_item_id"
class="task-card section-card"
>
<view class="task-card__row">
<view class="task-card__title">{{ item.item_name }}</view>
<text :class="item.files.length ? 'tag tag--success' : item.is_required ? 'tag tag--warning' : 'tag tag--neutral'">
{{ item.files.length ? "已上传" : item.is_required ? "待上传" : "选传" }}
</text>
</view>
<view class="task-card__desc">{{ item.guide_text }}</view>
<view class="task-card__row" style="margin-top: 12rpx">
<text class="info-list__label">已上传</text>
<text class="info-list__value">{{ item.files.length }} </text>
</view>
<view v-if="item.files.length" class="task-files">
<view v-for="file in item.files" :key="file.file_id" class="task-file">
<image
class="task-file__img"
:src="file.thumbnail_url"
mode="aspectFill"
@click="previewFiles(item.files, file.file_url)"
/>
<view class="task-file__remove" @click="removeFile(item.upload_item_id, file.file_id)">删除</view>
</view>
</view>
<view class="task-card__row" style="margin-top: 20rpx">
<text class="info-list__label">{{ item.is_required ? "必传资料" : "辅助资料" }}</text>
<text class="btn btn--ghost" @click="chooseAndUpload(item.upload_item_id)">
{{ uploadingItemId === item.upload_item_id ? "上传中..." : item.files.length ? "继续补充" : "上传资料" }}
</text>
</view>
</view>
</view>
<view v-if="detail" class="section section-note">
<view class="support-banner__title">提交后会发生什么</view>
<view class="support-banner__desc">系统会将您的补充资料同步给鉴定师订单重新进入鉴定流程若资料仍不充分客服会继续通过消息中心通知您</view>
</view>
<view v-if="detail" class="fixed-action-bar">
<view class="btn btn--secondary" @click="goBack">稍后再传</view>
<view :class="['btn', 'btn--primary', submitting ? 'btn--disabled' : '']" @click="submitSupplement">
{{ submitting ? "提交中..." : "提交补资料" }}
</view>
</view>
</view>
</template>