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
+1 -5
View File
@@ -14,7 +14,6 @@
</t-cell>
<t-cell v-if="customerPackage.salesOrderId && canReadOrder" title="销售订单" :note="order?.orderNo || customerPackage.salesOrderId" arrow @click="toOrder" />
<t-cell v-if="canReadPackageConfig && customerPackage.packageConfigId" title="套餐方案详情" arrow @click="toPackageConfig" />
<t-cell v-if="canReadEventRecord && customerPackage.purchaseEventRecord?.id" title="购买登记记录" arrow @click="toEventRecord" />
</t-cell-group>
<t-cell-group v-if="order" title="订单资金" theme="card" v-entrance-transition="140">
<t-cell title="成交金额" :note="formatPackageAmount(order.saleAmount)" />
@@ -95,7 +94,6 @@ const canCreateFulfillment = computed(() => userStore.hasPermissions(permissionC
const canReadOrder = computed(() => userStore.hasPermissions(permissionCodes.salesOrder.read));
const canReadFund = computed(() => userStore.hasPermissions(permissionCodes.fundRecord.read));
const canReadPackageConfig = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.read));
const canReadEventRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.read));
const hasAvailableItem = computed(() => customerPackage.value?.itemList?.some(item => 0 < Number(item.availableQuantity)) ?? false);
async function loadData(): Promise<void> {
@@ -117,9 +115,7 @@ async function loadData(): Promise<void> {
function getStatusTheme(status?: string): "success" | "warning" | "danger" | "default" { return status === "ACTIVE" ? "success" : status === "CANCELLED" ? "danger" : "default"; }
function getFulfillmentSummary(item: OrderFulfillment): string { return `${item.deliveredAt ? Time.toDateTime(item.deliveredAt) : "时间未知"} · ${(item.itemList || []).length} 项`; }
async function toOrder(): Promise<void> { if (order.value?.id) await router.push(`/management/sales-orders/${order.value.id}`); }
async function toPackageConfig(): Promise<void> { if (customerPackage.value?.packageConfigId) await router.push(`/management/package-configs/${customerPackage.value.packageConfigId}`); }
async function toEventRecord(): Promise<void> { if (customerPackage.value?.purchaseEventRecord?.id) await router.push(`/management/registration-event-records/${customerPackage.value.purchaseEventRecord.id}`); }
async function toFundRecords(): Promise<void> { if (order.value?.id) await router.push({ path: "/management/fund-records", query: { salesOrderId: order.value.id } }); }
async function toPackageConfig(): Promise<void> { if (customerPackage.value?.packageConfigId) await router.push(`/management/package-configs/${customerPackage.value.packageConfigId}`); }async function toFundRecords(): Promise<void> { if (order.value?.id) await router.push({ path: "/management/fund-records", query: { salesOrderId: order.value.id } }); }
async function toFulfillment(id?: string): Promise<void> { if (id) await router.push(`/management/order-fulfillments/${id}`); }
async function toCreateFulfillment(): Promise<void> {
if (order.value?.id && customerPackage.value?.id) {
+18
View File
@@ -22,6 +22,13 @@
/>
<div v-if="!config.itemList?.length" class="empty" v-text="'暂无产品权益'" />
</t-cell-group>
<t-button
v-if="canPurchase"
class="cell-button"
theme="primary"
content="购买套餐"
@click="toPurchase"
/>
<t-button
v-if="canDelete"
class="cell-button"
@@ -58,6 +65,11 @@ const config = ref<GaoPackageConfig>();
const isLoading = ref(false);
const canUpdate = computed(() => !!config.value && userStore.hasPermissions(permissionCodes.packageConfig.update));
const canDelete = computed(() => !!config.value && userStore.hasPermissions(permissionCodes.packageConfig.delete));
const canPurchase = computed(() =>
!!config.value
&& config.value.status === "ACTIVE"
&& userStore.hasPermissions(permissionCodes.customerPackage.create)
);
function getStatusTheme(status?: PackageConfigStatus): "success" | "warning" | "default" {
return status === "ACTIVE" ? "success" : status === "INACTIVE" ? "warning" : "default";
@@ -89,6 +101,12 @@ async function toEdit(): Promise<void> {
}
}
async function toPurchase(): Promise<void> {
if (config.value?.id && canPurchase.value) {
await router.push(`/management/package-configs/${config.value.id}/purchase`);
}
}
function removeConfig(): void {
if (!config.value?.id || !canDelete.value) {
return;
+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>
+136
View File
@@ -0,0 +1,136 @@
import { computed, ref, type Ref } from "vue";
import PackageAPI from "@/api/PackageAPI";
import type { Customer } from "@/types/Customer";
import type { GaoCustomerPackage, GaoPackageConfig, PackagePurchaseItem } from "@/types/Package";
import { createPackageRequestNo, formatPackageAmountInput, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { permissionCodes } from "@/utils/permission";
import { Message } from "@/utils/message";
interface UsePackagePurchaseOptions {
config: Ref<GaoPackageConfig | undefined>;
selectedCustomerList: Ref<Customer[]>;
note: Ref<string>;
hasPermission: (permissionCode: string) => boolean;
buildRegisteredAt: () => number;
}
/** 套餐配置直接购买流程,购买请求只依赖套餐配置 ID,不再关联购买事件。 */
export function usePackagePurchase(options: UsePackagePurchaseOptions) {
const canPriceOverride = computed(() => options.hasPermission(permissionCodes.customerPackage.priceOverride));
const saleAmountText = ref("");
const packagePurchaseItemList = ref<PackagePurchaseItem[]>([]);
const packageRequestNo = ref("");
/** 套餐配置当前是否处于可购买状态。 */
function isConfigSaleable(config?: GaoPackageConfig): boolean {
if (!config || config.status !== "ACTIVE") {
return false;
}
const now = Date.now();
return (config.beginAt === undefined || config.beginAt === null || config.beginAt <= now)
&& (config.endAt === undefined || config.endAt === null || now <= config.endAt);
}
const isSaleable = computed(() => isConfigSaleable(options.config.value));
/** 按套餐配置初始化成交价与权益明细。 */
function applyConfig(config?: GaoPackageConfig): void {
if (!config) {
resetPurchaseForm();
return;
}
saleAmountText.value = formatPackageAmountInput(config.standardAmount);
packagePurchaseItemList.value = (config.itemList || []).map((item, index) => ({
productId: item.productId,
productUnitId: item.productUnitId,
totalQuantity: normalizePackageQuantity(item.defaultQuantity) || "",
sort: item.sort ?? index,
remark: item.remark ?? null
}));
}
function resetPurchaseForm(): void {
saleAmountText.value = "";
packagePurchaseItemList.value = [];
packageRequestNo.value = "";
}
function resetRequestNo(): void {
packageRequestNo.value = "";
}
async function submitPurchase(): Promise<GaoCustomerPackage | undefined> {
const config = options.config.value;
const customerId = options.selectedCustomerList.value[0]?.id;
const saleAmount = parsePackageAmount(saleAmountText.value);
if (!config?.id || !customerId || saleAmount === undefined) {
return undefined;
}
if (!packageRequestNo.value) {
packageRequestNo.value = createPackageRequestNo();
}
return await PackageAPI.purchase({
requestNo: packageRequestNo.value,
packageConfigId: config.id,
customerId,
saleAmount,
purchasedAt: options.buildRegisteredAt(),
itemList: packagePurchaseItemList.value.map((item, index) => ({
productId: item.productId,
productUnitId: item.productUnitId,
totalQuantity: normalizePackageQuantity(item.totalQuantity)!,
sort: item.sort ?? index,
remark: item.remark ?? null
})),
remark: options.note.value.trim() || null
});
}
function validatePurchaseForm(): boolean {
if (!options.hasPermission(permissionCodes.customerPackage.create)) {
Message.warning("当前账号没有套餐购买权限");
return false;
}
if (options.selectedCustomerList.value.length !== 1 || !options.selectedCustomerList.value[0]?.id) {
Message.warning("套餐购买只能选择一个客户");
return false;
}
if (!options.config.value?.id) {
Message.warning("套餐配置不存在,无法购买");
return false;
}
if (!isSaleable.value) {
Message.warning("套餐当前不可购买,请检查套餐状态和有效期");
return false;
}
if (parsePackageAmount(saleAmountText.value) === undefined) {
Message.warning("成交价必须是合法的元金额,最多两位小数");
return false;
}
if (!packagePurchaseItemList.value.length) {
Message.warning("购买权益至少需要一项");
return false;
}
for (const item of packagePurchaseItemList.value) {
const quantity = normalizePackageQuantity(item.totalQuantity);
if (!item.productId || !item.productUnitId || !quantity || quantity === "0") {
Message.warning("产品单位和正数量不能为空");
return false;
}
}
return true;
}
return {
isSaleable,
canPriceOverride,
saleAmountText,
packagePurchaseItemList,
applyConfig,
resetPurchaseForm,
resetRequestNo,
submitPurchase,
validatePurchaseForm
};
}