Files
gao-customer-h5/src/pages/point/PointAdjust.vue
T
2026-08-21 15:02:28 +08:00

376 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div v-can="permissionCodes.point.adjust" class="point-adjust">
<t-notice-bar
theme="warning"
visible
content="人工调整会追加一条不可修改的 ADJUST 流水,请填写真实、明确的调整原因。"
/>
<t-form class="form" label-align="left" label-width="7rem">
<t-form-item label="客户" required-mark>
<div class="customer-actions">
<t-button
class="picker-button"
size="extra-small"
variant="outline"
@click="selectorVisible = true"
>选择</t-button>
<t-button theme="primary" size="extra-small" variant="outline" @click="toggleScanner">
<template #icon>
<t-icon name="scan" />
</template>
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
</t-button>
</div>
</t-form-item>
<div v-if="scannerVisible" class="scanner-wrapper">
<code-scanner
visible
:status-text="scanStatus"
active-text="正在识别客户编码,请将二维码移入取景框"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/>
</div>
<section v-if="selectedCustomer" class="selected-customer">
<customer-profile-card :customer="selectedCustomer" />
<div class="balance" v-text="balanceText" />
</section>
<t-form-item label="调整动作" arrow @click="openPointActionPicker">
<span class="picker-text" v-text="pointAction === 'CONSUME' ? '扣除积分' : '补发积分'" />
</t-form-item>
<t-form-item label="调整数量" required-mark>
<t-input
v-model.trim="pointDeltaText"
type="number"
align="right"
placeholder="请输入数量"
borderless
/>
</t-form-item>
<t-form-item label="调整原因" required-mark>
<t-textarea
v-model.trim="remark"
:autosize="{ minRows: 4, maxRows: 7 }"
placeholder="例如:服务补偿、活动补发、异常扣除"
/>
</t-form-item>
<t-form-item label="幂等请求 ID" content-align="right">
<span class="request-id" v-text="adjustRequestId" />
</t-form-item>
</t-form>
<div class="bottom-actions">
<t-button theme="primary" :loading="isSaving" :disabled="!canSubmit" @click="submitAdjust">
提交调整
</t-button>
</div>
<customer-selector
v-model="selectedCustomerId"
v-model:visible="selectorVisible"
@change="handleCustomerChange"
/>
<t-popup v-model="pointActionPickerVisible" placement="bottom">
<t-picker
v-model="pointActionPickerDraft"
:columns="pointActionPickerColumns"
title="调整动作"
@cancel="pointActionPickerVisible = false"
@confirm="confirmPointActionPicker"
/>
</t-popup>
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { useUserStore } from "timi-web";
import type { PointAction } from "@/types/Point";
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 { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { PointAccount } from "@/types/Point";
import { getErrorMessage } from "@/utils/error";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
name: "PointAdjust"
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const settingStore = useSettingStore();
// ---------- 权限与门店 ----------
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
async function prepareStoreScope(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
return;
}
await currentStoreStore.loadLoginStore();
}
// ---------- 客户与账户 ----------
const selectedCustomerId = ref(String(route.query.customerId || ""));
const selectedCustomer = ref<Customer>();
const account = ref<PointAccount>();
const selectorVisible = ref(false);
const balanceText = computed(() => `当前余额:${(account.value?.balance ?? 0).toLocaleString("zh-CN")} 分`);
async function handleCustomerChange(value: string | string[]): Promise<void> {
selectedCustomerId.value = Array.isArray(value) ? String(value[0] || "") : value;
await loadCustomerAccount();
}
async function loadCustomerByCode(code: string): Promise<void> {
const normalizedCode = code.trim();
if (!normalizedCode) {
return;
}
try {
const matched = await CustomerAPI.findByCode(normalizedCode);
if (!matched?.id) {
Toast.warning("未找到客户");
return;
}
selectedCustomerId.value = matched.id;
selectedCustomer.value = matched;
await loadCustomerAccount();
} catch {
selectedCustomer.value = undefined;
account.value = undefined;
Toast.error("客户信息加载失败");
}
}
async function loadCustomerAccount(): Promise<void> {
if (!selectedCustomerId.value) {
selectedCustomer.value = undefined;
account.value = undefined;
return;
}
try {
selectedCustomer.value = await CustomerAPI.detail(selectedCustomerId.value);
if (userStore.hasPermissions(permissionCodes.point.accountRead)) {
try {
account.value = await PointAPI.accountDetail(selectedCustomerId.value, currentStoreStore.currentStoreId || undefined);
} catch {
account.value = undefined;
}
}
} catch {
selectedCustomer.value = undefined;
account.value = undefined;
Toast.error("客户信息加载失败");
}
}
// ---------- 扫码 ----------
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别");
let latestDecodedText = "";
async function toggleScanner(): Promise<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();
scanStatus.value = `已识别 ${normalizedText},正在加载客户档案`;
scannerVisible.value = false;
await loadCustomerByCode(normalizedText);
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
// ---------- 调整表单 ----------
const pointDeltaText = ref("");
const pointAction = ref<PointAction>("EARN");
const pointActionPickerVisible = ref(false);
const pointActionPickerDraft = ref<Array<string | number>>([]);
const pointActionPickerColumns = [[
{ label: "补发积分", value: "EARN" },
{ label: "扣除积分", value: "CONSUME" }
]];
const remark = ref("");
const adjustRequestId = ref(generateRequestId());
const isSaving = ref(false);
const canSubmit = computed(() => !!selectedCustomerId.value && !!currentStoreStore.currentStoreId && !!parsePointDelta() && !!remark.value.trim());
function parsePointDelta(): number {
const value = Number(pointDeltaText.value);
return Number.isSafeInteger(value) && 0 < value ? pointAction.value === "CONSUME" ? -value : value : 0;
}
function openPointActionPicker(): void {
pointActionPickerDraft.value = [pointAction.value];
pointActionPickerVisible.value = true;
}
function confirmPointActionPicker(value: Array<string | number>): void {
const action = value[0];
if (action === "EARN" || action === "CONSUME") {
pointAction.value = action;
}
pointActionPickerVisible.value = false;
}
async function submitAdjust(): Promise<void> {
const pointDelta = parsePointDelta();
if (!selectedCustomerId.value) {
Toast.warning("请选择客户");
return;
}
if (!currentStoreStore.currentStoreId) {
Toast.warning("当前门店未设置");
return;
}
if (!pointDelta) {
Toast.warning("调整积分不能为 0,且必须是整数");
return;
}
if (!remark.value.trim()) {
Toast.warning("请填写调整原因");
return;
}
if (pointDelta < 0 && Math.abs(pointDelta) > (account.value?.balance ?? 0)) {
Toast.warning("扣分后余额不能小于 0");
return;
}
isSaving.value = true;
try {
await PointAPI.adjust({
adjustRequestId: adjustRequestId.value,
storeId: currentStoreStore.currentStoreId,
customerId: selectedCustomerId.value,
pointDelta,
remark: remark.value.trim()
});
Toast.success("积分调整已提交");
await router.replace({
name: "PointLedgerList",
query: { customerId: selectedCustomerId.value }
});
} catch (error) {
Toast.error(getErrorMessage(error, "积分调整失败"));
} finally {
isSaving.value = false;
}
}
function generateRequestId(): string {
return `point-adjust-${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
}
onMounted(async () => {
try {
await prepareStoreScope();
await loadCustomerAccount();
} catch (error) {
Toast.error(getErrorMessage(error, "门店信息加载失败"));
}
});
</script>
<style scoped lang="less">
.point-adjust {
padding: calc(var(--app-nav-height)) 0 5rem;
.form {
margin: 1rem;
padding: .5rem 0;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
.customer-actions {
gap: .75rem;
width: 100%;
display: flex;
justify-content: end;
}
.scanner-wrapper {
margin: 1rem .75rem .75rem;
}
.selected-customer {
margin: 1rem .75rem .5rem;
.balance {
color: #52607a;
margin-top: .5rem;
font-size: .875rem;
text-align: right;
}
}
.request-id {
color: #52607a;
font-size: .75rem;
}
}
.bottom-actions {
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
position: fixed;
.t-button {
width: 100%;
}
}
}
:global(html.theme-dark) {
.point-adjust {
.notice {
color: #a5f3fc;
background: rgba(8, 47, 73, .72);
}
.form {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
.selected-customer {
.balance {
color: #94a3b8;
}
}
.request-id {
color: #94a3b8;
}
}
}
}
</style>