批量优化产品和产品分类
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="crm-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>产品分类</h2>
|
||||
<p>维护 CRM 产品的自定义归类,产品表单的分类下拉取自本表</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增分类</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-form :inline="true" :model="filters" @submit.prevent>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
clearable
|
||||
placeholder="搜索分类名称 / 编码"
|
||||
:prefix-icon="Search"
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 120px">
|
||||
<el-option v-for="i in PRODUCT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="table-container" v-loading="loading">
|
||||
<el-table :data="tableData" stripe border row-key="id">
|
||||
<el-table-column prop="name" label="分类名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="code" label="分类编码" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="sort" label="排序" width="90" align="center" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="productStatusTag(row.status)" size="small">{{ productStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无产品分类" :image-size="80" /></template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSearch"
|
||||
@current-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
:model-value="dialogVisible"
|
||||
:title="isEdit ? '编辑分类' : '新增分类'"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleClose"
|
||||
@opened="handleOpened"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" label-position="right">
|
||||
<el-form-item label="分类名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="如:服务器类 / 软件许可" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类编码" prop="code">
|
||||
<el-input v-model="form.code" placeholder="选填,如:SERVER" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="form.sort" :min="0" :max="9999" :controls="false" style="width: 140px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in PRODUCT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注(选填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getProductCategoryList,
|
||||
createProductCategory,
|
||||
updateProductCategory,
|
||||
deleteProductCategory,
|
||||
} from "@/api/crmProductCategory";
|
||||
import { PRODUCT_STATUS_OPTIONS, productStatusText, productStatusTag } from "../dict";
|
||||
|
||||
const loading = ref(false);
|
||||
const tableData = ref([]);
|
||||
const dialogVisible = ref(false);
|
||||
const submitting = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const currentId = ref(null);
|
||||
|
||||
const formRef = ref();
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: "",
|
||||
code: "",
|
||||
sort: 0,
|
||||
status: 1,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: "请输入分类名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
const filters = reactive({ keyword: "", status: "" });
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getProductCategoryList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters,
|
||||
});
|
||||
tableData.value = res?.data?.list || [];
|
||||
pagination.total = res?.data?.total || 0;
|
||||
} catch (e) {
|
||||
tableData.value = [];
|
||||
pagination.total = 0;
|
||||
ElMessage.error(e.message || "查询失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = "";
|
||||
filters.status = "";
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
isEdit.value = false;
|
||||
currentId.value = null;
|
||||
Object.assign(form, defaultForm());
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
isEdit.value = true;
|
||||
currentId.value = row.id;
|
||||
Object.assign(form, defaultForm(), row);
|
||||
form.status = Number(row.status);
|
||||
if (isNaN(form.status)) form.status = 1;
|
||||
form.sort = Number(row.sort) || 0;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
|
||||
function handleOpened() {
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = { ...form };
|
||||
if (isEdit.value) {
|
||||
await updateProductCategory(currentId.value, payload);
|
||||
ElMessage.success("更新成功");
|
||||
} else {
|
||||
await createProductCategory(payload);
|
||||
ElMessage.success("创建成功");
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
fetchList();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "操作失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除产品分类「${row.name}」吗?删除后不可恢复。`, "删除确认", {
|
||||
type: "warning",
|
||||
});
|
||||
await deleteProductCategory(row.id);
|
||||
ElMessage.success("删除成功");
|
||||
fetchList();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped src="../styles/crm-page.less"></style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="visible"
|
||||
:title="product?.product_name || '产品详情'"
|
||||
size="520px"
|
||||
direction="rtl"
|
||||
@update:model-value="handleClose"
|
||||
>
|
||||
<div v-if="product" class="product-detail">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="产品编号">{{ product.product_no || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="产品名称">{{ product.product_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="产品分类">
|
||||
<el-tag :type="categoryType(product.category)" size="small" effect="plain">
|
||||
{{ categoryDisplay(product.category) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="规格型号">{{ product.spec || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="单位">{{ product.unit || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="销售单价">¥{{ formatMoney(product.price) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="成本单价">¥{{ formatMoney(product.cost_price) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="税率">{{ formatPercent(product.tax_rate) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="毛利">
|
||||
¥{{ formatMoney((Number(product.price) || 0) - (Number(product.cost_price) || 0)) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="productStatusTag(product.status)" size="small">{{ productStatusText(product.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="来源项目">{{ product.project_name || "手动创建" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ product.remark || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(product.create_time) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(product.update_time) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from "vue";
|
||||
import {
|
||||
productCategoryText,
|
||||
productCategoryTag,
|
||||
productStatusText,
|
||||
productStatusTag,
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
formatDateTime,
|
||||
} from "../../dict";
|
||||
import { getProductCategoryList } from "@/api/crmProductCategory";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
product: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["update:visible"]);
|
||||
|
||||
const categoryMap = reactive({});
|
||||
|
||||
/** 详情分类展示:优先管理分类名称;同步写入的产品带「金额归属」编码时回退 */
|
||||
function categoryDisplay(val) {
|
||||
if (!val) return "-";
|
||||
if (categoryMap[val]) return val;
|
||||
return productCategoryText(val);
|
||||
}
|
||||
function categoryType(val) {
|
||||
if (!val || categoryMap[val]) return "primary";
|
||||
return productCategoryTag(val);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val && Object.keys(categoryMap).length === 0) {
|
||||
getProductCategoryList({ page: 1, pageSize: 200 })
|
||||
.then((res) => {
|
||||
(res?.data?.list || []).forEach((c) => {
|
||||
categoryMap[c.name] = c.name;
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.product-detail {
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="isEdit ? '编辑产品' : '新增产品'"
|
||||
width="720px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleClose"
|
||||
@opened="handleOpened"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" label-position="right">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="产品名称" prop="product_name">
|
||||
<el-input v-model="form.product_name" placeholder="请输入产品名称" maxlength="100" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="产品编号" prop="product_no">
|
||||
<el-input v-model="form.product_no" placeholder="如:P-2026-001" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="产品分类" prop="category">
|
||||
<el-select
|
||||
v-model="form.category"
|
||||
filterable
|
||||
allow-create
|
||||
clearable
|
||||
placeholder="请选择或输入分类"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="i in categoryOptions" :key="i.id" :label="i.name" :value="i.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="单位" prop="unit">
|
||||
<el-select v-model="form.unit" filterable allow-create clearable placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in PRODUCT_UNIT_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="规格型号" prop="spec">
|
||||
<el-input v-model="form.spec" placeholder="如:型号 / 配置 / 功能模块" maxlength="255" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="销售单价" prop="price">
|
||||
<el-input-number v-model="form.price" :min="0" :precision="2" :controls="false" style="width: 100%">
|
||||
<template #prefix>¥</template>
|
||||
</el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="成本单价" prop="cost_price">
|
||||
<el-input-number v-model="form.cost_price" :min="0" :precision="2" :controls="false" style="width: 100%">
|
||||
<template #prefix>¥</template>
|
||||
</el-input-number>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="税率(%)" prop="tax_rate">
|
||||
<el-input-number v-model="form.tax_rate" :min="0" :max="100" :precision="2" :controls="false" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
|
||||
<el-option v-for="i in PRODUCT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注(选填)" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { createProduct, updateProduct } from "@/api/crmProduct";
|
||||
import { getProductCategoryList } from "@/api/crmProductCategory";
|
||||
import { PRODUCT_STATUS_OPTIONS, PRODUCT_UNIT_OPTIONS } from "../../dict";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
editData: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(["update:visible", "success"]);
|
||||
|
||||
const formRef = ref();
|
||||
const submitting = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const internalId = ref(null);
|
||||
const categoryOptions = ref([]);
|
||||
|
||||
const defaultForm = () => ({
|
||||
product_name: "",
|
||||
product_no: "",
|
||||
category: "",
|
||||
unit: "",
|
||||
spec: "",
|
||||
price: 0,
|
||||
cost_price: 0,
|
||||
tax_rate: 0,
|
||||
status: 1,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
const rules = {
|
||||
product_name: [{ required: true, message: "请输入产品名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.editData) {
|
||||
isEdit.value = true;
|
||||
internalId.value = props.editData.id;
|
||||
Object.assign(form, defaultForm(), props.editData);
|
||||
form.status = Number(props.editData.status);
|
||||
if (isNaN(form.status)) form.status = 1;
|
||||
} else {
|
||||
isEdit.value = false;
|
||||
internalId.value = null;
|
||||
Object.assign(form, defaultForm());
|
||||
}
|
||||
fetchCategories();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/** 加载产品分类(用于分类下拉;取启用中的分类,允许新建时输入自定义分类) */
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await getProductCategoryList({ page: 1, pageSize: 200, status: 1 });
|
||||
categoryOptions.value = res?.data?.list || [];
|
||||
} catch (e) {
|
||||
categoryOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function handleOpened() {
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formRef.value) return;
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = { ...form };
|
||||
if (internalId.value) {
|
||||
await updateProduct(internalId.value, payload);
|
||||
ElMessage.success("更新成功");
|
||||
} else {
|
||||
await createProduct(payload);
|
||||
ElMessage.success("创建成功");
|
||||
}
|
||||
emit("success");
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
ElMessage.error(e.message || "操作失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div class="crm-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>产品管理</h2>
|
||||
<p>维护可售产品台账(目录),项目生成时自动写入产品参数</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增产品</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stat-cards">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">产品总数</span>
|
||||
<span class="stat-value">{{ pagination.total }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">启用中</span>
|
||||
<span class="stat-value success">{{ stats.enabled }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">已停用</span>
|
||||
<span class="stat-value"> {{ stats.disabled }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">平均销售单价</span>
|
||||
<span class="stat-value primary">¥{{ formatMoney(stats.avgPrice) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左分类 / 右数据 -->
|
||||
<div class="page-body">
|
||||
<!-- 左侧:产品分类 -->
|
||||
<aside class="cate-panel">
|
||||
<div class="cate-title">产品分类</div>
|
||||
<ul class="cate-list">
|
||||
<li :class="['cate-item', { active: activeCate === '' }]" @click="selectCate('')">
|
||||
<span class="cate-name">全部产品</span>
|
||||
</li>
|
||||
<li
|
||||
v-for="c in categoryOptions"
|
||||
:key="c.id"
|
||||
:class="['cate-item', { active: activeCate === c.name }]"
|
||||
@click="selectCate(c.name)"
|
||||
>
|
||||
<span class="cate-name">{{ c.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧:当前分类下的产品数据 -->
|
||||
<section class="main-panel">
|
||||
<div class="filter-bar">
|
||||
<el-form :inline="true" :model="filters" @submit.prevent>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
clearable
|
||||
placeholder="搜索产品名称 / 编号 / 规格"
|
||||
:prefix-icon="Search"
|
||||
style="width: 260px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 120px">
|
||||
<el-option v-for="i in PRODUCT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="table-container" v-loading="loading">
|
||||
<el-table :data="tableData" stripe border row-key="id">
|
||||
<el-table-column label="产品名称" min-width="180" show-overflow-tooltip fixed>
|
||||
<template #default="{ row }">
|
||||
<span class="name-link" @click="openDetail(row)">{{ row.product_name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="product_no" label="产品编号" width="130" show-overflow-tooltip />
|
||||
<el-table-column label="分类" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="categoryType(row.category)" size="small" effect="plain">
|
||||
{{ categoryDisplay(row.category) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="spec" label="规格型号" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="unit" label="单位" width="70" align="center" />
|
||||
<el-table-column label="销售单价" width="120" align="right">
|
||||
<template #default="{ row }">¥{{ formatMoney(row.price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成本单价" width="120" align="right">
|
||||
<template #default="{ row }">¥{{ formatMoney(row.cost_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="税率" width="80" align="center">
|
||||
<template #default="{ row }">{{ formatPercent(row.tax_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="productStatusTag(row.status)" size="small">{{ productStatusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源项目" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.project_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无产品" :image-size="80" /></template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSearch"
|
||||
@current-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ProductEdit v-model:visible="editVisible" :edit-data="currentRow" @success="fetchList" />
|
||||
<ProductDetail v-model:visible="detailVisible" :product="currentRow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||||
import { getProductList, deleteProduct } from "@/api/crmProduct";
|
||||
import { getProductCategoryList } from "@/api/crmProductCategory";
|
||||
import ProductEdit from "./components/edit.vue";
|
||||
import ProductDetail from "./components/detail.vue";
|
||||
import {
|
||||
PRODUCT_STATUS_OPTIONS,
|
||||
productCategoryText,
|
||||
productCategoryTag,
|
||||
productStatusText,
|
||||
productStatusTag,
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
} from "../dict";
|
||||
|
||||
const loading = ref(false);
|
||||
const tableData = ref([]);
|
||||
const editVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const currentRow = ref(null);
|
||||
const categoryOptions = ref([]);
|
||||
const categoryMap = reactive({});
|
||||
|
||||
/** 左侧当前选中的分类值:空=全部;自定义分类=分类名称;系统分类=编码 */
|
||||
const activeCate = ref("");
|
||||
|
||||
/** 产品分类展示:优先显示管理分类名称;同步写入的产品带「金额归属」编码时回退显示 */
|
||||
function categoryDisplay(val) {
|
||||
if (!val) return "-";
|
||||
if (categoryMap[val]) return val;
|
||||
return productCategoryText(val);
|
||||
}
|
||||
function categoryType(val) {
|
||||
if (!val || categoryMap[val]) return "primary";
|
||||
return productCategoryTag(val);
|
||||
}
|
||||
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
// 与「产品分类」管理界面同源同序(后端按 sort, -id 排序,pageSize 上限 200)
|
||||
const res = await getProductCategoryList({ page: 1, pageSize: 200 });
|
||||
const list = res?.data?.list || [];
|
||||
categoryOptions.value = list;
|
||||
list.forEach((c) => {
|
||||
categoryMap[c.name] = c.name;
|
||||
});
|
||||
} catch (e) {
|
||||
categoryOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
const stats = reactive({ enabled: 0, disabled: 0, avgPrice: 0 });
|
||||
|
||||
// 分类筛选由左侧面板控制,这里只保留关键词与状态
|
||||
const filters = reactive({ keyword: "", status: "" });
|
||||
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
fetchList();
|
||||
});
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getProductList({
|
||||
page: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: filters.keyword,
|
||||
status: filters.status,
|
||||
category: activeCate.value,
|
||||
});
|
||||
const list = res?.data?.list || [];
|
||||
tableData.value = list;
|
||||
pagination.total = res?.data?.total || 0;
|
||||
// 本地汇总统计(后端按当前过滤返回,统计基于当前列表即可)
|
||||
let enabled = 0;
|
||||
let disabled = 0;
|
||||
let priceSum = 0;
|
||||
list.forEach((p) => {
|
||||
if (Number(p.status) === 1) enabled++;
|
||||
else disabled++;
|
||||
priceSum += Number(p.price) || 0;
|
||||
});
|
||||
stats.enabled = enabled;
|
||||
stats.disabled = disabled;
|
||||
stats.avgPrice = list.length ? priceSum / list.length : 0;
|
||||
} catch (e) {
|
||||
tableData.value = [];
|
||||
pagination.total = 0;
|
||||
ElMessage.error(e.message || "查询失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换左侧分类:回到第一页并重新加载对应数据 */
|
||||
function selectCate(val) {
|
||||
if (activeCate.value === val) return;
|
||||
activeCate.value = val;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = "";
|
||||
filters.status = "";
|
||||
activeCate.value = "";
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
currentRow.value = null;
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
currentRow.value = { ...row };
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
currentRow.value = { ...row };
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除产品「${row.product_name}」吗?删除后不可恢复。`, "删除确认", {
|
||||
type: "warning",
|
||||
});
|
||||
await deleteProduct(row.id);
|
||||
ElMessage.success("删除成功");
|
||||
fetchList();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped src="../styles/crm-page.less"></style>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.stat-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.stat-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&.primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 左分类 + 右数据 */
|
||||
.page-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cate-panel {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
|
||||
.cate-title {
|
||||
padding: 8px 10px 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.cate-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cate-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cate-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user