scanner in CustomerSelector.vue

This commit is contained in:
Timi
2026-09-09 17:49:37 +08:00
parent 809bfa8520
commit 8e77b114f9
17 changed files with 804 additions and 410 deletions
+380 -30
View File
@@ -1,7 +1,43 @@
<template>
<div v-can="permissionCodes.orderFulfillment.read" class="fulfillment-list">
<div class="filters">
<t-input
class="customer"
v-model.trim="customerSearchKeyword"
borderless
placeholder="输入客户编码或姓名"
@enter="applyCustomerSearch"
>
<template #suffix-icon>
<div class="actions">
<t-button
v-if="customerId"
shape="circle"
theme="danger"
variant="text"
size="extra-small"
aria-label="清除客户选择"
@click.stop="clearCustomer"
>
<t-icon name="close" size="1rem" />
</t-button>
<t-button
size="extra-small"
theme="primary"
variant="outline"
content="选择客户"
@click.stop="customerSelectorVisible = true"
/>
</div>
</template>
</t-input>
<t-dropdown-menu class="menus">
<t-dropdown-item
v-model="dateFilterType"
:label="dateFilterLabel"
:options="dateOptions"
@change="selectDateFilter"
/>
<t-dropdown-item
v-model="status"
label="履约状态"
@@ -11,38 +47,86 @@
</t-dropdown-menu>
</div>
<t-pull-down-refresh
class="list-wrap"
v-model="refreshing"
:refresh-timeout="30000"
@refresh="refresh"
@scrolltolower="loadMore"
>
<div v-if="list.length" class="items">
<order-fulfillment-list-cell
v-for="(item, index) in list"
:key="item.id"
class="item"
:fulfillment="item"
v-entrance-transition="index * 30"
@detail="toDetail(item)"
/>
<div class="list-wrap">
<t-cell-group v-if="isCustomDate" class="date-summary" theme="card">
<t-cell
title="开始时间"
:note="dateRange[0] || '-'"
arrow
@click="openStartTime"
/>
<t-cell
title="结束时间"
:note="dateRange[1] || '-'"
arrow
@click="openEndTime"
/>
</t-cell-group>
<div v-if="list.length" class="items">
<order-fulfillment-list-cell
v-for="(item, index) in list"
:key="item.id"
class="item"
:fulfillment="item"
v-entrance-transition="index * 30"
@detail="toDetail(item)"
/>
</div>
<div v-else class="center-tips" v-text="loading ? '正在加载履约单' : '暂无履约单'" />
<div class="center-tips" v-text="loading ? '正在加载' : noMore ? '已经到底了' : '上拉加载更多'" />
</div>
<div v-else class="center-tips" v-text="loading ? '正在加载履约单' : '暂无履约单'" />
<div class="center-tips" v-text="loading ? '正在加载' : noMore ? '已经到底了' : '上拉加载更多'" />
</t-pull-down-refresh>
</div>
<nav-bar-action-menu v-if="canCreate" :actions="actions" />
<customer-selector
v-model="customerId"
v-model:visible="customerSelectorVisible"
@change="handleCustomerChange"
/>
<t-popup v-model="startVisible" placement="bottom">
<t-date-time-picker
v-if="startVisible"
v-model="startDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="开始时间"
:default-value="dateRange[0]"
@cancel="cancelStartTime"
@confirm="confirmStartTime"
/>
</t-popup>
<t-popup v-model="endVisible" placement="bottom">
<t-date-time-picker
v-if="endVisible"
v-model="endDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="结束时间"
:default-value="dateRange[1]"
@cancel="cancelEndTime"
@confirm="confirmEndTime"
/>
</t-popup>
</template>
<script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI";
import FulfillmentAPI from "@/api/FulfillmentAPI";
import CustomerSelector from "@/components/CustomerSelector.vue";
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import OrderFulfillmentListCell from "@/pages/fulfillment/components/OrderFulfillmentListCell.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoFulfillmentStatus, OrderFulfillment } from "@/types/Fulfillment";
import type { NavBarAction } from "@/types/NavBarAction";
import type { Customer } from "@/types/Customer";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
@@ -70,6 +154,27 @@ const actions = computed<NavBarAction[]>(() => [
action: toPackageOrderList
}
]);
type DateFilterType = "today" | "yesterday" | "lastWeek" | "thisMonth" | "lastMonth" | "all" | "custom";
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
{ label: "今天", value: "today" },
{ label: "昨天", value: "yesterday" },
{ label: "上周", value: "lastWeek" },
{ label: "本月", value: "thisMonth" },
{ label: "上个月", value: "lastMonth" },
{ label: "全部", value: "all" },
{ label: "自定义范围", value: "custom" }
];
const customerId = ref(getQueryText(route.query.customerId));
const selectedCustomer = ref<Customer>();
const customerSelectorVisible = ref(false);
const customerSearchKeyword = ref("");
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
const dateRange = ref<[string, string]>(parseDateRange(dateFilterType.value));
const startDraft = ref("");
const endDraft = ref("");
const startVisible = ref(false);
const endVisible = ref(false);
const status = ref<"ALL" | GaoFulfillmentStatus>(getInitialStatus(route.query.status));
const list = ref<OrderFulfillment[]>([]);
const pageIndex = ref(0);
@@ -87,6 +192,8 @@ const statusOptions = [
{ label: "已撤销", value: "CANCELLED" },
{ label: "已冲正", value: "REVOKED" }
];
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "时间范围");
const isCustomDate = computed(() => dateFilterType.value === "custom");
function getQueryText(value: unknown): string {
return Array.isArray(value) ? "" : typeof value === "string" ? value : "";
@@ -100,6 +207,18 @@ function getInitialStatus(value: unknown): "ALL" | GaoFulfillmentStatus {
return "ALL";
}
function parseDateFilterType(value: unknown): DateFilterType {
const dateFilter = getQueryText(value);
return dateOptions.some(item => item.value === dateFilter) ? dateFilter as DateFilterType : "today";
}
function parseDateRange(type: DateFilterType): [string, string] {
if (type !== "custom") {
return buildPresetRange(type);
}
return [getQueryText(route.query.beginAt), getQueryText(route.query.endAt)];
}
async function fetchList(reset: boolean): Promise<void> {
if (loading.value || (!reset && noMore.value)) {
return;
@@ -112,10 +231,14 @@ async function fetchList(reset: boolean): Promise<void> {
}
loading.value = true;
try {
const [beginAt, endAt] = buildUnixRange();
const result = await FulfillmentAPI.list({
index: pageIndex.value,
size: pageSize,
salesOrderId: getQueryText(route.query.salesOrderId) || undefined,
customerId: customerId.value || undefined,
beginAt,
endAt,
status: status.value === "ALL" ? undefined : status.value
});
const rows = result.list || [];
@@ -128,6 +251,14 @@ async function fetchList(reset: boolean): Promise<void> {
}
}
async function reloadList(): Promise<void> {
try {
await fetchList(true);
} catch (error) {
Message.error(getErrorMessage(error, "履约单加载失败"));
}
}
async function refresh(): Promise<void> {
try {
await fetchList(true);
@@ -149,10 +280,198 @@ async function loadMore(): Promise<void> {
async function selectStatus(value: DropdownValue): Promise<void> {
if (!Array.isArray(value)) {
status.value = String(value) as "ALL" | GaoFulfillmentStatus;
await fetchList(true);
await reloadList();
}
}
async function handleCustomerChange(value: string | string[]): Promise<void> {
customerId.value = Array.isArray(value) ? String(value[0] || "") : value;
try {
selectedCustomer.value = customerId.value ? await CustomerAPI.detail(customerId.value) : undefined;
customerSearchKeyword.value = getCustomerSearchKeyword(selectedCustomer.value);
await fetchList(true);
} catch (error) {
Message.error(getErrorMessage(error, "客户履约单加载失败"));
}
}
async function applyCustomerSearch(): Promise<void> {
const keyword = customerSearchKeyword.value.trim();
if (!keyword) {
await clearCustomer();
return;
}
if (keyword === getCustomerSearchKeyword(selectedCustomer.value)) {
return;
}
try {
const customer = await CustomerAPI.findByCode(keyword) || await CustomerAPI.findByName(keyword);
if (!customer?.id) {
Message.warning("未找到客户");
return;
}
customerId.value = customer.id;
selectedCustomer.value = customer;
customerSearchKeyword.value = getCustomerSearchKeyword(customer);
await fetchList(true);
} catch (error) {
Message.error(getErrorMessage(error, "客户信息加载失败"));
}
}
function getCustomerSearchKeyword(customer?: Customer): string {
return customer?.name?.trim() || customer?.code?.trim() || "";
}
async function clearCustomer(): Promise<void> {
customerId.value = "";
selectedCustomer.value = undefined;
customerSearchKeyword.value = "";
await reloadList();
}
async function selectDateFilter(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value);
await replaceFilterQuery();
await reloadList();
} else {
await replaceFilterQuery();
}
}
function openStartTime(): void {
startDraft.value = dateRange.value[0];
startVisible.value = true;
}
function openEndTime(): void {
endDraft.value = dateRange.value[1];
endVisible.value = true;
}
function cancelStartTime(): void {
startVisible.value = false;
startDraft.value = dateRange.value[0];
}
function cancelEndTime(): void {
endVisible.value = false;
endDraft.value = dateRange.value[1];
}
async function confirmStartTime(value?: string | number): Promise<void> {
startVisible.value = false;
dateRange.value = [String(value || startDraft.value), dateRange.value[1]];
await confirmCustomRange();
}
async function confirmEndTime(value?: string | number): Promise<void> {
endVisible.value = false;
dateRange.value = [dateRange.value[0], String(value || endDraft.value)];
await confirmCustomRange();
}
async function confirmCustomRange(): Promise<void> {
if (dateRange.value[0] && dateRange.value[1] && dateRange.value[1] < dateRange.value[0]) {
dateRange.value = [dateRange.value[1], dateRange.value[0]];
}
dateFilterType.value = "custom";
await replaceFilterQuery();
await reloadList();
}
function buildFilterQuery(transitionDepth = getQueryText(route.query.transitionDepth)): Record<string, string> {
const query = {
salesOrderId: getQueryText(route.query.salesOrderId),
customerId: customerId.value,
dateType: dateFilterType.value,
beginAt: dateRange.value[0] || "",
endAt: dateRange.value[1] || "",
status: status.value === "ALL" ? "" : status.value
};
if (isValidTransitionDepth(transitionDepth)) {
return { ...query, transitionDepth };
}
return query;
}
async function replaceFilterQuery(): Promise<void> {
await router.replace({
name: "OrderFulfillmentList",
query: buildFilterQuery()
});
}
function isValidTransitionDepth(value: string): boolean {
const depth = Number(value);
return value !== "" && Number.isInteger(depth) && 0 <= depth && depth <= 20;
}
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
if (type === "lastWeek") {
const current = new Date();
current.setHours(0, 0, 0, 0);
const day = current.getDay() || 7;
const thisWeekMonday = new Date(current);
thisWeekMonday.setDate(current.getDate() - day + 1);
const lastWeekMonday = new Date(thisWeekMonday);
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
const lastWeekSunday = new Date(thisWeekMonday);
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
}
if (type === "thisMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
}
if (type === "lastMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
const end = new Date(current.getFullYear(), current.getMonth(), 0);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth" | "lastMonth">, number> = {
today: 0,
yesterday: 1
};
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetMap[type]);
const value = `${Time.formatDate(date)} 00:00`;
return [value, `${Time.formatDate(date)} 23:59`];
}
function buildUnixRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") {
return [0, Time.toTimestamp(new Date())];
}
return [
Time.toTimestamp(parseDateTime(dateRange.value[0], false)),
Time.toTimestamp(parseDateTime(dateRange.value[1], true))
];
}
function parseDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
async function toDetail(item: OrderFulfillment): Promise<void> {
if (item.id) {
await router.push({
@@ -194,6 +513,10 @@ onMounted(async () => {
navBarStore.clearRight();
navBarStore.setCustomRightVisible(canCreate.value);
try {
if (customerId.value) {
selectedCustomer.value = await CustomerAPI.detail(customerId.value);
customerSearchKeyword.value = getCustomerSearchKeyword(selectedCustomer.value);
}
await fetchList(true);
} catch (error) {
Message.error(getErrorMessage(error, "履约单加载失败"));
@@ -215,38 +538,65 @@ onUnmounted(() => navBarStore.clearRight());
display: flex;
padding: .75rem 1rem;
position: sticky;
background: var(--td-bg-color-page);
background: #F5F5F8;
box-shadow: var(--td-shadow-1);
flex-direction: column;
.customer {
--td-input-vertical-padding: .25rem 1rem;
width: 100%;
border: 1px solid var(--td-component-border);
background: var(--td-bg-color-container);
padding-right: .3rem;
border-radius: 1rem;
.actions {
--td-button-border-radius: .75rem;
gap: .25rem;
display: flex;
align-items: center;
}
}
.menus {
--td-spacer-1: 6px;
width: 100%;
border: 1px solid rgba(148, 163, 184, .18);
z-index: -1;
overflow: hidden;
background: var(--td-bg-color-container);
margin-top: -1rem;
background: #fff;
border-top: none;
border-radius: 1rem;
&:after {
display: none;
}
:deep(.t-dropdown-menu__item) {
min-width: 0;
}
:deep(.t-dropdown-menu__title) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
padding-top: .8rem;
border-radius: 0 0 1rem 1rem;
}
}
.list-wrap {
gap: 1rem;
display: flex;
padding-top: 1rem;
flex-direction: column;
.date-summary {
:deep(.t-cell__title) {
flex: 0 0 4.5rem;
}
:deep(.t-cell__note) {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.items {
gap: 1rem;