add product

This commit is contained in:
Timi
2026-09-03 14:27:40 +08:00
parent 7f669621fb
commit 333999fe21
63 changed files with 5662 additions and 2210 deletions
+269
View File
@@ -0,0 +1,269 @@
<template>
<div v-can="permissionCodes.product.read" class="product-detail">
<t-loading v-if="isLoading" class="loading" size="32px" />
<template v-else-if="product">
<section class="hero" v-entrance-transition>
<span class="eyebrow" v-text="'产品库'" />
<div class="hero-title">
<h1 v-text="product.name || '未命名产品'" />
<t-tag :content="getProductStatusText(product.status)" variant="light-outline" :theme="getProductStatusTheme(product.status)" />
</div>
<div class="hero-meta">
<span v-text="`${product.code || '-'} · ${getProductTypeText(product.productType)}`" />
<span v-text="`${product.unitList?.length || 0} 个业务单位`" />
</div>
</section>
<card-container v-if="product.attachmentList?.length" title="产品附件">
<photo-scroller
:model-value="attachmentUrlList"
:photo-items="attachmentItemList"
title="附件列表"
:show-add="false"
:show-remove="false"
:show-viewer-actions="false"
/>
</card-container>
<card-container title="基本信息">
<t-cell-group theme="card" class="info">
<t-cell title="产品名称" :note="product.name || '未命名产品'" />
<t-cell title="产品编码" :note="product.code || '-'" />
<t-cell title="产品类型" :note="getProductTypeText(product.productType)" />
<t-cell title="产品分类" :note="product.category?.name || '未分类'" />
<t-cell title="基础单位" :note="product.baseUnit || '-'" />
<t-cell title="参考成本" :note="formatProductAmount(product.referenceCost)" />
<t-cell title="库存管理" :note="product.stockEnabled ? '已开启' : '未开启'" />
<t-cell title="状态" :note="getProductStatusText(product.status)" />
</t-cell-group>
</card-container>
<card-container title="业务单位" :description="product.unitList?.length ? '名称、基础单位数量、售价和可用范围由产品库统一维护' : '暂无业务单位'">
<t-cell-group theme="card" class="units">
<t-cell v-for="unit in product.unitList || []" :key="unit.id || unit.name">
<template #title>
<div class="unit-title">
<strong v-text="unit.name || '未命名单位'" />
<t-tag v-if="unit.isBase" size="small" theme="primary" variant="light-outline" content="基础单位" />
</div>
</template>
<template #note>
<span v-text="`1 ${unit.name || '单位'} = ${unit.baseQuantityPerUnit} ${product.baseUnit || '基础单位'}`" />
</template>
<template #description>
<div class="unit-description">
<span v-text="`建议售价:${formatProductAmount(unit.salePrice)}`" />
<span v-text="getUnitUsageText(unit)" />
</div>
</template>
</t-cell>
</t-cell-group>
</card-container>
<card-container v-if="product.remark" title="备注">
<div class="remark" v-text="product.remark" />
</card-container>
</template>
<t-empty v-else description="产品不存在或已被删除" />
</div>
<nav-bar-action-menu v-if="actionList.length" :actions="actionList" />
</template>
<script setup lang="ts">
import { DialogPlugin } from "tdesign-mobile-vue";
import { useUserStore } from "timi-web-api";
import ProductAPI from "@/api/ProductAPI";
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { NavBarAction } from "@/types/NavBarAction";
import type { PhotoItem } from "@/types/Photo";
import type { Product, ProductUnit } from "@/types/Product";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getErrorMessage } from "@/utils/error";
import { formatProductAmount, getProductStatusText, getProductStatusTheme, getProductTypeText } from "@/utils/product";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "ProductDetail" });
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const product = ref<Product>();
const isLoading = ref(false);
const attachmentItemList = computed<PhotoItem[]>(() => (product.value?.attachmentList || [])
.filter(item => !!item.id)
.map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
previewUrl: getAttachmentReadURL(item.id || ""),
url: getAttachmentReadURL(item.id || "", 90)
})));
const attachmentUrlList = computed(() => attachmentItemList.value.map(item => item.url));
const actionList = computed<NavBarAction[]>(() => {
const list: NavBarAction[] = [];
if (userStore.hasPermissions(permissionCodes.product.update)) {
list.push({ key: "edit", text: "编辑产品", icon: "edit-1", theme: "primary", action: edit });
if (product.value?.status === "ACTIVE") {
list.push({ key: "disable", text: "停用产品", icon: "close", theme: "danger", action: disable });
}
}
if (product.value?.stockEnabled && userStore.hasPermissions(permissionCodes.productStock.read)) {
list.push({ key: "stock", text: "查看库存", icon: "wallet", theme: "default", action: viewStock });
}
return list;
});
function getUnitUsageText(unit: ProductUnit): string {
const usage: string[] = [];
if (unit.allowSale) {
usage.push("可销售");
}
if (unit.allowPurchase) {
usage.push("可采购");
}
return usage.length ? usage.join("、") : "暂无业务用途";
}
async function load(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
product.value = await ProductAPI.detail(id);
} finally {
isLoading.value = false;
}
}
async function edit(): Promise<void> {
if (product.value?.id) {
await router.push(`/management/products/${product.value.id}/edit`);
}
}
async function viewStock(): Promise<void> {
if (product.value?.id) {
await router.push(`/management/product-stocks/${product.value.id}`);
}
}
function disable(): void {
const id = product.value?.id;
if (!id) {
return;
}
const dialog = DialogPlugin.confirm({
title: "停用产品",
content: "停用后不能用于新订单、套餐或库存操作,历史记录仍会保留。确定停用?",
confirmBtn: { content: "停用", theme: "danger" },
onConfirm: async () => {
try {
await ProductAPI.disable(id);
await load();
Message.success("产品已停用");
} catch (error) {
Message.error(getErrorMessage(error, "产品停用失败"));
} finally {
dialog.destroy();
}
}
});
}
onMounted(async () => {
navBarStore.clearRight();
navBarStore.setCustomRightVisible(actionList.value.length > 0);
try {
await load();
navBarStore.setCustomRightVisible(actionList.value.length > 0);
} catch (error) {
Message.error(getErrorMessage(error, "产品详情加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.product-detail {
gap: 1rem;
display: grid;
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
.hero {
margin: 0 1rem;
padding: 1rem;
background: linear-gradient(135deg, #e8fffa, #eff6ff);
box-shadow: var(--td-shadow-1);
border-radius: 1.25rem;
.eyebrow {
color: #0f766e;
display: block;
font-size: .6875rem;
font-weight: 700;
letter-spacing: .08em;
}
.hero-title {
gap: .5rem;
display: flex;
margin-top: .35rem;
align-items: center;
justify-content: space-between;
}
h1 {
margin: 0;
min-width: 0;
font-size: 1.25rem;
line-height: 1.35;
}
.hero-meta {
gap: .75rem;
display: flex;
margin-top: .6rem;
color: var(--td-text-color-secondary);
align-items: center;
justify-content: space-between;
}
}
.loading {
margin: 3rem auto;
}
.info,
.units {
margin: 0;
}
.unit-title {
gap: .5rem;
display: flex;
align-items: center;
}
.unit-description {
gap: .125rem;
display: grid;
}
.remark {
padding: 1rem;
white-space: pre-wrap;
}
}
:global(html.theme-dark) .product-detail .hero {
background: linear-gradient(135deg, rgba(15, 118, 110, .2), rgba(37, 99, 235, .18));
.eyebrow {
color: #99f6e4;
}
}
</style>