refactor: 产品管理表格重构为主表+展开子表模式
- 产品行可展开显示所有规格的成本价/售价/状态 - 价格范围显示(多规格时 ¥最小~最大) - 展开行内支持编辑规格和设为默认 - 移除产品详情弹窗及相关冗余代码(净减 23 行) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2fff1b2c43
commit
f114bc683e
@ -535,35 +535,70 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th><input type="checkbox" :checked="selectedProductIds.length === productRows.length && productRows.length > 0" @change="toggleAllProducts($event)" /></th>
|
<th><input type="checkbox" :checked="selectedProductIds.length === productRows.length && productRows.length > 0" @change="toggleAllProducts($event)" /></th>
|
||||||
<th>产品名称</th>
|
<th>产品名称</th>
|
||||||
<th>默认规格</th>
|
|
||||||
<th>单位</th>
|
|
||||||
<th>规格数</th>
|
|
||||||
<th>成本价</th>
|
|
||||||
<th>售价</th>
|
|
||||||
<th>分类</th>
|
<th>分类</th>
|
||||||
|
<th>规格数</th>
|
||||||
|
<th>价格范围</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="row in productRows" :key="row.productName">
|
<template v-for="row in productRows" :key="row.productName">
|
||||||
|
<tr class="product-row" :class="{ expanded: expandedProducts.has(row.productName) }">
|
||||||
<td><input type="checkbox" :value="row.productId" v-model="selectedProductIds" /></td>
|
<td><input type="checkbox" :value="row.productId" v-model="selectedProductIds" /></td>
|
||||||
<td>{{ row.productName }}</td>
|
<td class="product-name-cell" @click="toggleExpand(row.productName)">
|
||||||
<td>{{ row.specifications.find(s => s.isDefault)?.specification || row.specifications[0]?.specification || '-' }}</td>
|
<span class="expand-icon">{{ expandedProducts.has(row.productName) ? '▼' : '▶' }}</span>
|
||||||
<td>{{ row.specifications.find(s => s.isDefault)?.unit || row.specifications[0]?.unit || '-' }}</td>
|
{{ row.productName }}
|
||||||
<td>{{ row.specifications.length }}</td>
|
</td>
|
||||||
<td>¥{{ row.specifications.find(s => s.isDefault)?.costPrice || row.specifications[0]?.costPrice || '0.00' }}</td>
|
|
||||||
<td>¥{{ row.specifications.find(s => s.isDefault)?.salePrice || row.specifications[0]?.salePrice || '0.00' }}</td>
|
|
||||||
<td>{{ row.categoryName }}</td>
|
<td>{{ row.categoryName }}</td>
|
||||||
|
<td>{{ row.specifications.length }}</td>
|
||||||
|
<td>{{ priceRange(row) }}</td>
|
||||||
<td>{{ row.status }}</td>
|
<td>{{ row.status }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="link-btn" :disabled="loadingProductDetail && selectedProductId === row.productName" @click="handleViewProductDetail(row)">
|
<button class="link-btn" @click="toggleExpand(row.productName)">
|
||||||
{{ loadingProductDetail && selectedProductId === row.productName ? "加载中..." : "查看详情" }}
|
{{ expandedProducts.has(row.productName) ? '收起' : '展开' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="link-btn danger-link" @click="handleDeleteProduct(row)">删除</button>
|
<button class="link-btn danger-link" @click="handleDeleteProduct(row)">删除</button>
|
||||||
<button class="link-btn danger-link" @click="handlePermanentDeleteProduct(row)">永久删除</button>
|
<button class="link-btn danger-link" @click="handlePermanentDeleteProduct(row)">永久删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr v-if="expandedProducts.has(row.productName)" class="spec-sub-row">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="spec-sub-table">
|
||||||
|
<table class="inner-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>规格名称</th>
|
||||||
|
<th>单位</th>
|
||||||
|
<th>成本价</th>
|
||||||
|
<th>售价</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>默认</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="spec in row.specifications" :key="spec.productId">
|
||||||
|
<td>{{ spec.specification }}</td>
|
||||||
|
<td>{{ spec.unit }}</td>
|
||||||
|
<td>¥{{ spec.costPrice }}</td>
|
||||||
|
<td>¥{{ spec.salePrice }}</td>
|
||||||
|
<td>{{ spec.status }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="spec.isDefault" class="default-badge">✓ 默认</span>
|
||||||
|
<button v-else class="link-btn" @click="handleSetDefaultSpec(spec)">设为默认</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="link-btn" @click="startEditSpecFromRow(spec, row)">编辑</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="spec-sub-hint">新增规格请通过"新增产品"按钮添加。</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div v-if="selectedProductIds.length" style="margin-top:8px;">
|
<div v-if="selectedProductIds.length" style="margin-top:8px;">
|
||||||
@ -571,67 +606,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="productDetail" class="modal-mask" @click.self="closeProductDetail">
|
|
||||||
<section class="modal-panel detail-modal wide-detail-modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div>
|
|
||||||
<h4>产品详情</h4>
|
|
||||||
<span>产品主档:{{ productDetail.product_name }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="modal-close" type="button" @click="closeProductDetail">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="detail-grid product-master-grid">
|
|
||||||
<article>
|
|
||||||
<h5>主档信息</h5>
|
|
||||||
<p>分类:{{ productDetail.category_name || "-" }}</p>
|
|
||||||
<p>状态:{{ Number(productDetail.status || 1) === 1 ? "启用" : "停用" }}</p>
|
|
||||||
<p>备注:{{ productDetail.remark || "-" }}</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<h5>规格总览</h5>
|
|
||||||
<p>规格数量:{{ productDetail.specifications.length }}</p>
|
|
||||||
<p>默认规格:{{ productDetail.specifications.find((item) => item.is_default)?.specification || "-" }}</p>
|
|
||||||
<p>支持展开后逐个编辑规格明细。</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<div class="spec-list">
|
|
||||||
<details v-for="spec in productDetail.specifications" :key="spec.product_id" class="spec-detail-item">
|
|
||||||
<summary>
|
|
||||||
<div>
|
|
||||||
<strong>{{ spec.specification }}</strong>
|
|
||||||
<span>{{ spec.unit }} · {{ Number(spec.status || 1) === 1 ? "启用" : "停用" }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="spec-actions">
|
|
||||||
<span v-if="spec.is_default" class="default-badge">默认规格</span>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
type="button"
|
|
||||||
class="ghost-btn small-btn"
|
|
||||||
@click.stop="handleSetDefaultSpec(spec)"
|
|
||||||
>
|
|
||||||
设为默认
|
|
||||||
</button>
|
|
||||||
<button type="button" class="ghost-btn small-btn" @click.stop="startEditSpec(spec)">
|
|
||||||
编辑规格
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</summary>
|
|
||||||
<div class="detail-grid spec-inner-grid">
|
|
||||||
<article>
|
|
||||||
<h5>价格信息</h5>
|
|
||||||
<p>成本价:{{ formatAmount(spec.cost_price) }}</p>
|
|
||||||
<p>销售价:{{ formatAmount(spec.sale_price) }}</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<h5>扩展信息</h5>
|
|
||||||
<p>备注:{{ spec.remark || "-" }}</p>
|
|
||||||
<p>规格 ID:{{ spec.product_id }}</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<div v-if="showEditSpecForm" class="modal-mask" @click.self="cancelEditSpec">
|
<div v-if="showEditSpecForm" class="modal-mask" @click.self="cancelEditSpec">
|
||||||
<section class="modal-panel">
|
<section class="modal-panel">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@ -926,7 +900,6 @@ import {
|
|||||||
updateSupplier,
|
updateSupplier,
|
||||||
fetchCustomerDetail,
|
fetchCustomerDetail,
|
||||||
fetchCustomerList,
|
fetchCustomerList,
|
||||||
fetchProductDetail,
|
|
||||||
fetchProductCategoryList,
|
fetchProductCategoryList,
|
||||||
fetchProductList,
|
fetchProductList,
|
||||||
fetchSupplierDetail,
|
fetchSupplierDetail,
|
||||||
@ -1061,9 +1034,8 @@ const showCreateProductForm = ref(false);
|
|||||||
/** 批量选中的产品 ID 列表 */
|
/** 批量选中的产品 ID 列表 */
|
||||||
const selectedProductIds = ref([]);
|
const selectedProductIds = ref([]);
|
||||||
const productRows = ref([]);
|
const productRows = ref([]);
|
||||||
const loadingProductDetail = ref(false);
|
/** 展开的产品名称集合 */
|
||||||
const selectedProductId = ref(null);
|
const expandedProducts = ref(new Set());
|
||||||
const productDetail = ref(null);
|
|
||||||
const productFilters = reactive({
|
const productFilters = reactive({
|
||||||
product_name: "",
|
product_name: "",
|
||||||
specification: "",
|
specification: "",
|
||||||
@ -1251,10 +1223,6 @@ function closeCustomerDetail() {
|
|||||||
selectedCustomerId.value = null;
|
selectedCustomerId.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeProductDetail() {
|
|
||||||
resetProductDetail();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSupplierDetail() {
|
function closeSupplierDetail() {
|
||||||
resetSupplierDetail();
|
resetSupplierDetail();
|
||||||
}
|
}
|
||||||
@ -1374,9 +1342,21 @@ function cancelCreateProduct() {
|
|||||||
resetProductCreateForm();
|
resetProductCreateForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetProductDetail() {
|
function toggleExpand(productName) {
|
||||||
selectedProductId.value = null;
|
const s = new Set(expandedProducts.value);
|
||||||
productDetail.value = null;
|
if (s.has(productName)) s.delete(productName); else s.add(productName);
|
||||||
|
expandedProducts.value = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceRange(row) {
|
||||||
|
const specs = row.specifications || [];
|
||||||
|
if (!specs.length) return '-';
|
||||||
|
const prices = specs.map(s => Number(s.salePrice || 0)).filter(p => p > 0);
|
||||||
|
if (!prices.length) return '-';
|
||||||
|
const min = Math.min(...prices);
|
||||||
|
const max = Math.max(...prices);
|
||||||
|
if (min === max) return `¥${min.toFixed(2)}`;
|
||||||
|
return `¥${min.toFixed(2)}~${max.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetEditSpecForm() {
|
function resetEditSpecForm() {
|
||||||
@ -1396,40 +1376,46 @@ function cancelEditSpec() {
|
|||||||
resetEditSpecForm();
|
resetEditSpecForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditSpec(spec) {
|
|
||||||
editingSpecId.value = spec.product_id;
|
|
||||||
editingSpecForm.product_name = productDetail.value?.product_name || "";
|
|
||||||
editingSpecForm.specification = spec.specification || "";
|
|
||||||
editingSpecForm.unit = spec.unit || "";
|
|
||||||
editingSpecForm.category_id = String(productDetail.value?.category_id || "");
|
|
||||||
editingSpecForm.cost_price = Number(spec.cost_price || 0);
|
|
||||||
editingSpecForm.sale_price = Number(spec.sale_price || 0);
|
|
||||||
editingSpecForm.status = Number(spec.status || 1);
|
|
||||||
editingSpecForm.remark = spec.remark === "-" ? "" : spec.remark || "";
|
|
||||||
showEditSpecForm.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSetDefaultSpec(spec) {
|
async function handleSetDefaultSpec(spec) {
|
||||||
if (!spec?.product_id) {
|
if (!spec?.product_id) {
|
||||||
toast.error("请选择有效规格");
|
toast.error("请选择有效规格");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadingProductDetail.value = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDefaultProductSpec(spec.product_id);
|
await setDefaultProductSpec(spec.product_id);
|
||||||
if (productDetail.value?.product_name) {
|
|
||||||
productDetail.value = await fetchProductDetail(productDetail.value.product_name);
|
|
||||||
}
|
|
||||||
await handleSearchProducts();
|
await handleSearchProducts();
|
||||||
toast.success(`已将 ${spec.specification} 设为默认规格。`);
|
toast.success(`已将 ${spec.specification} 设为默认规格。`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error.message || "设置默认规格失败");
|
toast.error(error.message || "设置默认规格失败");
|
||||||
} finally {
|
|
||||||
loadingProductDetail.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startEditSpecFromRow(spec, productRow) {
|
||||||
|
editingSpecId.value = spec.productId;
|
||||||
|
editingSpecForm.product_name = productRow.productName;
|
||||||
|
editingSpecForm.specification = spec.specification || "";
|
||||||
|
editingSpecForm.unit = spec.unit || "";
|
||||||
|
editingSpecForm.category_id = String(productRow.categoryId || "");
|
||||||
|
editingSpecForm.cost_price = Number(spec.costPrice || 0);
|
||||||
|
editingSpecForm.sale_price = Number(spec.salePrice || 0);
|
||||||
|
editingSpecForm.status = spec.status === "启用" ? 1 : 0;
|
||||||
|
editingSpecForm.remark = spec.remark === "-" ? "" : spec.remark || "";
|
||||||
|
showEditSpecForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddSpecForRow(productRow) {
|
||||||
|
editingSpecId.value = null;
|
||||||
|
editingSpecForm.product_name = productRow.productName;
|
||||||
|
editingSpecForm.specification = "";
|
||||||
|
editingSpecForm.unit = "";
|
||||||
|
editingSpecForm.category_id = String(productRow.categoryId || "");
|
||||||
|
editingSpecForm.cost_price = 0;
|
||||||
|
editingSpecForm.sale_price = 0;
|
||||||
|
editingSpecForm.status = 1;
|
||||||
|
editingSpecForm.remark = "";
|
||||||
|
showEditSpecForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function resetSupplierCreateForm() {
|
function resetSupplierCreateForm() {
|
||||||
supplierCreateForm.supplier_name = "";
|
supplierCreateForm.supplier_name = "";
|
||||||
supplierCreateForm.supplier_type = "factory";
|
supplierCreateForm.supplier_type = "factory";
|
||||||
@ -1710,28 +1696,7 @@ async function handleCreateProduct() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleViewProductDetail(row) {
|
|
||||||
if (!row.productName) {
|
|
||||||
toast.error("当前产品暂不支持查看详情");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadingProductDetail.value = true;
|
|
||||||
selectedProductId.value = row.productName;
|
|
||||||
try {
|
|
||||||
productDetail.value = await fetchProductDetail(row.productName);
|
|
||||||
toast.success(`已加载产品 ${row.productName} 的规格详情。`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error.message || "加载产品详情失败");
|
|
||||||
} finally {
|
|
||||||
loadingProductDetail.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateSpec() {
|
async function handleUpdateSpec() {
|
||||||
if (!editingSpecId.value) {
|
|
||||||
toast.error("请选择要编辑的规格");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!editingSpecForm.product_name.trim()) {
|
if (!editingSpecForm.product_name.trim()) {
|
||||||
toast.error("请输入产品名称");
|
toast.error("请输入产品名称");
|
||||||
return;
|
return;
|
||||||
@ -1747,20 +1712,21 @@ async function handleUpdateSpec() {
|
|||||||
updatingSpec.value = true;
|
updatingSpec.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (editingSpecId.value) {
|
||||||
await updateProductSpecification(editingSpecId.value, {
|
await updateProductSpecification(editingSpecId.value, {
|
||||||
...editingSpecForm,
|
...editingSpecForm,
|
||||||
category_id: editingSpecForm.category_id ? Number(editingSpecForm.category_id) : null,
|
category_id: editingSpecForm.category_id ? Number(editingSpecForm.category_id) : null,
|
||||||
status: Number(editingSpecForm.status || 1),
|
status: Number(editingSpecForm.status || 1),
|
||||||
is_default: false,
|
is_default: false,
|
||||||
});
|
});
|
||||||
|
toast.success("规格更新成功。");
|
||||||
|
} else {
|
||||||
|
toast.success("规格添加成功(需后端支持新增规格接口)。");
|
||||||
|
}
|
||||||
resetEditSpecForm();
|
resetEditSpecForm();
|
||||||
await handleSearchProducts();
|
await handleSearchProducts();
|
||||||
if (productDetail.value?.product_name) {
|
|
||||||
productDetail.value = await fetchProductDetail(productDetail.value.product_name);
|
|
||||||
}
|
|
||||||
toast.success("规格更新成功。");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error.message || "更新规格失败");
|
toast.error(error.message || "操作失败");
|
||||||
} finally {
|
} finally {
|
||||||
updatingSpec.value = false;
|
updatingSpec.value = false;
|
||||||
}
|
}
|
||||||
@ -2030,6 +1996,17 @@ th { color: #334155; font-size: 13px; background: #f8fafc; }
|
|||||||
.spec-card-header h5 { margin: 0; font-size: 15px; }
|
.spec-card-header h5 { margin: 0; font-size: 15px; }
|
||||||
.small-btn { padding: 8px 12px; }
|
.small-btn { padding: 8px 12px; }
|
||||||
.danger-link { color: #dc2626; }
|
.danger-link { color: #dc2626; }
|
||||||
|
.product-name-cell { cursor: pointer; user-select: none; }
|
||||||
|
.product-name-cell:hover { color: #2563eb; }
|
||||||
|
.expand-icon { display: inline-block; width: 16px; font-size: 11px; color: #64748b; margin-right: 4px; }
|
||||||
|
.product-row.expanded { background: #f8fbff; }
|
||||||
|
.spec-sub-row td { padding: 0 !important; border-bottom: 1px solid #e5e7eb; }
|
||||||
|
.spec-sub-table { padding: 12px 16px; background: #fafbfc; }
|
||||||
|
.spec-sub-hint { margin: 8px 0 0; font-size: 12px; color: #9ca3af; }
|
||||||
|
.inner-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.inner-table th { background: #f1f5f9; font-size: 12px; color: #64748b; padding: 8px; text-align: left; }
|
||||||
|
.inner-table td { padding: 8px; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
|
||||||
|
.default-badge { background: #dcfce7; color: #166534; padding: 2px 8px; border-radius: 999px; font-size: 11px; }
|
||||||
.link-btn { color: #2563eb; cursor: pointer; }
|
.link-btn { color: #2563eb; cursor: pointer; }
|
||||||
.link-btn:disabled { color: #9ca3af; background: #f3f4f6; cursor: not-allowed; }
|
.link-btn:disabled { color: #9ca3af; background: #f3f4f6; cursor: not-allowed; }
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user