refactor: 产品管理表格重构为主表+展开子表模式

- 产品行可展开显示所有规格的成本价/售价/状态
- 价格范围显示(多规格时 ¥最小~最大)
- 展开行内支持编辑规格和设为默认
- 移除产品详情弹窗及相关冗余代码(净减 23 行)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
taiyi 2026-06-04 20:18:59 +08:00
parent 2fff1b2c43
commit f114bc683e

View File

@ -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">
<td><input type="checkbox" :value="row.productId" v-model="selectedProductIds" /></td> <tr class="product-row" :class="{ expanded: expandedProducts.has(row.productName) }">
<td>{{ row.productName }}</td> <td><input type="checkbox" :value="row.productId" v-model="selectedProductIds" /></td>
<td>{{ row.specifications.find(s => s.isDefault)?.specification || row.specifications[0]?.specification || '-' }}</td> <td class="product-name-cell" @click="toggleExpand(row.productName)">
<td>{{ row.specifications.find(s => s.isDefault)?.unit || row.specifications[0]?.unit || '-' }}</td> <span class="expand-icon">{{ expandedProducts.has(row.productName) ? '▼' : '▶' }}</span>
<td>{{ row.specifications.length }}</td> {{ row.productName }}
<td>¥{{ row.specifications.find(s => s.isDefault)?.costPrice || row.specifications[0]?.costPrice || '0.00' }}</td> </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>{{ row.status }}</td> <td>{{ priceRange(row) }}</td>
<td> <td>{{ row.status }}</td>
<button class="link-btn" :disabled="loadingProductDetail && selectedProductId === row.productName" @click="handleViewProductDetail(row)"> <td>
{{ loadingProductDetail && selectedProductId === row.productName ? "加载中..." : "查看详情" }} <button class="link-btn" @click="toggleExpand(row.productName)">
</button> {{ expandedProducts.has(row.productName) ? '收起' : '展开' }}
<button class="link-btn danger-link" @click="handleDeleteProduct(row)">删除</button> </button>
<button class="link-btn danger-link" @click="handlePermanentDeleteProduct(row)">永久删除</button> <button class="link-btn danger-link" @click="handleDeleteProduct(row)">删除</button>
</td> <button class="link-btn danger-link" @click="handlePermanentDeleteProduct(row)">永久删除</button>
</tr> </td>
</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 {
await updateProductSpecification(editingSpecId.value, { if (editingSpecId.value) {
...editingSpecForm, await updateProductSpecification(editingSpecId.value, {
category_id: editingSpecForm.category_id ? Number(editingSpecForm.category_id) : null, ...editingSpecForm,
status: Number(editingSpecForm.status || 1), category_id: editingSpecForm.category_id ? Number(editingSpecForm.category_id) : null,
is_default: false, status: Number(editingSpecForm.status || 1),
}); 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; }