376 lines
10 KiB
Vue
376 lines
10 KiB
Vue
<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>
|