add package

This commit is contained in:
Timi
2026-09-01 01:14:54 +08:00
parent 100d4c4c44
commit 2d94abfb32
39 changed files with 4425 additions and 59 deletions
@@ -0,0 +1,94 @@
<template>
<div v-can="permissionCodes.customerPackage.cancel" class="package-operation-edit">
<t-cell-group v-if="customerPackage" title="待取消套餐" theme="card" v-entrance-transition>
<t-cell title="客户" :note="customerPackage.customer?.name || customerPackage.customerId || '未知客户'" />
<t-cell title="套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
<t-cell title="当前状态" :note="getCustomerPackageStatusText(customerPackage.status)" />
</t-cell-group>
<t-form class="form" label-align="left" v-entrance-transition="70">
<t-form-item label="取消原因" required-mark>
<t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入取消原因" />
</t-form-item>
</t-form>
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoCustomerPackage } from "@/types/Package";
import { getCustomerPackageStatusText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackageCancel" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const customerPackage = ref<GaoCustomerPackage>();
const reason = ref("");
const isLoading = ref(false);
const isSaving = ref(false);
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
customerPackage.value = await PackageAPI.customerPackageDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "套餐加载失败"));
} finally {
isLoading.value = false;
}
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
if (!customerPackage.value?.id || !reason.value.trim()) {
Message.warning("请输入取消原因");
return;
}
isSaving.value = true;
try {
await PackageAPI.cancel(customerPackage.value.id, reason.value.trim());
Message.success("客户套餐已取消");
await router.replace(`/management/customer-packages/${customerPackage.value.id}`);
} catch (error) {
Message.error(getErrorMessage(error, "套餐取消失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.customerPackage.cancel)) {
navBarStore.setRightText("确认取消", "var(--td-danger-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
}
</style>
@@ -0,0 +1,226 @@
<template>
<div v-can="permissionCodes.packageDelivery.create" class="package-operation-edit">
<t-cell-group v-if="customerPackage" title="发放前状态(以提交时后端校验为准)" theme="card" v-entrance-transition>
<t-cell title="客户套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
<t-cell title="收款状态" :note="getPaymentStatusText(customerPackage.paymentStatus)" />
</t-cell-group>
<section v-if="customerPackage" class="items" v-entrance-transition="70">
<div class="section-title"><strong>本次发放权益</strong><span>只能提交不超过可发数量的产品</span></div>
<div v-for="item in customerPackage.itemList || []" :key="item.id" class="item-card">
<div class="item-title"><strong v-text="`${item.productNameSnapshot}(${item.unitSnapshot})`" /><span v-text="`可发 ${getPackageQuantityText(item.availableQuantity)}`" /></div>
<t-input v-model.trim="quantityMap[item.id || '']" type="number" borderless placeholder="本次发放数量,不填表示不发放" />
</div>
</section>
<t-form v-if="customerPackage" class="form" label-align="left" label-width="6rem" v-entrance-transition="140">
<t-form-item label="发放时间" arrow @click="deliveredPickerVisible = true">
<span class="picker-text" v-text="deliveredAtText || '使用当前时间'" />
</t-form-item>
<t-form-item label="备注">
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="发放备注" />
</t-form-item>
</t-form>
<package-photo-field v-if="customerPackage" v-model="photoItems" required title="发放照片" />
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
<t-popup v-model="deliveredPickerVisible" placement="bottom">
<t-date-time-picker v-model="deliveredDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="发放时间" @cancel="deliveredPickerVisible = false" @confirm="confirmDeliveredAt" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoCustomerPackage, PackageAttachment } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { comparePackageQuantity, createPackageRequestNo, getPackageQuantityText, getPaymentStatusText, normalizePackageQuantity } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackageDelivery" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const customerPackage = ref<GaoCustomerPackage>();
const quantityMap = reactive<Record<string, string>>({});
const photoItems = ref<PhotoItem[]>([]);
const remark = ref("");
const deliveredAtText = ref("");
const deliveredDraft = ref("");
const deliveredPickerVisible = ref(false);
const isLoading = ref(false);
const isSaving = ref(false);
function confirmDeliveredAt(value: string | number): void {
deliveredAtText.value = String(value);
deliveredPickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
customerPackage.value = await PackageAPI.customerPackageDetail(id);
for (const item of customerPackage.value.itemList || []) {
if (item.id) {
quantityMap[item.id] = "";
}
}
} catch (error) {
Message.error(getErrorMessage(error, "套餐加载失败"));
} finally {
isLoading.value = false;
}
}
function validateItems(): Array<{ customerPackageItemId: string; quantity: string }> | undefined {
const result: Array<{ customerPackageItemId: string; quantity: string }> = [];
for (const item of customerPackage.value?.itemList || []) {
if (!item.id) {
continue;
}
const text = quantityMap[item.id] || "";
if (!text.trim()) {
continue;
}
const quantity = normalizePackageQuantity(text);
if (!quantity || quantity === "0") {
Message.warning(`${item.productNameSnapshot} 的数量必须是正数,最多三位小数`);
return undefined;
}
if (comparePackageQuantity(quantity, item.availableQuantity) > 0) {
Message.warning(`${item.productNameSnapshot} 的发放数量超过当前可发数量,请刷新后重新确认`);
return undefined;
}
result.push({ customerPackageItemId: item.id, quantity });
}
if (!result.length) {
Message.warning("请至少填写一项本次发放数量");
return undefined;
}
return result;
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
const customerPackageId = customerPackage.value?.id;
const itemList = validateItems();
if (!customerPackageId || !itemList) {
return;
}
if (customerPackage.value?.paymentStatus !== "PAID") {
Message.warning("套餐未付清,不能提交发放");
return;
}
if (!photoItems.value.some(item => item.tempFileId)) {
Message.warning("发放必须上传至少一张照片");
return;
}
isSaving.value = true;
try {
const result = await PackageAPI.createDelivery({
requestNo: createPackageRequestNo(),
customerPackageId,
deliveredAt: resolveTimestamp(deliveredAtText.value),
itemList,
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("发放单已提交,等待核销");
await PackageAPI.customerPackageDetail(customerPackageId);
if (result.id) {
await router.replace(`/management/package-deliveries/${result.id}`);
return;
}
await router.replace(`/management/customer-packages/${customerPackageId}`);
} catch (error) {
Message.error(getErrorMessage(error, "发放单提交失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.packageDelivery.create)) {
navBarStore.setRightText("提交发放", "var(--td-brand-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.items,
.package-photo-field {
padding: 0 1rem;
}
.section-title {
color: var(--td-text-color-secondary);
margin-bottom: .5rem;
font-size: .8125rem;
}
.section-title span {
margin-left: .5rem;
}
.item-card {
gap: .5rem;
padding: .75rem;
margin-bottom: .75rem;
display: grid;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: .75rem;
}
.item-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.item-title span {
color: var(--td-brand-color);
font-size: .75rem;
}
}
</style>
+278
View File
@@ -0,0 +1,278 @@
<template>
<div v-can="permissionCodes.customerPackage.read" class="customer-package-detail">
<template v-if="customerPackage">
<customer-profile-card class="customer" :customer="customerPackage.customer" v-entrance-transition />
<section class="hero" v-entrance-transition="40">
<div class="title-row">
<h2 v-text="customerPackage.packageNameSnapshot || '未命名套餐'" />
<t-tag :content="getCustomerPackageStatusText(customerPackage.status)" variant="light-outline" :theme="getStatusTheme(customerPackage.status)" />
</div>
<div class="meta" v-text="customerPackage.purchasedAt ? `购买于 ${Time.toDateTime(customerPackage.purchasedAt)}` : '购买时间未知'" />
</section>
<t-cell-group title="金额与状态" theme="card" v-entrance-transition="70">
<t-cell title="标准价快照" :note="formatPackageAmount(customerPackage.standardAmountSnapshot)" />
<t-cell title="成交价" :note="formatPackageAmount(customerPackage.saleAmount)" />
<t-cell title="已核销收款" :note="formatPackageAmount(customerPackage.paidAmount)" />
<t-cell title="待核销收款" :note="formatPackageAmount(customerPackage.pendingPaidAmount)" />
<t-cell title="有效退款" :note="formatPackageAmount(customerPackage.refundedAmount)" />
<t-cell title="收款状态" :note="getPaymentStatusText(customerPackage.paymentStatus)" />
<t-cell title="退款状态" :note="getRefundStatusText(customerPackage.refundStatus)" />
</t-cell-group>
<t-cell-group title="产品权益" theme="card" v-entrance-transition="140">
<t-cell v-for="item in customerPackage.itemList || []" :key="item.id" :title="item.productNameSnapshot" :description="`${getPackageQuantityText(item.deliveredQuantity)} ${item.unitSnapshot} 已核销`">
<template #note><div class="quantity" v-text="`可发 ${getPackageQuantityText(item.availableQuantity)}|待核销 ${getPackageQuantityText(item.pendingQuantity)}`" /></template>
</t-cell>
<div v-if="!customerPackage.itemList?.length" class="empty" v-text="'暂无产品权益'" />
</t-cell-group>
<t-cell-group v-if="customerPackage.purchaseEventRecord" title="购买记录照片" theme="card" v-entrance-transition="210">
<t-cell
v-if="canReadEventRecord && customerPackage.purchaseEventRecord.id"
title="购买登记记录"
note="查看关联的事件登记记录"
arrow
hover
@click="toPurchaseEventRecord"
/>
<photo-scroller :model-value="purchasePhotoURLList" :photo-items="purchasePhotoItems" title="购买照片" :show-add="false" :show-remove="false" :show-viewer-actions="false" />
</t-cell-group>
<section class="actions" v-entrance-transition="240">
<t-button v-if="canReceivePayment" theme="primary" variant="outline" @click="toPayment">登记收款</t-button>
<t-button v-if="canCreateDelivery" theme="primary" variant="outline" @click="toDelivery">创建发放单</t-button>
<t-button v-if="canCancelPackage" theme="default" variant="outline" @click="toCancel">取消套餐</t-button>
<t-button v-if="canRefund" theme="danger" variant="outline" @click="toRefund">处理退款</t-button>
</section>
<t-cell-group title="业务记录" theme="card" v-entrance-transition="280">
<t-cell v-if="canReadDelivery" title="发放历史" :note="`${customerPackage.deliveryList?.length || 0} 条`" arrow hover @click="toDeliveryList" />
<t-cell v-if="canReadFund" title="资金历史" :note="`${customerPackage.fundRecordList?.length || 0} 条`" arrow hover @click="toFundList" />
</t-cell-group>
<t-cell-group v-if="customerPackage.deliveryList?.length" title="最近发放记录" theme="card" v-entrance-transition="320">
<t-cell
v-for="item in customerPackage.deliveryList"
:key="item.id"
:title="`${getDeliveryOperationText(item.operationType)} · ${getDeliveryStatusText(item.status)}`"
:description="item.deliveredAt ? Time.toDateTime(item.deliveredAt) : '时间未知'"
arrow
hover
@click="toDeliveryDetail(item)"
>
<template #note><span v-text="getDeliveryItemSummary(item)" /></template>
</t-cell>
</t-cell-group>
<t-cell-group v-if="customerPackage.fundRecordList?.length" title="最近资金记录" theme="card" v-entrance-transition="360">
<t-cell
v-for="item in customerPackage.fundRecordList"
:key="item.id"
:title="`${getFundOperationText(item.operationType)} · ${getFundStatusText(item.status)}`"
:description="item.occurredAt ? Time.toDateTime(item.occurredAt) : '时间未知'"
arrow
hover
@click="toFundDetail(item)"
>
<template #note><span v-text="formatPackageAmount(item.amount)" /></template>
</t-cell>
</t-cell-group>
</template>
<t-empty v-else :description="isLoading ? '正在加载客户套餐' : '客户套餐不存在'" />
</div>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { GaoCustomerPackage, GaoFundRecord, GaoPackageDelivery, CustomerPackageStatus } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getErrorMessage } from "@/utils/error";
import {
formatPackageAmount,
getCustomerPackageStatusText,
getDeliveryOperationText,
getDeliveryStatusText,
getFundOperationText,
getFundStatusText,
getPackageQuantityText,
getPaymentStatusText,
getRefundStatusText
} from "@/utils/package";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackageDetail" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const customerPackage = ref<GaoCustomerPackage>();
const isLoading = ref(false);
const canReceivePayment = computed(() => customerPackage.value?.status === "ACTIVE" && customerPackage.value.paymentStatus !== "PAID" && userStore.hasPermissions(permissionCodes.fundReceive.create));
const canCreateDelivery = computed(() => customerPackage.value?.status === "ACTIVE" && customerPackage.value.paymentStatus === "PAID" && userStore.hasPermissions(permissionCodes.packageDelivery.create));
const canCancelPackage = computed(() => customerPackage.value?.status === "ACTIVE" && userStore.hasPermissions(permissionCodes.customerPackage.cancel));
const canRefund = computed(() => customerPackage.value?.status === "CANCELLED" && customerPackage.value.refundStatus === "PENDING" && userStore.hasPermissions(permissionCodes.fund.refund));
const canReadEventRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.read));
const canReadDelivery = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.read));
const canReadFund = computed(() => userStore.hasPermissions(permissionCodes.fundRecord.read));
const purchasePhotoItems = computed<PhotoItem[]>(() => (customerPackage.value?.purchaseEventRecord?.attachmentList || []).filter(item => !!item.id).map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
previewUrl: getAttachmentReadURL(item.id || ""),
url: getAttachmentReadURL(item.id || "")
})));
const purchasePhotoURLList = computed(() => purchasePhotoItems.value.map(item => item.url));
function getStatusTheme(status?: CustomerPackageStatus): "success" | "default" {
return status === "ACTIVE" ? "success" : "default";
}
function getDeliveryItemSummary(item: GaoPackageDelivery): string {
return (item.itemList || []).map(detail => `${detail.productNameSnapshot || "产品"} ${getPackageQuantityText(detail.quantity)} ${detail.unitSnapshot || ""}`).join("、") || "无明细";
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
if (userStore.hasRoles(gaoRoleCodes.globalManager)) {
await currentStoreStore.loadStoreList();
} else {
await currentStoreStore.loadLoginStore();
}
customerPackage.value = await PackageAPI.customerPackageDetail(id);
} catch (error) {
customerPackage.value = undefined;
Message.error(getErrorMessage(error, "客户套餐加载失败"));
} finally {
isLoading.value = false;
}
}
async function toPayment(): Promise<void> {
if (customerPackage.value?.id) {
await router.push(`/management/customer-packages/${customerPackage.value.id}/payment`);
}
}
async function toPurchaseEventRecord(): Promise<void> {
const id = customerPackage.value?.purchaseEventRecord?.id;
if (id && canReadEventRecord.value) {
await router.push(`/management/registration-event-records/${id}`);
}
}
async function toDelivery(): Promise<void> {
if (customerPackage.value?.id) {
await router.push(`/management/customer-packages/${customerPackage.value.id}/delivery/create`);
}
}
async function toCancel(): Promise<void> {
if (customerPackage.value?.id) {
await router.push(`/management/customer-packages/${customerPackage.value.id}/cancel`);
}
}
async function toRefund(): Promise<void> {
if (customerPackage.value?.id) {
await router.push(`/management/customer-packages/${customerPackage.value.id}/refund`);
}
}
async function toDeliveryList(): Promise<void> {
if (customerPackage.value?.id) {
await router.push({ path: "/management/package-deliveries", query: { customerPackageId: customerPackage.value.id } });
}
}
async function toFundList(): Promise<void> {
if (customerPackage.value?.id) {
await router.push({ path: "/management/fund-records", query: { customerPackageId: customerPackage.value.id } });
}
}
async function toDeliveryDetail(item: GaoPackageDelivery): Promise<void> {
if (item.id) {
await router.push(`/management/package-deliveries/${item.id}`);
}
}
async function toFundDetail(item: GaoFundRecord): Promise<void> {
if (item.id) {
await router.push(`/management/fund-records/${item.id}`);
}
}
onMounted(loadData);
watch(() => route.params.id, loadData);
</script>
<style scoped lang="less">
.customer-package-detail {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.customer {
margin: 0 1rem;
}
.hero {
margin: 0 1rem;
padding: 1rem;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: 1rem;
.title-row {
gap: .5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
h2 {
margin: 0;
font-size: 1.125rem;
}
.meta {
color: var(--td-text-color-secondary);
margin-top: .5rem;
font-size: .8125rem;
}
}
.quantity {
color: var(--td-brand-color);
font-size: .75rem;
}
.actions {
gap: .5rem;
padding: 0 1rem;
display: flex;
flex-wrap: wrap;
.t-button {
flex: 1 1 8rem;
}
}
:deep(.photo-scroller) {
padding: 1rem;
}
.empty {
color: var(--td-text-color-secondary);
padding: 1rem;
text-align: center;
}
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<template>
<div v-can="permissionCodes.customerPackage.read" class="customer-package-list">
<div class="filters">
<t-input v-model.trim="keyword" class="keyword" borderless clearable placeholder="搜索客户、编码或套餐名称" @enter="reload" />
<t-button theme="primary" size="small" @click="reload">搜索</t-button>
</div>
<t-dropdown-menu>
<t-dropdown-item v-model="status" label="套餐状态" :options="statusOptions" @change="selectStatus" />
<t-dropdown-item v-model="paymentStatus" label="收款状态" :options="paymentOptions" @change="selectPaymentStatus" />
</t-dropdown-menu>
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
<t-cell-group v-if="packageList.length" theme="card" class="list">
<t-cell v-for="(item, index) in packageList" :key="item.id" class="item" :arrow="!!item.id" :hover="!!item.id" v-entrance-transition="index * 30" @click="toDetail(item)">
<template #title>
<div class="title-row">
<strong v-text="item.packageNameSnapshot || '未命名套餐'" />
<t-tag :content="getCustomerPackageStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
</div>
</template>
<template #description>
<div class="description">
<span v-text="item.customer?.name || item.customer?.code || item.customerId || '未知客户'" />
<span v-text="formatPackageAmount(item.saleAmount)" />
<span v-text="getPaymentStatusText(item.paymentStatus)" />
<span v-text="item.purchasedAt ? Time.toPassedDateTime(item.purchasedAt) : '未知时间'" />
</div>
</template>
<template #note>
<div class="paid" v-text="`已收 ${formatPackageAmount(item.paidAmount)}`" />
</template>
</t-cell>
</t-cell-group>
<div v-else class="empty" v-text="loading ? '正在加载客户套餐' : '暂无客户套餐'" />
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
<nav-bar-action-menu v-if="canCreate" :actions="actionList" />
</template>
<script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { CustomerPackageStatus, GaoCustomerPackage, PaymentStatus } from "@/types/Package";
import type { NavBarAction } from "@/types/NavBarAction";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getCustomerPackageStatusText, getPaymentStatusText } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackageList" });
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.customerPackage.create));
const actionList = computed<NavBarAction[]>(() => canCreate.value ? [{
key: "purchase",
text: "购买套餐",
icon: "add",
theme: "primary",
action: toPurchase
}] : []);
const keyword = ref("");
const status = ref("ALL");
const paymentStatus = ref("ALL");
const packageList = ref<GaoCustomerPackage[]>([]);
const pageIndex = ref(0);
const total = ref(0);
const loading = ref(false);
const isRefreshing = ref(false);
const noMore = ref(false);
const statusOptions = [
{ label: "全部状态", value: "ALL" },
{ label: "进行中", value: "ACTIVE" },
{ label: "已履约", value: "FULFILLED" },
{ label: "已取消", value: "CANCELLED" }
];
const paymentOptions = [
{ label: "全部收款", value: "ALL" },
{ label: "未收款", value: "UNPAID" },
{ label: "部分收款", value: "PARTIALLY_PAID" },
{ label: "已收款", value: "PAID" }
];
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
async function loadMore(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
loading.value = true;
try {
const result = await PackageAPI.customerPackageList({
index: pageIndex.value,
size: 15,
keyword: keyword.value || undefined,
status: status.value === "ALL" ? undefined : status.value as CustomerPackageStatus,
paymentStatus: paymentStatus.value === "ALL" ? undefined : paymentStatus.value as PaymentStatus
});
const list = result.list || [];
packageList.value = packageList.value.concat(list);
total.value = result.total || 0;
pageIndex.value += 1;
noMore.value = list.length < 15 || total.value <= packageList.value.length;
} finally {
loading.value = false;
}
}
async function reload(): Promise<void> {
if (loading.value) {
return;
}
pageIndex.value = 0;
packageList.value = [];
total.value = 0;
noMore.value = false;
await loadMore();
}
async function selectStatus(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
status.value = String(value);
await reload();
}
async function selectPaymentStatus(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
paymentStatus.value = String(value);
await reload();
}
async function toPurchase(): Promise<void> {
await router.push("/management/customer-packages/purchase");
}
async function toDetail(item: GaoCustomerPackage): Promise<void> {
if (item.id) {
await router.push(`/management/customer-packages/${item.id}`);
}
}
function getStatusTheme(statusValue?: CustomerPackageStatus): "success" | "warning" | "default" {
return statusValue === "ACTIVE" ? "success" : statusValue === "CANCELLED" ? "warning" : "default";
}
onMounted(async () => {
navBarStore.clearRight();
navBarStore.setCustomRightVisible(canCreate.value);
try {
await reload();
} catch (error) {
Message.error(getErrorMessage(error, "客户套餐加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.customer-package-list {
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
.filters {
gap: .5rem;
margin: 0 1rem .75rem;
display: flex;
.keyword {
flex: 1;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
}
}
.item {
.title-row,
.description {
gap: .5rem;
display: flex;
align-items: center;
}
.description {
color: var(--td-text-color-secondary);
margin-top: .35rem;
font-size: .8125rem;
flex-wrap: wrap;
}
.paid {
color: var(--td-brand-color);
font-size: .75rem;
}
}
.empty {
color: var(--td-text-color-secondary);
padding: 2rem 1rem;
text-align: center;
}
}
</style>
@@ -0,0 +1,151 @@
<template>
<div v-can="permissionCodes.fundReceive.create" class="package-operation-edit">
<t-cell-group v-if="customerPackage" title="收款信息" theme="card" v-entrance-transition>
<t-cell title="客户套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
<t-cell title="成交价" :note="formatPackageAmount(customerPackage.saleAmount)" />
<t-cell title="已核销收款" :note="formatPackageAmount(customerPackage.paidAmount)" />
<t-cell title="待核销收款" :note="formatPackageAmount(customerPackage.pendingPaidAmount)" />
</t-cell-group>
<t-form v-if="customerPackage" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
<t-form-item label="本次收款(元)" required-mark>
<t-input v-model.trim="amountText" type="number" align="right" borderless placeholder="请输入本次实际收款金额" />
</t-form-item>
<t-form-item label="发生时间" arrow @click="occurredPickerVisible = true">
<span class="picker-text" v-text="occurredAtText || '使用当前时间'" />
</t-form-item>
<t-form-item label="备注">
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="收款备注" />
</t-form-item>
</t-form>
<package-photo-field v-if="customerPackage" v-model="photoItems" required title="收款凭证照片" />
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
<t-popup v-model="occurredPickerVisible" placement="bottom">
<t-date-time-picker v-model="occurredDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="发生时间" @cancel="occurredPickerVisible = false" @confirm="confirmOccurredAt" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoCustomerPackage, PackageAttachment } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, formatPackageAmount, parsePackageAmount } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackagePayment" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const customerPackage = ref<GaoCustomerPackage>();
const isLoading = ref(false);
const isSaving = ref(false);
const amountText = ref("");
const occurredAtText = ref("");
const occurredDraft = ref("");
const occurredPickerVisible = ref(false);
const remark = ref("");
const photoItems = ref<PhotoItem[]>([]);
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function confirmOccurredAt(value: string | number): void {
occurredAtText.value = String(value);
occurredPickerVisible.value = false;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
customerPackage.value = await PackageAPI.customerPackageDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "套餐加载失败"));
} finally {
isLoading.value = false;
}
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
const customerPackageId = customerPackage.value?.id;
const amount = parsePackageAmount(amountText.value);
if (!customerPackageId || amount === undefined || 0 >= amount) {
Message.warning("请输入大于 0 的合法收款金额");
return;
}
if (!photoItems.value.some(item => item.tempFileId)) {
Message.warning("收款必须上传至少一张凭证照片");
return;
}
isSaving.value = true;
try {
await PackageAPI.receivePayment({
requestNo: createPackageRequestNo(),
customerPackageId,
amount,
occurredAt: resolveTimestamp(occurredAtText.value),
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("收款已提交,等待核销");
await router.replace(`/management/customer-packages/${customerPackageId}`);
} catch (error) {
Message.error(getErrorMessage(error, "收款提交失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.fundReceive.create)) {
navBarStore.setRightText("提交收款", "var(--td-brand-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.package-photo-field {
padding: 0 1rem;
}
}
</style>
@@ -0,0 +1,405 @@
<template>
<div v-can="permissionCodes.customerPackage.create" class="customer-package-purchase">
<t-form class="form" label-align="left" label-width="6rem" v-entrance-transition>
<t-form-item v-if="isGlobalManager" label="所属门店" arrow @click="openStorePicker">
<span class="picker-text" v-text="selectedStoreName" />
</t-form-item>
<t-form-item label="客户" required-mark arrow @click="customerSelectorVisible = true">
<span class="picker-text" v-text="selectedCustomer?.name || selectedCustomer?.code || '请选择客户'" />
</t-form-item>
<customer-profile-card v-if="selectedCustomer" class="customer-profile" :customer="selectedCustomer" />
<t-form-item label="购买事件" required-mark arrow @click="openEventPicker">
<span class="picker-text" v-text="selectedEvent?.name || '请选择 PACKAGE_PURCHASE 事件'" />
</t-form-item>
<div v-if="selectedConfig" class="config-summary">
<strong v-text="selectedConfig.name || '未命名套餐'" />
<span v-text="`标准价:${formatPackageAmount(selectedConfig.standardAmount)}|${getPackageConfigStatusText(selectedConfig.status)}`" />
</div>
<div v-if="!eventList.length" class="option-tip">
<div v-if="!eventList.length">当前门店没有可用的 PACKAGE_PURCHASE 购买事件,请先新增并启用购买事件。</div>
</div>
<div v-if="selectedEvent && !selectedConfig" class="option-tip">当前购买事件未返回绑定套餐,无法购买,请联系管理员检查事件配置。</div>
<div v-if="selectedConfig && !isSelectedConfigSaleable" class="option-tip">事件绑定的套餐当前不可购买,请检查套餐状态和有效期。</div>
<t-form-item label="成交价(元)" required-mark>
<t-input v-model.trim="saleAmountText" type="number" align="right" borderless :readonly="!canPriceOverride" placeholder="请输入成交价" />
</t-form-item>
<t-form-item label="购买时间" arrow @click="openPurchaseTime">
<span class="picker-text" v-text="purchasedAtText || '使用当前时间'" />
</t-form-item>
<t-form-item label="备注">
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="购买备注" />
</t-form-item>
</t-form>
<section class="items" v-entrance-transition="70">
<div class="section-title"><strong>购买权益明细</strong><span v-if="!canPriceOverride">当前账号无定制权限,明细不可修改</span></div>
<div v-for="(item, index) in purchaseItems" :key="item.clientKey" class="item-card">
<strong v-text="`权益 ${index + 1}`" />
<t-input v-model.trim="item.productName" borderless readonly placeholder="产品名称" />
<t-input v-model.trim="item.unit" borderless readonly placeholder="单位" />
<t-input v-model.trim="item.totalQuantity" type="number" borderless :readonly="!canPriceOverride" placeholder="总权益数量" />
<span v-if="item.remark" class="item-remark" v-text="`备注:${item.remark}`" />
</div>
<div v-if="!purchaseItems.length" class="empty" v-text="'选择购买事件后加载绑定套餐权益明细'" />
</section>
<package-photo-field v-model="photoItems" :required="selectedEvent?.requirePhoto === true" title="购买照片" />
<customer-selector v-model="selectedCustomerId" v-model:visible="customerSelectorVisible" :store-id="formStoreId" @change="handleCustomerChange" />
<t-popup v-model="storePickerVisible" placement="bottom">
<t-picker v-model="storeDraft" :columns="storePickerColumns" title="选择门店" @cancel="storePickerVisible = false" @confirm="confirmStore" />
</t-popup>
<t-popup v-model="eventPickerVisible" placement="bottom">
<t-picker v-model="eventDraft" :columns="eventPickerColumns" title="选择购买事件" @cancel="eventPickerVisible = false" @confirm="confirmEvent" />
</t-popup>
<t-popup v-model="purchasePickerVisible" placement="bottom">
<t-date-time-picker v-model="purchaseDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="购买时间" @cancel="purchasePickerVisible = false" @confirm="confirmPurchaseTime" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI";
import EventAPI from "@/api/EventAPI";
import PackageAPI from "@/api/PackageAPI";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem";
import type { GaoPackageConfig, PackageAttachment, PackagePurchaseItem } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, formatPackageAmount, formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
type PickerValue = string | number;
type PurchaseFormItem = PackagePurchaseItem & { clientKey: string };
defineOptions({ name: "CustomerPackagePurchase" });
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const currentStoreStore = useCurrentStoreStore();
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const canPriceOverride = computed(() => userStore.hasPermissions(permissionCodes.customerPackage.priceOverride));
const formStoreId = ref("");
const selectedCustomerId = ref("");
const selectedCustomer = ref<Customer>();
const selectedEventId = ref("");
const eventList = ref<EventItem[]>([]);
const purchaseItems = ref<PurchaseFormItem[]>([]);
const selectedEvent = computed(() => eventList.value.find(item => item.id === selectedEventId.value));
const selectedConfig = computed<GaoPackageConfig | undefined>(() => selectedEvent.value?.packageConfig);
const isSelectedConfigSaleable = computed(() => {
const config = selectedConfig.value;
if (!config || config.status !== "ACTIVE") {
return false;
}
const now = Date.now();
const beginAt = config.beginAt;
const endAt = config.endAt;
return (beginAt === undefined || beginAt === null || beginAt <= now)
&& (endAt === undefined || endAt === null || now <= endAt);
});
const saleAmountText = ref("");
const purchasedAtText = ref("");
const purchaseDraft = ref("");
const remark = ref("");
const photoItems = ref<PhotoItem[]>([]);
const isSaving = ref(false);
const customerSelectorVisible = ref(false);
const storePickerVisible = ref(false);
const eventPickerVisible = ref(false);
const purchasePickerVisible = ref(false);
const storeDraft = ref<PickerValue[]>([]);
const eventDraft = ref<PickerValue[]>([]);
const storePickerColumns = computed(() => [[...currentStoreStore.storeList].map(item => ({ label: item.name || item.code || item.id || "未知门店", value: item.id || "" }))]);
const eventPickerColumns = computed(() => [[...eventList.value].map(item => ({ label: item.name || "未命名事件", value: item.id || "" }))]);
const selectedStoreName = computed(() => currentStoreStore.storeList.find(item => item.id === formStoreId.value)?.name || formStoreId.value || "请选择门店");
async function loadOptions(): Promise<void> {
eventList.value = formStoreId.value
? (await EventAPI.listValid(formStoreId.value)).filter(item => item.bizType === "PACKAGE_PURCHASE" && !!item.packageConfigId)
: [];
if (!eventList.value.some(item => item.id === selectedEventId.value)) {
selectedEventId.value = "";
resetPurchaseConfig();
}
}
async function handleCustomerChange(id: string): Promise<void> {
selectedCustomerId.value = id;
selectedCustomer.value = id ? await CustomerAPI.detail(id) : undefined;
}
function openStorePicker(): void {
storeDraft.value = [formStoreId.value];
storePickerVisible.value = true;
}
function openEventPicker(): void {
if (!eventList.value.length) {
Message.warning("当前门店没有 PACKAGE_PURCHASE 类型购买事件,请先启用购买事件");
return;
}
eventDraft.value = [selectedEventId.value];
eventPickerVisible.value = true;
}
async function confirmStore(value: Array<string | number>): Promise<void> {
const storeId = String(value[0] || "");
if (storeId && storeId !== formStoreId.value) {
formStoreId.value = storeId;
selectedCustomerId.value = "";
selectedCustomer.value = undefined;
await loadOptions();
}
storePickerVisible.value = false;
}
function resetPurchaseConfig(): void {
saleAmountText.value = "";
purchaseItems.value = [];
}
function applySelectedEvent(): void {
const config = selectedConfig.value;
if (!config) {
resetPurchaseConfig();
return;
}
saleAmountText.value = formatPackageAmountInput(config.standardAmount);
purchaseItems.value = (config.itemList || []).map((item, index) => ({
clientKey: window.crypto.randomUUID(),
productName: item.productName,
unit: item.unit,
totalQuantity: normalizePackageQuantity(item.defaultQuantity) || "",
sort: item.sort ?? index,
remark: item.remark ?? null
}));
}
function confirmEvent(value: Array<string | number>): void {
selectedEventId.value = String(value[0] || "");
applySelectedEvent();
eventPickerVisible.value = false;
}
function openPurchaseTime(): void {
purchaseDraft.value = purchasedAtText.value || Time.formatDateTime(new Date());
purchasePickerVisible.value = true;
}
function confirmPurchaseTime(value: string | number): void {
purchasedAtText.value = String(value);
purchasePickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function validateForm(): boolean {
if (!selectedCustomerId.value) {
Message.warning("请选择客户");
return false;
}
if (!selectedEventId.value || !selectedEvent.value) {
Message.warning("请选择 PACKAGE_PURCHASE 类型购买事件");
return false;
}
if (!selectedConfig.value) {
Message.warning("当前购买事件未绑定有效套餐配置");
return false;
}
if (!isSelectedConfigSaleable.value) {
Message.warning("事件绑定的套餐当前不可购买,请检查套餐状态和有效期");
return false;
}
if (parsePackageAmount(saleAmountText.value) === undefined) {
Message.warning("成交价必须是合法的元金额,最多两位小数");
return false;
}
if (!purchaseItems.value.length) {
Message.warning("购买权益至少需要一项");
return false;
}
for (const item of purchaseItems.value) {
if (!item.productName.trim() || !item.unit.trim() || !normalizePackageQuantity(item.totalQuantity) || normalizePackageQuantity(item.totalQuantity) === "0") {
Message.warning("产品名称、单位和正数量不能为空");
return false;
}
}
if (selectedEvent.value.requirePhoto && !photoItems.value.some(item => item.tempFileId)) {
Message.warning("当前购买事件要求上传照片");
return false;
}
return true;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
}
async function save(): Promise<void> {
if (isSaving.value || !validateForm()) {
return;
}
isSaving.value = true;
try {
const result = await PackageAPI.purchase({
requestNo: createPackageRequestNo(),
eventId: selectedEventId.value,
customerId: selectedCustomerId.value,
saleAmount: parsePackageAmount(saleAmountText.value)!,
purchasedAt: resolveTimestamp(purchasedAtText.value),
itemList: purchaseItems.value.map((item, index) => ({
productName: item.productName.trim(),
unit: item.unit.trim(),
totalQuantity: normalizePackageQuantity(item.totalQuantity)!,
sort: item.sort ?? index,
remark: item.remark ?? null
})),
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("套餐购买已提交");
if (result.id) {
await router.replace(`/management/customer-packages/${result.id}`);
return;
}
await router.replace("/management/customer-packages");
} catch (error) {
Message.error(getErrorMessage(error, "套餐购买失败,请刷新后重试"));
} finally {
isSaving.value = false;
}
}
async function loadData(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
} else {
await currentStoreStore.loadLoginStore();
}
formStoreId.value = currentStoreStore.currentStoreId;
if (!formStoreId.value) {
const store = await currentStoreStore.loadLoginStore();
formStoreId.value = store?.id || "";
}
await loadOptions();
const queryEventId = String(route.query.eventId || "");
if (queryEventId && eventList.value.some(item => item.id === queryEventId)) {
selectedEventId.value = queryEventId;
applySelectedEvent();
}
const queryCustomerId = String(route.query.customerId || "");
if (queryCustomerId) {
await handleCustomerChange(queryCustomerId);
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
navBarStore.setRightText("提交购买", "var(--td-brand-color)", save);
}
try {
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "购买页面数据加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.customer-package-purchase {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.customer-profile {
margin: 0 1rem;
}
.items,
.package-photo-field {
padding: 0 1rem;
}
.section-title {
color: var(--td-text-color-secondary);
margin-bottom: .5rem;
font-size: .8125rem;
}
.section-title span {
margin-left: .5rem;
}
.item-card {
gap: .5rem;
padding: .75rem;
margin-bottom: .75rem;
display: grid;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: .75rem;
}
.config-summary {
gap: .25rem;
padding: .75rem 1rem;
display: flex;
color: var(--td-text-color-secondary);
font-size: .8125rem;
flex-direction: column;
background: var(--td-bg-color-secondarycontainer);
strong {
color: var(--td-text-color-primary);
font-size: .9375rem;
}
}
.item-remark {
color: var(--td-text-color-secondary);
font-size: .8125rem;
}
.empty {
color: var(--td-text-color-secondary);
padding: 1rem;
text-align: center;
}
.option-tip {
padding: .75rem 1rem;
color: var(--td-text-color-secondary);
font-size: .8125rem;
line-height: 1.5;
background: var(--td-bg-color-secondarycontainer);
}
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div v-can="permissionCodes.fund.refund" class="package-operation-edit">
<t-cell-group v-if="customerPackage" title="退款信息" theme="card" v-entrance-transition>
<t-cell title="客户套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
<t-cell title="已核销收款" :note="formatPackageAmount(customerPackage.paidAmount)" />
<t-cell title="当前退款状态" :note="getRefundStatusText(customerPackage.refundStatus)" />
</t-cell-group>
<t-form v-if="customerPackage" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
<t-form-item label="确认无需退款" content-align="right">
<t-switch v-model="confirmNone" />
</t-form-item>
<t-form-item v-if="!confirmNone" label="退款金额(元)" required-mark>
<t-input v-model.trim="amountText" type="number" align="right" borderless placeholder="请输入实际退款金额,不自动填充" />
</t-form-item>
<t-form-item label="退款原因" required-mark>
<t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入原因" />
</t-form-item>
<t-form-item label="发生时间" arrow @click="occurredPickerVisible = true">
<span class="picker-text" v-text="occurredAtText || '使用当前时间'" />
</t-form-item>
<t-form-item label="备注">
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="备注(可选)" />
</t-form-item>
</t-form>
<package-photo-field v-if="customerPackage && !confirmNone" v-model="photoItems" required title="退款凭证照片" />
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
<t-popup v-model="occurredPickerVisible" placement="bottom">
<t-date-time-picker v-model="occurredDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="发生时间" @cancel="occurredPickerVisible = false" @confirm="confirmOccurredAt" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoCustomerPackage, PackageAttachment } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, formatPackageAmount, parsePackageAmount, getRefundStatusText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackageRefund" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const customerPackage = ref<GaoCustomerPackage>();
const confirmNone = ref(false);
const amountText = ref("");
const reason = ref("");
const remark = ref("");
const occurredAtText = ref("");
const occurredDraft = ref("");
const occurredPickerVisible = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
function confirmOccurredAt(value: string | number): void {
occurredAtText.value = String(value);
occurredPickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
customerPackage.value = await PackageAPI.customerPackageDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "套餐加载失败"));
} finally {
isLoading.value = false;
}
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
const customerPackageId = customerPackage.value?.id;
if (!customerPackageId || !reason.value.trim()) {
Message.warning("请输入退款原因");
return;
}
const amount = confirmNone.value ? undefined : parsePackageAmount(amountText.value);
if (!confirmNone.value && (amount === undefined || 0 >= amount)) {
Message.warning("请输入大于 0 的合法退款金额");
return;
}
if (!confirmNone.value && !photoItems.value.some(item => item.tempFileId)) {
Message.warning("退款必须上传至少一张凭证照片");
return;
}
isSaving.value = true;
try {
if (confirmNone.value) {
await PackageAPI.confirmNoRefund({
requestNo: createPackageRequestNo(),
customerPackageId,
occurredAt: resolveTimestamp(occurredAtText.value),
reason: reason.value.trim(),
remark: remark.value.trim() || null
});
} else {
await PackageAPI.refund({
requestNo: createPackageRequestNo(),
customerPackageId,
amount: amount!,
occurredAt: resolveTimestamp(occurredAtText.value),
reason: reason.value.trim(),
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
}
Message.success(confirmNone.value ? "已确认无需退款" : "退款已登记");
await router.replace(`/management/customer-packages/${customerPackageId}`);
} catch (error) {
Message.error(getErrorMessage(error, "退款操作失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.fund.refund)) {
navBarStore.setRightText("提交处理", "var(--td-brand-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.package-photo-field {
padding: 0 1rem;
}
}
</style>
+190
View File
@@ -0,0 +1,190 @@
<template>
<div v-can="permissionCodes.fundRecord.read" class="package-record-detail">
<template v-if="record">
<t-cell-group title="资金记录" theme="card" v-entrance-transition>
<t-cell title="操作类型" :note="getFundOperationText(record.operationType)" />
<t-cell title="状态"><template #note><t-tag :content="getFundStatusText(record.status)" :theme="getStatusTheme(record.status)" variant="light-outline" /></template></t-cell>
<t-cell title="金额" :note="formatPackageAmount(record.amount)" />
<t-cell title="客户套餐" :note="record.customerPackageId || record.bizId || '未知'" />
<t-cell title="发生时间" :note="record.occurredAt ? Time.toDateTime(record.occurredAt) : '未知'" />
<t-cell title="提交人" :note="record.operator?.name || record.operator?.nick || record.operatorUserId || '未知'" />
<t-cell title="处理人" :note="record.verifier?.name || record.verifier?.nick || record.verifierUserId || '未处理'" />
<t-cell v-if="record.sourceRecordId" title="原资金记录" :note="record.sourceRecordId" />
<t-cell v-if="record.reason" title="原因" :note="record.reason" />
<t-cell v-if="record.remark" title="备注" :note="record.remark" />
</t-cell-group>
<photo-scroller :model-value="photoURLList" :photo-items="photoItems" title="业务照片" :show-add="false" :show-remove="false" :show-viewer-actions="false" />
<section v-if="isPendingReceive || canRevoke" class="actions" v-entrance-transition="70">
<t-button v-if="canVerifyReceive" theme="primary" @click="verifyReceive">核销收款</t-button>
<t-button v-if="canVerifyReceive" theme="default" variant="outline" @click="openReasonDialog('reject')">拒绝收款</t-button>
<t-button v-if="canCancelReceive" theme="default" variant="outline" @click="openReasonDialog('cancel')">撤销收款</t-button>
<t-button v-if="canRevoke" theme="danger" variant="outline" @click="toRevoke">资金冲正</t-button>
</section>
</template>
<t-empty v-else :description="isLoading ? '正在加载资金记录' : '资金记录不存在'" />
<t-dialog v-model:visible="reasonDialogVisible" :title="reasonTitle" :confirm-btn="reasonActionText" @confirm="submitReason">
<t-textarea v-model.trim="reasonText" borderless autosize placeholder="请输入原因" />
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { DialogPlugin } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { FundRecordStatus, GaoFundRecord } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getFundOperationText, getFundStatusText } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
type ReasonAction = "reject" | "cancel";
defineOptions({ name: "FundRecordDetail" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const record = ref<GaoFundRecord>();
const isLoading = ref(false);
const isOperating = ref(false);
const reasonDialogVisible = ref(false);
const reasonText = ref("");
const reasonAction = ref<ReasonAction>("reject");
const isPendingReceive = computed(() => record.value?.operationType === "RECEIVE" && record.value.status === "PENDING");
const canVerifyReceive = computed(() => isPendingReceive.value && userStore.hasPermissions(permissionCodes.fundReceive.verify));
const canCancelReceive = computed(() => isPendingReceive.value && userStore.hasPermissions(permissionCodes.fundReceive.create));
const canRevoke = computed(() => record.value?.status === "VERIFIED" && record.value.operationType !== "REVOKE" && userStore.hasPermissions(permissionCodes.fund.revoke));
const reasonTitle = computed(() => reasonAction.value === "reject" ? "拒绝收款" : "撤销收款");
const reasonActionText = computed(() => reasonAction.value === "reject" ? "拒绝" : "撤销");
const photoItems = computed<PhotoItem[]>(() => (record.value?.attachmentList || [])
.filter(item => !!item.id)
.map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
url: getAttachmentReadURL(item.id || "")
})));
const photoURLList = computed(() => photoItems.value.map(item => item.url));
function getStatusTheme(status?: FundRecordStatus): "success" | "danger" | "default" {
return status === "VERIFIED" ? "success" : status === "REJECTED" ? "danger" : "default";
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
record.value = await PackageAPI.fundRecordDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "资金记录加载失败"));
} finally {
isLoading.value = false;
}
}
async function refreshRelatedPackage(): Promise<void> {
const customerPackageId = record.value?.customerPackageId || record.value?.bizId;
if (customerPackageId) {
await PackageAPI.customerPackageDetail(customerPackageId);
}
}
async function verifyReceive(): Promise<void> {
if (!record.value?.id || !canVerifyReceive.value || isOperating.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "核销收款",
content: "核销后该金额才会计入套餐的已收款。确定继续?",
confirmBtn: "核销",
onConfirm: async () => {
isOperating.value = true;
try {
await PackageAPI.verifyPayment(record.value!.id!);
await refreshRelatedPackage();
Message.success("收款已核销");
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "收款核销失败,请刷新后确认状态"));
} finally {
isOperating.value = false;
dialog.destroy();
}
}
});
}
function openReasonDialog(action: ReasonAction): void {
if (!isPendingReceive.value || isOperating.value) {
return;
}
reasonAction.value = action;
reasonText.value = "";
reasonDialogVisible.value = true;
}
async function submitReason(): Promise<void> {
if (!record.value?.id || !reasonText.value.trim() || isOperating.value) {
Message.warning("请输入原因");
return;
}
isOperating.value = true;
try {
if (reasonAction.value === "reject") {
await PackageAPI.rejectPayment(record.value.id, reasonText.value.trim());
} else {
await PackageAPI.cancelPayment(record.value.id, reasonText.value.trim());
}
reasonDialogVisible.value = false;
await refreshRelatedPackage();
Message.success(reasonAction.value === "reject" ? "收款已拒绝" : "收款已撤销");
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "收款状态操作失败,请刷新后确认状态"));
} finally {
isOperating.value = false;
}
}
async function toRevoke(): Promise<void> {
if (record.value?.id && canRevoke.value) {
await router.push(`/management/fund-records/${record.value.id}/revoke`);
}
}
onMounted(loadData);
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-record-detail {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
:deep(.photo-scroller) {
padding: 0 1rem;
}
.actions {
gap: .5rem;
padding: 0 1rem;
display: flex;
flex-wrap: wrap;
.t-button {
flex: 1 1 8rem;
}
}
}
</style>
+168
View File
@@ -0,0 +1,168 @@
<template>
<div v-can="permissionCodes.fundRecord.read" class="package-record-list">
<t-dropdown-menu>
<t-dropdown-item v-model="operationType" label="操作类型" :options="operationOptions" @change="selectOperation" />
<t-dropdown-item v-model="status" label="状态" :options="statusOptions" @change="selectStatus" />
</t-dropdown-menu>
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
<t-cell-group v-if="recordList.length" theme="card">
<t-cell
v-for="(item, index) in recordList"
:key="item.id"
:title="`${getFundOperationText(item.operationType)} · ${getFundStatusText(item.status)}`"
:description="getDescription(item)"
arrow
hover
v-entrance-transition="index * 30"
@click="toDetail(item)"
>
<template #title>
<div class="title-row">
<span v-text="getFundOperationText(item.operationType)" />
<t-tag :content="getFundStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
</div>
</template>
<template #note><span class="amount" v-text="formatPackageAmount(item.amount)" /></template>
</t-cell>
</t-cell-group>
<div v-else class="empty" v-text="loading ? '正在加载资金流水' : '暂无资金流水'" />
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
</template>
<script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import PackageAPI from "@/api/PackageAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { FundOperationType, FundRecordStatus, GaoFundRecord } from "@/types/Package";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getFundOperationText, getFundStatusText } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "FundRecordList" });
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const recordList = ref<GaoFundRecord[]>([]);
const operationType = ref("ALL");
const status = ref("ALL");
const pageIndex = ref(0);
const total = ref(0);
const loading = ref(false);
const isRefreshing = ref(false);
const noMore = ref(false);
const operationOptions = [
{ label: "全部类型", value: "ALL" },
{ label: "线下收款", value: "RECEIVE" },
{ label: "退款", value: "REFUND" },
{ label: "确认无需退款", value: "CONFIRM_NO_REFUND" },
{ label: "资金冲正", value: "REVOKE" }
];
const statusOptions = [
{ label: "全部状态", value: "ALL" },
{ label: "待核销", value: "PENDING" },
{ label: "已核销", value: "VERIFIED" },
{ label: "已拒绝", value: "REJECTED" },
{ label: "已撤销", value: "CANCELLED" }
];
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
async function loadMore(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
loading.value = true;
try {
const result = await PackageAPI.fundRecordList({
index: pageIndex.value,
size: 15,
customerPackageId: String(route.query.customerPackageId || "") || undefined,
operationType: operationType.value === "ALL" ? undefined : operationType.value as FundOperationType,
status: status.value === "ALL" ? undefined : status.value as FundRecordStatus
});
const list = result.list || [];
recordList.value = recordList.value.concat(list);
total.value = result.total || 0;
pageIndex.value += 1;
noMore.value = list.length < 15 || total.value <= recordList.value.length;
} finally {
loading.value = false;
}
}
async function reload(): Promise<void> {
if (loading.value) {
return;
}
pageIndex.value = 0;
recordList.value = [];
total.value = 0;
noMore.value = false;
await loadMore();
}
async function selectOperation(value: DropdownValue): Promise<void> {
if (!Array.isArray(value)) {
operationType.value = String(value);
await reload();
}
}
async function selectStatus(value: DropdownValue): Promise<void> {
if (!Array.isArray(value)) {
status.value = String(value);
await reload();
}
}
function getDescription(item: GaoFundRecord): string {
return `${item.customer?.name || item.customerId || "未知客户"}|${item.occurredAt ? Time.toDateTime(item.occurredAt) : "时间未知"}`;
}
function getStatusTheme(statusValue?: FundRecordStatus): "success" | "warning" | "danger" | "default" {
return statusValue === "VERIFIED" ? "success" : statusValue === "PENDING" ? "warning" : statusValue === "REJECTED" ? "danger" : "default";
}
async function toDetail(item: GaoFundRecord): Promise<void> {
if (item.id) {
await router.push(`/management/fund-records/${item.id}`);
}
}
onMounted(async () => {
navBarStore.clearRight();
try {
await reload();
} catch (error) {
Message.error(getErrorMessage(error, "资金流水加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-record-list {
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
.title-row {
gap: .5rem;
display: flex;
align-items: center;
}
.amount {
color: var(--td-brand-color);
}
.empty {
color: var(--td-text-color-secondary);
padding: 2rem 1rem;
text-align: center;
}
}
</style>
+152
View File
@@ -0,0 +1,152 @@
<template>
<div v-can="permissionCodes.fund.revoke" class="package-operation-edit">
<t-cell-group v-if="record" title="原资金记录" theme="card" v-entrance-transition>
<t-cell title="操作类型" :note="getFundOperationText(record.operationType)" />
<t-cell title="金额" :note="formatPackageAmount(record.amount)" />
<t-cell title="原状态" :note="getFundStatusText(record.status)" />
<t-cell title="客户套餐" :note="record.customerPackageId || record.bizId || '未知'" />
</t-cell-group>
<t-form v-if="record" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
<t-form-item label="冲正原因" required-mark><t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入冲正原因" /></t-form-item>
<t-form-item label="冲正时间" arrow @click="pickerVisible = true"><span class="picker-text" v-text="occurredAtText || '使用当前时间'" /></t-form-item>
<t-form-item label="备注"><t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="备注(可选)" /></t-form-item>
</t-form>
<package-photo-field v-if="record" v-model="photoItems" required title="冲正凭证照片" />
<t-empty v-if="!record" :description="isLoading ? '正在加载资金记录' : '资金记录不存在'" />
<t-popup v-model="pickerVisible" placement="bottom">
<t-date-time-picker v-model="draftTime" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="冲正时间" @cancel="pickerVisible = false" @confirm="confirmTime" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoFundRecord, PackageAttachment } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, formatPackageAmount, getFundOperationText, getFundStatusText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "FundRecordRevoke" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const record = ref<GaoFundRecord>();
const reason = ref("");
const remark = ref("");
const occurredAtText = ref("");
const draftTime = ref("");
const pickerVisible = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
function confirmTime(value: string | number): void {
occurredAtText.value = String(value);
pickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
record.value = await PackageAPI.fundRecordDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "资金记录加载失败"));
} finally {
isLoading.value = false;
}
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
if (!record.value?.id || record.value.status !== "VERIFIED" || record.value.operationType === "REVOKE") {
Message.warning("只有已核销且非冲正的资金记录可以冲正");
return;
}
if (!reason.value.trim()) {
Message.warning("请输入冲正原因");
return;
}
if (!photoItems.value.some(item => item.tempFileId)) {
Message.warning("资金冲正必须上传至少一张照片");
return;
}
isSaving.value = true;
try {
const result = await PackageAPI.revokeFundRecord({
requestNo: createPackageRequestNo(),
fundRecordId: record.value.id,
occurredAt: resolveTimestamp(occurredAtText.value),
reason: reason.value.trim(),
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("资金记录已冲正");
const customerPackageId = record.value.customerPackageId || record.value.bizId;
if (customerPackageId) {
await router.replace(`/management/customer-packages/${customerPackageId}`);
return;
}
await router.replace(`/management/fund-records/${result.id || record.value.id}`);
} catch (error) {
Message.error(getErrorMessage(error, "资金冲正失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.fund.revoke)) {
navBarStore.setRightText("提交冲正", "var(--td-danger-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.package-photo-field {
padding: 0 1rem;
}
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<template>
<div v-can="permissionCodes.packageConfig.read" class="package-config-detail">
<template v-if="config">
<section class="hero" v-entrance-transition>
<div class="title-row"><h2 v-text="config.name || '未命名套餐'" /><t-tag :content="getPackageConfigStatusText(config.status)" variant="light-outline" :theme="getStatusTheme(config.status)" /></div>
<p v-text="config.description || '暂无套餐说明'" />
</section>
<t-cell-group title="基本信息" theme="card" v-entrance-transition="70">
<t-cell title="所属门店" :note="storeName" />
<t-cell title="标准价" :note="formatPackageAmount(config.standardAmount)" />
<t-cell title="有效期" :note="formatValidity" />
<t-cell title="最后修改人" :note="config.updater?.name || config.updater?.nick || '未知'" />
</t-cell-group>
<t-cell-group title="产品权益" theme="card" v-entrance-transition="140">
<t-cell
v-for="item in config.itemList || []"
:key="item.id || `${item.productName}-${item.unit}`"
:title="item.productName"
:note="`${item.defaultQuantity} ${item.unit}${item.remark ? `|${item.remark}` : ''}`"
/>
<div v-if="!config.itemList?.length" class="empty" v-text="'暂无产品权益'" />
</t-cell-group>
<t-button v-if="canDelete" class="delete-button" theme="danger" variant="outline" @click="removeConfig">删除配置</t-button>
</template>
<t-empty v-else :description="isLoading ? '正在加载套餐配置' : '套餐配置不存在'" />
</div>
</template>
<script setup lang="ts">
import { DialogPlugin } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoPackageConfig, PackageConfigStatus } from "@/types/Package";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({ name: "PackageConfigDetail" });
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const config = ref<GaoPackageConfig>();
const isLoading = ref(false);
const canUpdate = computed(() => !!config.value && userStore.hasPermissions(permissionCodes.packageConfig.update));
const canDelete = computed(() => !!config.value && userStore.hasPermissions(permissionCodes.packageConfig.delete));
const storeName = computed(() => {
const storeId = config.value?.storeId;
return currentStoreStore.storeList.find(item => item.id === storeId)?.name || storeId || "未绑定门店";
});
const formatValidity = computed(() => {
const begin = config.value?.beginAt ? Time.toDateTime(config.value.beginAt) : "不限起始";
const end = config.value?.endAt ? Time.toDateTime(config.value.endAt) : "不限结束";
return `${begin} 至 ${end}`;
});
function getStatusTheme(status?: PackageConfigStatus): "success" | "warning" | "default" {
return status === "ACTIVE" ? "success" : status === "INACTIVE" ? "warning" : "default";
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
if (userStore.hasRoles(gaoRoleCodes.globalManager)) {
await currentStoreStore.loadStoreList();
} else {
await currentStoreStore.loadLoginStore();
}
config.value = await PackageAPI.configDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置加载失败"));
} finally {
isLoading.value = false;
}
}
async function toEdit(): Promise<void> {
if (config.value?.id && canUpdate.value) {
await router.push(`/management/package-configs/${config.value.id}/edit`);
}
}
function removeConfig(): void {
if (!config.value?.id || !canDelete.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "删除套餐配置",
content: "删除后该配置不再出现在可购买列表中,已购买的客户套餐不受影响。",
confirmBtn: { content: "删除", theme: "danger" },
onConfirm: async () => {
try {
await PackageAPI.configRemove(config.value!.id!);
Message.success("套餐配置已删除");
await router.replace("/management/package-configs");
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置删除失败"));
} finally {
dialog.destroy();
}
}
});
}
onMounted(async () => {
await loadData();
navBarStore.clearRight();
if (canUpdate.value) {
navBarStore.setRightText("编辑", "var(--td-brand-color)", toEdit);
}
});
watch(canUpdate, value => {
navBarStore.clearRight();
if (value) {
navBarStore.setRightText("编辑", "var(--td-brand-color)", toEdit);
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-config-detail {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.hero {
margin: 0 1rem;
padding: 1rem;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: 1rem;
.title-row {
gap: .5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
h2 {
margin: 0;
font-size: 1.125rem;
}
p {
color: var(--td-text-color-secondary);
margin: .5rem 0 0;
line-height: 1.5;
}
}
.empty {
color: var(--td-text-color-secondary);
padding: 1rem;
text-align: center;
}
.delete-button {
margin: 0 1rem;
}
}
</style>
+358
View File
@@ -0,0 +1,358 @@
<template>
<div v-can="savePermissionCode" class="package-config-edit">
<t-form class="form" label-align="left" label-width="6rem" v-entrance-transition>
<t-form-item v-if="isGlobalManager" label="所属门店" arrow @click="openStorePicker">
<span class="picker-text" v-text="selectedStoreName" />
</t-form-item>
<t-form-item label="套餐名称" required-mark>
<t-input v-model="form.name" borderless maxlength="100" placeholder="请输入套餐名称" />
</t-form-item>
<t-form-item label="套餐说明">
<t-textarea v-model="form.description" borderless maxlength="500" autosize placeholder="可填写套餐说明" />
</t-form-item>
<t-form-item label="标准价(元)" required-mark>
<t-input v-model.trim="standardAmountText" type="number" align="right" borderless placeholder="例如 1288.00" />
</t-form-item>
<t-form-item label="状态" arrow @click="openStatusPicker">
<span class="picker-text" v-text="getPackageConfigStatusText(form.status)" />
</t-form-item>
<t-form-item label="开始时间" arrow @click="openBeginPicker">
<span class="picker-text" v-text="beginAtText || '不限'" />
</t-form-item>
<t-form-item label="结束时间" arrow @click="openEndPicker">
<span class="picker-text" v-text="endAtText || '不限'" />
</t-form-item>
</t-form>
<section class="items" v-entrance-transition="70">
<div class="section-title"><strong>产品权益</strong><span>至少一项,数量最多三位小数</span></div>
<div v-for="(item, index) in form.itemList" :key="item.clientKey" class="item-card">
<div class="item-head"><span v-text="`权益 ${index + 1}`" /><t-button theme="danger" variant="text" size="small" @click="removeItem(index)">删除</t-button></div>
<t-input v-model="item.productName" borderless placeholder="产品名称" />
<t-input v-model.trim="item.unit" borderless placeholder="单位,例如 盒、次" />
<t-input v-model.trim="item.defaultQuantity" type="number" borderless placeholder="默认数量" />
<t-input v-model="item.remark" borderless placeholder="备注(可选)" />
</div>
<t-button class="add-item" theme="primary" variant="dashed" block @click="addItem">添加产品权益</t-button>
</section>
<t-popup v-model="storePickerVisible" placement="bottom">
<t-picker v-model="storePickerDraft" :columns="storePickerColumns" title="选择门店" @cancel="storePickerVisible = false" @confirm="confirmStore" />
</t-popup>
<t-popup v-model="statusPickerVisible" placement="bottom">
<t-picker v-model="statusPickerDraft" :columns="statusOptions" title="状态" @cancel="statusPickerVisible = false" @confirm="confirmStatus" />
</t-popup>
<t-popup v-model="beginPickerVisible" placement="bottom">
<t-date-time-picker v-model="beginDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="开始时间" @cancel="beginPickerVisible = false" @confirm="confirmBegin" />
</t-popup>
<t-popup v-model="endPickerVisible" placement="bottom">
<t-date-time-picker v-model="endDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="结束时间" @cancel="endPickerVisible = false" @confirm="confirmEnd" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
type PickerValue = string | number;
type PackageConfigFormItem = PackageConfigItem & { clientKey: string };
defineOptions({ name: "PackageConfigEdit" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const currentStoreStore = useCurrentStoreStore();
const routeId = computed(() => String(route.params.id || ""));
const isCreateMode = computed(() => !routeId.value);
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.packageConfig.create : permissionCodes.packageConfig.update);
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const isSaving = ref(false);
const form = reactive({
id: "",
storeId: "",
name: "",
description: "",
status: "DRAFT" as PackageConfigStatus,
beginAt: undefined as number | undefined,
endAt: undefined as number | undefined,
itemList: [] as PackageConfigFormItem[]
});
const standardAmountText = ref("");
const beginAtText = ref("");
const endAtText = ref("");
const storePickerVisible = ref(false);
const storePickerDraft = ref<PickerValue[]>([]);
const statusPickerVisible = ref(false);
const statusPickerDraft = ref<PickerValue[]>([]);
const beginPickerVisible = ref(false);
const endPickerVisible = ref(false);
const beginDraft = ref("");
const endDraft = ref("");
const statusOptions = [
{ label: "草稿", value: "DRAFT" },
{ label: "启用", value: "ACTIVE" },
{ label: "停用", value: "INACTIVE" }
];
const storePickerColumns = computed(() => [[...currentStoreStore.storeList].map(item => ({ label: item.name || item.code || item.id || "未知门店", value: item.id || "" }))]);
const selectedStoreName = computed(() => currentStoreStore.storeList.find(item => item.id === form.storeId)?.name || form.storeId || "请选择门店");
function createItem(item?: Partial<PackageConfigItem>): PackageConfigFormItem {
return {
...item,
clientKey: window.crypto.randomUUID(),
productName: item?.productName || "",
unit: item?.unit || "",
defaultQuantity: item?.defaultQuantity ?? "",
remark: item?.remark || ""
};
}
function addItem(): void {
form.itemList.push(createItem());
}
function openStorePicker(): void {
storePickerDraft.value = [form.storeId];
storePickerVisible.value = true;
}
function openStatusPicker(): void {
statusPickerDraft.value = [form.status];
statusPickerVisible.value = true;
}
function removeItem(index: number): void {
if (1 < form.itemList.length) {
form.itemList.splice(index, 1);
return;
}
Message.warning("套餐至少需要一项产品权益");
}
function confirmStore(value: Array<string | number>): void {
const storeId = String(value[0] || "");
if (storeId) {
form.storeId = storeId;
}
storePickerVisible.value = false;
}
function confirmStatus(value: Array<string | number>): void {
const status = String(value[0] || "");
if (status === "DRAFT" || status === "ACTIVE" || status === "INACTIVE") {
form.status = status;
}
statusPickerVisible.value = false;
}
function openBeginPicker(): void {
beginDraft.value = beginAtText.value || Time.formatDateTime(new Date());
beginPickerVisible.value = true;
}
function openEndPicker(): void {
endDraft.value = endAtText.value || Time.formatDateTime(new Date());
endPickerVisible.value = true;
}
function confirmBegin(value: string | number): void {
beginAtText.value = String(value);
beginPickerVisible.value = false;
}
function confirmEnd(value: string | number): void {
endAtText.value = String(value);
endPickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function validateForm(): boolean {
if (isGlobalManager.value && !form.storeId) {
Message.warning("请选择所属门店");
return false;
}
if (!form.name.trim()) {
Message.warning("请输入套餐名称");
return false;
}
if (parsePackageAmount(standardAmountText.value) === undefined) {
Message.warning("标准价必须是合法的元金额,最多两位小数");
return false;
}
if (!form.itemList.length) {
Message.warning("请至少添加一项产品权益");
return false;
}
const keySet = new Set<string>();
for (const item of form.itemList) {
const quantity = normalizePackageQuantity(item.defaultQuantity);
const key = `${item.productName.trim()}\u0000${item.unit.trim()}`;
if (!item.productName.trim() || !item.unit.trim() || !quantity || quantity === "0") {
Message.warning("产品名称、单位和正数量不能为空");
return false;
}
if (keySet.has(key)) {
Message.warning("产品名称和单位组合不能重复");
return false;
}
keySet.add(key);
}
form.beginAt = resolveTimestamp(beginAtText.value);
form.endAt = resolveTimestamp(endAtText.value);
if (form.beginAt !== undefined && form.endAt !== undefined && form.endAt < form.beginAt) {
Message.warning("结束时间不能早于开始时间");
return false;
}
return true;
}
async function save(): Promise<void> {
if (isSaving.value || !validateForm()) {
return;
}
isSaving.value = true;
try {
const payload: GaoPackageConfig = {
id: form.id || undefined,
storeId: form.storeId || undefined,
name: form.name.trim(),
description: form.description.trim(),
standardAmount: parsePackageAmount(standardAmountText.value)!,
status: form.status,
beginAt: form.beginAt ?? null,
endAt: form.endAt ?? null,
itemList: form.itemList.map((item, index) => ({
productName: item.productName.trim(),
unit: item.unit.trim(),
defaultQuantity: normalizePackageQuantity(item.defaultQuantity)!,
sort: index,
remark: item.remark?.trim() || null
}))
};
if (isCreateMode.value) {
const id = await PackageAPI.configCreate(payload);
Message.success("套餐配置已创建");
await router.replace(`/management/package-configs/${id}`);
return;
}
await PackageAPI.configUpdate(payload);
Message.success("套餐配置已保存");
await router.replace(`/management/package-configs/${routeId.value}`);
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置保存失败"));
} finally {
isSaving.value = false;
}
}
async function loadData(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
} else {
await currentStoreStore.loadLoginStore();
}
if (!form.storeId) {
form.storeId = currentStoreStore.currentStoreId;
}
if (isCreateMode.value) {
addItem();
return;
}
const result = await PackageAPI.configDetail(routeId.value);
Object.assign(form, {
id: result.id || routeId.value,
storeId: result.storeId || form.storeId,
name: result.name || "",
description: result.description || "",
status: result.status || "DRAFT",
beginAt: result.beginAt || undefined,
endAt: result.endAt || undefined,
itemList: (result.itemList || []).map(createItem)
});
standardAmountText.value = formatPackageAmountInput(result.standardAmount);
beginAtText.value = result.beginAt ? Time.toDateTime(result.beginAt) : "";
endAtText.value = result.endAt ? Time.toDateTime(result.endAt) : "";
if (!form.itemList.length) {
addItem();
}
}
onMounted(async () => {
if (userStore.hasPermissions(savePermissionCode.value)) {
navBarStore.setRightText("保存", "var(--td-brand-color)", save);
}
try {
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-config-edit {
padding: calc(var(--app-nav-height) + 1rem) 0 calc(1rem + env(safe-area-inset-bottom, 0));
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.items {
padding: 1rem;
}
.section-title,
.item-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
color: var(--td-text-color-secondary);
margin-bottom: .5rem;
font-size: .8125rem;
}
.item-card {
gap: .5rem;
padding: .75rem;
margin-bottom: .75rem;
display: grid;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: .75rem;
}
.item-head {
color: var(--td-text-color-secondary);
}
.add-item {
border-radius: .75rem;
}
}
</style>
+233
View File
@@ -0,0 +1,233 @@
<template>
<div v-can="permissionCodes.packageConfig.read" class="package-config-list">
<div class="filters">
<t-input
v-model.trim="keyword"
class="keyword"
borderless
clearable
placeholder="搜索套餐名称或说明"
@enter="reload"
/>
<t-button theme="primary" size="small" @click="reload">搜索</t-button>
</div>
<t-dropdown-menu>
<t-dropdown-item v-model="status" label="状态" :options="statusOptions" @change="selectStatus" />
</t-dropdown-menu>
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
<t-cell-group v-if="configList.length" theme="card" class="list">
<t-swipe-cell v-for="(item, index) in configList" :key="item.id" v-entrance-transition="index * 30">
<t-cell class="item" :arrow="!!item.id" :hover="!!item.id" @click="toDetail(item)">
<template #title>
<div class="title-row">
<strong v-text="item.name || '未命名套餐'" />
<t-tag :content="getPackageConfigStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
</div>
</template>
<template #description>
<div class="description">
<span v-text="formatPackageAmount(item.standardAmount)" />
<span v-text="`${item.itemList?.length || 0} 项权益`" />
<span v-text="formatValidity(item)" />
</div>
</template>
</t-cell>
<template v-if="canDelete && item.id" #right>
<t-button class="delete" theme="danger" @click.stop="confirmRemove(item)">删除</t-button>
</template>
</t-swipe-cell>
</t-cell-group>
<div v-else class="empty" v-text="loading ? '正在加载套餐配置' : '暂无套餐配置'" />
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
<nav-bar-action-menu v-if="canCreate" :actions="actionList" />
</template>
<script setup lang="ts">
import { DialogPlugin, type DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import type { GaoPackageConfig, PackageConfigStatus } from "@/types/Package";
import type { NavBarAction } from "@/types/NavBarAction";
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import { useNavBarStore } from "@/store/navBarStore";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "PackageConfigList" });
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.create));
const canDelete = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.delete));
const actionList = computed<NavBarAction[]>(() => canCreate.value ? [{
key: "create",
text: "新增套餐配置",
icon: "add",
theme: "primary",
action: toCreate
}] : []);
const keyword = ref("");
const status = ref("ALL");
const configList = ref<GaoPackageConfig[]>([]);
const pageIndex = ref(0);
const total = ref(0);
const loading = ref(false);
const isRefreshing = ref(false);
const noMore = ref(false);
const statusOptions = [
{ label: "全部状态", value: "ALL" },
{ label: "草稿", value: "DRAFT" },
{ label: "启用", value: "ACTIVE" },
{ label: "停用", value: "INACTIVE" }
];
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
async function loadMore(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
loading.value = true;
try {
const result = await PackageAPI.configList({
index: pageIndex.value,
size: 15,
keyword: keyword.value || undefined,
status: status.value === "ALL" ? undefined : status.value as PackageConfigStatus
});
const list = result.list || [];
configList.value = configList.value.concat(list);
total.value = result.total || 0;
pageIndex.value += 1;
noMore.value = list.length < 15 || total.value <= configList.value.length;
} finally {
loading.value = false;
}
}
async function reload(): Promise<void> {
if (loading.value) {
return;
}
pageIndex.value = 0;
configList.value = [];
total.value = 0;
noMore.value = false;
await loadMore();
}
async function selectStatus(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
status.value = String(value);
await reload();
}
async function toCreate(): Promise<void> {
await router.push("/management/package-configs/create");
}
async function toDetail(item: GaoPackageConfig): Promise<void> {
if (item.id) {
await router.push(`/management/package-configs/${item.id}`);
}
}
function getStatusTheme(statusValue?: PackageConfigStatus): "success" | "warning" | "default" {
return statusValue === "ACTIVE" ? "success" : statusValue === "INACTIVE" ? "warning" : "default";
}
function formatValidity(item: GaoPackageConfig): string {
const begin = item.beginAt ? Time.toDateTime(item.beginAt) : "不限起始";
const end = item.endAt ? Time.toDateTime(item.endAt) : "不限结束";
return `${begin} 至 ${end}`;
}
function confirmRemove(item: GaoPackageConfig): void {
if (!item.id || !canDelete.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "删除套餐配置",
content: `确定删除“${item.name || "未命名套餐"}”?删除后不可再用于购买。`,
confirmBtn: { content: "删除", theme: "danger" },
onConfirm: async () => {
try {
await PackageAPI.configRemove(item.id!);
configList.value = configList.value.filter(config => config.id !== item.id);
total.value = Math.max(0, total.value - 1);
Message.success("套餐配置已删除");
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置删除失败"));
} finally {
dialog.destroy();
}
}
});
}
onMounted(async () => {
navBarStore.clearRight();
navBarStore.setCustomRightVisible(canCreate.value);
try {
await reload();
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-config-list {
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
.filters {
gap: .5rem;
margin: 0 1rem .75rem;
display: flex;
.keyword {
flex: 1;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
}
}
.item {
.title-row,
.description {
gap: .5rem;
display: flex;
align-items: center;
}
.description {
color: var(--td-text-color-secondary);
margin-top: .35rem;
font-size: .8125rem;
flex-wrap: wrap;
}
.delete {
height: 100%;
padding: 0 1rem;
border-radius: 0;
}
}
.empty {
color: var(--td-text-color-secondary);
padding: 2rem 1rem;
text-align: center;
}
}
</style>
+203
View File
@@ -0,0 +1,203 @@
<template>
<div v-can="permissionCodes.packageDelivery.read" class="package-record-detail">
<template v-if="delivery">
<t-cell-group title="发放单信息" theme="card" v-entrance-transition>
<t-cell title="操作类型" :note="getDeliveryOperationText(delivery.operationType)" />
<t-cell title="状态"><template #note><t-tag :content="getDeliveryStatusText(delivery.status)" :theme="getStatusTheme(delivery.status)" variant="light-outline" /></template></t-cell>
<t-cell title="客户套餐" :note="delivery.customerPackageId || '未知'" />
<t-cell title="发放时间" :note="delivery.deliveredAt ? Time.toDateTime(delivery.deliveredAt) : '未知'" />
<t-cell title="提交人" :note="delivery.operator?.name || delivery.operator?.nick || delivery.operatorUserId || '未知'" />
<t-cell title="处理人" :note="delivery.verifier?.name || delivery.verifier?.nick || delivery.verifierUserId || '未处理'" />
<t-cell v-if="delivery.sourceDeliveryId" title="原发放单" :note="delivery.sourceDeliveryId" />
<t-cell v-if="delivery.reason" title="原因" :note="delivery.reason" />
</t-cell-group>
<t-cell-group title="本单明细" theme="card" v-entrance-transition="70">
<t-cell
v-for="item in delivery.itemList || []"
:key="item.customerPackageItemId || item.productNameSnapshot"
:title="item.productNameSnapshot || '产品'"
:note="`${getPackageQuantityText(item.quantity)} ${item.unitSnapshot || ''}`"
/>
<div v-if="!delivery.itemList?.length" class="empty" v-text="'暂无明细'" />
</t-cell-group>
<photo-scroller :model-value="photoURLList" :photo-items="photoItems" title="业务照片" :show-add="false" :show-remove="false" :show-viewer-actions="false" />
<section class="actions" v-if="canVerify || canCancel || canRevoke" v-entrance-transition="140">
<t-button v-if="canVerify && isPending" theme="primary" @click="verify">核销发放</t-button>
<t-button v-if="canVerify && isPending" theme="default" variant="outline" @click="openReasonDialog('reject')">拒绝发放</t-button>
<t-button v-if="canCancel && isPending" theme="default" variant="outline" @click="openReasonDialog('cancel')">撤销发放</t-button>
<t-button v-if="canRevoke && isVerifiedDelivery" theme="danger" variant="outline" @click="toRevoke">整单冲正</t-button>
</section>
</template>
<t-empty v-else :description="isLoading ? '正在加载发放单' : '发放单不存在'" />
<t-dialog v-model:visible="reasonDialogVisible" :title="reasonTitle" :confirm-btn="reasonActionText" @confirm="submitReason">
<t-textarea v-model.trim="reasonText" borderless autosize placeholder="请输入原因" />
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { DialogPlugin } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { DeliveryStatus, GaoPackageDelivery } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getDeliveryOperationText, getDeliveryStatusText, getPackageQuantityText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
type ReasonAction = "reject" | "cancel";
defineOptions({ name: "PackageDeliveryDetail" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const delivery = ref<GaoPackageDelivery>();
const isLoading = ref(false);
const isOperating = ref(false);
const reasonDialogVisible = ref(false);
const reasonText = ref("");
const reasonAction = ref<ReasonAction>("reject");
const isPending = computed(() => delivery.value?.operationType === "DELIVER" && delivery.value.status === "PENDING");
const isVerifiedDelivery = computed(() => delivery.value?.operationType === "DELIVER" && delivery.value.status === "VERIFIED");
const canVerify = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.verify));
const canCancel = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.create));
const canRevoke = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.revoke));
const reasonTitle = computed(() => reasonAction.value === "reject" ? "拒绝发放" : "撤销发放");
const reasonActionText = computed(() => reasonAction.value === "reject" ? "拒绝" : "撤销");
const photoItems = computed<PhotoItem[]>(() => (delivery.value?.attachmentList || [])
.filter(item => !!item.id)
.map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
url: getAttachmentReadURL(item.id || "")
})));
const photoURLList = computed(() => photoItems.value.map(item => item.url));
function getStatusTheme(status?: DeliveryStatus): "success" | "danger" | "default" {
return status === "VERIFIED" ? "success" : status === "REJECTED" ? "danger" : "default";
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
delivery.value = await PackageAPI.deliveryDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "发放单加载失败"));
} finally {
isLoading.value = false;
}
}
async function refreshRelatedPackage(): Promise<void> {
if (delivery.value?.customerPackageId) {
await PackageAPI.customerPackageDetail(delivery.value.customerPackageId);
}
}
async function verify(): Promise<void> {
if (!delivery.value?.id || !isPending.value || !canVerify.value || isOperating.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "核销发放",
content: "核销后会增加已发数量,所有权益完成核销后套餐可能变为已履约。确定继续?",
confirmBtn: "核销",
onConfirm: async () => {
isOperating.value = true;
try {
await PackageAPI.verifyDelivery(delivery.value!.id!);
await refreshRelatedPackage();
Message.success("发放单已核销");
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "发放核销失败,请刷新后确认状态"));
} finally {
isOperating.value = false;
dialog.destroy();
}
}
});
}
function openReasonDialog(action: ReasonAction): void {
if (isOperating.value || !isPending.value) {
return;
}
reasonAction.value = action;
reasonText.value = "";
reasonDialogVisible.value = true;
}
async function submitReason(): Promise<void> {
if (!delivery.value?.id || !reasonText.value.trim() || isOperating.value) {
Message.warning("请输入原因");
return;
}
isOperating.value = true;
try {
if (reasonAction.value === "reject") {
await PackageAPI.rejectDelivery(delivery.value.id, reasonText.value.trim());
} else {
await PackageAPI.cancelDelivery(delivery.value.id, reasonText.value.trim());
}
await refreshRelatedPackage();
reasonDialogVisible.value = false;
Message.success(reasonAction.value === "reject" ? "发放单已拒绝" : "发放单已撤销");
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "发放状态操作失败,请刷新后确认状态"));
} finally {
isOperating.value = false;
}
}
async function toRevoke(): Promise<void> {
if (delivery.value?.id && isVerifiedDelivery.value && canRevoke.value) {
await router.push(`/management/package-deliveries/${delivery.value.id}/revoke`);
}
}
onMounted(loadData);
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-record-detail {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
:deep(.photo-scroller) {
padding: 0 1rem;
}
.actions {
gap: .5rem;
padding: 0 1rem;
display: flex;
flex-wrap: wrap;
.t-button {
flex: 1 1 8rem;
}
}
.empty {
color: var(--td-text-color-secondary);
padding: 1rem;
text-align: center;
}
}
</style>
+157
View File
@@ -0,0 +1,157 @@
<template>
<div v-can="permissionCodes.packageDelivery.read" class="package-record-list">
<div class="filters">
<t-dropdown-menu>
<t-dropdown-item v-model="status" label="状态" :options="statusOptions" @change="selectStatus" />
<t-dropdown-item v-model="operationType" label="操作类型" :options="operationOptions" @change="selectOperation" />
</t-dropdown-menu>
</div>
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
<t-cell-group v-if="deliveryList.length" theme="card">
<t-cell
v-for="(item, index) in deliveryList"
:key="item.id"
:title="`${getDeliveryOperationText(item.operationType)} · ${getDeliveryStatusText(item.status)}`"
:description="getDescription(item)"
arrow
hover
v-entrance-transition="index * 30"
@click="toDetail(item)"
>
<template #note><span v-text="getItemSummary(item)" /></template>
</t-cell>
</t-cell-group>
<div v-else class="empty" v-text="loading ? '正在加载发放记录' : '暂无发放记录'" />
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
</template>
<script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import PackageAPI from "@/api/PackageAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { DeliveryOperationType, DeliveryStatus, GaoPackageDelivery } from "@/types/Package";
import { getDeliveryOperationText, getDeliveryStatusText, getPackageQuantityText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "PackageDeliveryList" });
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const deliveryList = ref<GaoPackageDelivery[]>([]);
const pageIndex = ref(0);
const total = ref(0);
const loading = ref(false);
const isRefreshing = ref(false);
const noMore = ref(false);
const status = ref("ALL");
const operationType = ref("ALL");
const statusOptions = [
{ label: "全部状态", value: "ALL" },
{ label: "待核销", value: "PENDING" },
{ label: "已核销", value: "VERIFIED" },
{ label: "已拒绝", value: "REJECTED" },
{ label: "已撤销", value: "CANCELLED" },
{ label: "已冲正", value: "REVOKED" }
];
const operationOptions = [
{ label: "全部类型", value: "ALL" },
{ label: "正常发放", value: "DELIVER" },
{ label: "发放冲正", value: "REVOKE" }
];
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
async function loadMore(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
loading.value = true;
try {
const result = await PackageAPI.deliveryList({
index: pageIndex.value,
size: 15,
customerPackageId: String(route.query.customerPackageId || "") || undefined,
status: status.value === "ALL" ? undefined : status.value as DeliveryStatus,
operationType: operationType.value === "ALL" ? undefined : operationType.value as DeliveryOperationType
});
const list = result.list || [];
deliveryList.value = deliveryList.value.concat(list);
total.value = result.total || 0;
pageIndex.value += 1;
noMore.value = list.length < 15 || total.value <= deliveryList.value.length;
} finally {
loading.value = false;
}
}
async function reload(): Promise<void> {
if (loading.value) {
return;
}
pageIndex.value = 0;
deliveryList.value = [];
total.value = 0;
noMore.value = false;
await loadMore();
}
async function selectStatus(value: DropdownValue): Promise<void> {
if (!Array.isArray(value)) {
status.value = String(value);
await reload();
}
}
async function selectOperation(value: DropdownValue): Promise<void> {
if (!Array.isArray(value)) {
operationType.value = String(value);
await reload();
}
}
function getDescription(item: GaoPackageDelivery): string {
return `${item.customerPackageId || "未知客户套餐"}|${item.deliveredAt ? Time.toDateTime(item.deliveredAt) : "时间未知"}`;
}
function getItemSummary(item: GaoPackageDelivery): string {
return (item.itemList || []).map(detail => `${detail.productNameSnapshot || "产品"} ${getPackageQuantityText(detail.quantity)} ${detail.unitSnapshot || ""}`).join("、") || "无明细";
}
async function toDetail(item: GaoPackageDelivery): Promise<void> {
if (item.id) {
await router.push(`/management/package-deliveries/${item.id}`);
}
}
onMounted(async () => {
navBarStore.clearRight();
try {
await reload();
} catch (error) {
Message.error(getErrorMessage(error, "发放记录加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-record-list {
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
.filters {
margin-bottom: .75rem;
}
.empty {
color: var(--td-text-color-secondary);
padding: 2rem 1rem;
text-align: center;
}
}
</style>
+152
View File
@@ -0,0 +1,152 @@
<template>
<div v-can="permissionCodes.packageDelivery.revoke" class="package-operation-edit">
<t-cell-group v-if="delivery" title="原发放单" theme="card" v-entrance-transition>
<t-cell title="发放单号" :note="delivery.id" />
<t-cell title="客户套餐" :note="delivery.customerPackageId || '未知'" />
<t-cell title="原状态" :note="getDeliveryStatusText(delivery.status)" />
<t-cell title="原明细" :note="getItemSummary" />
</t-cell-group>
<t-form v-if="delivery" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
<t-form-item label="冲正原因" required-mark><t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入冲正原因" /></t-form-item>
<t-form-item label="冲正时间" arrow @click="pickerVisible = true"><span class="picker-text" v-text="occurredAtText || '使用当前时间'" /></t-form-item>
<t-form-item label="备注"><t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="备注(可选)" /></t-form-item>
</t-form>
<package-photo-field v-if="delivery" v-model="photoItems" required title="冲正凭证照片" />
<t-empty v-if="!delivery" :description="isLoading ? '正在加载发放单' : '发放单不存在'" />
<t-popup v-model="pickerVisible" placement="bottom">
<t-date-time-picker v-model="draftTime" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="冲正时间" @cancel="pickerVisible = false" @confirm="confirmTime" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoPackageDelivery, PackageAttachment } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, getDeliveryStatusText, getPackageQuantityText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "PackageDeliveryRevoke" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const delivery = ref<GaoPackageDelivery>();
const reason = ref("");
const remark = ref("");
const occurredAtText = ref("");
const draftTime = ref("");
const pickerVisible = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
const getItemSummary = computed(() => (delivery.value?.itemList || []).map(item => `${item.productNameSnapshot || "产品"} ${getPackageQuantityText(item.quantity)} ${item.unitSnapshot || ""}`).join("、") || "无明细");
function confirmTime(value: string | number): void {
occurredAtText.value = String(value);
pickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
delivery.value = await PackageAPI.deliveryDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "发放单加载失败"));
} finally {
isLoading.value = false;
}
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
if (!delivery.value?.id || delivery.value.status !== "VERIFIED" || delivery.value.operationType !== "DELIVER") {
Message.warning("只有已核销的正常发放单可以冲正");
return;
}
if (!reason.value.trim()) {
Message.warning("请输入冲正原因");
return;
}
if (!photoItems.value.some(item => item.tempFileId)) {
Message.warning("冲正必须上传至少一张照片");
return;
}
isSaving.value = true;
try {
const result = await PackageAPI.revokeDelivery({
requestNo: createPackageRequestNo(),
deliveryId: delivery.value.id,
deliveredAt: resolveTimestamp(occurredAtText.value),
reason: reason.value.trim(),
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("发放单已冲正");
if (result.customerPackageId) {
await router.replace(`/management/customer-packages/${result.customerPackageId}`);
return;
}
await router.replace(`/management/package-deliveries/${delivery.value.id}`);
} catch (error) {
Message.error(getErrorMessage(error, "发放冲正失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.packageDelivery.revoke)) {
navBarStore.setRightText("提交冲正", "var(--td-danger-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.package-photo-field {
padding: 0 1rem;
}
}
</style>