<template>
|
<cl-page background-color="#f7f7f7">
|
<view class="appointment-page">
|
<cl-sticky>
|
<view class="tab-bar">
|
<view class="tab-left">
|
<view
|
v-for="tab in tabs"
|
:key="tab.value"
|
class="tab-item"
|
:class="{ 'is-active': activeTab === tab.value }"
|
@tap="switchTab(tab.value)"
|
>
|
<text>{{ tab.label }}</text>
|
<view v-if="activeTab === tab.value" class="tab-line"></view>
|
</view>
|
</view>
|
<view class="add-btn" @tap="openAdd">新增预约</view>
|
</view>
|
</cl-sticky>
|
|
<scroll-view
|
class="record-scroll"
|
scroll-y
|
:lower-threshold="80"
|
@scrolltolower="loadMore"
|
:refresher-enabled="true"
|
:refresher-triggered="refreshing"
|
@refresherrefresh="onRefresh"
|
>
|
<view class="record-list">
|
<view
|
v-for="item in list"
|
:key="item.id"
|
class="record-card"
|
:class="{ 'is-clickable': activeTab === 'booked' }"
|
@tap="openAppointmentQrcode(item)"
|
>
|
<view class="card-header">
|
<text class="order-no">单号:{{ item.appointmentNo }}</text>
|
<text class="status" :class="`status-${item.status}`">
|
{{ item.status }}
|
</text>
|
</view>
|
<view class="card-body">
|
<view class="info-row">
|
<text class="info-label">预约时间</text>
|
<text class="info-value">
|
{{ item.appointmentDate || "-" }}
|
{{ dict.getLabel("appointmentslot", item.appointmentSlot) || item.appointmentSlot || "" }}
|
</text>
|
</view>
|
<view class="info-row">
|
<text class="info-label">预计重量</text>
|
<text class="info-value">{{ item.expectedWeight ?? 0 }} kg</text>
|
</view>
|
<view class="info-row">
|
<text class="info-label">联系人</text>
|
<text class="info-value">
|
{{ item.contactName || "-" }}
|
{{ item.contactPhone || "" }}
|
</text>
|
</view>
|
<view class="info-row">
|
<text class="info-label">上门地址</text>
|
<text class="info-value addr">{{ item.address || "-" }}</text>
|
</view>
|
</view>
|
<view v-if="activeTab === 'booked'" class="card-toggle">
|
<text>点击出示预约码</text>
|
</view>
|
</view>
|
</view>
|
|
<cl-loadmore
|
v-if="list.length > 0"
|
:loading="loading"
|
:finish="finished"
|
:divider="false"
|
/>
|
|
<cl-empty
|
v-if="!loading && list.length === 0"
|
:fixed="false"
|
text="暂无预约记录"
|
/>
|
</scroll-view>
|
</view>
|
|
<!-- 新增预约 -->
|
<cl-popup
|
v-model="showAdd"
|
direction="bottom"
|
title="新增预约"
|
:padding="0"
|
:border-radius="[32, 32, 0, 0]"
|
show-close-btn
|
>
|
<scroll-view scroll-y class="add-scroll">
|
<view class="form-card">
|
<view class="form-item" @tap="openAddressPicker">
|
<text class="form-label">上门地址</text>
|
<view class="form-picker">
|
<view v-if="form.address" class="addr-preview">
|
<text class="addr-line">
|
{{ form.contactName }} {{ form.contactPhone }}
|
</text>
|
<text class="addr-line muted">{{ form.address }}</text>
|
</view>
|
<text v-else class="placeholder">从地址库选择,也可下方自行填写</text>
|
<cl-icon name="arrow-right" color="#ccc" />
|
</view>
|
</view>
|
|
<view class="form-item">
|
<text class="form-label">联系人</text>
|
<input
|
class="form-input"
|
v-model="form.contactName"
|
placeholder="请输入联系人"
|
placeholder-class="placeholder"
|
/>
|
</view>
|
|
<view class="form-item">
|
<text class="form-label">联系电话</text>
|
<input
|
class="form-input"
|
type="number"
|
maxlength="11"
|
v-model="form.contactPhone"
|
placeholder="请输入手机号"
|
placeholder-class="placeholder"
|
/>
|
</view>
|
|
<view class="form-item">
|
<text class="form-label">详细地址</text>
|
<input
|
class="form-input"
|
v-model="form.address"
|
placeholder="请输入上门地址"
|
placeholder-class="placeholder"
|
/>
|
</view>
|
|
<view class="form-item">
|
<text class="form-label">预约日期</text>
|
<picker mode="date" :start="minDate" :value="form.appointmentDate" @change="onDateChange">
|
<view class="form-picker">
|
<text v-if="form.appointmentDate">{{ form.appointmentDate }}</text>
|
<text v-else class="placeholder">请选择预约日期</text>
|
<cl-icon name="arrow-right" color="#ccc" />
|
</view>
|
</picker>
|
</view>
|
|
<view class="form-item">
|
<text class="form-label">时间段</text>
|
<picker
|
mode="selector"
|
:range="slotOptions"
|
:value="slotIndex"
|
@change="onSlotChange"
|
>
|
<view class="form-picker">
|
<text v-if="slotLabel">{{ slotLabel }}</text>
|
<text v-else class="placeholder">请选择时间段</text>
|
<cl-icon name="arrow-right" color="#ccc" />
|
</view>
|
</picker>
|
</view>
|
|
<view class="form-item">
|
<text class="form-label">预计重量</text>
|
<input
|
class="form-input"
|
type="digit"
|
v-model="form.expectedWeight"
|
placeholder="请输入预计重量(kg)"
|
placeholder-class="placeholder"
|
/>
|
</view>
|
</view>
|
</scroll-view>
|
|
<view class="add-footer">
|
<button class="submit-btn" :disabled="submitting" @tap="submitAppointment">
|
{{ submitting ? "提交中..." : "确认预约" }}
|
</button>
|
</view>
|
</cl-popup>
|
|
<!-- 选择收件地址 -->
|
<cl-popup
|
v-model="showAddress"
|
direction="bottom"
|
title="选择地址"
|
:padding="0"
|
:border-radius="[32, 32, 0, 0]"
|
:z-index="700"
|
show-close-btn
|
>
|
<scroll-view scroll-y class="addr-scroll">
|
<view class="addr-list">
|
<view
|
v-for="item in addressList"
|
:key="item.id"
|
class="addr-item"
|
:class="{ 'is-active': selectedAddressId === item.id }"
|
@tap="selectAddress(item)"
|
>
|
<address-item :item="item" background-color="#f7f7f7">
|
<template #icon>
|
<text class="cl-icon-check-border"></text>
|
</template>
|
</address-item>
|
</view>
|
</view>
|
<cl-empty
|
v-if="!addressLoading && addressList.length === 0"
|
icon="address"
|
:fixed="false"
|
text="暂无地址"
|
/>
|
</scroll-view>
|
<view class="add-footer">
|
<cl-button custom type="primary" @tap="goAddAddress">添加收货地址</cl-button>
|
</view>
|
</cl-popup>
|
|
<!-- 预约码 -->
|
<cl-popup
|
v-model="showQrcode"
|
direction="center"
|
:close-on-click-overlay="true"
|
background-color="transparent"
|
@close="showQrcode = false"
|
>
|
<view class="qrcode-popup" @tap.stop>
|
<view class="qrcode-card">
|
<text class="qrcode-title">预约码</text>
|
<text class="qrcode-desc">向分拣员出示此码,关联本次预约</text>
|
<view class="qrcode-image-wrap">
|
<image
|
v-if="qrcodeUrl"
|
class="qrcode-image"
|
:src="qrcodeUrl"
|
mode="aspectFit"
|
/>
|
<text v-else class="placeholder">生成中...</text>
|
</view>
|
<text class="qrcode-code">预约单号:{{ currentAppointmentNo }}</text>
|
<view class="qrcode-close" @tap="showQrcode = false">
|
<cl-icon name="close" :size="36" color="#999" />
|
</view>
|
</view>
|
</view>
|
</cl-popup>
|
</cl-page>
|
</template>
|
|
<script lang="ts" setup>
|
import { computed, reactive, ref, watch } from "vue";
|
import { onReady, onShow, onPullDownRefresh } from "@dcloudio/uni-app";
|
import { useCool, useStore } from "/@/cool";
|
import { useAddress } from "/@/hooks";
|
import AddressItem from "/@/components/address/item.vue";
|
import { generateQrcode } from "/@/cool/utils/qrcode";
|
|
const { service, router } = useCool();
|
const { user, dict } = useStore();
|
const address = useAddress();
|
|
const tabs = [
|
{ label: "已预约", value: "booked" },
|
{ label: "已完成", value: "done" },
|
] as const;
|
|
type TabValue = (typeof tabs)[number]["value"];
|
|
const activeTab = ref<TabValue>("booked");
|
const list = ref<Eps.RecycleAppointmentEntity[]>([]);
|
const loading = ref(false);
|
const refreshing = ref(false);
|
const finished = ref(false);
|
const page = ref(1);
|
const size = 10;
|
|
const showAdd = ref(false);
|
const submitting = ref(false);
|
const showAddress = ref(false);
|
const addressLoading = ref(false);
|
const addressList = ref<Eps.UserAddressEntity[]>([]);
|
const selectedAddressId = ref<number>();
|
const showQrcode = ref(false);
|
const qrcodeUrl = ref("");
|
const currentAppointmentNo = ref("");
|
|
const form = reactive({
|
contactName: "",
|
contactPhone: "",
|
address: "",
|
appointmentDate: "",
|
appointmentSlot: "",
|
expectedWeight: "",
|
});
|
|
const slotList = computed(() => dict.get("appointmentslot"));
|
const slotOptions = computed(() => slotList.value.map((e) => e.name || e.label));
|
const slotLabel = computed(() => {
|
return dict.getLabel("appointmentslot", form.appointmentSlot) || form.appointmentSlot;
|
});
|
|
const minDate = computed(() => {
|
const d = new Date();
|
const pad = (n: number) => String(n).padStart(2, "0");
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
});
|
|
const slotIndex = computed(() => {
|
const idx = slotList.value.findIndex(
|
(e) => e.value == form.appointmentSlot || e.name == form.appointmentSlot,
|
);
|
return idx >= 0 ? idx : 0;
|
});
|
|
function defaultSlot() {
|
const first = slotList.value[0];
|
return first?.value ?? first?.name ?? "";
|
}
|
|
let reqSeq = 0;
|
|
function getUnionid() {
|
return user.info?.phone || "18918594752";
|
}
|
|
function formatAddress(item: Eps.UserAddressEntity) {
|
return `${item.province || ""}${item.city || ""}${item.district || ""}${item.address || ""}`;
|
}
|
|
function fillFormByAddress(item?: Eps.UserAddressEntity) {
|
if (!item) return;
|
form.contactName = item.contact || "";
|
form.contactPhone = item.phone || "";
|
form.address = formatAddress(item);
|
selectedAddressId.value = item.id;
|
}
|
|
function buildQuery() {
|
return {
|
page: page.value,
|
size,
|
campusUserId: getUnionid(),
|
unionid: getUnionid(),
|
tab: activeTab.value,
|
status: activeTab.value === "done" ? "已完成" : undefined,
|
};
|
}
|
|
async function loadList(reset = false) {
|
if (loading.value && !reset) return;
|
if (!reset && finished.value) return;
|
|
if (reset) {
|
page.value = 1;
|
finished.value = false;
|
list.value = [];
|
}
|
|
const current = ++reqSeq;
|
loading.value = true;
|
|
try {
|
const res = await service.shop.recycleappointment.page(buildQuery());
|
if (current !== reqSeq) return;
|
|
console.log("【预约记录返回】", res);
|
|
const newList = res?.list || [];
|
list.value = page.value === 1 ? newList : [...list.value, ...newList];
|
|
const total = res?.pagination?.total;
|
finished.value =
|
newList.length < size || (typeof total === "number" && list.value.length >= total);
|
page.value++;
|
} catch (e) {
|
if (current !== reqSeq) return;
|
console.error("【预约记录加载失败】", e);
|
} finally {
|
if (current === reqSeq) {
|
loading.value = false;
|
refreshing.value = false;
|
}
|
}
|
}
|
|
function switchTab(val: TabValue) {
|
if (activeTab.value === val) return;
|
activeTab.value = val;
|
loadList(true);
|
}
|
|
function loadMore() {
|
if (!loading.value && !finished.value) {
|
loadList(false);
|
}
|
}
|
|
function onRefresh() {
|
refreshing.value = true;
|
loadList(true);
|
}
|
|
async function openAppointmentQrcode(item: Eps.RecycleAppointmentEntity) {
|
if (activeTab.value !== "booked" || !item.appointmentNo) return;
|
|
currentAppointmentNo.value = item.appointmentNo;
|
qrcodeUrl.value = "";
|
showQrcode.value = true;
|
|
try {
|
qrcodeUrl.value = await generateQrcode(item.appointmentNo);
|
} catch (e) {
|
console.error("【预约码生成失败】", e);
|
uni.showToast({ title: "预约码生成失败", icon: "none" });
|
}
|
}
|
|
function resetForm() {
|
form.contactName = "";
|
form.contactPhone = "";
|
form.address = "";
|
form.appointmentDate = minDate.value;
|
form.appointmentSlot = defaultSlot();
|
form.expectedWeight = "";
|
selectedAddressId.value = undefined;
|
}
|
|
function openAdd() {
|
resetForm();
|
address.getDefault();
|
fillFormByAddress(address.info);
|
showAdd.value = true;
|
}
|
|
function onDateChange(e: any) {
|
form.appointmentDate = e.detail.value;
|
}
|
|
function onSlotChange(e: any) {
|
const item = slotList.value[Number(e.detail.value)];
|
form.appointmentSlot = item?.value ?? item?.name ?? "";
|
}
|
|
async function refreshAddressList() {
|
addressLoading.value = true;
|
try {
|
addressList.value = (await service.user.address.list()) || [];
|
} catch (e) {
|
console.error("【地址列表加载失败】", e);
|
addressList.value = [];
|
} finally {
|
addressLoading.value = false;
|
}
|
}
|
|
async function openAddressPicker() {
|
await refreshAddressList();
|
showAddress.value = true;
|
}
|
|
function selectAddress(item: Eps.UserAddressEntity) {
|
address.set(item);
|
fillFormByAddress(item);
|
showAddress.value = false;
|
}
|
|
function goAddAddress() {
|
showAddress.value = false;
|
router.push("/pages/user/address-edit");
|
}
|
|
function genAppointmentNo() {
|
const d = new Date();
|
const pad = (n: number) => String(n).padStart(2, "0");
|
const rand = String(Math.floor(Math.random() * 1000)).padStart(3, "0");
|
return `AP${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}${rand}`;
|
}
|
|
async function submitAppointment() {
|
if (!form.contactName) {
|
uni.showToast({ title: "请填写联系人", icon: "none" });
|
return;
|
}
|
if (!form.contactPhone || form.contactPhone.length !== 11) {
|
uni.showToast({ title: "请填写正确手机号", icon: "none" });
|
return;
|
}
|
if (!form.address) {
|
uni.showToast({ title: "请填写上门地址", icon: "none" });
|
return;
|
}
|
if (!form.appointmentDate) {
|
uni.showToast({ title: "请选择预约日期", icon: "none" });
|
return;
|
}
|
if (!form.appointmentSlot) {
|
uni.showToast({ title: "请选择时间段", icon: "none" });
|
return;
|
}
|
|
submitting.value = true;
|
try {
|
await service.shop.recycleappointment.add({
|
appointmentNo: genAppointmentNo(),
|
campusUserId: getUnionid(),
|
campusUserName: user.info?.nickName || "",
|
contactName: form.contactName,
|
contactPhone: form.contactPhone,
|
address: form.address,
|
appointmentDate: form.appointmentDate,
|
appointmentSlot: form.appointmentSlot,
|
expectedWeight: Number(form.expectedWeight) || 0,
|
status: "已预约",
|
});
|
|
uni.showToast({ title: "预约成功", icon: "success" });
|
showAdd.value = false;
|
activeTab.value = "booked";
|
loadList(true);
|
} catch (e) {
|
console.error("【预约提交失败】", e);
|
uni.showToast({ title: "预约失败,请重试", icon: "none" });
|
} finally {
|
submitting.value = false;
|
}
|
}
|
|
watch(
|
() => address.info,
|
(val) => {
|
if (showAdd.value && val && !form.contactName && !form.address) {
|
fillFormByAddress(val);
|
}
|
},
|
);
|
|
onReady(() => {
|
dict.refresh(["appointmentslot"]);
|
loadList(true);
|
address.getDefault();
|
});
|
|
onShow(() => {
|
refreshAddressList();
|
if (showAdd.value && address.info) {
|
fillFormByAddress(address.info);
|
}
|
});
|
|
onPullDownRefresh(() => {
|
loadList(true).finally(() => {
|
uni.stopPullDownRefresh();
|
});
|
});
|
</script>
|
|
<style lang="scss" scoped>
|
.appointment-page {
|
height: 100vh;
|
display: flex;
|
flex-direction: column;
|
|
.tab-bar {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
background: #fff;
|
padding: 0 24rpx 0 40rpx;
|
border-bottom: 1rpx solid #f0f0f0;
|
}
|
|
.tab-left {
|
display: flex;
|
}
|
|
.tab-item {
|
position: relative;
|
padding: 28rpx 30rpx;
|
font-size: 28rpx;
|
color: #666;
|
transition: color 0.2s;
|
|
&.is-active {
|
color: #2ecc71;
|
font-weight: 600;
|
}
|
|
.tab-line {
|
position: absolute;
|
bottom: 0;
|
left: 50%;
|
transform: translateX(-50%);
|
width: 40rpx;
|
height: 4rpx;
|
background: #2ecc71;
|
border-radius: 2rpx;
|
}
|
}
|
|
.add-btn {
|
padding: 10rpx 22rpx;
|
border-radius: 28rpx;
|
background: #2ecc71;
|
color: #fff;
|
font-size: 24rpx;
|
font-weight: 500;
|
}
|
|
.record-scroll {
|
flex: 1;
|
height: 0;
|
padding: 24rpx;
|
box-sizing: border-box;
|
}
|
|
.record-list {
|
display: flex;
|
flex-direction: column;
|
gap: 20rpx;
|
}
|
|
.record-card {
|
background: #fff;
|
border-radius: 20rpx;
|
padding: 28rpx;
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
|
&.is-clickable:active {
|
opacity: 0.92;
|
}
|
}
|
|
.card-toggle {
|
margin-top: 16rpx;
|
padding-top: 16rpx;
|
border-top: 1rpx dashed #f0f0f0;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
font-size: 22rpx;
|
color: #2ecc71;
|
}
|
|
.card-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
padding-bottom: 20rpx;
|
border-bottom: 1rpx solid #f5f5f5;
|
}
|
|
.order-no {
|
font-size: 26rpx;
|
color: #333;
|
font-weight: 500;
|
}
|
|
.status {
|
font-size: 22rpx;
|
padding: 6rpx 16rpx;
|
border-radius: 20rpx;
|
background: #f5f5f5;
|
color: #999;
|
|
&.status-待审核,
|
&.status-待接单 {
|
background: #fef5e7;
|
color: #e67e22;
|
}
|
&.status-已接单,
|
&.status-上门中 {
|
background: #eaf4fc;
|
color: #2980b9;
|
}
|
&.status-已完成 {
|
background: #e8f8f0;
|
color: #27ae60;
|
}
|
&.status-已取消 {
|
background: #fef0f0;
|
color: #e74c3c;
|
}
|
}
|
|
.card-body {
|
padding-top: 20rpx;
|
display: flex;
|
flex-direction: column;
|
gap: 14rpx;
|
}
|
|
.info-row {
|
display: flex;
|
justify-content: space-between;
|
align-items: flex-start;
|
gap: 24rpx;
|
}
|
|
.info-label {
|
font-size: 24rpx;
|
color: #999;
|
flex-shrink: 0;
|
}
|
|
.info-value {
|
font-size: 26rpx;
|
color: #333;
|
font-weight: 500;
|
text-align: right;
|
|
&.addr {
|
max-width: 420rpx;
|
line-height: 1.4;
|
}
|
}
|
}
|
|
.add-scroll {
|
height: 62vh;
|
padding: 0 32rpx 20rpx;
|
box-sizing: border-box;
|
}
|
|
.form-card {
|
background: #fff;
|
}
|
|
.form-item {
|
display: flex;
|
align-items: flex-start;
|
padding: 24rpx 0;
|
border-bottom: 1rpx solid #f5f5f5;
|
}
|
|
.form-label {
|
width: 160rpx;
|
font-size: 26rpx;
|
color: #666;
|
flex-shrink: 0;
|
line-height: 60rpx;
|
}
|
|
.form-input {
|
flex: 1;
|
height: 60rpx;
|
font-size: 26rpx;
|
color: #333;
|
}
|
|
.form-picker {
|
flex: 1;
|
min-height: 60rpx;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
gap: 12rpx;
|
font-size: 26rpx;
|
color: #333;
|
}
|
|
.addr-preview {
|
flex: 1;
|
display: flex;
|
flex-direction: column;
|
gap: 6rpx;
|
}
|
|
.addr-line {
|
font-size: 26rpx;
|
color: #333;
|
line-height: 1.4;
|
|
&.muted {
|
font-size: 22rpx;
|
color: #999;
|
}
|
}
|
|
.placeholder {
|
color: #ccc;
|
font-size: 26rpx;
|
}
|
|
.add-footer {
|
padding: 16rpx 32rpx calc(16rpx + env(safe-area-inset-bottom));
|
}
|
|
.submit-btn {
|
width: 100%;
|
height: 80rpx;
|
line-height: 80rpx;
|
background: linear-gradient(135deg, #2ecc71, #27ae60);
|
color: #fff;
|
font-size: 30rpx;
|
font-weight: 600;
|
border-radius: 40rpx;
|
border: none;
|
letter-spacing: 4rpx;
|
|
&[disabled] {
|
opacity: 0.6;
|
}
|
}
|
|
.addr-scroll {
|
height: 50vh;
|
}
|
|
.addr-list {
|
padding: 0 32rpx 24rpx;
|
}
|
|
.addr-item {
|
border: 2rpx solid #f7f7f7;
|
border-radius: 24rpx;
|
margin-bottom: 24rpx;
|
|
.cl-icon-check-border {
|
display: none;
|
color: #2ecc71;
|
font-size: 46rpx;
|
}
|
|
&.is-active {
|
border-color: #2ecc71;
|
|
.cl-icon-check-border {
|
display: block;
|
}
|
}
|
|
&:last-child {
|
margin-bottom: 0;
|
}
|
}
|
|
.qrcode-popup {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
width: 100%;
|
}
|
|
.qrcode-card {
|
position: relative;
|
width: 600rpx;
|
background: #fff;
|
border-radius: 28rpx;
|
padding: 50rpx 40rpx 40rpx;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
}
|
|
.qrcode-title {
|
font-size: 34rpx;
|
font-weight: 700;
|
color: #333;
|
margin-bottom: 10rpx;
|
}
|
|
.qrcode-desc {
|
font-size: 24rpx;
|
color: #999;
|
margin-bottom: 36rpx;
|
}
|
|
.qrcode-image-wrap {
|
width: 480rpx;
|
height: 480rpx;
|
border-radius: 16rpx;
|
overflow: hidden;
|
background: #f7f7f7;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-bottom: 28rpx;
|
}
|
|
.qrcode-image {
|
width: 100%;
|
height: 100%;
|
}
|
|
.qrcode-code {
|
font-size: 26rpx;
|
color: #666;
|
letter-spacing: 2rpx;
|
word-break: break-all;
|
text-align: center;
|
}
|
|
.qrcode-close {
|
position: absolute;
|
top: 20rpx;
|
right: 20rpx;
|
width: 60rpx;
|
height: 60rpx;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
border-radius: 50%;
|
background: #f5f5f5;
|
}
|
</style>
|