add point redemption
This commit is contained in:
+66
-2
@@ -7,11 +7,20 @@ import type {
|
||||
PointLedger,
|
||||
PointLedgerPage,
|
||||
PointRule,
|
||||
GroovyScript
|
||||
GroovyScript,
|
||||
PointRedeemSku,
|
||||
PointRedeemSkuPage,
|
||||
PointRedemption,
|
||||
PointRedemptionCreateRequest,
|
||||
PointRedemptionPage,
|
||||
PointRedemptionReturn,
|
||||
PointRedemptionReturnRequest
|
||||
} from "@/types/Point";
|
||||
import { getCurrentStoreId, withCurrentStoreId, withCurrentStorePage } from "@/utils/store";
|
||||
|
||||
const BASE_URI = "/gao/customer/point";
|
||||
const REDEEM_SKU_URI = "/gao/point/redeem-sku";
|
||||
const REDEMPTION_URI = "/gao/point/redemption";
|
||||
|
||||
type ListOptions = {
|
||||
allStores?: boolean;
|
||||
@@ -118,6 +127,50 @@ async function ruleRemove(id: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function redeemSkuList(page: PointRedeemSkuPage): Promise<PageResult<PointRedeemSku>> {
|
||||
return await axios.post(`${REDEEM_SKU_URI}/list`, { ...page, storeId: page.storeId || getCurrentStoreId() });
|
||||
}
|
||||
|
||||
async function redeemSkuDetail(id: string, storeId?: string): Promise<PointRedeemSku> {
|
||||
return await axios.post(`${REDEEM_SKU_URI}/detail`, undefined, { params: { id, storeId: storeId || getCurrentStoreId() || undefined } });
|
||||
}
|
||||
|
||||
async function redeemSkuCreate(item: PointRedeemSku): Promise<PointRedeemSku> {
|
||||
return await axios.post(`${REDEEM_SKU_URI}/create`, withCurrentStoreId(item));
|
||||
}
|
||||
|
||||
async function redeemSkuUpdate(item: PointRedeemSku): Promise<PointRedeemSku> {
|
||||
return await axios.post(`${REDEEM_SKU_URI}/update`, item);
|
||||
}
|
||||
|
||||
async function redeemSkuDisable(id: string): Promise<void> {
|
||||
await axios.post(`${REDEEM_SKU_URI}/disable`, undefined, { params: { id, storeId: getCurrentStoreId() || undefined } });
|
||||
}
|
||||
|
||||
async function redeemSkuRemove(id: string): Promise<void> {
|
||||
await axios.post(`${REDEEM_SKU_URI}/delete`, undefined, { params: { id, storeId: getCurrentStoreId() || undefined } });
|
||||
}
|
||||
|
||||
async function redemptionList(page: PointRedemptionPage): Promise<PageResult<PointRedemption>> {
|
||||
return await axios.post(`${REDEMPTION_URI}/list`, { ...page, storeId: page.storeId || getCurrentStoreId() });
|
||||
}
|
||||
|
||||
async function redemptionDetail(id: string, storeId?: string): Promise<PointRedemption> {
|
||||
return await axios.post(`${REDEMPTION_URI}/detail`, undefined, { params: { id, storeId: storeId || getCurrentStoreId() || undefined } });
|
||||
}
|
||||
|
||||
async function redemptionCreate(request: PointRedemptionCreateRequest): Promise<PointRedemption> {
|
||||
return await axios.post(`${REDEMPTION_URI}/create`, withCurrentStoreId(request));
|
||||
}
|
||||
|
||||
async function redemptionReturn(request: PointRedemptionReturnRequest): Promise<PointRedemption> {
|
||||
return await axios.post(`${REDEMPTION_URI}/return`, request);
|
||||
}
|
||||
|
||||
async function redemptionReturnList(redemptionId: string): Promise<PointRedemptionReturn[]> {
|
||||
return await axios.post(`${REDEMPTION_URI}/return/list`, undefined, { params: { redemptionId, storeId: getCurrentStoreId() || undefined } });
|
||||
}
|
||||
|
||||
export default {
|
||||
list,
|
||||
detailByCustomerId,
|
||||
@@ -130,5 +183,16 @@ export default {
|
||||
ruleCreate,
|
||||
ruleUpdate,
|
||||
ruleSort,
|
||||
ruleRemove
|
||||
ruleRemove,
|
||||
redeemSkuList,
|
||||
redeemSkuDetail,
|
||||
redeemSkuCreate,
|
||||
redeemSkuUpdate,
|
||||
redeemSkuDisable,
|
||||
redeemSkuRemove,
|
||||
redemptionList,
|
||||
redemptionDetail,
|
||||
redemptionCreate,
|
||||
redemptionReturn,
|
||||
redemptionReturnList
|
||||
};
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.pointRedeemSku.manage" class="redeem-sku-edit">
|
||||
<t-form class="form" v-entrance-transition>
|
||||
<product-selector
|
||||
v-model="selection"
|
||||
:selected-product="form.product"
|
||||
required-mark
|
||||
/>
|
||||
<t-form-item label="默认积分" required-mark>
|
||||
<t-input
|
||||
v-model.trim="form.pointText"
|
||||
type="number"
|
||||
align="right"
|
||||
borderless
|
||||
placeholder="每单位所需积分"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="配置状态" content-align="right" arrow @click="statusVisible = true">
|
||||
<span v-text="statusText(form.status)" />
|
||||
</t-form-item>
|
||||
<t-form-item label="累计上限">
|
||||
<t-input v-model.trim="form.totalText" type="number" align="right" borderless placeholder="不限" />
|
||||
</t-form-item>
|
||||
<t-form-item label="每日上限">
|
||||
<t-input v-model.trim="form.dailyText" type="number" align="right" borderless placeholder="不限" />
|
||||
</t-form-item>
|
||||
<t-form-item label="每月上限">
|
||||
<t-input v-model.trim="form.monthlyText" type="number" align="right" borderless placeholder="不限" />
|
||||
</t-form-item>
|
||||
<t-form-item label-width="0">
|
||||
<span class="tips gray">数量上限按兑换业务单位计算,最多三位小数。留空表示不限</span>
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model.trim="form.remark" maxlength="500" autosize />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
<div v-if="form.id" class="actions" v-entrance-transition="70">
|
||||
<t-button class="bg-white" theme="danger" variant="outline" @click="disable">停用配置</t-button>
|
||||
<t-button theme="danger" @click="remove">删除</t-button>
|
||||
</div>
|
||||
</div>
|
||||
<t-popup v-model="statusVisible" placement="bottom">
|
||||
<t-picker
|
||||
v-model="statusDraft"
|
||||
:columns="statusOptions"
|
||||
title="配置状态"
|
||||
@cancel="statusVisible = false"
|
||||
@confirm="confirmStatus"
|
||||
/>
|
||||
</t-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PointAPI from "@/api/PointAPI";
|
||||
import ProductSelector from "@/components/ProductSelector.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { PointRedeemSku, PointRedeemSkuStatus } from "@/types/Point";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { normalizePackageQuantity } from "@/utils/package";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PointRedeemSkuEdit" });
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const selection = ref<{ skuId: string; skuUnitId: string }>();
|
||||
const statusVisible = ref(false);
|
||||
const statusDraft = ref<Array<string | number>>(["DRAFT"]);
|
||||
const isSaving = ref(false);
|
||||
const form = reactive({
|
||||
id: "",
|
||||
product: undefined as PointRedeemSku["product"],
|
||||
pointText: "",
|
||||
status: "DRAFT" as PointRedeemSkuStatus,
|
||||
totalText: "",
|
||||
dailyText: "",
|
||||
monthlyText: "",
|
||||
remark: ""
|
||||
});
|
||||
const statusOptions = [
|
||||
{ label: "草稿", value: "DRAFT" },
|
||||
{ label: "启用", value: "ACTIVE" },
|
||||
{ label: "停用", value: "INACTIVE" }
|
||||
];
|
||||
|
||||
function statusText(status: PointRedeemSkuStatus): string {
|
||||
return ({ DRAFT: "草稿", ACTIVE: "启用", INACTIVE: "停用", DELETED: "已删除" } as Record<string, string>)[status];
|
||||
}
|
||||
|
||||
function parsePoint(): number | undefined {
|
||||
const value = Number(form.pointText);
|
||||
return Number.isSafeInteger(value) && 0 < value ? value : undefined;
|
||||
}
|
||||
|
||||
function parseLimit(value: string): string | null | undefined {
|
||||
return value ? normalizePackageQuantity(value) : null;
|
||||
}
|
||||
|
||||
function confirmStatus(value: Array<string | number>): void {
|
||||
form.status = String(value[0]) as PointRedeemSkuStatus;
|
||||
statusVisible.value = false;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const item = await PointAPI.redeemSkuDetail(id);
|
||||
form.id = item.id || "";
|
||||
form.product = item.product;
|
||||
selection.value = item.skuId && item.skuUnitId ? { skuId: item.skuId, skuUnitId: item.skuUnitId } : undefined;
|
||||
form.pointText = String(item.defaultPointCost || "");
|
||||
form.status = item.status || "DRAFT";
|
||||
form.totalText = item.maxTotalQuantity === null || item.maxTotalQuantity === undefined ? "" : String(item.maxTotalQuantity);
|
||||
form.dailyText = item.maxDailyQuantity === null || item.maxDailyQuantity === undefined ? "" : String(item.maxDailyQuantity);
|
||||
form.monthlyText = item.maxMonthlyQuantity === null || item.maxMonthlyQuantity === undefined ? "" : String(item.maxMonthlyQuantity);
|
||||
form.remark = item.remark || "";
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const point = parsePoint();
|
||||
const total = parseLimit(form.totalText);
|
||||
const daily = parseLimit(form.dailyText);
|
||||
const monthly = parseLimit(form.monthlyText);
|
||||
if (isSaving.value || !selection.value || !point || total === undefined || daily === undefined || monthly === undefined) {
|
||||
Message.warning("请完整填写产品、积分和合法数量上限");
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const data: PointRedeemSku = {
|
||||
id: form.id || undefined,
|
||||
skuId: selection.value.skuId,
|
||||
skuUnitId: selection.value.skuUnitId,
|
||||
status: form.status,
|
||||
defaultPointCost: point,
|
||||
maxTotalQuantity: total,
|
||||
maxDailyQuantity: daily,
|
||||
maxMonthlyQuantity: monthly,
|
||||
remark: form.remark || null
|
||||
};
|
||||
if (form.id) {
|
||||
await PointAPI.redeemSkuUpdate(data);
|
||||
} else {
|
||||
await PointAPI.redeemSkuCreate(data);
|
||||
}
|
||||
Message.success("兑换配置已保存");
|
||||
await router.replace({ name: "PointRedeemSkuList" });
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换配置保存失败"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disable(): Promise<void> {
|
||||
if (!form.id || isSaving.value) {
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await PointAPI.redeemSkuDisable(form.id);
|
||||
Message.success("兑换配置已停用");
|
||||
await router.replace({ name: "PointRedeemSkuList" });
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换配置停用失败"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(): Promise<void> {
|
||||
if (!form.id || isSaving.value) {
|
||||
return;
|
||||
}
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await PointAPI.redeemSkuRemove(form.id);
|
||||
Message.success("兑换配置已标记删除");
|
||||
await router.replace({ name: "PointRedeemSkuList" });
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换配置删除失败"));
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.setRightText("保存", "var(--td-brand-color)", save);
|
||||
try {
|
||||
await load();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换配置加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.redeem-sku-edit {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
flex-direction: column;
|
||||
|
||||
.tips {
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
--td-button-border-radius: 1rem;
|
||||
|
||||
gap: .75rem;
|
||||
margin: 0 1rem;
|
||||
display: flex;
|
||||
|
||||
.t-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.pointRedeemSku.manage" class="redeem-sku-list">
|
||||
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="load">
|
||||
<t-cell-group v-if="result.list.length" theme="card">
|
||||
<t-cell
|
||||
v-for="(item, index) in result.list"
|
||||
:key="item.id"
|
||||
arrow
|
||||
v-entrance-transition="index * 30"
|
||||
@click="toEdit(item)"
|
||||
>
|
||||
<template #title>
|
||||
<div class="title">
|
||||
<strong v-text="item.product?.name || item.sku?.name || '未命名产品'" />
|
||||
<t-tag
|
||||
size="small"
|
||||
:theme="item.status === 'ACTIVE' ? 'success' : 'default'"
|
||||
:content="statusText(item.status)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<span
|
||||
v-text="`${item.sku?.code || item.sku?.specSummary || '默认 SKU'} · ${item.unit?.name || '单位未知'} · ${formatPoint(item.defaultPointCost)} / 单位`"
|
||||
/>
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
<t-empty v-else :description="isLoading ? '正在加载兑换配置' : '暂无兑换配置'" />
|
||||
<div class="tips" v-text="isNoMore ? `共 ${result.total} 条配置` : '上拉加载更多'" />
|
||||
</t-pull-down-refresh>
|
||||
</div>
|
||||
<nav-bar-action-menu
|
||||
v-can="permissionCodes.pointRedeemSku.manage"
|
||||
:actions="[{ key: 'create', icon: 'add', text: '新增兑换配置', theme: 'primary', action: toCreate }]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PointAPI from "@/api/PointAPI";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { PointRedeemSku, PointRedeemSkuStatus } from "@/types/Point";
|
||||
import { Message } from "@/utils/message";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PointRedeemSkuList" });
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const result = reactive({ total: 0, list: [] as PointRedeemSku[] });
|
||||
const index = ref(0);
|
||||
const isLoading = ref(false);
|
||||
const isRefreshing = ref(false);
|
||||
const isNoMore = ref(false);
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
index.value = 0;
|
||||
result.total = 0;
|
||||
result.list = [];
|
||||
isNoMore.value = false;
|
||||
try {
|
||||
await load();
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (isLoading.value || isNoMore.value) {
|
||||
return;
|
||||
}
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const page = await PointAPI.redeemSkuList({ index: index.value, size: 20 });
|
||||
const list = page.list || [];
|
||||
result.list = result.list.concat(list);
|
||||
result.total = page.total || 0;
|
||||
index.value++;
|
||||
isNoMore.value = result.total <= result.list.length || list.length < 20;
|
||||
} catch {
|
||||
Message.error("兑换配置加载失败");
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPoint(value?: number): string {
|
||||
return `${(value || 0).toLocaleString("zh-CN")} 分`;
|
||||
}
|
||||
|
||||
function statusText(status?: PointRedeemSkuStatus): string {
|
||||
return ({
|
||||
DRAFT: "草稿",
|
||||
ACTIVE: "启用",
|
||||
INACTIVE: "停用",
|
||||
DELETED: "已删除"
|
||||
} as Record<string, string>)[status || ""] || "未知";
|
||||
}
|
||||
|
||||
async function toCreate(): Promise<void> {
|
||||
await router.push({ name: "PointRedeemSkuCreate" });
|
||||
}
|
||||
|
||||
async function toEdit(item: PointRedeemSku): Promise<void> {
|
||||
if (item.id) {
|
||||
await router.push({ name: "PointRedeemSkuEdit", params: { id: item.id } });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.setCustomRightVisible(true);
|
||||
await reload();
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.redeem-sku-list {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 5rem;
|
||||
|
||||
.title {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tips {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.pointRedemption.create" class="redemption-create">
|
||||
<t-form class="form" label-width="7rem" v-entrance-transition>
|
||||
<t-form-item label="客户" required-mark>
|
||||
<t-input
|
||||
:model-value="customerName || '请选择客户'"
|
||||
class="customer"
|
||||
readonly
|
||||
borderless
|
||||
placeholder="选择或扫码客户"
|
||||
@click="customerVisible = true"
|
||||
>
|
||||
<template #suffix-icon>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
size="extra-small"
|
||||
@click.stop="customerVisible = true"
|
||||
>选择</t-button>
|
||||
<t-button theme="primary" size="extra-small" @click.stop="toggleScanner">
|
||||
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
</t-input>
|
||||
</t-form-item>
|
||||
<div v-if="scannerVisible" class="code-scanner-wrapper">
|
||||
<code-scanner
|
||||
visible
|
||||
:status-text="scanStatus"
|
||||
active-text="正在识别客户编码,请将条码或二维码移入取景框。"
|
||||
@decode="handleDecodedText"
|
||||
@status-change="handleScanStatusChange"
|
||||
/>
|
||||
</div>
|
||||
<customer-profile-card
|
||||
v-if="customer"
|
||||
class="customer-profile"
|
||||
:customer="customer"
|
||||
v-entrance-transition
|
||||
/>
|
||||
<t-form-item v-if="customer" label="积分余额" content-align="right">
|
||||
<span v-text="pointBalanceText" />
|
||||
</t-form-item>
|
||||
<t-form-item label="兑换商品" required-mark content-align="right" arrow @click="skuVisible = true">
|
||||
<span v-text="skuName || '请选择兑换商品'" />
|
||||
</t-form-item>
|
||||
<t-form-item label="兑换数量" content-align="right" required-mark>
|
||||
<t-stepper
|
||||
v-model="quantity"
|
||||
size="large"
|
||||
:min="quantityStep"
|
||||
:step="quantityStep"
|
||||
:integer="quantityIsInteger"
|
||||
theme="outline"
|
||||
/>
|
||||
</t-form-item>
|
||||
<t-form-item label="预计消耗积分" content-align="right">
|
||||
<span v-text="estimatedPointText" />
|
||||
</t-form-item>
|
||||
<template v-if="canAdjust">
|
||||
<t-form-item label="实际消耗扣分">
|
||||
<t-input v-model.trim="actualPointText" type="number" align="right" borderless placeholder="默认扣分" />
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model="remark" maxlength="500" autosize />
|
||||
</t-form-item>
|
||||
</template>
|
||||
<div class="photo-wrap">
|
||||
<photo-scroller
|
||||
v-model="photoUrls"
|
||||
v-model:photo-items="photos"
|
||||
:upload="uploadPhotoList"
|
||||
edit-mode
|
||||
required
|
||||
title="兑换凭证照片"
|
||||
/>
|
||||
</div>
|
||||
</t-form>
|
||||
</div>
|
||||
<customer-selector
|
||||
v-model="customerId"
|
||||
v-model:visible="customerVisible"
|
||||
:enable-scan="false"
|
||||
@change="loadCustomer"
|
||||
/>
|
||||
<bottom-popup
|
||||
v-model="skuVisible"
|
||||
title="选择兑换商品"
|
||||
height="70vh"
|
||||
right-button-text=""
|
||||
@left-button-click="closeSkuPicker"
|
||||
>
|
||||
<div class="sku-picker">
|
||||
<t-cell
|
||||
v-for="item in skuList"
|
||||
:key="item.id"
|
||||
:title="item.product?.name || item.sku?.name || '未命名产品'"
|
||||
:note="`${item.sku?.specSummary || item.sku?.code || '默认 SKU'} · ${item.unit?.name || ''} · ${formatPoint(item.defaultPointCost)} / 单位`"
|
||||
arrow
|
||||
@click="selectSku(item)"
|
||||
/>
|
||||
<t-empty v-if="!skuList.length" description="暂无启用的兑换商品" />
|
||||
</div>
|
||||
</bottom-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from "timi-web-api";
|
||||
import { BottomPopup } from "timi-tdesign-mobile";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import PointAPI from "@/api/PointAPI";
|
||||
import CodeScanner from "@/components/CodeScanner.vue";
|
||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import { useSettingStore } from "@/store/settingStore";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import type { PointAccount, PointRedeemSku } from "@/types/Point";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { normalizePackageQuantity } from "@/utils/package";
|
||||
import { getProductQuantityStep } from "@/utils/product";
|
||||
import { uploadPhotoList } from "@/utils/photo";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PointRedemptionCreate" });
|
||||
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const settingStore = useSettingStore();
|
||||
const userStore = useUserStore();
|
||||
const customerId = ref("");
|
||||
const customer = ref<Customer>();
|
||||
const pointAccount = ref<PointAccount>();
|
||||
const pointAccountLoading = ref(false);
|
||||
const customerVisible = ref(false);
|
||||
const skuVisible = ref(false);
|
||||
const skuList = ref<PointRedeemSku[]>([]);
|
||||
const selectedSku = ref<PointRedeemSku>();
|
||||
const quantity = ref("1");
|
||||
const actualPointText = ref("");
|
||||
const remark = ref("");
|
||||
const photos = ref<PhotoItem[]>([]);
|
||||
const requestNo = ref("");
|
||||
const saving = ref(false);
|
||||
const canReadPointAccount = computed(() => userStore.hasPermissions(permissionCodes.point.accountRead));
|
||||
const canAdjust = computed(() => userStore.hasPermissions(permissionCodes.pointRedemption.pointAdjust));
|
||||
const photoUrls = computed(() => photos.value.map(item => item.url));
|
||||
const customerName = computed(() => customer.value?.name);
|
||||
const skuName = computed(() => selectedSku.value ? `${selectedSku.value.product?.name || selectedSku.value.sku?.name || "未命名产品"} · ${selectedSku.value.unit?.name || ""}` : "");
|
||||
const quantityStep = computed(() => getProductQuantityStep(selectedSku.value?.sku));
|
||||
const quantityIsInteger = computed(() => !selectedSku.value?.sku?.allowDecimalQuantity);
|
||||
const estimatedPointText = computed(() => {
|
||||
const unitPoint = selectedSku.value?.defaultPointCost || 0;
|
||||
const count = Number(quantity.value);
|
||||
return Number.isFinite(count) && 0 < count ? formatPoint(Math.round(unitPoint * count)) : "填写数量后计算";
|
||||
});
|
||||
const pointBalanceText = computed(() => {
|
||||
if (!canReadPointAccount.value) {
|
||||
return "无余额读取权限";
|
||||
}
|
||||
return pointAccountLoading.value ? "正在加载" : `${(pointAccount.value?.balance ?? 0).toLocaleString("zh-CN")} 分`;
|
||||
});
|
||||
const scannerVisible = ref(false);
|
||||
const scanStatus = ref("点击扫码开始识别。");
|
||||
let latestDecodedText = "";
|
||||
|
||||
|
||||
async function loadCustomer(value: string | string[]): Promise<void> {
|
||||
const id = Array.isArray(value) ? value[0] : value;
|
||||
if (!id) {
|
||||
customer.value = undefined;
|
||||
pointAccount.value = undefined;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
customer.value = await CustomerAPI.detail(id);
|
||||
if (canReadPointAccount.value) {
|
||||
await loadPointAccount(id, customer.value.storeId);
|
||||
}
|
||||
} catch {
|
||||
customer.value = undefined;
|
||||
pointAccount.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPointAccount(customerIdValue: string, storeId?: string): Promise<void> {
|
||||
pointAccountLoading.value = true;
|
||||
try {
|
||||
pointAccount.value = await PointAPI.detailByCustomerId(customerIdValue, storeId);
|
||||
} catch {
|
||||
pointAccount.value = undefined;
|
||||
} finally {
|
||||
pointAccountLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleScanner(): void {
|
||||
scannerVisible.value = !scannerVisible.value;
|
||||
if (scannerVisible.value) {
|
||||
latestDecodedText = "";
|
||||
scanStatus.value = "点击扫码开始识别。";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecodedText(rawText: string): Promise<void> {
|
||||
const normalizedText = rawText.trim();
|
||||
if (!normalizedText || normalizedText === latestDecodedText) {
|
||||
return;
|
||||
}
|
||||
latestDecodedText = normalizedText;
|
||||
await settingStore.playScanSuccessSound();
|
||||
await applyScanCode(normalizedText);
|
||||
}
|
||||
|
||||
async function applyScanCode(code: string): Promise<void> {
|
||||
scanStatus.value = `已识别 ${code},正在加载客户档案。`;
|
||||
try {
|
||||
const result = await CustomerAPI.findByCode(code);
|
||||
if (!result?.id) {
|
||||
customer.value = undefined;
|
||||
customerId.value = "";
|
||||
scanStatus.value = `客户 ${code} 不存在,请先建档。`;
|
||||
return;
|
||||
}
|
||||
customer.value = result;
|
||||
customerId.value = result.id;
|
||||
if (canReadPointAccount.value) {
|
||||
await loadPointAccount(result.id, result.storeId);
|
||||
}
|
||||
scanStatus.value = `已识别 ${result.code || code},已自动选中 ${result.name || "未命名客户"}。`;
|
||||
scannerVisible.value = false;
|
||||
} catch (error) {
|
||||
scanStatus.value = getErrorMessage(error, "客户查询失败,请重试。");
|
||||
}
|
||||
}
|
||||
|
||||
function handleScanStatusChange(value: string): void {
|
||||
scanStatus.value = value;
|
||||
}
|
||||
|
||||
async function loadSkuList(): Promise<void> {
|
||||
const page = await PointAPI.redeemSkuList({ index: 0, size: 1000, status: "ACTIVE" });
|
||||
skuList.value = page.list || [];
|
||||
}
|
||||
|
||||
function selectSku(item: PointRedeemSku): void {
|
||||
selectedSku.value = item;
|
||||
skuVisible.value = false;
|
||||
}
|
||||
|
||||
function closeSkuPicker(): void {
|
||||
skuVisible.value = false;
|
||||
}
|
||||
|
||||
function attachments(): Array<{ tempFileId: string; attachType: "PHOTO"; name?: string }> {
|
||||
return photos.value.flatMap(item => item.tempFileId ? [{
|
||||
tempFileId: item.tempFileId,
|
||||
attachType: "PHOTO" as const
|
||||
}] : []);
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const normalized = normalizePackageQuantity(quantity.value);
|
||||
const actual = actualPointText.value ? Number(actualPointText.value) : undefined;
|
||||
if (
|
||||
saving.value
|
||||
|| !customerId.value
|
||||
|| !selectedSku.value?.id
|
||||
|| !normalized
|
||||
|| !attachments().length
|
||||
|| (actual !== undefined && (!canAdjust.value || !Number.isSafeInteger(actual) || actual < 0))
|
||||
) {
|
||||
Message.warning("请填写客户、商品、正数数量并上传兑换照片");
|
||||
return;
|
||||
}
|
||||
if (!requestNo.value) {
|
||||
requestNo.value = window.crypto.randomUUID();
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const result = await PointAPI.redemptionCreate({
|
||||
requestNo: requestNo.value,
|
||||
customerId: customerId.value,
|
||||
redeemSkuId: selectedSku.value.id,
|
||||
quantity: normalized,
|
||||
actualPointCost: actual,
|
||||
attachmentList: attachments(),
|
||||
remark: remark.value || null
|
||||
});
|
||||
Message.success("兑换已完成");
|
||||
await router.replace({ name: "PointRedemptionDetail", params: { id: result.id } });
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换失败,重试将复用同一请求号"));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPoint(value?: number): string {
|
||||
return `${(value || 0).toLocaleString("zh-CN")} 分`;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.setRightText("兑换", "var(--td-brand-color)", save);
|
||||
try {
|
||||
await loadSkuList();
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换商品加载失败"));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => navBarStore.clearRight());
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.redemption-create {
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
|
||||
.form {
|
||||
margin: 0 1rem;
|
||||
padding: .5rem 0;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
box-shadow: var(--td-shadow-1);
|
||||
border-radius: 1.4rem;
|
||||
|
||||
.customer {
|
||||
.actions {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.code-scanner-wrapper,
|
||||
.photo-wrap {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.customer-profile {
|
||||
margin: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sku-picker {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.redemption-create {
|
||||
.form {
|
||||
background: rgba(15, 23, 42, .88);
|
||||
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.pointRedemption.create" class="redemption-detail">
|
||||
<customer-profile-card
|
||||
v-if="customer"
|
||||
class="customer-profile"
|
||||
:customer="customer"
|
||||
v-entrance-transition
|
||||
/>
|
||||
<t-cell-group v-if="item" title="兑换信息" theme="card">
|
||||
<t-cell title="客户" :note="item.customer?.name || item.customerId || '-'" />
|
||||
<t-cell title="兑换商品" :note="item.productNameSnapshot || item.skuNameSnapshot || '-'" />
|
||||
<t-cell title="兑换数量" :note="`${quantityText(item.quantity)} ${item.unitSnapshot || ''}`" />
|
||||
<t-cell title="积分" :note="`${formatPoint(item.actualPointCost)}${item.actualPointCost !== item.defaultPointCost ? '(已减免)' : ''}`" />
|
||||
<t-cell title="状态" :note="statusText(item.status)" />
|
||||
<t-cell title="兑换时间" :note="timeText(item.redeemedAt)" />
|
||||
<t-cell v-if="item.remark" title="备注" :note="item.remark" />
|
||||
<div class="photo-wrap bg-white">
|
||||
<photo-scroller
|
||||
:model-value="attachmentUrls"
|
||||
:show-add="false"
|
||||
:show-remove="false"
|
||||
:show-viewer-actions="false"
|
||||
title="兑换凭证照片"
|
||||
/>
|
||||
</div>
|
||||
</t-cell-group>
|
||||
<t-cell-group v-if="returnList.length" title="退货记录" theme="card">
|
||||
<t-cell
|
||||
v-for="record in returnList"
|
||||
:key="record.id"
|
||||
:title="`退货 ${quantityText(record.quantity)} ${item?.unitSnapshot || ''}`"
|
||||
:note="`${formatPoint(record.pointCost)} · ${timeText(record.returnedAt)}`"
|
||||
/>
|
||||
</t-cell-group>
|
||||
<t-empty v-if="!item" :description="loading ? '正在加载兑换单' : '兑换单不存在'" />
|
||||
<t-button v-if="canReturn" class="return-button cell-button" theme="danger" @click="returnVisible = true">
|
||||
冲正兑换
|
||||
</t-button>
|
||||
</div>
|
||||
<bottom-popup
|
||||
v-model="returnVisible"
|
||||
title="冲正兑换"
|
||||
left-button-text="取消"
|
||||
:right-button-text="returning ? '提交中' : '确认冲正'"
|
||||
@left-button-click="returnVisible = false"
|
||||
@right-button-click="submitReturn"
|
||||
>
|
||||
<div class="return-form">
|
||||
<t-form class="form" label-width="6rem">
|
||||
<t-form-item label="剩余数量">
|
||||
<span v-text="`${remainText} ${item?.unitSnapshot || ''}`" />
|
||||
</t-form-item>
|
||||
<t-form-item label="冲正数量" required-mark>
|
||||
<t-input v-model.trim="returnQuantity" type="number" align="right" borderless />
|
||||
</t-form-item>
|
||||
<t-form-item label="冲正原因">
|
||||
<t-textarea v-model.trim="returnRemark" maxlength="500" autosize />
|
||||
</t-form-item>
|
||||
<t-form-item label-width="0">
|
||||
<photo-scroller
|
||||
v-model="returnPhotoUrls"
|
||||
v-model:photo-items="returnPhotos"
|
||||
:upload="uploadPhotoList"
|
||||
edit-mode
|
||||
required
|
||||
title="冲正凭证照片"
|
||||
/>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</div>
|
||||
</bottom-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BottomPopup } from "timi-tdesign-mobile";
|
||||
import { AttachmentAPI, useUserStore } from "timi-web-api";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import PointAPI from "@/api/PointAPI";
|
||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { PhotoItem } from "@/types/Photo";
|
||||
import type { PointRedemption, PointRedemptionReturn, PointRedemptionStatus } from "@/types/Point";
|
||||
import { getErrorMessage } from "@/utils/error";
|
||||
import { Message } from "@/utils/message";
|
||||
import { comparePackageQuantity, getPackageQuantityText, normalizePackageQuantity } from "@/utils/package";
|
||||
import { uploadPhotoList } from "@/utils/photo";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PointRedemptionDetail" });
|
||||
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const item = ref<PointRedemption>();
|
||||
const customer = ref<Customer>();
|
||||
const returnList = ref<PointRedemptionReturn[]>([]);
|
||||
const loading = ref(false);
|
||||
const returnVisible = ref(false);
|
||||
const returnQuantity = ref("");
|
||||
const returnRemark = ref("");
|
||||
const returnPhotos = ref<PhotoItem[]>([]);
|
||||
const returnRequestNo = ref("");
|
||||
const returning = ref(false);
|
||||
const attachmentUrls = computed(() => (item.value?.attachmentList || [])
|
||||
.filter(attachment => !!attachment.id)
|
||||
.map(attachment => AttachmentAPI.getReadURL(attachment.id!)));
|
||||
const returnPhotoUrls = computed(() => returnPhotos.value.map(photo => photo.url));
|
||||
const canReturn = computed(() => userStore.hasPermissions(permissionCodes.pointRedemption.return)
|
||||
&& (item.value?.status === "COMPLETED" || item.value?.status === "PARTIALLY_RETURNED")
|
||||
&& 0 < comparePackageQuantity(remaining(), "0"));
|
||||
const remainText = computed(() => quantityText(remaining()));
|
||||
|
||||
function remaining(): string {
|
||||
const total = Number(item.value?.quantity || 0);
|
||||
const returned = Number(item.value?.returnedQuantity || 0);
|
||||
return String(Math.max(0, total - returned));
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const id = String(route.params.id || "");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
item.value = await PointAPI.redemptionDetail(id);
|
||||
customer.value = item.value.customer;
|
||||
if (!customer.value && item.value.customerId) {
|
||||
try {
|
||||
customer.value = await CustomerAPI.detail(item.value.customerId);
|
||||
} catch {
|
||||
customer.value = undefined;
|
||||
}
|
||||
}
|
||||
returnList.value = await PointAPI.redemptionReturnList(id);
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "兑换单加载失败"));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function attachments(): Array<{ tempFileId: string; attachType: "PHOTO"; name?: string }> {
|
||||
return returnPhotos.value.flatMap(photo => photo.tempFileId ? [{
|
||||
tempFileId: photo.tempFileId,
|
||||
attachType: "PHOTO" as const
|
||||
}] : []);
|
||||
}
|
||||
|
||||
async function submitReturn(): Promise<void> {
|
||||
const quantity = normalizePackageQuantity(returnQuantity.value);
|
||||
const redemptionId = item.value?.id;
|
||||
if (
|
||||
returning.value
|
||||
|| !redemptionId
|
||||
|| !quantity
|
||||
|| 0 < comparePackageQuantity(quantity, remaining())
|
||||
|| !attachments().length
|
||||
) {
|
||||
Message.warning("请填写不超过剩余数量的退货数量并上传照片");
|
||||
return;
|
||||
}
|
||||
if (!returnRequestNo.value) {
|
||||
returnRequestNo.value = window.crypto.randomUUID();
|
||||
}
|
||||
returning.value = true;
|
||||
try {
|
||||
item.value = await PointAPI.redemptionReturn({
|
||||
redemptionId,
|
||||
requestNo: returnRequestNo.value,
|
||||
quantity,
|
||||
attachmentList: attachments(),
|
||||
remark: returnRemark.value || null
|
||||
});
|
||||
customer.value = item.value.customer || customer.value;
|
||||
returnList.value = await PointAPI.redemptionReturnList(redemptionId);
|
||||
returnVisible.value = false;
|
||||
returnQuantity.value = "";
|
||||
returnRemark.value = "";
|
||||
returnPhotos.value = [];
|
||||
returnRequestNo.value = "";
|
||||
Message.success("退货已完成");
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, "退货失败,重试将复用同一请求号"));
|
||||
} finally {
|
||||
returning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPoint(value?: number): string {
|
||||
return `${(value || 0).toLocaleString("zh-CN")} 分`;
|
||||
}
|
||||
|
||||
function quantityText(value?: number | string): string {
|
||||
return getPackageQuantityText(value);
|
||||
}
|
||||
|
||||
function statusText(value?: PointRedemptionStatus): string {
|
||||
return ({
|
||||
COMPLETED: "已兑换",
|
||||
PARTIALLY_RETURNED: "部分退货",
|
||||
FULLY_RETURNED: "已退完"
|
||||
} as Record<string, string>)[value || ""] || "未知";
|
||||
}
|
||||
|
||||
function timeText(value?: number): string {
|
||||
return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "-";
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.redemption-detail {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
|
||||
flex-direction: column;
|
||||
|
||||
.return-button {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.customer-profile {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.photo-wrap {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.return-form {
|
||||
padding-bottom: 1rem;
|
||||
|
||||
.form {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,574 @@
|
||||
<template>
|
||||
<div v-can="permissionCodes.pointRedemption.create" class="redemption-list">
|
||||
<div class="filters">
|
||||
<t-input
|
||||
v-model.trim="keyword"
|
||||
class="keyword"
|
||||
clearable
|
||||
borderless
|
||||
placeholder="搜索客户、产品或 SKU"
|
||||
>
|
||||
<template #suffix-icon>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
v-if="customerId"
|
||||
shape="circle"
|
||||
theme="danger"
|
||||
variant="text"
|
||||
size="extra-small"
|
||||
aria-label="清除客户选择"
|
||||
@click.stop="clearCustomer"
|
||||
>
|
||||
<t-icon name="close" size="1rem" />
|
||||
</t-button>
|
||||
<t-button
|
||||
size="extra-small"
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
content="选择客户"
|
||||
@click.stop="customerSelectorVisible = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</t-input>
|
||||
<t-dropdown-menu class="menus">
|
||||
<t-dropdown-item
|
||||
v-model="dateFilterType"
|
||||
:label="dateFilterLabel"
|
||||
:options="dateOptions"
|
||||
@change="selectDateFilter"
|
||||
/>
|
||||
</t-dropdown-menu>
|
||||
</div>
|
||||
<t-pull-down-refresh
|
||||
v-model="refreshing"
|
||||
:refresh-timeout="30000"
|
||||
@refresh="reload"
|
||||
@scrolltolower="load"
|
||||
>
|
||||
<div class="list-wrap">
|
||||
<t-cell-group v-if="isCustomDate" class="date-summary" theme="card">
|
||||
<t-cell
|
||||
title="开始时间"
|
||||
:note="dateRange[0] || '-'"
|
||||
arrow
|
||||
@click="openStartTime"
|
||||
/>
|
||||
<t-cell
|
||||
title="结束时间"
|
||||
:note="dateRange[1] || '-'"
|
||||
arrow
|
||||
@click="openEndTime"
|
||||
/>
|
||||
</t-cell-group>
|
||||
<div v-if="result.list.length" class="items">
|
||||
<t-cell-group theme="card">
|
||||
<t-cell
|
||||
v-for="(item, index) in result.list"
|
||||
:key="item.id"
|
||||
class="item"
|
||||
arrow
|
||||
v-entrance-transition="index * 30"
|
||||
@click="toDetail(item)"
|
||||
>
|
||||
<template #left-icon>
|
||||
<t-image
|
||||
v-if="getCustomerPhoto(item)"
|
||||
:src="getCustomerPhoto(item)"
|
||||
:alt="item.customer?.name || '客户照片'"
|
||||
fit="cover"
|
||||
class="avatar"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="avatar fallback"
|
||||
v-text="(item.customer?.name || item.customer?.code || '客').slice(0, 1)"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
<div class="title">
|
||||
<strong v-text="item.productNameSnapshot || item.skuNameSnapshot || '未命名产品'" />
|
||||
<t-tag
|
||||
size="small"
|
||||
:theme="item.status === 'FULLY_RETURNED' ? 'default' : 'success'"
|
||||
:content="statusText(item.status)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<span v-text="`${item.customer?.name || item.customerId || '未知客户'} · ${quantityText(item.quantity)} ${item.unitSnapshot || ''} · 实扣 ${formatPoint(item.actualPointCost)}`" />
|
||||
</template>
|
||||
</t-cell>
|
||||
</t-cell-group>
|
||||
</div>
|
||||
<t-empty
|
||||
v-else
|
||||
:description="loading ? '正在加载兑换记录' : hasFilter ? '未找到匹配的兑换记录' : '暂无兑换记录'"
|
||||
/>
|
||||
<div class="tips" v-text="`共 ${result.total} 条兑换记录`" />
|
||||
</div>
|
||||
</t-pull-down-refresh>
|
||||
</div>
|
||||
<customer-selector
|
||||
v-model="customerId"
|
||||
v-model:visible="customerSelectorVisible"
|
||||
@change="handleCustomerChange"
|
||||
/>
|
||||
<nav-bar-action-menu
|
||||
v-can="permissionCodes.pointRedemption.create"
|
||||
:actions="[{ key: 'create', icon: 'add', text: '客户兑换', theme: 'primary', action: toCreate }]"
|
||||
/>
|
||||
<t-popup v-model="startVisible" placement="bottom">
|
||||
<t-date-time-picker
|
||||
v-if="startVisible"
|
||||
v-model="startDraft"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
:mode="['date', 'minute']"
|
||||
title="开始时间"
|
||||
:default-value="dateRange[0]"
|
||||
@cancel="cancelStartTime"
|
||||
@confirm="confirmStartTime"
|
||||
/>
|
||||
</t-popup>
|
||||
<t-popup v-model="endVisible" placement="bottom">
|
||||
<t-date-time-picker
|
||||
v-if="endVisible"
|
||||
v-model="endDraft"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
:mode="['date', 'minute']"
|
||||
title="结束时间"
|
||||
:default-value="dateRange[1]"
|
||||
@cancel="cancelEndTime"
|
||||
@confirm="confirmEndTime"
|
||||
/>
|
||||
</t-popup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type DropdownValue } from "tdesign-mobile-vue";
|
||||
import { Time } from "timi-web";
|
||||
import CustomerAPI from "@/api/CustomerAPI";
|
||||
import PointAPI from "@/api/PointAPI";
|
||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||
import { useNavBarStore } from "@/store/navBarStore";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
import type { PointRedemption, PointRedemptionStatus } from "@/types/Point";
|
||||
import { Message } from "@/utils/message";
|
||||
import { getPackageQuantityText } from "@/utils/package";
|
||||
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||
import { permissionCodes } from "@/utils/permission";
|
||||
|
||||
defineOptions({ name: "PointRedemptionList" });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navBarStore = useNavBarStore();
|
||||
const result = reactive({ total: 0, list: [] as PointRedemption[] });
|
||||
type DateFilterType = "today" | "yesterday" | "lastWeek" | "thisMonth" | "lastMonth" | "all" | "custom";
|
||||
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
|
||||
{ label: "今天", value: "today" },
|
||||
{ label: "昨天", value: "yesterday" },
|
||||
{ label: "上周", value: "lastWeek" },
|
||||
{ label: "本月", value: "thisMonth" },
|
||||
{ label: "上个月", value: "lastMonth" },
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "自定义范围", value: "custom" }
|
||||
];
|
||||
const keyword = ref(getQueryText(route.query.keyword));
|
||||
const customerId = ref(getQueryText(route.query.customerId));
|
||||
const selectedCustomer = ref<Customer>();
|
||||
const customerSelectorVisible = ref(false);
|
||||
const dateFilterType = ref<DateFilterType>(parseDateFilterType(route.query.dateType));
|
||||
const dateRange = ref<[string, string]>(parseDateRange(dateFilterType.value));
|
||||
const startDraft = ref("");
|
||||
const endDraft = ref("");
|
||||
const startVisible = ref(false);
|
||||
const endVisible = ref(false);
|
||||
const index = ref(0);
|
||||
const loading = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const noMore = ref(false);
|
||||
let keywordTimer: number | undefined;
|
||||
let reloadRequested = false;
|
||||
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "时间范围");
|
||||
const isCustomDate = computed(() => dateFilterType.value === "custom");
|
||||
const hasFilter = computed(() => Boolean(keyword.value.trim() || customerId.value || dateFilterType.value !== "all"));
|
||||
|
||||
function getQueryText(value: unknown): string {
|
||||
return Array.isArray(value) ? "" : typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function parseDateFilterType(value: unknown): DateFilterType {
|
||||
const dateFilter = getQueryText(value);
|
||||
return dateOptions.some(item => item.value === dateFilter) ? dateFilter as DateFilterType : "today";
|
||||
}
|
||||
|
||||
function parseDateRange(type: DateFilterType): [string, string] {
|
||||
if (type !== "custom") {
|
||||
return buildPresetRange(type);
|
||||
}
|
||||
return [getQueryText(route.query.beginAt), getQueryText(route.query.endAt)];
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
if (loading.value) {
|
||||
reloadRequested = true;
|
||||
return;
|
||||
}
|
||||
index.value = 0;
|
||||
result.total = 0;
|
||||
result.list = [];
|
||||
noMore.value = false;
|
||||
try {
|
||||
await load();
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (loading.value || noMore.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const [beginAt, endAt] = buildUnixRange();
|
||||
const page = await PointAPI.redemptionList({
|
||||
index: index.value,
|
||||
size: 20,
|
||||
customerId: customerId.value || undefined,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
beginAt,
|
||||
endAt
|
||||
});
|
||||
const list = page.list || [];
|
||||
result.list = result.list.concat(list);
|
||||
result.total = page.total || 0;
|
||||
index.value++;
|
||||
noMore.value = result.total <= result.list.length || list.length < 20;
|
||||
} catch {
|
||||
Message.error("兑换记录加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (reloadRequested) {
|
||||
reloadRequested = false;
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatPoint(value?: number): string {
|
||||
return `${(value || 0).toLocaleString("zh-CN")} 分`;
|
||||
}
|
||||
|
||||
function quantityText(value?: number | string): string {
|
||||
return getPackageQuantityText(value);
|
||||
}
|
||||
|
||||
function getCustomerPhoto(item: PointRedemption): string {
|
||||
return getCustomerPhotoURL(item.customer, 64);
|
||||
}
|
||||
|
||||
async function handleCustomerChange(value: string | string[]): Promise<void> {
|
||||
customerId.value = Array.isArray(value) ? String(value[0] || "") : value;
|
||||
try {
|
||||
selectedCustomer.value = customerId.value ? await CustomerAPI.detail(customerId.value) : undefined;
|
||||
await reload();
|
||||
} catch {
|
||||
selectedCustomer.value = undefined;
|
||||
Message.error("客户信息加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCustomer(): Promise<void> {
|
||||
customerId.value = "";
|
||||
selectedCustomer.value = undefined;
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function selectDateFilter(value: DropdownValue): Promise<void> {
|
||||
if (Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
dateFilterType.value = String(value) as DateFilterType;
|
||||
if (dateFilterType.value !== "custom") {
|
||||
dateRange.value = buildPresetRange(dateFilterType.value);
|
||||
await replaceFilterQuery();
|
||||
await reload();
|
||||
} else {
|
||||
await replaceFilterQuery();
|
||||
}
|
||||
}
|
||||
|
||||
function openStartTime(): void {
|
||||
startDraft.value = dateRange.value[0];
|
||||
startVisible.value = true;
|
||||
}
|
||||
|
||||
function openEndTime(): void {
|
||||
endDraft.value = dateRange.value[1];
|
||||
endVisible.value = true;
|
||||
}
|
||||
|
||||
function cancelStartTime(): void {
|
||||
startVisible.value = false;
|
||||
startDraft.value = dateRange.value[0];
|
||||
}
|
||||
|
||||
function cancelEndTime(): void {
|
||||
endVisible.value = false;
|
||||
endDraft.value = dateRange.value[1];
|
||||
}
|
||||
|
||||
async function confirmStartTime(value?: string | number): Promise<void> {
|
||||
startVisible.value = false;
|
||||
dateRange.value = [String(value || startDraft.value), dateRange.value[1]];
|
||||
await confirmCustomRange();
|
||||
}
|
||||
|
||||
async function confirmEndTime(value?: string | number): Promise<void> {
|
||||
endVisible.value = false;
|
||||
dateRange.value = [dateRange.value[0], String(value || endDraft.value)];
|
||||
await confirmCustomRange();
|
||||
}
|
||||
|
||||
async function confirmCustomRange(): Promise<void> {
|
||||
if (dateRange.value[0] && dateRange.value[1] && dateRange.value[1] < dateRange.value[0]) {
|
||||
dateRange.value = [dateRange.value[1], dateRange.value[0]];
|
||||
}
|
||||
dateFilterType.value = "custom";
|
||||
await replaceFilterQuery();
|
||||
await reload();
|
||||
}
|
||||
|
||||
async function replaceFilterQuery(): Promise<void> {
|
||||
await router.replace({
|
||||
name: "PointRedemptionList",
|
||||
query: {
|
||||
customerId: customerId.value || undefined,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
dateType: dateFilterType.value,
|
||||
beginAt: dateRange.value[0] || undefined,
|
||||
endAt: dateRange.value[1] || undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
|
||||
if (type === "all") {
|
||||
return ["", ""];
|
||||
}
|
||||
if (type === "lastWeek") {
|
||||
const current = new Date();
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const day = current.getDay() || 7;
|
||||
const thisWeekMonday = new Date(current);
|
||||
thisWeekMonday.setDate(current.getDate() - day + 1);
|
||||
const lastWeekMonday = new Date(thisWeekMonday);
|
||||
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
|
||||
const lastWeekSunday = new Date(thisWeekMonday);
|
||||
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
|
||||
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
|
||||
}
|
||||
if (type === "thisMonth") {
|
||||
const current = new Date();
|
||||
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
|
||||
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
|
||||
}
|
||||
if (type === "lastMonth") {
|
||||
const current = new Date();
|
||||
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
|
||||
const end = new Date(current.getFullYear(), current.getMonth(), 0);
|
||||
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
|
||||
}
|
||||
|
||||
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth" | "lastMonth">, number> = {
|
||||
today: 0,
|
||||
yesterday: 1
|
||||
};
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() - offsetMap[type]);
|
||||
const value = `${Time.formatDate(date)} 00:00`;
|
||||
return [value, `${Time.formatDate(date)} 23:59`];
|
||||
}
|
||||
|
||||
function buildUnixRange(): [number | undefined, number | undefined] {
|
||||
if (dateFilterType.value === "all") {
|
||||
return [0, Time.toTimestamp(new Date())];
|
||||
}
|
||||
return [
|
||||
Time.toTimestamp(parseDateTime(dateRange.value[0], false)),
|
||||
Time.toTimestamp(parseDateTime(dateRange.value[1], true))
|
||||
];
|
||||
}
|
||||
|
||||
function parseDateTime(value: string, isEnd: boolean): Date {
|
||||
if (!value) {
|
||||
return isEnd ? new Date() : new Date(0);
|
||||
}
|
||||
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
|
||||
const [year, month, day] = dateText.split("-").map(Number);
|
||||
const [hour, minute] = timeText.split(":").map(Number);
|
||||
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
|
||||
}
|
||||
|
||||
function statusText(value?: PointRedemptionStatus): string {
|
||||
return ({
|
||||
COMPLETED: "已兑换",
|
||||
PARTIALLY_RETURNED: "部分退货",
|
||||
FULLY_RETURNED: "已退完"
|
||||
} as Record<string, string>)[value || ""] || "未知";
|
||||
}
|
||||
|
||||
async function toCreate(): Promise<void> {
|
||||
await router.push({ name: "PointRedemptionCreate" });
|
||||
}
|
||||
|
||||
async function toDetail(item: PointRedemption): Promise<void> {
|
||||
if (item.id) {
|
||||
await router.push({ name: "PointRedemptionDetail", params: { id: item.id } });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
navBarStore.setCustomRightVisible(true);
|
||||
try {
|
||||
if (customerId.value) {
|
||||
selectedCustomer.value = await CustomerAPI.detail(customerId.value);
|
||||
}
|
||||
await reload();
|
||||
} catch {
|
||||
Message.error("兑换记录加载失败");
|
||||
}
|
||||
});
|
||||
|
||||
watch(keyword, () => {
|
||||
window.clearTimeout(keywordTimer);
|
||||
keywordTimer = window.setTimeout(async () => {
|
||||
await reload();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(keywordTimer);
|
||||
navBarStore.clearRight();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.redemption-list {
|
||||
padding: var(--app-nav-height) 0 1rem;
|
||||
|
||||
.filters {
|
||||
top: var(--app-nav-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
padding: .75rem 1rem;
|
||||
position: sticky;
|
||||
background: #f5f5f8;
|
||||
box-shadow: var(--td-shadow-1);
|
||||
flex-direction: column;
|
||||
|
||||
.keyword {
|
||||
--td-input-vertical-padding: .25rem 1rem;
|
||||
|
||||
flex: 1;
|
||||
border: 1px solid var(--td-component-border);
|
||||
z-index: 2;
|
||||
background: var(--td-bg-color-container);
|
||||
padding-right: .3rem;
|
||||
border-radius: 1rem;
|
||||
|
||||
.actions {
|
||||
--td-button-border-radius: .75rem;
|
||||
|
||||
gap: .25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.menus {
|
||||
--td-spacer-1: 6px;
|
||||
|
||||
width: 100%;
|
||||
border: 1px solid rgba(148, 163, 184, .18);
|
||||
overflow: hidden;
|
||||
margin-top: -1rem;
|
||||
background: #fff;
|
||||
border-top: none;
|
||||
padding-top: .8rem;
|
||||
border-radius: 0 0 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-wrap {
|
||||
padding-top: 1rem;
|
||||
|
||||
.date-summary {
|
||||
:deep(.t-cell__title) {
|
||||
flex: 0 0 4.5rem;
|
||||
}
|
||||
|
||||
:deep(.t-cell__note) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.item {
|
||||
padding: 10px;
|
||||
border-radius: 1rem;
|
||||
|
||||
.title {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: .875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
color: var(--td-text-color-secondary);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
.redemption-list {
|
||||
.filters {
|
||||
background: #10161f;
|
||||
|
||||
.menus {
|
||||
background: #1d2733;
|
||||
}
|
||||
}
|
||||
|
||||
.fallback {
|
||||
color: #ccfbf1;
|
||||
background: #115e59;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -22,7 +22,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="tips gray" v-entrance-transition="visibleEntryCount * 70">微信内打开本站可能会经常在扫码时需要授权,建议使用浏览器打开(右上角菜单,使用默认浏览器打开),并添加快捷入口到桌面。</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -99,6 +98,12 @@ const entryGroupList: HomeEntryGroup[] = [
|
||||
permission: permissionCodes.customerPackage.create,
|
||||
icon: "layers",
|
||||
title: "创建套餐订单"
|
||||
},
|
||||
{
|
||||
path: "/management/points/redemptions",
|
||||
permission: permissionCodes.pointRedemption.create,
|
||||
icon: "gift",
|
||||
title: "积分兑换"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -110,7 +115,6 @@ const visibleEntryGroupList = computed(() => entryGroupList
|
||||
entryList: group.entryList.filter(item => userStore.hasPermissions(item.permission))
|
||||
}))
|
||||
.filter(group => group.entryList.length));
|
||||
const visibleEntryCount = computed(() => visibleEntryGroupList.value.reduce((count, group) => count + group.entryList.length, 0));
|
||||
const canReadNotify = computed(() => userStore.hasPermissions(corePermissionCodes.notify.read));
|
||||
|
||||
async function go(path: string): Promise<void> {
|
||||
@@ -214,11 +218,6 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tips {
|
||||
font-size: .875rem;
|
||||
text-indent: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.theme-dark) {
|
||||
|
||||
@@ -119,6 +119,23 @@ const menuGroupList = computed<MenuGroup[]>(() => [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "积分兑换",
|
||||
items: [
|
||||
{
|
||||
icon: "cart",
|
||||
path: "/management/points/redeem-skus",
|
||||
title: "兑换商品",
|
||||
visible: userStore.hasPermissions(permissionCodes.pointRedeemSku.manage)
|
||||
},
|
||||
{
|
||||
icon: "gift",
|
||||
path: "/management/points/redemptions",
|
||||
title: "积分兑换",
|
||||
visible: userStore.hasPermissions(permissionCodes.pointRedemption.create)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "经营分析",
|
||||
items: [
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
|
||||
const pointRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/management/points/redeem-skus", name: "PointRedeemSkuList", component: () => import("@/pages/point/PointRedeemSkuList.vue"),
|
||||
meta: { depth: 3, navBarVisible: true, navBarCanBack: true, navBarTitle: "兑换商品", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/redeem-skus/create", name: "PointRedeemSkuCreate", component: () => import("@/pages/point/PointRedeemSkuEdit.vue"),
|
||||
meta: { depth: 4, navBarVisible: true, navBarCanBack: true, navBarTitle: "新增兑换商品", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/redeem-skus/:id/edit", name: "PointRedeemSkuEdit", component: () => import("@/pages/point/PointRedeemSkuEdit.vue"),
|
||||
meta: { depth: 4, navBarVisible: true, navBarCanBack: true, navBarTitle: "编辑兑换商品", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/redemptions", name: "PointRedemptionList", component: () => import("@/pages/point/PointRedemptionList.vue"),
|
||||
meta: { depth: 3, navBarVisible: true, navBarCanBack: true, navBarTitle: "积分兑换", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/redemptions/create", name: "PointRedemptionCreate", component: () => import("@/pages/point/PointRedemptionCreate.vue"),
|
||||
meta: { depth: 4, navBarVisible: true, navBarCanBack: true, navBarTitle: "客户兑换", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/redemptions/:id", name: "PointRedemptionDetail", component: () => import("@/pages/point/PointRedemptionDetail.vue"),
|
||||
meta: { depth: 4, navBarVisible: true, navBarCanBack: true, navBarTitle: "兑换详情", tabBarVisible: false, bodyBackground: "#F5F5F8", darkBodyBackground: "#10161f" }
|
||||
},
|
||||
{
|
||||
path: "/management/points/account",
|
||||
name: "PointAccountList",
|
||||
|
||||
@@ -108,3 +108,94 @@ export type PointAdjustRequest = {
|
||||
pointDelta: number;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
export type PointRedeemSkuStatus = "DRAFT" | "ACTIVE" | "INACTIVE" | "DELETED";
|
||||
export type PointRedemptionStatus = "COMPLETED" | "PARTIALLY_RETURNED" | "FULLY_RETURNED";
|
||||
|
||||
export type PointRedeemSku = {
|
||||
storeId?: string;
|
||||
skuId?: string;
|
||||
skuUnitId?: string;
|
||||
status?: PointRedeemSkuStatus;
|
||||
defaultPointCost?: number;
|
||||
beginAt?: number | null;
|
||||
endAt?: number | null;
|
||||
maxTotalQuantity?: number | string | null;
|
||||
maxDailyQuantity?: number | string | null;
|
||||
maxMonthlyQuantity?: number | string | null;
|
||||
remark?: string | null;
|
||||
product?: import("@/types/Product").Product;
|
||||
sku?: import("@/types/Product").ProductSku;
|
||||
unit?: import("@/types/Product").ProductSkuUnit;
|
||||
} & Model;
|
||||
|
||||
export type PointRedeemSkuPage = {
|
||||
index: number;
|
||||
size: number;
|
||||
storeId?: string;
|
||||
status?: PointRedeemSkuStatus;
|
||||
keyword?: string;
|
||||
};
|
||||
|
||||
export type PointRedemption = {
|
||||
storeId?: string;
|
||||
customerId?: string;
|
||||
requestNo?: string;
|
||||
redeemSkuIdSnapshot?: string;
|
||||
productNameSnapshot?: string;
|
||||
productTypeSnapshot?: string;
|
||||
skuCodeSnapshot?: string;
|
||||
skuNameSnapshot?: string;
|
||||
unitSnapshot?: string;
|
||||
quantity?: number | string;
|
||||
defaultPointCost?: number;
|
||||
actualPointCost?: number;
|
||||
returnedQuantity?: number | string;
|
||||
returnedPointCost?: number;
|
||||
status?: PointRedemptionStatus;
|
||||
redeemedAt?: number;
|
||||
remark?: string | null;
|
||||
customer?: Customer;
|
||||
attachmentList?: import("timi-web-api").Attachment[];
|
||||
returnList?: PointRedemptionReturn[];
|
||||
} & Model;
|
||||
|
||||
export type PointRedemptionReturn = {
|
||||
redemptionId?: string;
|
||||
requestNo?: string;
|
||||
quantity?: number | string;
|
||||
pointCost?: number;
|
||||
returnedAt?: number;
|
||||
remark?: string | null;
|
||||
attachmentList?: import("timi-web-api").Attachment[];
|
||||
} & Model;
|
||||
|
||||
export type PointRedemptionPage = {
|
||||
index: number;
|
||||
size: number;
|
||||
storeId?: string;
|
||||
customerId?: string;
|
||||
keyword?: string;
|
||||
status?: PointRedemptionStatus;
|
||||
beginAt?: number;
|
||||
endAt?: number;
|
||||
};
|
||||
|
||||
export type PointRedemptionCreateRequest = {
|
||||
requestNo: string;
|
||||
storeId?: string;
|
||||
customerId: string;
|
||||
redeemSkuId: string;
|
||||
quantity: string;
|
||||
actualPointCost?: number;
|
||||
attachmentList: Array<{ tempFileId: string; attachType: "PHOTO"; name?: string }>;
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
export type PointRedemptionReturnRequest = {
|
||||
redemptionId: string;
|
||||
requestNo: string;
|
||||
quantity: string;
|
||||
attachmentList: Array<{ tempFileId: string; attachType: "PHOTO"; name?: string }>;
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,14 @@ export const permissionCodes = {
|
||||
adjust: "GAO:POINT:ADJUST",
|
||||
sourceValue: "GAO:POINT:SOURCE_VALUE"
|
||||
},
|
||||
pointRedeemSku: {
|
||||
manage: "GAO:POINT_REDEEM_SKU:MANAGE"
|
||||
},
|
||||
pointRedemption: {
|
||||
create: "GAO:POINT_REDEMPTION:CREATE",
|
||||
pointAdjust: "GAO:POINT_REDEMPTION:POINT_ADJUST",
|
||||
return: "GAO:POINT_REDEMPTION:RETURN"
|
||||
},
|
||||
pointRule: {
|
||||
create: "GAO:POINT_RULE:CREATE",
|
||||
read: "GAO:POINT_RULE:READ",
|
||||
@@ -188,6 +196,10 @@ export const gaoStoreScopePermissionCodes = [
|
||||
permissionCodes.point.ledgerRead,
|
||||
permissionCodes.point.adjust,
|
||||
permissionCodes.point.sourceValue,
|
||||
permissionCodes.pointRedeemSku.manage,
|
||||
permissionCodes.pointRedemption.create,
|
||||
permissionCodes.pointRedemption.pointAdjust,
|
||||
permissionCodes.pointRedemption.return,
|
||||
permissionCodes.pointRule.create,
|
||||
permissionCodes.pointRule.read,
|
||||
permissionCodes.pointRule.update,
|
||||
|
||||
Reference in New Issue
Block a user