update permission and product

This commit is contained in:
Timi
2026-09-04 23:07:59 +08:00
parent 1df5748f54
commit fc9e6a5023
48 changed files with 1670 additions and 2475 deletions
+439
View File
@@ -0,0 +1,439 @@
<template>
<div v-can="permissionCodes.customerPackage.create" class="package-purchase">
<t-form class="form" label-align="left" v-entrance-transition>
<t-form-item label="客户" required-mark>
<t-input
:model-value="customerDisplayText"
class="customer"
readonly
borderless
placeholder="选择或扫码客户"
@click="openSelectedCustomerList()"
>
<template #suffix-icon>
<div class="actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
@click.stop="selectorVisible = 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="selectedCustomer"
class="customer-profile"
:customer="selectedCustomer"
v-entrance-transition
/>
<div v-if="config && !isSaleable" class="saleable-tip red">套餐当前不可购买,请检查套餐状态和有效期</div>
<package-purchase-form
:package-config="config"
:can-price-override="canPriceOverride"
v-model:item-list="packagePurchaseItemList"
v-model:sale-amount="saleAmountText"
/>
<t-form-item label="购买时间" content-align="right">
<div class="time-actions">
<t-button
theme="primary"
variant="outline"
size="extra-small"
:content="purchasedAtText || '此刻'"
@click="openPurchasedAtPicker"
/>
<t-button
v-if="purchasedAtText"
theme="primary"
variant="text"
size="extra-small"
content="此刻"
@click="resetPurchasedAt"
/>
</div>
</t-form-item>
<t-form-item label="购买备注">
<t-textarea
v-model.trim="note"
class="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="购买说明"
/>
</t-form-item>
</t-form>
<customer-selector
v-model="selectedCustomerId"
v-model:visible="selectorVisible"
selection-mode="single"
@change="handleCustomerChange"
/>
<t-popup v-model="selectedCustomerListVisible" placement="bottom">
<div class="selected-customer-popup bg-white">
<div class="popup-header">
<t-button variant="text" size="small" @click="selectedCustomerListVisible = false">关闭</t-button>
<h3>已选客户</h3>
<t-button theme="primary" variant="text" size="small" @click="openCustomerSelector">选择</t-button>
</div>
<customer-list
class="list"
:customer-list="selectedCustomerList"
removable
empty-text="暂未选择客户"
@remove="removeSelectedCustomer"
/>
</div>
</t-popup>
</div>
<t-popup v-model="purchasedAtPickerVisible" placement="bottom">
<t-date-time-picker
v-if="purchasedAtPickerVisible"
v-model="purchasedAtDraftText"
:start="minimumDateTimeText"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="购买时间"
@cancel="purchasedAtPickerVisible = false"
@confirm="confirmPurchasedAt"
/>
</t-popup>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI";
import PackageAPI from "@/api/PackageAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerList from "@/components/CustomerList.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PackagePurchaseForm from "@/components/PackagePurchaseForm.vue";
import { usePackagePurchase } from "@/pages/package/usePackagePurchase";
import { useNavBarStore } from "@/store/navBarStore";
import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { GaoPackageConfig } from "@/types/Package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({
name: "PackagePurchase"
});
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const settingStore = useSettingStore();
const userStore = useUserStore();
// ---------- 套餐配置 ----------
const configId = computed(() => String(route.params.id || ""));
const config = ref<GaoPackageConfig>();
// ---------- 客户 ----------
const customerCode = ref("");
const selectorVisible = ref(false);
const selectedCustomerListVisible = ref(false);
const selectedCustomerId = ref("");
const selectedCustomerList = ref<Customer[]>([]);
const selectedCustomer = computed(() => selectedCustomerList.value[0]);
const customerDisplayText = computed(() => selectedCustomer.value?.name || selectedCustomer.value?.code || customerCode.value);
async function handleCustomerChange(value: string | string[]): Promise<void> {
selectedCustomerId.value = Array.isArray(value) ? value[0] || "" : value;
resetRequestNo();
await loadSelectedCustomer();
}
async function loadSelectedCustomer(): Promise<void> {
if (!selectedCustomerId.value) {
selectedCustomerList.value = [];
return;
}
const customer = await CustomerAPI.detail(selectedCustomerId.value);
selectedCustomerList.value = customer?.id ? [customer] : [];
}
function openSelectedCustomerList(): void {
selectedCustomerListVisible.value = true;
}
async function openCustomerSelector(): Promise<void> {
selectedCustomerListVisible.value = false;
await nextTick();
selectorVisible.value = true;
}
async function removeSelectedCustomer(): Promise<void> {
selectedCustomerId.value = "";
selectedCustomerList.value = [];
}
// ---------- 扫码 ----------
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。");
let latestDecodedText = "";
async function toggleScanner(): Promise<void> {
scannerVisible.value = !scannerVisible.value;
if (scannerVisible.value) {
latestDecodedText = "";
}
}
async function handleDecodedText(rawText: string): Promise<void> {
const normalizedText = rawText.trim();
if (!normalizedText || normalizedText === latestDecodedText) {
return;
}
latestDecodedText = normalizedText;
await settingStore.playScanSuccessSound();
try {
const customer = await CustomerAPI.findByCode(normalizedText);
if (!customer?.id) {
scanStatus.value = `客户 ${normalizedText} 不存在,请先建档。`;
return;
}
selectedCustomerId.value = customer.id;
await loadSelectedCustomer();
resetRequestNo();
scanStatus.value = `已识别 ${customer.code || normalizedText},已选中 ${customer.name || "未命名客户"}。`;
scannerVisible.value = false;
} catch {
scanStatus.value = "客户查询失败,请重试。";
}
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
// ---------- 购买时间 ----------
const minimumDateTimeText = "1970-01-01 00:00";
const purchasedAtText = ref("");
const purchasedAtDraftText = ref("");
const purchasedAtPickerVisible = ref(false);
function openPurchasedAtPicker(): void {
purchasedAtDraftText.value = purchasedAtText.value || Time.formatDateTime(new Date());
purchasedAtPickerVisible.value = true;
}
function confirmPurchasedAt(value?: string | number): void {
purchasedAtText.value = String(value || purchasedAtDraftText.value);
purchasedAtPickerVisible.value = false;
}
function resetPurchasedAt(): void {
purchasedAtText.value = "";
purchasedAtDraftText.value = "";
}
function buildPurchasedAt(): number {
return Time.toTimestamp(parseDateTime(purchasedAtText.value));
}
function parseDateTime(value: string): Date {
if (!value) {
return new Date();
}
const [dateText, timeText = "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, 0, 0);
}
// ---------- 购买 ----------
const note = ref("");
const isSaving = ref(false);
const {
isSaleable,
canPriceOverride,
saleAmountText,
packagePurchaseItemList,
applyConfig,
resetRequestNo,
submitPurchase,
validatePurchaseForm
} = usePackagePurchase({
config,
selectedCustomerList,
note,
hasPermission: permissionCode => userStore.hasPermissions(permissionCode),
buildRegisteredAt: buildPurchasedAt
});
async function submitRecord(): Promise<void> {
if (isSaving.value) {
return;
}
if (!validatePurchaseForm()) {
return;
}
isSaving.value = true;
try {
const result = await submitPurchase();
if (!result?.id) {
return;
}
await settingStore.playRegisterSuccessSound();
Message.success("套餐购买已提交");
await router.replace(`/management/customer-packages/${result.id}`);
} catch (error) {
Message.error(getErrorMessage(error, "套餐购买提交失败"));
} finally {
isSaving.value = false;
}
}
// ---------- 页面数据 ----------
async function loadPageData(): Promise<void> {
if (!configId.value) {
return;
}
config.value = await PackageAPI.configDetail(configId.value);
applyConfig(config.value);
}
onMounted(async () => {
navBarStore.clearRight();
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
navBarStore.setRightText("提交", "var(--td-brand-color)", submitRecord);
}
try {
await loadPageData();
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
watch(config, value => {
if (value) {
applyConfig(value);
}
});
</script>
<style scoped lang="less">
.package-purchase {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
flex-direction: column;
.form {
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-part {
padding: 1rem;
}
.textarea {
width: 100%;
}
.customer-profile {
margin: 1rem;
}
.saleable-tip {
padding: 0 1rem .5rem;
font-size: .8125rem;
line-height: 1.5;
}
.time-actions {
gap: .5rem;
display: flex;
align-items: center;
justify-content: flex-end;
}
}
.file-input {
display: none;
}
}
.selected-customer-popup {
height: 70vh;
overflow: auto;
padding-top: 1rem;
border-radius: 1.25rem 1.25rem 0 0;
.popup-header {
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
}
}
.list {
margin-top: 1rem;
}
}
:global(html.theme-dark) {
.package-purchase {
:deep(.form-item .t-form__label) {
color: #94a3b8;
}
.form {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
:deep(.textarea .t-textarea__inner) {
color: #e2e8f0;
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
.saleable-tip {
color: #fbbf24;
}
}
}
</style>