Explorar el Código

feat: 添加销售分析模块,包括分析数据管理、报表分析和分类管理功能。新增多个API接口和Vue组件,支持商品编码联想、数据导入导出、筛选和展示分析结果。更新样式以提升用户体验。

master
lizhuang hace 4 días
padre
commit
c9bea0e401
Se han modificado 30 ficheros con 8016 adiciones y 154 borrados
  1. 457
    0
      src/api/sales-analysis.js
  2. 28
    10
      src/assets/styles/index.scss
  3. 170
    0
      src/components/ProductCodeAutocomplete/index.vue
  4. 2
    2
      src/layout/components/Navbar.vue
  5. 169
    96
      src/router/index.js
  6. 1
    0
      src/store/getters.js
  7. 4
    0
      src/store/modules/user.js
  8. 54
    0
      src/utils/filters.js
  9. 8
    8
      src/views/components/daka/index.vue
  10. 4
    4
      src/views/components/user/choose-workflow-user .vue
  11. 4
    4
      src/views/components/user/multi-user.vue
  12. 4
    4
      src/views/components/user/sys-dept-user.vue
  13. 4
    4
      src/views/components/user/sys-user.vue
  14. 2
    3
      src/views/daka/attendanceteam/index.vue
  15. 3
    1
      src/views/index.vue
  16. 2
    3
      src/views/oa/attendance/groups/index.vue
  17. 619
    0
      src/views/sales-analysis/analysis-data/DataList.vue
  18. 1134
    0
      src/views/sales-analysis/analysis-data/ImportData.vue
  19. 850
    0
      src/views/sales-analysis/base-data/BaseData.vue
  20. 524
    0
      src/views/sales-analysis/categories/Categories.vue
  21. 939
    0
      src/views/sales-analysis/reports/CategoryAnalysis.vue
  22. 1049
    0
      src/views/sales-analysis/reports/OverallAnalysis.vue
  23. 507
    0
      src/views/sales-analysis/reports/ProductAnalysis.vue
  24. 807
    0
      src/views/sales-analysis/reports/ShopAnalysis.vue
  25. 656
    0
      src/views/sales-analysis/shop-customer/ShopCustomer.vue
  26. 2
    2
      src/views/system/role/authUser.vue
  27. 2
    2
      src/views/system/role/selectUser.vue
  28. 2
    2
      src/views/system/user/authRole.vue
  29. 7
    7
      src/views/system/user/index.vue
  30. 2
    2
      src/views/system/user/profile/userInfo.vue

+ 457
- 0
src/api/sales-analysis.js Ver fichero

@@ -0,0 +1,457 @@
import request from "@/utils/request";

export function getProductCodeSuggestions(params) {
return request({
url: "/reports/product-code-suggestions",
method: "get",
params,
});
}
/**
* 销售分析模块API接口
* 包含分析数据、基准数据、分类管理、店铺客户关联、报表分析等功能
*/

// ==================== 分析数据管理 ====================

/**
* 获取分析数据列表
* @param {Object} params - 查询参数
* @param {number} params.page - 页码
* @param {number} params.pageSize - 每页数量
* @param {string} params.productCode - 商品编号
* @param {string} params.productName - 商品名称
* @param {string} params.customerName - 客户名称
* @param {string} params.shopName - 店铺名称
* @param {string} params.status - 状态
* @param {string} params.startDate - 开始日期
* @param {string} params.endDate - 结束日期
* @returns {Promise} 返回数据列表和分页信息
*/
export function getAnalysisDataList(params) {
return request({
url: "/analysis-data",
method: "get",
params,
});
}

/**
* 更新分析数据
* @param {number} id - 数据ID
* @param {Object} data - 更新数据
* @returns {Promise} 更新结果
*/
export function updateAnalysisData(id, data) {
return request({
url: `/analysis-data/${id}`,
method: "put",
data,
});
}

/**
* 删除分析数据
* @param {number} id - 数据ID
* @returns {Promise} 删除结果
*/
export function deleteAnalysisData(id) {
return request({
url: `/analysis-data/${id}`,
method: "delete",
});
}

/**
* 批量删除分析数据
* @param {string} ids - 数据ID列表,逗号分隔
* @returns {Promise} 批量删除结果
*/
export function batchDeleteAnalysisData(ids) {
return request({
url: `/analysis-data/batch/${ids}`,
method: "delete",
});
}

/**
* 导出分析数据
* @param {Object} params - 导出参数
* @returns {string} 导出文件URL
*/
export function exportAnalysisData(params) {
const queryString = new URLSearchParams(params).toString();
return request({
url: `/analysis-data/export?${queryString}`,
responseType: "blob",
method: "get",
});
}

// ==================== 数据导入 ====================

/**
* 批量导入分析数据(物流数据)
* @param {FormData} formData - 包含文件和分类ID的表单数据
* @returns {Promise} 导入结果
*/
export function importAnalysisDataBatch(formData) {
return request({
url: "/analysis-data/import-batch",
method: "post",
data: formData,
headers: {
"Content-Type": "multipart/form-data",
},
});
}

/**
* 批量导入FBA数据
* @param {FormData} formData - 包含文件和店铺名称的表单数据
* @returns {Promise} 导入结果
*/
export function importFBADataBatch(formData) {
return request({
url: "/analysis-data/import/fba/batch",
method: "post",
data: formData,
headers: {
"Content-Type": "multipart/form-data",
},
});
}

/**
* 保存导入的数据
* @param {Object} data - 导入的数据
* @returns {Promise} 保存结果
*/
export function saveImportedData(data) {
return request({
url: "/analysis-data/save-imported",
method: "post",
data,
});
}

// ==================== 基准数据管理 ====================

/**
* 获取分类列表(简化版)
* @returns {Promise} 分类列表
*/
export function getCategoriesSimple() {
return request({
url: "/categories/simple",
method: "get",
});
}

/**
* 获取分类详情
* @param {number} categoryId - 分类ID
* @returns {Promise} 分类详情
*/
export function getCategoryDetail(categoryId) {
return request({
url: `/categories/${categoryId}`,
method: "get",
});
}

/**
* 获取基准数据列表
* @param {Object} params - 查询参数
* @param {number} params.page - 页码
* @param {number} params.pageSize - 每页数量
* @param {number} params.categoryId - 分类ID
* @param {string} params.productCode - 商品编号
* @param {string} params.productName - 商品名称
* @returns {Promise} 基准数据列表和分页信息
*/
export function getBaseDataList(params) {
return request({
url: "/basedata",
method: "get",
params,
});
}

/**
* 导出基准数据
* @param {number} categoryId - 分类ID
* @returns {Promise} 导出文件URL
*/
export function exportBaseData(categoryId) {
return request({
url: `/basedata/export/${categoryId}`,
responseType: "blob",
method: "get",
});
}

/**
* 删除基准数据
* @param {number} id - 数据ID
* @returns {Promise} 删除结果
*/
export function deleteBaseData(id) {
return request({
url: `/basedata/${id}`,
method: "delete",
});
}

/**
* 批量删除基准数据
* @param {string} ids - 数据ID列表,逗号分隔
* @returns {Promise} 批量删除结果
*/
export function batchDeleteBaseData(ids) {
return request({
url: `/basedata/batch/${ids}`,
method: "delete",
});
}

/**
* 新增基准数据
* @param {Object} data - 新增数据
* @returns {Promise} 新增结果
*/
export function BaseDataAdd(data) {
return request({
url: "/basedata",
method: "post",
data,
});
}

/**
* 更新基准数据
* @param {number} id - 数据ID
* @param {Object} data - 更新数据
* @returns {Promise} 更新结果
*/
export function BaseDataUpdate(id, data) {
return request({
url: `/basedata/${id}`,
method: "put",
data,
});
}

// ==================== 分类管理 ====================

/**
* 获取分类列表
* @param {Object} params - 查询参数
* @param {number} params.page - 页码
* @param {number} params.pageSize - 每页数量
* @param {string} params.name - 分类名称
* @returns {Promise} 分类列表和分页信息
*/
export function getCategoriesList(params) {
return request({
url: "/categories",
method: "get",
params,
});
}

/**
* 创建分类
* @param {Object} data - 分类数据
* @returns {Promise} 创建结果
*/
export function createCategory(data) {
return request({
url: "/categories",
method: "post",
data,
});
}

/**
* 更新分类
* @param {number} id - 分类ID
* @param {Object} data - 分类数据
* @returns {Promise} 更新结果
*/
export function updateCategory(id, data) {
return request({
url: `/categories/${id}`,
method: "put",
data,
});
}

/**
* 删除分类
* @param {number} id - 分类ID
* @returns {Promise} 删除结果
*/
export function deleteCategory(id) {
return request({
url: `/categories/${id}`,
method: "delete",
});
}

// ==================== 店铺客户关联管理 ====================

/**
* 获取店铺客户关联列表
* @param {Object} params - 查询参数
* @param {number} params.page - 页码
* @param {number} params.pageSize - 每页数量
* @param {string} params.shopName - 店铺名称
* @param {string} params.customerName - 客户名称
* @returns {Promise} 关联列表和分页信息
*/
export function getShopCustomerList(params) {
return request({
url: "/shop-customer",
method: "get",
params,
});
}

/**
* 删除店铺客户关联
* @param {number} id - 关联ID
* @returns {Promise} 删除结果
*/
export function deleteShopCustomer(id) {
return request({
url: `/shop-customer/${id}`,
method: "delete",
});
}

/**
* 批量删除店铺客户关联
* @param {string} ids - 关联ID列表,逗号分隔
* @returns {Promise} 批量删除结果
*/
export function batchDeleteShopCustomer(ids) {
return request({
url: `/shop-customer/batch/${ids}`,
method: "delete",
});
}

/**
* 导出店铺客户关联数据
* @returns {string} 导出文件URL
*/
export function exportShopCustomer() {
return request({
url: "/shop-customer/export",
responseType: "blob",
method: "get",
});
}

/**
* 新增店铺客户关联
* @param {Object} data - 新增数据
* @returns {Promise} 新增结果
*/
export function ShopCustomerAdd(data) {
return request({
url: "/shop-customer",
method: "post",
data,
});
}

/**
* 更新店铺客户关联
* @param {number} id - 关联ID
* @param {Object} data - 更新数据
* @returns {Promise} 更新结果
*/
export function ShopCustomerUpdate(id, data) {
return request({
url: `/shop-customer/${id}`,
method: "put",
data,
});
}

// ==================== 报表分析 ====================

/**
* 获取报表筛选选项
* @returns {Promise} 筛选选项数据
*/
export function getReportFilterOptions() {
return request({
url: "/reports/filter-options",
method: "get",
});
}

/**
* 获取商品分析报表数据
* @param {Object} params - 查询参数
* @param {string} params.startDate - 开始日期
* @param {string} params.endDate - 结束日期
* @param {string} params.productCode - 商品编号
* @returns {Promise} 商品分析数据
*/
export function getProductAnalysisReport(params) {
return request({
url: "/reports/product-analysis",
method: "get",
params,
});
}

/**
* 获取店铺分析报表数据
* @param {Object} params - 查询参数
* @param {string} params.startDate - 开始日期
* @param {string} params.endDate - 结束日期
* @param {string} params.shop - 店铺名称
* @returns {Promise} 店铺分析数据
*/
export function getShopAnalysisReport(params) {
return request({
url: "/reports/shop-analysis",
method: "get",
params,
});
}

/**
* 获取分类分析报表数据
* @param {Object} params - 查询参数
* @param {string} params.startDate - 开始日期
* @param {string} params.endDate - 结束日期
* @returns {Promise} 分类分析数据
*/
export function getCategoryAnalysisReport(params) {
return request({
url: "/reports/category-analysis",
method: "get",
params,
});
}

/**
* 获取整体分析报表数据
* @param {Object} params - 查询参数
* @param {string} params.startDate - 开始日期
* @param {string} params.endDate - 结束日期
* @returns {Promise} 整体分析数据
*/
export function getOverallAnalysisReport(params) {
return request({
url: "/reports/overall-analysis",
method: "get",
params,
});
}

+ 28
- 10
src/assets/styles/index.scss Ver fichero

@@ -157,10 +157,12 @@ aside {
justify-content: center;
gap: 16px;
padding-left: 16px;
i{

i {
font-size: 22px;
cursor: pointer;
&:hover{

&:hover {
color: var(--primary-color);
}
}
@@ -225,6 +227,30 @@ aside {
}
}

.filter-container {
background-color: #FFF;
border-radius: 8px;
padding: 16px;
margin-bottom: 10px;
display: flex;
flex-direction: column;
gap: 10px;

.search-filters {
display: flex;
align-items: center;
gap: 10px;
.filter-item {
width: 240px;
}
}
.actions {
display: flex;
align-items: center;
gap: 10px;
}
}

.components-container {
margin: 30px 50px;
position: relative;
@@ -272,15 +298,7 @@ aside {
}
}

.filter-container {
padding-bottom: 10px;

.filter-item {
display: inline-block;
vertical-align: middle;
margin-bottom: 10px;
}
}

//refine vue-multiselect plugin
.multiselect {

+ 170
- 0
src/components/ProductCodeAutocomplete/index.vue Ver fichero

@@ -0,0 +1,170 @@
<template>
<el-autocomplete
v-model="inputValue"
:fetch-suggestions="fetchSuggestions"
placeholder="请输入商品编码"
:trigger-on-focus="false"
:debounce="300"
:clearable="true"
@select="handleSelect"
@clear="handleClear"
@input="handleInput"
:style="{ width: inputWidth }"
size="small"
class="product-code-autocomplete"
popper-class="product-code-autocomplete-popper"
>
<template #default="{ item }">
<div class="suggestion-item">
<div class="product-code">{{ item.value }}</div>
<div class="product-info" :title="item.label">{{ item.label }}</div>
</div>
</template>
</el-autocomplete>
</template>

<script>
import { getProductCodeSuggestions } from "@/api/sales-analysis";
export default {
name: 'ProductCodeAutocomplete',
props: {
value: {
type: String,
default: ''
},
width: {
type: String,
default: '200px'
},
placeholder: {
type: String,
default: '请输入商品编码'
}
},
data() {
return {
inputValue: this.value,
isLoading: false
}
},
computed: {
inputWidth() {
return this.width
}
},
watch: {
value(newVal) {
this.inputValue = newVal
}
},
methods: {
/**
* 获取联想建议
*/
async fetchSuggestions(queryString, callback) {
if (!queryString || queryString.trim().length < 1) {
callback([])
return
}

try {
this.isLoading = true
const response = await getProductCodeSuggestions({ keyword: queryString.trim() })

if (response.success) {
callback(response.data || [])
} else {
callback([])
}
} catch (error) {
console.error('获取商品编码联想数据失败:', error)
callback([])
} finally {
this.isLoading = false
}
},

/**
* 选择建议项
*/
handleSelect(item) {
this.inputValue = item.value
this.$emit('input', item.value)
this.$emit('select', item)
},

/**
* 清空输入
*/
handleClear() {
this.inputValue = ''
this.$emit('input', '')
this.$emit('clear')
},

/**
* 输入变化
*/
handleInput(value) {
this.inputValue = value
this.$emit('input', value)
}
}
}
</script>

<style scoped>
.product-code-autocomplete {
width: 100%;
}

.suggestion-item {
display: flex;
flex-direction: column;
padding: 8px 0;
}

.product-code {
font-weight: 500;
font-size: 14px;
color: #303133;
margin-bottom: 4px;
}

.product-info {
font-size: 12px;
color: #909399;
line-height: 1.4;
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
</style>

<style>
.product-code-autocomplete-popper {
max-width: 400px;
}

.product-code-autocomplete-popper .el-autocomplete-suggestion__wrap {
max-height: 280px;
}

.product-code-autocomplete-popper .el-autocomplete-suggestion__list {
padding: 0;
}

.product-code-autocomplete-popper .el-autocomplete-suggestion__item {
padding: 8px 20px;
border-bottom: 1px solid #f0f0f0;
}

.product-code-autocomplete-popper .el-autocomplete-suggestion__item:last-child {
border-bottom: none;
}

.product-code-autocomplete-popper .el-autocomplete-suggestion__item.highlighted {
background-color: #f5f7fa;
}
</style>

+ 2
- 2
src/layout/components/Navbar.vue Ver fichero

@@ -30,7 +30,7 @@
>
<div class="avatar-wrapper">
<img :src="avatar" class="user-avatar">
<span>{{ name }}</span>
<span>{{ nickname }}</span>
<i class="el-icon-caret-bottom" />
</div>
<el-dropdown-menu slot="dropdown">
@@ -68,7 +68,7 @@ export default {
Msg
},
computed: {
...mapGetters(['sidebar', 'avatar', 'device', 'name']),
...mapGetters(['sidebar', 'avatar', 'device', 'name', 'nickname']),
setting: {
get() {
return this.$store.state.settings.showSettings

+ 169
- 96
src/router/index.js Ver fichero

@@ -1,10 +1,10 @@
import Vue from 'vue'
import Router from 'vue-router'
import Vue from "vue";
import Router from "vue-router";

Vue.use(Router)
Vue.use(Router);

/* Layout */
import Layout from '@/layout'
import Layout from "@/layout";

/**
* Note: 路由配置项
@@ -31,167 +31,240 @@ import Layout from '@/layout'
// 公共路由
export const constantRoutes = [
{
path: '/redirect',
path: "/redirect",
component: Layout,
hidden: true,
children: [
{
path: '/redirect/:path(.*)',
component: () => import('@/views/redirect')
}
]
path: "/redirect/:path(.*)",
component: () => import("@/views/redirect"),
},
],
},
{
path: '/login',
component: () => import('@/views/login'),
hidden: true
path: "/login",
component: () => import("@/views/login"),
hidden: true,
},
{
path: '/register',
component: () => import('@/views/register'),
hidden: true
path: "/register",
component: () => import("@/views/register"),
hidden: true,
},
{
path: '/404',
component: () => import('@/views/error/404'),
hidden: true
path: "/404",
component: () => import("@/views/error/404"),
hidden: true,
},
{
path: '/401',
component: () => import('@/views/error/401'),
hidden: true
path: "/401",
component: () => import("@/views/error/401"),
hidden: true,
},
{
path: '',
path: "",
component: Layout,
redirect: 'index',
redirect: "index",
children: [
{
path: 'index',
component: () => import('@/views/index'),
name: 'Index',
meta: { title: '首页', icon: 'dashboard', affix: true }
}
]
path: "index",
component: () => import("@/views/index"),
name: "Index",
meta: { title: "首页", icon: "dashboard", affix: true },
},
],
},
{
path: '/user',
path: "/user",
component: Layout,
hidden: true,
redirect: 'noredirect',
redirect: "noredirect",
children: [
{
path: 'profile',
component: () => import('@/views/system/user/profile/index'),
name: 'Profile',
meta: { title: '个人中心', icon: 'user' }
}
]
path: "profile",
component: () => import("@/views/system/user/profile/index"),
name: "Profile",
meta: { title: "个人中心", icon: "user" },
},
],
},
{
path: '/m/checkin',
component: () => import('@/views/m/checkin'),
meta: { title: '考勤打卡', icon: 'date' }
path: "/m/checkin",
component: () => import("@/views/m/checkin"),
meta: { title: "考勤打卡", icon: "date" },
},
]
{
path: "/sales-analysis",
component: Layout,
meta: { title: "销售分析", icon: "dashboard", noCache: true },
children: [
// 基准数据管理
{
path: "/sales-analysis/categories",
name: "Categories",
component: () =>
import("@/views/sales-analysis/categories/Categories.vue"),
meta: { title: "分类管理", noCache: true },
},
{
path: "/sales-analysis/base-data",
name: "BaseData",
component: () =>
import("@/views/sales-analysis/base-data/BaseData.vue"),
meta: { title: "基准数据", noCache: true },
},
{
path: "/sales-analysis/shop-customer",
name: "ShopCustomer",
component: () =>
import("@/views/sales-analysis/shop-customer/ShopCustomer.vue"),
meta: { title: "店铺客户关联", noCache: true },
},
// 分析数据管理
{
path: "/sales-analysis/analysis-data/import",
name: "ImportData",
component: () =>
import("@/views/sales-analysis/analysis-data/ImportData.vue"),
meta: { title: "导入数据", noCache: true },
},
{
path: "/sales-analysis/analysis-data/list",
name: "DataList",
component: () =>
import("@/views/sales-analysis/analysis-data/DataList.vue"),
meta: { title: "数据展示", noCache: true },
},
{
path: "/sales-analysis/reports/overall",
name: "OverallAnalysis",
component: () =>
import("@/views/sales-analysis/reports/OverallAnalysis.vue"),
meta: { title: "整体销售分析", noCache: true },
},
{
path: "/sales-analysis/reports/shop",
name: "ShopReports",
component: () =>
import("@/views/sales-analysis/reports/ShopAnalysis.vue"),
meta: { title: "店铺销售分析", noCache: true },
},
{
path: "/sales-analysis/reports/category",
name: "CategoryReports",
component: () =>
import("@/views/sales-analysis/reports/CategoryAnalysis.vue"),
meta: { title: "品类销售分析", noCache: true },
},
{
path: "/sales-analysis/reports/product",
name: "ProductReports",
component: () =>
import("@/views/sales-analysis/reports/ProductAnalysis.vue"),
meta: { title: "单品销售分析", noCache: true },
},
],
},
];

// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
{
path: '/system/user-auth',
path: "/system/user-auth",
component: Layout,
hidden: true,
permissions: ['system:user:edit'],
permissions: ["system:user:edit"],
children: [
{
path: 'role/:userId(\\d+)',
component: () => import('@/views/system/user/authRole'),
name: 'AuthRole',
meta: { title: '分配角色', activeMenu: '/system/user' }
}
]
path: "role/:userId(\\d+)",
component: () => import("@/views/system/user/authRole"),
name: "AuthRole",
meta: { title: "分配角色", activeMenu: "/system/user" },
},
],
},
{
path: '/system/role-auth',
path: "/system/role-auth",
component: Layout,
hidden: true,
permissions: ['system:role:edit'],
permissions: ["system:role:edit"],
children: [
{
path: 'user/:roleId(\\d+)',
component: () => import('@/views/system/role/authUser'),
name: 'AuthUser',
meta: { title: '分配用户', activeMenu: '/system/role' }
}
]
path: "user/:roleId(\\d+)",
component: () => import("@/views/system/role/authUser"),
name: "AuthUser",
meta: { title: "分配用户", activeMenu: "/system/role" },
},
],
},
{
path: '/system/dict-data',
path: "/system/dict-data",
component: Layout,
hidden: true,
permissions: ['system:dict:list'],
permissions: ["system:dict:list"],
children: [
{
path: 'index/:dictId(\\d+)',
component: () => import('@/views/system/dict/data'),
name: 'Data',
meta: { title: '字典数据', activeMenu: '/system/dict' }
}
]
path: "index/:dictId(\\d+)",
component: () => import("@/views/system/dict/data"),
name: "Data",
meta: { title: "字典数据", activeMenu: "/system/dict" },
},
],
},
{
path: '/system/oss-config',
path: "/system/oss-config",
component: Layout,
hidden: true,
permissions: ['system:oss:list'],
permissions: ["system:oss:list"],
children: [
{
path: 'index',
component: () => import('@/views/system/oss/config'),
name: 'OssConfig',
meta: { title: '配置管理', activeMenu: '/system/oss' }
}
]
path: "index",
component: () => import("@/views/system/oss/config"),
name: "OssConfig",
meta: { title: "配置管理", activeMenu: "/system/oss" },
},
],
},
{
path: '/tool/gen-edit',
path: "/tool/gen-edit",
component: Layout,
hidden: true,
permissions: ['tool:gen:edit'],
permissions: ["tool:gen:edit"],
children: [
{
path: 'index/:tableId(\\d+)',
component: () => import('@/views/tool/gen/editTable'),
name: 'GenEdit',
meta: { title: '修改生成配置', activeMenu: '/tool/gen' }
}
]
path: "index/:tableId(\\d+)",
component: () => import("@/views/tool/gen/editTable"),
name: "GenEdit",
meta: { title: "修改生成配置", activeMenu: "/tool/gen" },
},
],
},
{
path: '/workflow/dynamicFormDesigner',
path: "/workflow/dynamicFormDesigner",
component: Layout,
hidden: true,
permissions: ['workflow:dynamicForm:edit'],
permissions: ["workflow:dynamicForm:edit"],
children: [
{
path: ':id(\\d+)',
component: () => import('@/views/workflow/dynamicForm/dynamicFormDesigner'),
name: 'dynamicFormDesigner',
meta: { title: '设计表单', activeMenu: '/workflow/dynamicForm' }
}
]
}
]
path: ":id(\\d+)",
component: () =>
import("@/views/workflow/dynamicForm/dynamicFormDesigner"),
name: "dynamicFormDesigner",
meta: { title: "设计表单", activeMenu: "/workflow/dynamicForm" },
},
],
},
];

// 防止连续点击多次路由报错
let routerPush = Router.prototype.push;
Router.prototype.push = function push(location) {
return routerPush.call(this, location).catch(err => err)
}
return routerPush.call(this, location).catch((err) => err);
};

export default new Router({
base: process.env.VUE_APP_CONTEXT_PATH,
mode: 'history', // 去掉url中的#
mode: "history", // 去掉url中的#
scrollBehavior: () => ({ y: 0 }),
routes: constantRoutes
})
routes: constantRoutes,
});

+ 1
- 0
src/store/getters.js Ver fichero

@@ -8,6 +8,7 @@ const getters = {
token: (state) => state.user.token,
avatar: (state) => state.user.avatar,
name: (state) => state.user.name,
nickname: (state) => state.user.nickname,
introduction: (state) => state.user.introduction,
roles: (state) => state.user.roles,
userinfo: (state) => state.user.userinfo,

+ 4
- 0
src/store/modules/user.js Ver fichero

@@ -21,6 +21,9 @@ const user = {
SET_AVATAR: (state, avatar) => {
state.avatar = avatar
},
SET_NICKNAME: (state, nickname) => {
state.nickname = nickname
},
SET_ROLES: (state, roles) => {
state.roles = roles
},
@@ -70,6 +73,7 @@ const user = {
commit('SET_ROLES', ['ROLE_DEFAULT'])
}
commit('SET_NAME', user.userName)
commit('SET_NICKNAME', user.nickName)
commit('SET_AVATAR', avatar)
commit('SET_USERINFO', user)
resolve(res)

+ 54
- 0
src/utils/filters.js Ver fichero

@@ -0,0 +1,54 @@
import Vue from 'vue'
import moment from 'moment'

// 日期格式化过滤器
Vue.filter('dateFormat', (value, format = 'YYYY-MM-DD') => {
if (!value) return ''
return moment(value).format(format)
})

// 日期时间格式化过滤器
Vue.filter('dateTimeFormat', (value, format = 'YYYY-MM-DD HH:mm:ss') => {
if (!value) return ''
return moment(value).format(format)
})

// 金额格式化过滤器
Vue.filter('currency', (value, currency = '¥') => {
if (!value) return '0.00'
return currency + parseFloat(value).toFixed(2)
})

// 数字格式化过滤器
Vue.filter('number', (value) => {
if (!value) return '0'
return parseFloat(value).toLocaleString()
})

// 状态格式化过滤器
Vue.filter('statusText', (value) => {
const statusMap = {
1: '正常',
2: '客户名称未匹配',
3: '分类规格未匹配'
}
return statusMap[value] || '未知'
})

// 字符串截取过滤器
Vue.filter('truncate', (value, length = 20) => {
if (!value) return ''
if (value.length <= length) return value
return value.substring(0, length) + '...'
})

// 导出格式化函数供组件使用
export const formatDateTime = (value, format = 'YYYY-MM-DD HH:mm:ss') => {
if (!value) return ''
return moment(value).format(format)
}

export const formatDate = (value, format = 'YYYY-MM-DD') => {
if (!value) return ''
return moment(value).format(format)
}

+ 8
- 8
src/views/components/daka/index.vue Ver fichero

@@ -172,7 +172,7 @@
<el-table-column
v-if="columns[1].visible"
key="userName"
label="用户名称"
label="用户编号"
align="center"
prop="userName"
:show-overflow-tooltip="true"
@@ -180,7 +180,7 @@
<el-table-column
v-if="columns[2].visible"
key="nickName"
label="用户称"
label="用户称"
align="center"
prop="nickName"
:show-overflow-tooltip="true"
@@ -290,10 +290,10 @@
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-row>
<el-col :span="12">
<el-form-item label="用户称" prop="nickName">
<el-form-item label="用户称" prop="nickName">
<el-input
v-model="form.nickName"
placeholder="请输入用户称"
placeholder="请输入用户称"
maxlength="30"
/>
</el-form-item>
@@ -568,8 +568,8 @@ export default {
// 列信息
columns: [
{ key: 0, label: `用户编号`, visible: true },
{ key: 1, label: `用户名称`, visible: true },
{ key: 2, label: `用户昵称`, visible: true },
{ key: 1, label: `员工编号`, visible: true },
{ key: 2, label: `姓名`, visible: true },
{ key: 3, label: `部门`, visible: true },
{ key: 4, label: `手机号码`, visible: true },
{ key: 5, label: `状态`, visible: true },
@@ -578,7 +578,7 @@ export default {
// 表单校验
rules: {
userName: [
{ required: true, message: '用户名称不能为空', trigger: 'blur' },
{ required: true, message: '员工编号不能为空', trigger: 'blur' },
{
min: 2,
max: 20,
@@ -587,7 +587,7 @@ export default {
}
],
nickName: [
{ required: true, message: '用户昵称不能为空', trigger: 'blur' }
{ required: true, message: '姓名不能为空', trigger: 'blur' }
],
password: [
{ required: true, message: '用户密码不能为空', trigger: 'blur' },

+ 4
- 4
src/views/components/user/choose-workflow-user .vue Ver fichero

@@ -90,7 +90,7 @@
<el-table-column
v-if="columns[1].visible"
key="userName"
label="用户名称"
label="员工编号"
align="center"
prop="userName"
:show-overflow-tooltip="true"
@@ -98,7 +98,7 @@
<el-table-column
v-if="columns[2].visible"
key="nickName"
label="用户昵称"
label="姓名"
align="center"
prop="nickName"
:show-overflow-tooltip="true"
@@ -220,8 +220,8 @@ export default {
// 列信息
columns: [
{ key: 0, label: `用户编号`, visible: true },
{ key: 1, label: `用户名称`, visible: true },
{ key: 2, label: `用户昵称`, visible: true },
{ key: 1, label: `员工编号`, visible: true },
{ key: 2, label: `姓名`, visible: true },
{ key: 3, label: `部门`, visible: true },
{ key: 4, label: `手机号码`, visible: true },
{ key: 5, label: `创建时间`, visible: true }

+ 4
- 4
src/views/components/user/multi-user.vue Ver fichero

@@ -109,7 +109,7 @@
<el-table-column
v-if="columns[1].visible"
key="userName"
label="用户名称"
label="员工编号"
align="center"
prop="userName"
:show-overflow-tooltip="true"
@@ -117,7 +117,7 @@
<el-table-column
v-if="columns[2].visible"
key="nickName"
label="用户昵称"
label="姓名"
align="center"
prop="nickName"
:show-overflow-tooltip="true"
@@ -240,8 +240,8 @@ export default {
// 列信息
columns: [
{ key: 0, label: `用户编号`, visible: true },
{ key: 1, label: `用户名称`, visible: true },
{ key: 2, label: `用户昵称`, visible: true },
{ key: 1, label: `员工编号`, visible: true },
{ key: 2, label: `姓名`, visible: true },
{ key: 3, label: `部门`, visible: true },
{ key: 4, label: `手机号码`, visible: true },
{ key: 5, label: `创建时间`, visible: true }

+ 4
- 4
src/views/components/user/sys-dept-user.vue Ver fichero

@@ -108,7 +108,7 @@
<el-table-column
v-if="columns[1].visible"
key="userName"
label="用户名称"
label="员工编号"
align="center"
prop="userName"
:show-overflow-tooltip="true"
@@ -116,7 +116,7 @@
<el-table-column
v-if="columns[2].visible"
key="nickName"
label="用户昵称"
label="姓名"
align="center"
prop="nickName"
:show-overflow-tooltip="true"
@@ -235,8 +235,8 @@ export default {
// 列信息
columns: [
{ key: 0, label: `用户编号`, visible: true },
{ key: 1, label: `用户名称`, visible: true },
{ key: 2, label: `用户昵称`, visible: true },
{ key: 1, label: `员工编号`, visible: true },
{ key: 2, label: `姓名`, visible: true },
{ key: 3, label: `部门`, visible: true },
{ key: 4, label: `手机号码`, visible: true },
{ key: 5, label: `创建时间`, visible: true }

+ 4
- 4
src/views/components/user/sys-user.vue Ver fichero

@@ -101,7 +101,7 @@
<el-table-column
v-if="columns[1].visible"
key="userName"
label="用户名称"
label="员工编号"
align="center"
prop="userName"
:show-overflow-tooltip="true"
@@ -109,7 +109,7 @@
<el-table-column
v-if="columns[2].visible"
key="nickName"
label="用户昵称"
label="姓名"
align="center"
prop="nickName"
:show-overflow-tooltip="true"
@@ -235,8 +235,8 @@ export default {
// 列信息
columns: [
{ key: 0, label: `用户编号`, visible: true },
{ key: 1, label: `用户名称`, visible: true },
{ key: 2, label: `用户昵称`, visible: true },
{ key: 1, label: `员工编号`, visible: true },
{ key: 2, label: `姓名`, visible: true },
{ key: 3, label: `部门`, visible: true },
{ key: 4, label: `手机号码`, visible: true },
{ key: 5, label: `创建时间`, visible: true }

+ 2
- 3
src/views/daka/attendanceteam/index.vue Ver fichero

@@ -180,9 +180,8 @@
style="width: 100%; margin-top: 20px"
empty-text="暂无关联员工"
>
<el-table-column prop="userName" label="姓名"></el-table-column>
<el-table-column prop="userId" label="工号"></el-table-column>
<el-table-column prop="nickName" label="用户昵称"></el-table-column>
<el-table-column prop="userName" label="员工编号"></el-table-column>
<el-table-column prop="nickName" label="姓名"></el-table-column>
<el-table-column prop="deptName" label="部门"></el-table-column>
<el-table-column label="操作" width="80">
<template #default="scope">

+ 3
- 1
src/views/index.vue Ver fichero

@@ -7,6 +7,8 @@
<div class="info">
<h3>{{ warmMessage }}</h3>
<p>
<span>{{ nickname }}</span>
<el-divider direction="vertical" />
<span>{{ userinfo.userName }}</span>
<el-divider direction="vertical" />
<span>{{ userinfo.dept.deptName || '未设置部门' }}</span>
@@ -50,7 +52,7 @@ export default {
}
},
computed: {
...mapGetters(['userinfo', 'avatar'])
...mapGetters(['userinfo', 'avatar', 'nickname'])
},
created() {
this.warmMessage = messageGenerator(

+ 2
- 3
src/views/oa/attendance/groups/index.vue Ver fichero

@@ -180,9 +180,8 @@
style="width: 100%; margin-top: 20px"
empty-text="暂无关联员工"
>
<el-table-column prop="userName" label="姓名"></el-table-column>
<el-table-column prop="userId" label="工号"></el-table-column>
<el-table-column prop="nickName" label="用户昵称"></el-table-column>
<el-table-column prop="userName" label="员工编号"></el-table-column>
<el-table-column prop="nickName" label="姓名"></el-table-column>
<el-table-column prop="deptName" label="部门"></el-table-column>
<el-table-column label="操作" width="80">
<template #default="scope">

+ 619
- 0
src/views/sales-analysis/analysis-data/DataList.vue Ver fichero

@@ -0,0 +1,619 @@
<template>
<div class="data-list-page app-container">
<!-- 搜索筛选 -->
<div class="filter-container" style="gap: 0">
<div class="search-filters">
<el-form :model="searchForm" :inline="true" size="small">
<el-form-item label="商品编号">
<el-input
v-model="searchForm.productCode"
placeholder="请输入商品编号"
style="width: 160px"
clearable
/>
</el-form-item>
<el-form-item label="商品名称">
<el-input
v-model="searchForm.productName"
placeholder="请输入商品名称"
style="width: 160px"
clearable
/>
</el-form-item>
<el-form-item label="客户名称">
<el-input
v-model="searchForm.customerName"
placeholder="请输入客户名称"
style="width: 160px"
clearable
/>
</el-form-item>
<el-form-item label="店铺名称">
<el-input
v-model="searchForm.shopName"
placeholder="请输入店铺名称"
style="width: 160px"
clearable
/>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="searchForm.status"
placeholder="请选择状态"
style="width: 160px"
clearable
>
<el-option label="全部" value="" />
<el-option label="正常" value="1" />
<el-option label="客户未匹配" value="2" />
<el-option label="规格未匹配" value="3" />
</el-select>
</el-form-item>
<br />
<el-form-item label="开始日期">
<el-date-picker
v-model="searchForm.startDate"
type="date"
placeholder="选择开始日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
style="width: 160px"
clearable
/>
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker
v-model="searchForm.endDate"
type="date"
placeholder="选择结束日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
style="width: 160px"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" icon="el-icon-search"></el-button>
<el-button @click="handleReset" icon="el-icon-refresh"></el-button>
</el-form-item>
</el-form>
</div>
<div class="actions" style="margin-top: 0; justify-content: flex-end;">
<el-button
type="success"
plain
@click="goToImport"
icon="el-icon-upload2"
size="small"
>导入Excel</el-button
>
<el-button
type="success"
plain
@click="handleExport"
icon="el-icon-download"
size="small"
>导出Excel</el-button
>
<el-divider direction="vertical"></el-divider>
<el-button
type="danger"
plain
icon="el-icon-delete"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
size="small"
>批量删除</el-button
>
</div>
</div>

<!-- 数据表格 -->
<el-table
:data="tableData"
stripe
v-loading="loading"
size="small"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="date" label="日期" width="100" sortable />
<el-table-column
prop="shopName"
label="店铺"
width="120"
show-overflow-tooltip
/>
<el-table-column
prop="productCode"
label="商品编号"
width="120"
show-overflow-tooltip
/>
<el-table-column
prop="productName"
label="商品名称"
show-overflow-tooltip
/>
<el-table-column
prop="customerName"
label="客户"
width="120"
show-overflow-tooltip
/>
<el-table-column
prop="category"
label="分类"
width="100"
show-overflow-tooltip
/>
<el-table-column prop="status" label="状态" width="120" align="center">
<template slot-scope="scope">
<el-tag
:type="
scope.row.status === 1
? 'success'
: scope.row.status === 2
? 'warning'
: 'danger'
"
size="mini"
>
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center" fixed="right">
<template slot-scope="scope">
<el-button
type="primary"
plain
size="mini"
@click="handleEdit(scope.row)"
>编辑</el-button
>
<el-button
type="danger"
:disabled="scope.row.status === 1"
size="mini"
plain
@click="handleDelete(scope.row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>

<pagination
v-show="pagination.total > 0"
:total="pagination.total"
:page.sync="pagination.page"
:limit.sync="pagination.pageSize"
@pagination="fetchData"
/>

<!-- 编辑对话框 -->
<el-dialog
title="编辑分析数据"
:visible.sync="editDialogVisible"
width="800px"
@close="handleEditDialogClose"
>
<el-form
:model="editForm"
:rules="editRules"
ref="editForm"
label-width="100px"
label-position="top"
>
<el-form-item label="日期" prop="date">
<el-date-picker
v-model="editForm.date"
type="date"
placeholder="选择日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="店铺名称" prop="shopName">
<el-input v-model="editForm.shopName" placeholder="请输入店铺名称" />
</el-form-item>
<el-form-item label="商品编号" prop="productCode">
<el-input
v-model="editForm.productCode"
placeholder="请输入商品编号"
/>
</el-form-item>
<el-form-item label="商品名称" prop="productName">
<el-input
v-model="editForm.productName"
placeholder="请输入商品名称"
/>
</el-form-item>
<el-form-item label="客户名称" prop="customerName">
<el-input
v-model="editForm.customerName"
placeholder="请输入客户名称"
/>
</el-form-item>
<el-form-item label="分类" prop="category">
<el-input v-model="editForm.category" placeholder="请输入分类" />
</el-form-item>
<el-form-item label="分类规格" prop="categorySpecs">
<el-input
v-model="editForm.categorySpecs"
placeholder="请输入分类规格"
/>
</el-form-item>
<el-form-item label="数量" prop="quantity">
<el-input-number
v-model="editForm.quantity"
:min="0"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="合计金额" prop="totalAmount">
<el-input-number
v-model="editForm.totalAmount"
:min="0"
:precision="2"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="出库类型" prop="deliveryType">
<el-input
v-model="editForm.deliveryType"
placeholder="请输入出库类型"
/>
</el-form-item>
<el-form-item label="目的地" prop="destination">
<el-input v-model="editForm.destination" placeholder="请输入目的地" />
</el-form-item>
<el-form-item label="备注" prop="remarks">
<el-input
v-model="editForm.remarks"
type="textarea"
placeholder="请输入备注"
:rows="3"
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleEditSubmit">确定</el-button>
</div>
</el-dialog>
</div>
</template>

<script>
import {
getAnalysisDataList,
updateAnalysisData,
deleteAnalysisData,
batchDeleteAnalysisData,
exportAnalysisData,
} from "@/api/sales-analysis";

export default {
name: "DataList",
data() {
return {
loading: false,
tableData: [],
selectedRows: [],
searchForm: {
productCode: "",
productName: "",
customerName: "",
shopName: "",
status: "",
startDate: "",
endDate: "",
},
pagination: {
page: 1,
pageSize: 50,
total: 0,
},
editDialogVisible: false,
editForm: {
id: null,
date: "",
shopName: "",
productCode: "",
productName: "",
customerName: "",
category: "",
categorySpecs: "",
quantity: 0,
totalAmount: 0,
deliveryType: "",
destination: "",
remarks: "",
},
editRules: {
date: [{ required: true, message: "请选择日期", trigger: "change" }],
shopName: [
{ required: true, message: "请输入店铺名称", trigger: "blur" },
],
productCode: [
{ required: true, message: "请输入商品编号", trigger: "blur" },
],
productName: [
{ required: true, message: "请输入商品名称", trigger: "blur" },
],
},
};
},
mounted() {
this.fetchData();
},
methods: {
// 获取数据
async fetchData() {
try {
this.loading = true;
const params = {
page: this.pagination.page,
pageSize: this.pagination.pageSize,
...this.searchForm,
};

const response = await getAnalysisDataList(params);

if (response.code === 200) {
this.tableData = response.data.list || [];
this.pagination = response.data.pagination || {};
} else {
this.$message.error(response.message || "获取数据失败");
}
} catch (error) {
console.error("获取数据失败:", error);
this.$message.error("获取数据失败");
} finally {
this.loading = false;
}
},

// 搜索
handleSearch() {
this.pagination.page = 1;
this.fetchData();
},

// 重置搜索
handleReset() {
this.searchForm = {
productCode: "",
productName: "",
customerName: "",
shopName: "",
status: "",
startDate: "",
endDate: "",
};
this.pagination.page = 1;
this.fetchData();
},

// 分页大小改变
handleSizeChange(size) {
this.pagination.pageSize = size;
this.pagination.page = 1;
this.fetchData();
},

// 当前页改变
handleCurrentChange(page) {
this.pagination.page = page;
this.fetchData();
},

// 选择行改变
handleSelectionChange(selection) {
this.selectedRows = selection;
},

// 编辑
handleEdit(row) {
this.editForm = {
id: row.id,
date: row.date,
shopName: row.shopName,
productCode: row.productCode,
productName: row.productName,
customerName: row.customerName,
category: row.category,
categorySpecs: row.categorySpecs,
quantity: row.quantity,
totalAmount: row.totalAmount,
deliveryType: row.deliveryType,
destination: row.destination,
remarks: row.remarks,
};
this.editDialogVisible = true;
},

// 提交编辑
handleEditSubmit() {
this.$refs.editForm.validate(async (valid) => {
if (valid) {
try {
const response = await updateAnalysisData(
this.editForm.id,
this.editForm
);

if (response.code === 200) {
this.$message.success("编辑成功");
this.editDialogVisible = false;
this.fetchData();
} else {
this.$message.error(response.message || "编辑失败");
}
} catch (error) {
console.error("编辑失败:", error);
this.$message.error("编辑失败");
}
}
});
},

// 编辑对话框关闭
handleEditDialogClose() {
this.$refs.editForm.resetFields();
},

// 删除
handleDelete(row) {
this.$confirm("确定要删除这条记录吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
const response = await deleteAnalysisData(row.id);

if (response.code === 200) {
this.$message.success("删除成功");
this.fetchData();
} else {
this.$message.error(response.message || "删除失败");
}
} catch (error) {
console.error("删除失败:", error);
this.$message.error("删除失败");
}
})
.catch(() => {
this.$message.info("已取消删除");
});
},

// 批量删除
handleBatchDelete() {
if (this.selectedRows.length === 0) {
this.$message.warning("请选择要删除的数据");
return;
}

this.$confirm(
`确定要删除选中的 ${this.selectedRows.length} 条记录吗?`,
"提示",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
)
.then(async () => {
try {
const ids = this.selectedRows.map((row) => row.id).join(",");
const response = await batchDeleteAnalysisData(ids);

if (response.code === 200) {
this.$message.success("批量删除成功");
this.fetchData();
} else {
this.$message.error(response.message || "批量删除失败");
}
} catch (error) {
console.error("批量删除失败:", error);
this.$message.error("批量删除失败");
}
})
.catch(() => {
this.$message.info("已取消删除");
});
},

// 导出数据
async handleExport() {
const response = await exportAnalysisData(this.searchForm);
const blob = new Blob([response], { type: "application/vnd.ms-excel" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `analysis-data-${new Date().toLocaleDateString()}.xlsx`;
a.click();
},

// 获取状态文本
getStatusText(status) {
const statusMap = {
1: "正常",
2: "客户未匹配",
3: "规格未匹配",
};
return statusMap[status] || "未知";
},

// 跳转到导入页面
goToImport() {
this.$router.push("/sales-analysis/analysis-data/import");
},
},
};
</script>

<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}

.page-header h2 {
font-size: 24px;
color: #333;
margin: 0;
}

.header-actions {
display: flex;
gap: 10px;
}

.search-card {
margin-bottom: 20px;
}

.table-card {
background: white;
padding: 20px;
border-radius: 8px;
border: 1px solid #e8e8e8;
}

.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}

.table-title {
font-size: 16px;
font-weight: bold;
color: #333;
}

.count {
font-size: 14px;
color: #666;
font-weight: normal;
}

.pagination {
margin-top: 20px;
text-align: center;
}

.dialog-footer {
text-align: center;
}
</style>

+ 1134
- 0
src/views/sales-analysis/analysis-data/ImportData.vue
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 850
- 0
src/views/sales-analysis/base-data/BaseData.vue Ver fichero

@@ -0,0 +1,850 @@
<template>
<div class="base-data-layout">
<!-- 左侧分类导航 -->
<div class="category-sidebar">
<div class="category-list-wrapper" v-loading="categoriesLoading">
<div
v-for="category in categoriesList"
:key="category.id"
:class="[
'category-item',
{ 'is-active': selectedCategoryId === category.id },
]"
@click="selectCategory(category)"
>
<span class="category-name">{{ category.name }}</span>
<i class="el-icon-arrow-right"></i>
</div>
<el-empty
v-if="!categoriesList.length && !categoriesLoading"
description="暂无分类数据"
:image-size="80"
></el-empty>
</div>
</div>

<!-- 右侧主内容区 -->
<div class="main-content-area">
<div v-if="!selectedCategory" class="placeholder-view card">
<el-empty
description="请从左侧选择一个分类进行操作"
:image-size="120"
></el-empty>
</div>

<div v-else class="data-view">
<div class="filter-container">
<div class="search-filters">
<el-input
v-model="searchForm.productCode"
placeholder="按商品编号搜索"
clearable
@clear="handleSearch"
@keyup.enter.native="handleSearch"
style="width: 160px"
size="small"
class="filter-item"
></el-input>
<el-input
v-model="searchForm.productName"
placeholder="按商品名称搜索"
clearable
@clear="handleSearch"
@keyup.enter.native="handleSearch"
style="width: 160px"
size="small"
class="filter-item"
></el-input>
<el-button
type="primary"
icon="el-icon-search"
@click="handleSearch"
size="small"
style="margin-right: 0"
></el-button>
<el-button
icon="el-icon-refresh"
@click="resetSearch"
size="small"
style="margin-left: 0"
></el-button>

<el-button
type="primary"
icon="el-icon-plus"
@click="handleAdd"
size="small"
style="margin-left: auto"
>新增数据</el-button
>
<el-divider direction="vertical"></el-divider>
<el-upload
ref="upload"
:action="uploadUrl"
:data="uploadData"
:before-upload="beforeUpload"
:on-success="onUploadSuccess"
:on-error="onUploadError"
:show-file-list="false"
accept=".xlsx,.xls"
size="small"
>
<el-button
type="success"
plain
icon="el-icon-upload2"
size="small"
>导入Excel</el-button
>
</el-upload>
<el-button
type="warning"
plain
icon="el-icon-download"
@click="handleExport"
:disabled="!baseDataList.length"
size="small"
>导出Excel</el-button
>
<el-divider direction="vertical"></el-divider>
<el-button
type="danger"
icon="el-icon-delete"
@click="handleBatchDelete"
:disabled="!selectedRows.length"
plain
size="small"
>批量删除</el-button
>
</div>
</div>

<!-- 数据表格 -->
<el-table
v-loading="dataLoading"
:data="baseDataList"
style="width: 100%"
@selection-change="handleSelectionChange"
row-key="id"
size="small"
>
<el-table-column
type="selection"
width="55"
align="center"
></el-table-column>
<el-table-column
prop="productCode"
label="商品编号"
min-width="140"
show-overflow-tooltip
sortable
></el-table-column>
<el-table-column
prop="productName"
label="商品名称"
min-width="180"
show-overflow-tooltip
></el-table-column>
<el-table-column
prop="brand"
label="品牌"
min-width="110"
show-overflow-tooltip
></el-table-column>

<el-table-column
v-for="field in categoryFields"
:key="field.fieldName"
:label="field.displayLabel"
:prop="'categorySpecs.' + field.fieldName"
min-width="120"
show-overflow-tooltip
sortable
>
<template slot-scope="scope">
<span>{{
scope.row.categorySpecs
? scope.row.categorySpecs[field.fieldName]
: ""
}}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template slot-scope="scope">
<el-button
type="primary"
size="mini"
@click="handleEdit(scope.row)"
plain
>编辑</el-button
>
<el-popconfirm
title="确定删除这条数据吗?"
@confirm="handleDelete(scope.row)"
style="margin-left: 10px"
>
<el-button slot="reference" type="danger" size="mini" plain
>删除</el-button
>
</el-popconfirm>
</template>
</el-table-column>
</el-table>

<pagination
v-show="pagination.total > 0"
:total="pagination.total"
:page.sync="pagination.page"
:limit.sync="pagination.pageSize"
@pagination="fetchBaseDataList"
/>
</div>
</div>

<!-- 新增/编辑对话框 -->
<el-dialog
:title="dialogTitle"
:visible.sync="dialogVisible"
width="600px"
:close-on-click-modal="false"
@close="handleDialogClose"
top="8vh"
custom-class="data-dialog"
>
<el-form
ref="dataForm"
:model="dataForm"
:rules="dataRules"
label-width="110px"
label-position="top"
class="data-form"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商品编号" prop="productCode">
<el-input
v-model="dataForm.productCode"
placeholder="请输入商品编号"
></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="品牌" prop="brand">
<el-input
v-model="dataForm.brand"
placeholder="请输入品牌"
></el-input>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="商品名称" prop="productName">
<el-input
v-model="dataForm.productName"
placeholder="请输入商品名称"
></el-input>
</el-form-item>

<!-- 动态分类规格字段 -->
<div v-if="categoryFields.length" class="spec-section">
<el-divider>分类规格 ({{ selectedCategory.name }})</el-divider>
<el-row :gutter="20">
<el-col
:span="12"
v-for="field in categoryFields"
:key="field.fieldName"
>
<el-form-item
:label="field.displayLabel"
:prop="`categorySpecs.${field.fieldName}`"
:rules="
field.isRequired
? [
{
required: true,
message: `${field.displayLabel}不能为空`,
trigger: 'blur',
},
]
: []
"
>
<el-input
v-if="field.fieldType === 'text'"
v-model="dataForm.categorySpecs[field.fieldName]"
:placeholder="`请输入${field.displayLabel}`"
></el-input>
<el-input-number
v-else-if="field.fieldType === 'number'"
v-model="dataForm.categorySpecs[field.fieldName]"
:placeholder="`请输入${field.displayLabel}`"
style="width: 100%"
controls-position="right"
></el-input-number>
<el-date-picker
v-else-if="field.fieldType === 'date'"
v-model="dataForm.categorySpecs[field.fieldName]"
type="date"
:placeholder="`请选择${field.displayLabel}`"
style="width: 100%"
value-format="yyyy-MM-dd"
></el-date-picker>
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>

<div slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit"
>确定</el-button
>
</div>
</el-dialog>
</div>
</template>

<script>
import { formatDateTime } from "@/utils/filters";
import {
getCategoriesSimple,
getCategoryDetail,
getBaseDataList,
deleteBaseData,
batchDeleteBaseData,
exportBaseData,
BaseDataAdd,
BaseDataUpdate,
} from "@/api/sales-analysis";

export default {
name: "BaseData",
data() {
return {
categoriesLoading: false,
dataLoading: false,
submitting: false,
categoriesList: [],
baseDataList: [],
selectedCategory: null,
selectedCategoryId: null,
selectedRows: [],
searchForm: {
productCode: "",
productName: "",
},
pagination: {
page: 1,
pageSize: 50,
total: 0,
},
dialogVisible: false,
isEdit: false,
dataForm: {
id: null,
productCode: "",
productName: "",
brand: "",
categorySpecs: {},
},
dataRules: {
productCode: [
{ required: true, message: "请输入商品编号", trigger: "blur" },
],
productName: [
{ required: true, message: "请输入商品名称", trigger: "blur" },
],
},
};
},
computed: {
dialogTitle() {
return this.isEdit ? "编辑基准数据" : "新增基准数据";
},
categoryFields() {
return this.selectedCategory && this.selectedCategory.fieldConfig
? this.parseFieldConfig(this.selectedCategory.fieldConfig)
: [];
},
uploadUrl() {
return process.env.VUE_APP_BASE_API + "/basedata/import";
},
uploadData() {
return {
categoryId: this.selectedCategoryId,
};
},
},
created() {
this.fetchCategoriesList();
},
methods: {
formatDateTime,

// 解析字段配置字符串为 JSON 对象
parseFieldConfig(fieldConfig) {
try {
if (!fieldConfig || typeof fieldConfig === "object") {
return fieldConfig || [];
}
return JSON.parse(fieldConfig);
} catch (error) {
console.warn("解析字段配置失败:", error);
return [];
}
},

// 解析分类规格字符串为对象
parseCategorySpecs(categorySpecs) {
try {
if (!categorySpecs) {
return {};
}
if (typeof categorySpecs === "object") {
return categorySpecs;
}
return JSON.parse(categorySpecs);
} catch (error) {
console.warn("解析分类规格失败:", error);
return {};
}
},

// 将分类规格对象转换为字符串
stringifyCategorySpecs(categorySpecs) {
try {
if (!categorySpecs || typeof categorySpecs === "string") {
return categorySpecs || "";
}
return JSON.stringify(categorySpecs);
} catch (error) {
console.warn("转换分类规格为字符串失败:", error);
return "";
}
},

// 获取分类列表
async fetchCategoriesList() {
try {
this.categoriesLoading = true;
const response = await getCategoriesSimple();

if (response.code === 200) {
this.categoriesList = response.data || [];
} else {
this.$message.error(response.message || "获取分类列表失败");
}
} catch (error) {
console.error("获取分类列表失败:", error);
this.$message.error("获取分类列表失败");
} finally {
this.categoriesLoading = false;
}
},

// 选择分类
async selectCategory(category) {
if (this.selectedCategoryId === category.id) return;

this.selectedCategoryId = category.id;
this.pagination.page = 1;
this.resetSearch();
await this.fetchCategoryDetails(category.id);
await this.fetchBaseDataList();
},

// 获取分类详情
async fetchCategoryDetails(categoryId) {
try {
const response = await getCategoryDetail(categoryId);
if (response.code === 200) {
this.selectedCategory = response.data;
} else {
this.$message.error(response.message || "获取分类详情失败");
this.selectedCategory = null; // 获取失败时重置
}
} catch (error) {
console.error("获取分类详情失败:", error);
this.$message.error("获取分类详情失败");
this.selectedCategory = null;
}
},

// 获取基准数据列表
async fetchBaseDataList() {
if (!this.selectedCategoryId) return;

try {
this.dataLoading = true;
const params = {
page: this.pagination.page,
pageSize: this.pagination.pageSize,
categoryId: this.selectedCategoryId,
productCode: this.searchForm.productCode,
productName: this.searchForm.productName,
};

const response = await getBaseDataList(params);

if (response.code === 200) {
// 处理 categorySpecs 字段,将字符串转换为对象
const list = response.data.list || [];
this.baseDataList = list.map((item) => ({
...item,
categorySpecs: this.parseCategorySpecs(item.categorySpecs),
}));
this.pagination.total = response.data.pagination.total;
} else {
this.$message.error(response.message || "获取基准数据失败");
}
} catch (error) {
console.error("获取基准数据失败:", error);
this.$message.error("获取基准数据失败");
} finally {
this.dataLoading = false;
}
},

// 搜索
handleSearch() {
this.pagination.page = 1;
this.fetchBaseDataList();
},

// 重置搜索
resetSearch() {
this.searchForm.productCode = "";
this.searchForm.productName = "";
this.handleSearch();
},

// 分页大小改变
handleSizeChange(val) {
this.pagination.pageSize = val;
this.pagination.page = 1;
this.fetchBaseDataList();
},

// 当前页改变
handleCurrentChange(val) {
this.pagination.page = val;
this.fetchBaseDataList();
},

// 表格选择改变
handleSelectionChange(selection) {
this.selectedRows = selection;
},

// 新增数据
handleAdd() {
this.isEdit = false;
this.dataForm = {
id: null,
productCode: "",
productName: "",
brand: "",
categorySpecs: {},
};

// 初始化分类规格字段
this.categoryFields.forEach((field) => {
this.$set(this.dataForm.categorySpecs, field.fieldName, "");
});

this.dialogVisible = true;
},

// 编辑数据
handleEdit(row) {
this.isEdit = true;
this.dataForm = {
id: row.id,
productCode: row.productCode,
productName: row.productName,
brand: row.brand || "",
categorySpecs: { ...row.categorySpecs },
};
this.dialogVisible = true;
},

// 删除数据
handleDelete(row) {
this.$confirm(`确定要删除商品"${row.productName}"吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
const response = await deleteBaseData(row.id);

if (response.code === 200) {
this.$message.success("删除成功");
this.fetchBaseDataList();
} else {
this.$message.error(response.message || "删除失败");
}
} catch (error) {
console.error("删除失败:", error);
this.$message.error("删除失败");
}
})
.catch(() => {});
},

// 批量删除
handleBatchDelete() {
if (!this.selectedRows.length) {
this.$message.warning("请选择要删除的数据");
return;
}

const ids = this.selectedRows.map((row) => row.id).join(",");

this.$confirm(
`确定要删除选中的${this.selectedRows.length}条数据吗?`,
"提示",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
)
.then(async () => {
try {
const response = await batchDeleteBaseData(ids);

if (response.code === 200) {
this.$message.success("批量删除成功");
this.fetchBaseDataList();
} else {
this.$message.error(response.message || "批量删除失败");
}
} catch (error) {
console.error("批量删除失败:", error);
this.$message.error("批量删除失败");
}
})
.catch(() => {});
},

// 导出Excel
async handleExport() {
try {
const response = await exportBaseData(this.selectedCategoryId);

// 创建下载链接
const blob = new Blob([response], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `basedata-${
this.selectedCategory?.name || "data"
}-${new Date().toLocaleDateString()}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);

this.$message.success("导出成功");
} catch (error) {
console.error("导出失败:", error);
this.$message.error("导出失败");
}
},

// 上传前验证
beforeUpload(file) {
const isExcel = /\.(xlsx|xls)$/.test(file.name.toLowerCase());
if (!isExcel) {
this.$message.error("只能上传Excel文件!");
return false;
}

const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
this.$message.error("上传文件大小不能超过10MB!");
return false;
}

return true;
},

// 上传成功
onUploadSuccess(response) {
if (response.code === 200) {
const data = response.data || {};
let message = response.message || "导入成功";

// 如果有错误详情,显示详细信息
if (data.errors > 0) {
message += `\n${data.errors}条数据有问题已跳过`;
if (data.errorDetails && data.errorDetails.length > 0) {
this.$alert(
`导入完成!\n\n统计信息:\n- 总共处理:${
data.total
}条\n- 成功导入:${data.imported}条\n- 跳过错误:${
data.errors
}条\n\n错误详情(前10条):\n${data.errorDetails
.slice(0, 10)
.join("\n")}`,
"导入结果",
{
confirmButtonText: "确定",
type: "warning",
}
);
} else {
this.$message.warning(message);
}
} else {
this.$message.success(message);
}

this.fetchBaseDataList();
} else {
this.$message.error(response.message || "导入失败");
}
},

// 上传失败
onUploadError(error) {
console.error("上传失败:", error);
this.$message.error("文件上传失败");
},

// 提交表单
async handleSubmit() {
try {
await this.$refs.dataForm.validate();

this.submitting = true;

// 将 categorySpecs 对象转换为字符串
const submitData = {
...this.dataForm,
categoryId: this.selectedCategoryId,
categorySpecs: this.stringifyCategorySpecs(
this.dataForm.categorySpecs
),
};

const response = this.isEdit
? await BaseDataUpdate(this.dataForm.id, submitData)
: await BaseDataAdd(submitData);

if (response.code === 200) {
this.$message.success(this.isEdit ? "编辑成功" : "新增成功");
this.dialogVisible = false;
this.fetchBaseDataList();
} else {
this.$message.error(response.message || "操作失败");
}
} catch (error) {
if (error.message !== "validation failed") {
console.error("提交失败:", error);
this.$message.error("操作失败");
}
} finally {
this.submitting = false;
}
},

// 对话框关闭
handleDialogClose() {
this.$refs.dataForm.resetFields();
this.dataForm.categorySpecs = {};
},
},
};
</script>

<style scoped>
.base-data-layout {
display: flex;
gap: 10px;
padding: 10px;
}

.category-sidebar {
width: 280px;
height: 100%;
flex-shrink: 0;
display: flex;
flex-direction: column;
background-color: #fff;
padding: 10px;
border-radius: 6px;
}

.sidebar-header i {
margin-right: 8px;
}

.category-list-wrapper {
flex-grow: 1;
overflow-y: auto;
padding: 10px 0;
}

.category-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
margin-bottom: 5px;
}

.category-item:hover {
background-color: #f5f7fa;
}

.category-item.is-active {
background-color: #ecf5ff;
color: #409eff;
font-weight: 500;
}

.category-name {
font-size: 14px;
}

.main-content-area {
width: 0;
flex-grow: 1;
display: flex;
flex-direction: column;
}

.placeholder-view {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}

.data-view {
display: flex;
flex-direction: column;
}

.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}

.search-filters {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
</style>

+ 524
- 0
src/views/sales-analysis/categories/Categories.vue Ver fichero

@@ -0,0 +1,524 @@
<template>
<div class="categories-container app-container">
<div class="filter-container">
<div class="search-filters">
<el-input
v-model="searchForm.name"
placeholder="按分类名称搜索"
clearable
@clear="handleSearch"
@keyup.enter.native="handleSearch"
class="filter-item"
style="width: 240px"
size="small"
>
</el-input>
<el-button icon="el-icon-search" type="primary" @click="handleSearch" size="small"></el-button>
<el-button
type="primary"
icon="el-icon-plus"
@click="handleAdd"
size="small"
style="margin-left: auto"
>新增分类</el-button
>
</div>
</div>

<el-table
v-loading="loading"
:data="categoryList"
style="width: 100%"
row-key="id"
size="small"
>
<el-table-column
prop="id"
label="ID"
width="80"
align="center"
></el-table-column>
<el-table-column
prop="name"
label="分类名称"
min-width="180"
show-overflow-tooltip
></el-table-column>
<el-table-column
prop="description"
label="分类描述"
min-width="150"
show-overflow-tooltip
></el-table-column>
<el-table-column label="字段配置" min-width="300">
<template slot-scope="scope">
<div
v-if="scope.row.fieldConfig && scope.row.fieldConfig.length"
class="field-tags-container"
>
<el-tag
v-for="field in parseFieldConfig(scope.row.fieldConfig)"
:key="field.fieldName"
:type="getFieldTagType(field.fieldType)"
size="small"
class="field-tag"
style="margin-right: 10px"
>
<i
:class="getFieldIcon(field.fieldType)"
style="margin-right: 4px"
></i>
<strong>{{ field.displayLabel }}</strong> ({{ field.fieldType }})
<span v-if="field.isRequired" class="required-indicator">*</span>
</el-tag>
</div>
<span v-else class="text-placeholder">暂无配置</span>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
label="创建时间"
width="200"
align="center"
>
<template slot-scope="scope">
<i class="el-icon-time" style="margin-right: 5px"></i>
<span>{{ formatDateTime(scope.row.createdAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template slot-scope="scope">
<el-button
type="primary"
plain
size="mini"
@click="handleEdit(scope.row)"
>编辑</el-button
>
<el-popconfirm
title="确定删除这个分类吗?"
@confirm="handleDelete(scope.row)"
style="margin-left: 10px"
>
<el-button slot="reference" type="danger" plain size="mini"
>删除</el-button
>
</el-popconfirm>
</template>
</el-table-column>
</el-table>

<pagination
v-show="pagination.total > 0"
:total="pagination.total"
:page.sync="pagination.page"
:limit.sync="pagination.pageSize"
@pagination="fetchCategoryList"
/>

<el-dialog
:title="dialogTitle"
:visible.sync="dialogVisible"
width="850px"
:close-on-click-modal="false"
@close="handleDialogClose"
top="5vh"
custom-class="category-dialog"
>
<el-form
ref="categoryForm"
:model="categoryForm"
:rules="categoryRules"
label-width="100px"
>
<el-form-item label="分类名称" prop="name">
<el-input
v-model="categoryForm.name"
placeholder="请输入分类名称"
></el-input>
</el-form-item>
<el-form-item label="分类描述" prop="description">
<el-input
v-model="categoryForm.description"
type="textarea"
:rows="3"
placeholder="请输入分类描述"
></el-input>
</el-form-item>
<el-form-item label="字段配置">
<div class="field-config-panel">
<div class="field-config-header">
<h4 class="field-config-title">自定义字段</h4>
<el-button
type="primary"
size="small"
@click="addField"
icon="el-icon-plus"
plain
>添加字段</el-button
>
</div>
<div
v-if="categoryForm.fieldConfig.length"
class="field-list-container"
>
<transition-group name="fade" tag="div">
<div
v-for="(field, index) in categoryForm.fieldConfig"
:key="field.id || index"
class="field-item-row"
>
<el-row :gutter="15" align="middle">
<el-col :span="5">
<el-form-item
:prop="'fieldConfig.' + index + '.fieldName'"
:rules="{
required: true,
message: '字段名不能为空',
trigger: 'blur',
}"
>
<el-input
v-model="field.fieldName"
placeholder="字段名称 (英文)"
size="small"
></el-input>
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item
:prop="'fieldConfig.' + index + '.displayLabel'"
:rules="{
required: true,
message: '显示标签不能为空',
trigger: 'blur',
}"
>
<el-input
v-model="field.displayLabel"
placeholder="显示标签 (中文)"
size="small"
></el-input>
</el-form-item>
</el-col>
<el-col :span="5">
<el-select
v-model="field.fieldType"
placeholder="字段类型"
size="small"
style="width: 100%"
>
<el-option label="文本" value="text"></el-option>
<el-option label="数字" value="number"></el-option>
<el-option label="日期" value="date"></el-option>
</el-select>
</el-col>
<el-col :span="4" class="text-center">
<el-checkbox v-model="field.isRequired"
>设为必填</el-checkbox
>
</el-col>
<el-col :span="3" class="text-center">
<el-button
type="danger"
size="mini"
@click="removeField(index)"
icon="el-icon-delete"
circle
plain
></el-button>
</el-col>
</el-row>
</div>
</transition-group>
</div>
<div v-else class="no-fields-placeholder">
<i class="el-icon-document-add placeholder-icon"></i>
<p>暂无字段配置,点击 "添加字段" 开始创建</p>
</div>
</div>
</el-form-item>
</el-form>

<div slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit"
>确定</el-button
>
</div>
</el-dialog>
</div>
</template>

<script>
import { formatDateTime } from "@/utils/filters";
import {
getCategoriesList,
createCategory,
updateCategory,
deleteCategory,
} from "@/api/sales-analysis";

export default {
name: "Categories",
data() {
return {
loading: false,
submitting: false,
categoryList: [],
searchForm: {
name: "",
},
pagination: {
page: 1,
pageSize: 10,
total: 0,
},
dialogVisible: false,
isEdit: false,
categoryForm: {
id: null,
name: "",
description: "",
fieldConfig: [],
},
categoryRules: {
name: [
{ required: true, message: "请输入分类名称", trigger: "blur" },
{
min: 1,
max: 100,
message: "分类名称长度在 1 到 100 个字符",
trigger: "blur",
},
],
description: [{ required: false }],
},
};
},
computed: {
dialogTitle() {
return this.isEdit ? "编辑分类" : "新增分类";
},
},
created() {
this.fetchCategoryList();
},
methods: {
formatDateTime,

// 解析字段配置字符串为 JSON 对象
parseFieldConfig(fieldConfig) {
try {
if (!fieldConfig || typeof fieldConfig === 'object') {
return fieldConfig || [];
}
return JSON.parse(fieldConfig);
} catch (error) {
console.warn('解析字段配置失败:', error);
return [];
}
},

// 获取分类列表
async fetchCategoryList() {
try {
this.loading = true;
const params = {
page: this.pagination.page,
pageSize: this.pagination.pageSize,
name: this.searchForm.name,
};

const response = await getCategoriesList(params);

console.log(response);

if (response.code === 200) {
// 处理返回的数据,将 fieldConfig 字符串转换为 JSON 对象
const categoryList = response.data.list || [];
this.categoryList = categoryList.map(category => ({
...category,
fieldConfig: this.parseFieldConfig(category.fieldConfig)
}));
this.pagination.total = response.data.pagination.total || 0;
} else {
this.$message.error(response.message || "获取分类列表失败");
}
} catch (error) {
console.error("获取分类列表失败:", error);
this.$message.error("获取分类列表失败");
} finally {
this.loading = false;
}
},

// 搜索
handleSearch() {
this.pagination.page = 1;
this.fetchCategoryList();
},

// 分页大小改变
handleSizeChange(val) {
this.pagination.pageSize = val;
this.pagination.page = 1;
this.fetchCategoryList();
},

// 当前页改变
handleCurrentChange(val) {
this.pagination.page = val;
this.fetchCategoryList();
},

getFieldTagType(type) {
switch (type) {
case "text":
return "primary";
case "number":
return "warning";
case "date":
return "success";
case "select":
return "info";
default:
return "info";
}
},

getFieldIcon(type) {
switch (type) {
case "text":
return "el-icon-tickets";
case "number":
return "el-icon-sort";
case "date":
return "el-icon-date";
case "select":
return "el-icon-arrow-down";
default:
return "el-icon-document";
}
},

// 新增分类
handleAdd() {
this.isEdit = false;
this.categoryForm = {
id: null,
name: "",
description: "",
fieldConfig: [],
};
this.dialogVisible = true;
},

// 编辑分类
handleEdit(row) {
this.isEdit = true;
this.categoryForm = {
id: row.id,
name: row.name,
description: row.description || "",
fieldConfig: JSON.parse(JSON.stringify(this.parseFieldConfig(row.fieldConfig))),
};
this.dialogVisible = true;
},

// 删除分类
handleDelete(row) {
this.$confirm(`确定要删除分类"${row.name}"吗?`, "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
const response = await deleteCategory(row.id);

if (response.code === 200) {
this.$message.success("删除成功");
this.fetchCategoryList();
} else {
this.$message.error(response.message || "删除失败");
}
} catch (error) {
console.error("删除分类失败:", error);
this.$message.error("删除失败");
}
})
.catch(() => {});
},

// 添加字段
addField() {
this.categoryForm.fieldConfig.push({
id: Date.now(), // 添加一个唯一ID用于key
fieldName: "",
displayLabel: "",
fieldType: "text",
isRequired: false,
});
},

// 移除字段
removeField(index) {
this.categoryForm.fieldConfig.splice(index, 1);
},

// 提交表单
async handleSubmit() {
try {
await this.$refs.categoryForm.validate();

// 验证字段配置
if (this.categoryForm.fieldConfig.length > 0) {
const invalidFields = this.categoryForm.fieldConfig.filter(
(field) =>
!field.fieldName || !field.displayLabel || !field.fieldType
);

if (invalidFields.length > 0) {
this.$message.error("请完善字段配置信息");
return;
}
}

this.submitting = true;

// 创建提交数据,将 fieldConfig 转换为字符串
const submitData = {
...this.categoryForm,
fieldConfig: JSON.stringify(this.categoryForm.fieldConfig)
};

const response = this.isEdit
? await updateCategory(this.categoryForm.id, submitData)
: await createCategory(submitData);

if (response.code === 200) {
this.$message.success(this.isEdit ? "编辑成功" : "新增成功");
this.dialogVisible = false;
this.fetchCategoryList();
} else {
this.$message.error(response.message || "操作失败");
}
} catch (error) {
if (error.message !== "validation failed") {
console.error("提交失败:", error);
this.$message.error("操作失败");
}
} finally {
this.submitting = false;
}
},

// 对话框关闭
handleDialogClose() {
this.$refs.categoryForm.resetFields();
this.categoryForm.fieldConfig = [];
},
},
};
</script>

+ 939
- 0
src/views/sales-analysis/reports/CategoryAnalysis.vue Ver fichero

@@ -0,0 +1,939 @@
<template>
<div class="overall-analysis-page">
<!-- 筛选 -->
<div class="filter-card">
<div class="filter-section">
<div class="filter-row">
<div class="filter-item">
<label>分类:</label>
<el-select
v-model="filters.category"
placeholder="请选择分类(默认全部)"
clearable
filterable
style="width: 280px"
size="small"
>
<el-option
v-for="category in filterOptions.categories"
:key="category"
:label="category"
:value="category"
/>
</el-select>
</div>

<div class="filter-item">
<label>日期:</label>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
@change="handleDateChange"
style="width: 380px"
size="small"
/>
</div>

<div class="filter-item">
<el-button type="primary" @click="fetchData" :loading="loading" size="small">
查询分析
</el-button>
<el-button @click="resetFilter" size="small">重置筛选</el-button>
</div>
</div>
</div>
</div>

<!-- 图表区域 -->
<div class="charts-section" v-if="Object.keys(dimensionCharts).length > 0">
<div
v-for="(chartData, dimension) in dimensionCharts"
:key="dimension"
class="dimension-section"
>
<div class="dimension-header">
<div class="dimension-title">
<i class="el-icon-data-analysis"></i>
<span>{{ dimension }}维度分析</span>
</div>
<div class="dimension-stats">
<span class="stat-item category-stat">
<i class="el-icon-folder-opened"></i>
归属分类: <strong>{{ currentCategory }}</strong>
</span>
<span class="stat-item">
<i class="el-icon-money"></i>
金额项: {{ chartData.amountData.length }}
</span>
<span class="stat-item">
<i class="el-icon-s-goods"></i>
数量项: {{ chartData.quantityData.length }}
</span>
</div>
</div>

<el-row :gutter="24">
<el-col :span="12">
<!-- 销售金额占比 -->
<div class="chart-card">
<div class="card-header">
<div class="header-left">
<i class="el-icon-money header-icon-amount"></i>
<span>销售金额占比</span>
</div>
<div class="header-right">
<span class="total-amount"
>总计: ¥{{
formatNumber(getTotalAmount(chartData.amountData))
}}</span
>
</div>
</div>
<div class="chart-container">
<div
:id="`${dimension}AmountChart`"
style="width: 100%; height: 450px"
></div>
</div>
</div>
</el-col>

<el-col :span="12">
<!-- 销售数量占比 -->
<div class="chart-card">
<div class="card-header">
<div class="header-left">
<i class="el-icon-s-goods header-icon-quantity"></i>
<span>销售数量占比</span>
</div>
<div class="header-right">
<span class="total-quantity"
>总计:
{{
formatNumber(getTotalQuantity(chartData.quantityData))
}}</span
>
</div>
</div>
<div class="chart-container">
<div
:id="`${dimension}QuantityChart`"
style="width: 100%; height: 450px"
></div>
</div>
</div>
</el-col>
</el-row>
</div>
</div>

<!-- 无数据提示 -->
<div v-else-if="!loading" class="no-data-section">
<div class="no-data-content">
<i class="el-icon-warning-outline"></i>
<p>暂无数据</p>
<small>请尝试调整筛选条件或检查数据</small>
</div>
</div>
</div>
</template>

<script>
import * as echarts from "echarts";
import { getReportFilterOptions, getCategoryAnalysisReport } from '@/api/sales-analysis'

export default {
name: "CategoryAnalysis",
components: {},
data() {
return {
loading: false,
dateRange: null,
filters: {
category: "",
},
filterOptions: {
categories: [],
},
dimensionCharts: {},
chartInstances: new Map(), // 存储图表实例
currentCategory: "全部分类",
};
},
mounted() {
this.initDateRange();
this.fetchFilterOptions();
// this.fetchData();
},
beforeDestroy() {
// 销毁所有图表实例
this.chartInstances.forEach((chart) => {
if (chart) {
chart.dispose();
}
});
this.chartInstances.clear();
},
methods: {
// 初始化日期范围(默认最近30天)
initDateRange() {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 30);

this.dateRange = [
startDate.toISOString().split("T")[0],
endDate.toISOString().split("T")[0],
];
},

// 日期变化处理
handleDateChange(dates) {
this.dateRange = dates;
},

// 重置筛选
resetFilter() {
this.initDateRange();
this.fetchData();
},

// 获取筛选选项
async fetchFilterOptions() {
try {
const response = await getReportFilterOptions();

if (response.code === 200) {
this.filterOptions = {
categories: response.data.categories || [],
};
} else {
console.error("获取筛选选项失败:", response.message);
}
} catch (error) {
console.error("获取筛选选项失败:", error);
}
},

// 获取数据
async fetchData() {
if (!this.dateRange || this.dateRange.length !== 2) {
this.$message.warning("请选择日期范围");
return;
}

try {
this.loading = true;

// 构建查询参数
const params = {
startDate: this.dateRange[0],
endDate: this.dateRange[1],
};

// 添加分类筛选条件(如果选择了特定分类)
if (this.filters.category) {
params.category = this.filters.category;
}

const response = await getCategoryAnalysisReport(params);

if (response.code === 200) {
this.dimensionCharts = response.data.dimensionCharts || {};
this.currentCategory = response.data.currentCategory || "全部分类";

this.$nextTick(() => {
this.renderAllCharts();
});
} else {
this.$message.error(response.message || "获取数据失败");
}
} catch (error) {
console.error("获取数据失败:", error);
this.$message.error("获取数据失败");
} finally {
this.loading = false;
}
},

// 渲染所有图表
renderAllCharts() {
// 先销毁所有现有图表
this.chartInstances.forEach((chart) => {
if (chart) {
chart.dispose();
}
});
this.chartInstances.clear();

// 为每个维度渲染图表
for (const [dimension, chartData] of Object.entries(
this.dimensionCharts
)) {
this.renderDimensionCharts(dimension, chartData);
}
},

// 渲染单个维度的图表
renderDimensionCharts(dimension, chartData) {
// 渲染金额占比图表
this.renderAmountChart(dimension, chartData.amountData);

// 渲染数量占比图表
this.renderQuantityChart(dimension, chartData.quantityData);
},

// 渲染金额占比图表
renderAmountChart(dimension, data) {
const chartId = `${dimension}AmountChart`;
const chartDom = document.getElementById(chartId);

if (!chartDom || !data || data.length === 0) return;

const chart = echarts.init(chartDom);
this.chartInstances.set(chartId, chart);
const totalAmount = this.getTotalAmount(data);
const processedData = this.processChartData(data);

const option = {
tooltip: {
trigger: "item",
backgroundColor: "rgba(255, 255, 255, 0.98)",
borderColor: "#E8E8E8",
borderWidth: 1,
textStyle: {
color: "#333",
fontSize: 13,
},
padding: 12,
formatter: (params) => {
const percentage = params.data.percentage;
const amount = this.formatNumber(params.value);
return `
<div style="font-weight: 600; margin-bottom: 8px;">${params.name}</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; min-width: 150px; gap: 20px;">
<span style="color: #666;">销售额</span>
<span style="font-weight: 600; color: #D9534F;">¥${amount}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: #666;">占比</span>
<span style="font-weight: 600; color: #5B9BD5;">${percentage}%</span>
</div>
`;
},
},
legend: {
type: "scroll",
orient: "vertical",
right: 20,
top: "center",
data: processedData.map((item) => "" + item.name + ""),
textStyle: {
color: "#666",
fontSize: 14,
},
itemWidth: 20,
itemHeight: 20,
itemGap: 10,
formatter: (name) => {
const item = processedData.find((p) => p.name === name);
const displayName =
name.length > 15 ? name.slice(0, 15) + "..." : name;

if (item && item.percentage !== undefined) {
return `${displayName} ${item.percentage}%`;
}
return displayName;
},
},
series: [
{
name: "销售金额",
type: "pie",
radius: ["50%", "75%"],
center: ["38%", "50%"],
avoidLabelOverlap: false,
itemStyle: {
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
position: "center",
formatter: () => {
return `{total|¥${this.formatNumber(
totalAmount,
true
)}}\n{name|总金额}`;
},
rich: {
total: {
fontSize: 22,
fontWeight: "bold",
color: "#D9534F",
},
name: {
fontSize: 14,
color: "#666",
padding: [4, 0, 0, 0],
},
},
},
emphasis: {
scale: true,
scaleSize: 10,
label: {
show: true,
formatter: (params) => {
return `{val|${params.percent}%}\n{name|${params.name}}`;
},
rich: {
val: {
fontSize: 22,
fontWeight: "bold",
color: "#D9534F",
},
name: {
fontSize: 14,
color: "#666",
padding: [4, 0, 0, 0],
},
},
},
},
labelLine: {
show: false,
},
data: processedData,
color: [
"#5B9BD5",
"#ED7D31",
"#A5A5A5",
"#FFC000",
"#4472C4",
"#70AD47",
"#255E91",
"#9E480E",
"#636363",
"#997300",
],
},
],
};

chart.setOption(option);
},

// 渲染数量占比图表
renderQuantityChart(dimension, data) {
const chartId = `${dimension}QuantityChart`;
const chartDom = document.getElementById(chartId);

if (!chartDom || !data || data.length === 0) return;

const chart = echarts.init(chartDom);
this.chartInstances.set(chartId, chart);
const totalQuantity = this.getTotalQuantity(data);
const processedData = this.processChartData(data, false);

const option = {
tooltip: {
trigger: "item",
backgroundColor: "rgba(255, 255, 255, 0.98)",
borderColor: "#E8E8E8",
borderWidth: 1,
textStyle: {
color: "#333",
fontSize: 13,
},
padding: 12,
formatter: (params) => {
const percentage = params.data.percentage;
const quantity = this.formatNumber(params.value);
return `
<div style="font-weight: 600; margin-bottom: 8px;">${params.name}</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; min-width: 150px; gap: 20px;">
<span style="color: #666;">销售数量</span>
<span style="font-weight: 600; color: #5CB85C;">${quantity}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: #666;">占比</span>
<span style="font-weight: 600; color: #5B9BD5;">${percentage}%</span>
</div>
`;
},
},
legend: {
type: "scroll",
orient: "vertical",
right: 20,
top: "center",
show: true,
data: processedData.map((item) => "" + item.name + ""),
textStyle: {
color: "#666",
fontSize: 14,
},
itemWidth: 20,
itemHeight: 20,
itemGap: 10,
formatter: (name) => {
const item = processedData.find((p) => p.name === name);
const displayName =
name.length > 30 ? name.slice(0, 30) + "..." : name;

if (item && item.percentage !== undefined) {
return `${displayName} ${item.percentage}%`;
}
return displayName;
},
},
series: [
{
name: "销售数量",
type: "pie",
radius: ["50%", "75%"],
center: ["38%", "50%"],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 5,
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
position: "center",
formatter: () => {
return `{total|${this.formatNumber(
totalQuantity
)}}\n{name|总数量}`;
},
rich: {
total: {
fontSize: 22,
fontWeight: "bold",
color: "#5CB85C",
},
name: {
fontSize: 14,
color: "#666",
padding: [4, 0, 0, 0],
},
},
},
emphasis: {
scale: true,
scaleSize: 10,
label: {
show: true,
formatter: (params) => {
return `{val|${params.percent}%}\n{name|${params.name}}`;
},
rich: {
val: {
fontSize: 22,
fontWeight: "bold",
color: "#5CB85C",
},
name: {
fontSize: 14,
color: "#666",
padding: [4, 0, 0, 0],
},
},
},
},
labelLine: {
show: false,
},
data: processedData,
color: [
"#70AD47",
"#4472C4",
"#FFC000",
"#ED7D31",
"#5B9BD5",
"#A5A5A5",
"#255E91",
"#9E480E",
"#636363",
"#997300",
],
},
],
};

chart.setOption(option);
},

// 格式化数字
formatNumber(num, isMoney = false) {
if (!num) return "0";

if (isMoney && num >= 10000) {
return (num / 10000).toFixed(1) + "万";
}

return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
},

// 处理图表数据,聚合TopN和'其它'
processChartData(data, isAmount = true) {
const legendLimit = 9;
if (data.length <= legendLimit + 1) {
return data;
}

const topItems = data.slice(0, legendLimit);
const otherItems = data.slice(legendLimit);

const otherSum = otherItems.reduce((sum, item) => sum + item.value, 0);
const totalSum = data.reduce((sum, item) => sum + item.value, 0);

const otherPercentage =
totalSum > 0 ? ((otherSum / totalSum) * 100).toFixed(2) : 0;

const otherItem = {
name: "其它",
value: otherSum,
percentage: otherPercentage,
};

if (isAmount) {
otherItem.amount = otherSum;
} else {
otherItem.quantity = otherSum;
}

return [...topItems, otherItem];
},

// 获取总图表数量
getTotalChartsCount() {
return Object.keys(this.dimensionCharts).length * 2;
},

// 获取总数据项数量
getTotalDataItems() {
let total = 0;
for (const chartData of Object.values(this.dimensionCharts)) {
total += chartData.amountData.length + chartData.quantityData.length;
}
return total;
},

// 获取主要维度
getMainDimension() {
if (Object.keys(this.dimensionCharts).length === 0) return "-";

let maxItems = 0;
let mainDimension = "";

for (const [dimension, chartData] of Object.entries(
this.dimensionCharts
)) {
const totalItems =
chartData.amountData.length + chartData.quantityData.length;
if (totalItems > maxItems) {
maxItems = totalItems;
mainDimension = dimension;
}
}

return mainDimension;
},

// 获取总金额
getTotalAmount(data) {
return data.reduce((sum, item) => sum + (item.value || 0), 0);
},

// 获取总数量
getTotalQuantity(data) {
return data.reduce((sum, item) => sum + (item.value || 0), 0);
},
},
};
</script>

<style scoped>
.overall-analysis-page {
height: 100vh;
overflow-y: auto;
margin: -20px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 15px;
}

.filter-card {
background-color: #fff;
padding: 20px;
border-radius: 8px;
border: 1px solid #e8e8e8;
}

.filter-section {
display: flex;
flex-direction: column;
gap: 10px;
}

.filter-row {
display: flex;
align-items: center;
gap: 15px;
}

.filter-item {
display: flex;
align-items: center;
gap: 6px;
}

.filter-item label {
font-weight: 500;
color: #333;
white-space: nowrap;
}

.filter-item .el-select {
width: 180px;
}

.filter-item .el-input {
width: 180px;
}

.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}

.stat-card {
background: #fff;
padding: 20px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 15px;
border: 1px solid #e8e8e8;
transition: background-color 0.2s ease;
}

.stat-card:hover {
background-color: #f8f9fa;
}

.stat-card-1 {
border-left: 4px solid #5b9bd5;
}
.stat-card-2 {
border-left: 4px solid #ed7d31;
}
.stat-card-3 {
border-left: 4px solid #70ad47;
}
.stat-card-4 {
border-left: 4px solid #ffc000;
}

.stat-card-1 .stat-icon {
background-color: #5b9bd5;
}
.stat-card-2 .stat-icon {
background-color: #ed7d31;
}
.stat-card-3 .stat-icon {
background-color: #70ad47;
}
.stat-card-4 .stat-icon {
background-color: #ffc000;
}

.stat-icon {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
flex-shrink: 0;
}

.stat-content {
flex: 1;
}

.stat-title {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}

.stat-value {
font-size: 28px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}

.stat-desc {
font-size: 12px;
color: #999;
}

.charts-section {
display: flex;
flex-direction: column;
gap: 30px;
}

.dimension-section {
display: flex;
flex-direction: column;
gap: 20px;
overflow: hidden;
}

.dimension-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #f8f9fa;
border-radius: 8px;
border-bottom: 1px solid #e8e8e8;
}

.dimension-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
font-weight: 600;
color: #333;
}

.dimension-title i {
font-size: 20px;
color: #5b9bd5;
}

.dimension-stats {
display: flex;
gap: 20px;
}

.stat-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #666;
}

.category-stat {
background-color: #e9ecef;
padding: 4px 8px;
border-radius: 4px;
}

.category-stat strong {
color: #0056b3;
}

.chart-card {
background-color: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
overflow: hidden;
}

.chart-container {
padding: 15px 0;
width: 100%;
}

.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
}

.header-left {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 16px;
color: #333;
}

.header-left i {
font-size: 18px;
}

.header-icon-amount {
color: #d9534f;
}

.header-icon-quantity {
color: #5cb85c;
}

.header-right {
font-size: 14px;
font-weight: 600;
}

.total-amount {
color: #d9534f;
}

.total-quantity {
color: #5cb85c;
}

.no-data-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}

.no-data-content {
text-align: center;
color: #999;
}

.no-data-content i {
font-size: 48px;
margin-bottom: 20px;
display: block;
color: #ccc;
}

.no-data-content p {
font-size: 16px;
margin: 0 0 10px 0;
color: #666;
}

.no-data-content small {
font-size: 14px;
color: #aaa;
}
</style>

+ 1049
- 0
src/views/sales-analysis/reports/OverallAnalysis.vue
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 507
- 0
src/views/sales-analysis/reports/ProductAnalysis.vue Ver fichero

@@ -0,0 +1,507 @@
<template>
<div class="product-analysis-page">
<!-- 筛选 -->
<div class="filter-card">
<div class="filter-section">
<div class="filter-row">
<div class="filter-item">
<label>商品编码:</label>
<product-code-autocomplete
v-model="filters.productCode"
placeholder="请选择要分析的商品编码"
width="300px"
@select="handleProductCodeSelect"
size="small"
/>
</div>

<div class="filter-item">
<label>日期范围:</label>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
style="width: 380px"
size="small"
@change="handleDateChange"
/>
</div>

<div class="filter-item">
<el-button type="primary" @click="fetchData" :loading="loading" size="small">
查询分析
</el-button>
<el-button @click="resetFilter" size="small">重置筛选</el-button>
</div>
</div>
</div>
</div>

<!-- 图表区域 -->
<div class="charts-section" v-if="!isDataEmpty">
<!-- 商品销售排行 -->
<div class="chart-card">
<div class="card-header">
<span>单品销售排行</span>
</div>
<div class="chart-container">
<div id="rankingChart" style="width: 100%; height: 600px"></div>
</div>
</div>

<!-- 商品销售趋势 -->
<div class="chart-card">
<div class="card-header">
<span>单品销售趋势</span>
</div>
<div class="chart-container">
<div id="trendChart" style="width: 100%; height: 400px"></div>
</div>
</div>
</div>

<!-- 无数据提示 -->
<div v-if="!loading && isDataEmpty" class="no-data-section">
<div class="no-data-content">
<i class="el-icon-warning-outline"></i>
<p>暂无数据</p>
<small>请尝试调整筛选条件或检查数据</small>
</div>
</div>
</div>
</template>

<script>
import * as echarts from "echarts";
import ProductCodeAutocomplete from "@/components/ProductCodeAutocomplete/index.vue";
import { getProductAnalysisReport } from '@/api/sales-analysis'

export default {
name: "ProductAnalysis",
components: {
ProductCodeAutocomplete,
},
data() {
return {
loading: false,
dateRange: null,
filters: {
productCode: "",
},
filterOptions: {},
statsData: {
rankingData: [],
trendData: [],
},
trendChart: null,
rankingChart: null,
};
},
computed: {
isDataEmpty() {
return (
(!this.statsData.rankingData ||
this.statsData.rankingData.length === 0) &&
(!this.statsData.trendData || this.statsData.trendData.length === 0)
);
},
},
mounted() {
this.initDateRange();
this.fetchFilterOptions();
// this.fetchData();
},
beforeDestroy() {
if (this.trendChart) {
this.trendChart.dispose();
}
if (this.rankingChart) {
this.rankingChart.dispose();
}
},
methods: {
// 初始化日期范围(默认最近30天)
initDateRange() {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 30);

this.dateRange = [
startDate.toISOString().split("T")[0],
endDate.toISOString().split("T")[0],
];
},

// 日期变化处理
handleDateChange(dates) {
this.dateRange = dates;
},

// 商品编码选择处理
handleProductCodeSelect(item) {
this.filters.productCode = item.value;
},

// 重置筛选
resetFilter() {
this.initDateRange();
this.filters = {
productCode: "",
};
// 重置后不清空数据,等待新的查询
},

// 获取筛选选项 - 此处可以留空或用于其他目的
async fetchFilterOptions() {},

// 获取数据
async fetchData() {
if (!this.dateRange || this.dateRange.length !== 2) {
this.$message.warning("请选择日期范围");
return;
}
if (!this.filters.productCode) {
this.$message.warning("请输入并选择一个商品编码");
return;
}

this.loading = true;
try {
const params = {
startDate: this.dateRange[0],
endDate: this.dateRange[1],
productCode: this.filters.productCode,
};

const response = await getProductAnalysisReport(params);

if (response.code === 200) {
this.statsData = response.data || { rankingData: [], trendData: [] };
this.$nextTick(() => {
this.renderTrendChart();
this.renderRankingChart();
});
} else {
this.$message.error(response.message || "获取数据失败");
this.statsData = { rankingData: [], trendData: [] };
this.$nextTick(() => {
this.renderTrendChart();
this.renderRankingChart();
});
}
} catch (error) {
console.error("获取数据失败:", error);
this.$message.error("获取数据失败");
this.statsData = { rankingData: [], trendData: [] };
this.$nextTick(() => {
this.renderTrendChart();
this.renderRankingChart();
});
} finally {
this.loading = false;
}
},

// 渲染趋势图
renderTrendChart() {
const chartDom = document.getElementById("trendChart");
if (!chartDom) return;

if (this.trendChart) {
this.trendChart.dispose();
}
this.trendChart = echarts.init(chartDom);

if (!this.statsData.trendData || this.statsData.trendData.length === 0) {
this.trendChart.clear();
return;
}

const dates = this.statsData.trendData.map((item) => item.date);
const amounts = this.statsData.trendData.map((item) => item.amount);
const quantities = this.statsData.trendData.map((item) => item.quantity);

const option = {
tooltip: {
trigger: "axis",
axisPointer: { type: "cross" },
},
legend: {
data: ["销售额", "销售数量"],
top: "top",
},
grid: {
left: "3%",
right: "4%",
bottom: "10%",
containLabel: true,
},
xAxis: [
{
type: "category",
data: dates,
axisPointer: { type: "shadow" },
},
],
yAxis: [
{
type: "value",
name: "销售额",
axisLabel: {
formatter: (value) => `¥${this.formatNumber(value)}`,
},
},
{
type: "value",
name: "销售数量",
axisLabel: { formatter: "{value}" },
},
],
series: [
{
name: "销售额",
type: "bar",
data: amounts,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#5470C6" },
{ offset: 1, color: "#80A4F3" },
]),
},
},
{
name: "销售数量",
type: "line",
yAxisIndex: 1,
smooth: true,
data: quantities,
itemStyle: { color: "#5BCCBD" },
},
],
dataZoom: [
{ type: "inside", start: 0, end: 100 },
{ start: 0, end: 100 },
],
};

this.trendChart.setOption(option);
},

// 渲染商品排行图
renderRankingChart() {
const chartDom = document.getElementById("rankingChart");
if (!chartDom) return;

if (this.rankingChart) {
this.rankingChart.dispose();
}
this.rankingChart = echarts.init(chartDom);

if (
!this.statsData.rankingData ||
this.statsData.rankingData.length === 0
) {
this.rankingChart.clear();
return;
}

const data = [...this.statsData.rankingData].sort(
(a, b) => a.totalAmount - b.totalAmount
);

const dates = data.map((item) => item.date);
const amounts = data.map((item) => item.totalAmount);
const quantities = data.map((item) => item.totalQuantity);

const option = {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter: (params) => {
const date = params[0].name;
const amountParam = params.find((p) => p.seriesName === "销售额");
const quantityParam = params.find(
(p) => p.seriesName === "销售数量"
);
const amount = amountParam ? amountParam.value : 0;
const quantity = quantityParam ? quantityParam.value : 0;
return `日期: ${date}<br/>
<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${
amountParam.color
};"></span>销售额: ¥${this.formatNumber(amount)}<br/>
<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${
quantityParam.color
};"></span>销售数量: ${this.formatNumber(quantity)}`;
},
},
legend: {
data: ["销售额", "销售数量"],
top: "top",
},
grid: {
left: "3%",
right: "10%",
bottom: "3%",
containLabel: true,
},
xAxis: [
{
type: "value",
name: "销售额",
position: "bottom",
splitLine: { show: true },
axisLabel: {
formatter: (value) => `¥${this.formatNumber(value)}`,
},
},
{
type: "value",
name: "销售数量",
position: "top",
splitLine: { show: false },
axisLine: { show: true, onZero: false },
axisTick: { show: true },
axisLabel: { formatter: "{value}" },
},
],
yAxis: {
type: "category",
data: dates,
axisTick: { show: false },
axisLabel: {
formatter: (value) =>
value.length > 30 ? value.substring(0, 30) + "..." : value,
},
},
series: [
{
name: "销售额",
type: "bar",
xAxisIndex: 0,
data: amounts,
itemStyle: { color: "#5470C6" },
label: {
show: true,
position: "right",
formatter: (params) => `¥${this.formatNumber(params.value)}`,
},
},
{
name: "销售数量",
type: "bar",
xAxisIndex: 1,
data: quantities,
itemStyle: { color: "#91CC75" },
label: {
show: true,
position: "right",
formatter: (params) => `${this.formatNumber(params.value)}`,
},
},
],
};

this.rankingChart.setOption(option);
},

// 格式化数字
formatNumber(num) {
if (num === null || num === undefined) return "0";
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
},
},
};
</script>

<style scoped>
.product-analysis-page {
height: 100vh;
overflow-y: auto;
margin: -20px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 15px;
}
.filter-card {
background-color: #fff;
padding: 20px;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.filter-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.filter-row {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.filter-item label {
font-weight: 500;
color: #333;
white-space: nowrap;
}

.charts-section {
display: flex;
flex-direction: column;
gap: 20px;
}

.chart-card {
background-color: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
font-weight: 600;
font-size: 18px;
padding: 15px 20px;
border-bottom: 1px solid #e8e8e8;
}
.chart-container {
padding: 20px;
}
.no-data-section {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
flex-direction: column;
}
.no-data-content {
text-align: center;
color: #999;
}
.no-data-content i {
font-size: 48px;
margin-bottom: 20px;
color: #ccc;
display: block;
}
.no-data-content p {
font-size: 16px;
margin: 0 0 10px 0;
color: #666;
}
.no-data-content small {
font-size: 14px;
color: #aaa;
}
</style>

+ 807
- 0
src/views/sales-analysis/reports/ShopAnalysis.vue Ver fichero

@@ -0,0 +1,807 @@
<template>
<div class="overall-analysis-page">
<!-- 筛选 -->
<div class="filter-card">
<div class="filter-section">
<div class="filter-row">
<div class="filter-item" v-if="false">
<label>店铺:</label>
<el-select
v-model="filters.shop"
placeholder="请选择店铺(默认全部)"
clearable
filterable
style="width: 180px"
size="small"
>
<el-option label="全部店铺" value="" />
<el-option
v-for="shop in filterOptions.shops"
:key="shop"
:label="shop"
:value="shop"
/>
</el-select>
</div>



<div class="filter-item">
<label>日期范围:</label>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
@change="handleDateChange"
style="width: 380px"
size="small"
/>
</div>

<div class="filter-item">
<el-button type="primary" @click="fetchData" :loading="loading" size="small">
查询分析
</el-button>
<el-button @click="resetFilter" size="small">重置筛选</el-button>
</div>
</div>
</div>
</div>


<!-- 图表区域 -->
<div class="charts-section">
<el-row :gutter="20">
<el-col :span="12">
<!-- 各店铺销售金额占比 -->
<div class="chart-card">
<div class="card-header">
<span>各店铺销售金额占比</span>
</div>
<div class="chart-container">
<div
id="shopAmountChart"
style="width: 100%; height: 400px"
></div>
</div>
</div>
</el-col>
<el-col :span="12">
<!-- 各店铺销售数量占比 -->
<div class="chart-card">
<div class="card-header">
<span>各店铺销售数量占比</span>
</div>
<div class="chart-container">
<div
id="shopQuantityChart"
style="width: 100%; height: 400px"
></div>
</div>
</div>
</el-col>
</el-row>

<!-- 各品类销售趋势 -->
<div class="chart-card">
<div class="card-header">
<span>各品类销售数量和销售额趋势</span>
</div>
<div class="chart-container">
<div id="categoryTrendChart" style="width: 100%; height: 500px"></div>
</div>
</div>



</div>
</div>
</template>

<script>
import * as echarts from "echarts";
import { getReportFilterOptions, getShopAnalysisReport } from '@/api/sales-analysis'

export default {
name: "ShopAnalysis",
components: {},
data() {
return {
loading: false,
dateRange: null,
filters: {
shop: "",
},
filterOptions: {
shops: [],
},
statsData: {
basicStats: null,
shopAmountData: [],
shopQuantityData: [],
categoryTrendData: [],
},
shopAmountChart: null,
shopQuantityChart: null,
categoryTrendChart: null,
};
},
filters: {
jpMoney(value) {
if (value === 0) {
return "0";
}
if (value < 10000) {
return value.toLocaleString("ja-JP", {
style: "currency",
currency: "JPY",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
} else {
// 将value转换为万日元
const jpValue = (value / 10000).toFixed(0);
return jpValue;
}
},
},
mounted() {
this.initDateRange();
this.fetchFilterOptions();
this.fetchData();
},
beforeDestroy() {
if (this.shopAmountChart) {
this.shopAmountChart.dispose();
}
if (this.shopQuantityChart) {
this.shopQuantityChart.dispose();
}
if (this.categoryTrendChart) {
this.categoryTrendChart.dispose();
}

},
methods: {
// 初始化日期范围(默认最近30天)
initDateRange() {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(endDate.getDate() - 30);

this.dateRange = [
startDate.toISOString().split("T")[0],
endDate.toISOString().split("T")[0],
];
},

// 日期变化处理
handleDateChange(dates) {
this.dateRange = dates;
},

// 重置筛选
resetFilter() {
this.initDateRange();
this.filters = {
shop: "",
};
this.fetchData();
},



// 获取筛选选项
async fetchFilterOptions() {
try {
const response = await getReportFilterOptions();

if (response.code === 200) {
this.filterOptions = {
shops: response.data.shops || [],
};
} else {
console.error("获取筛选选项失败:", response.message);
}
} catch (error) {
console.error("获取筛选选项失败:", error);
}
},

// 获取数据
async fetchData() {
if (!this.dateRange || this.dateRange.length !== 2) {
this.$message.warning("请选择日期范围");
return;
}

try {
this.loading = true;

// 构建查询参数
const params = {
startDate: this.dateRange[0],
endDate: this.dateRange[1],
};

// 添加店铺筛选条件(如果选择了特定店铺)
if (this.filters.shop) {
params.shop = this.filters.shop;
}



const response = await getShopAnalysisReport(params);

if (response.code === 200) {
this.statsData = response.data;

this.$nextTick(() => {
this.renderShopAmountChart();
this.renderShopQuantityChart();
this.renderCategoryTrendChart();
});
} else {
this.$message.error(response.message || "获取数据失败");
}
} catch (error) {
console.error("获取数据失败:", error);
this.$message.error("获取数据失败");
} finally {
this.loading = false;
}
},

// 渲染店铺销售金额占比图表
renderShopAmountChart() {
if (
!this.statsData.shopAmountData ||
this.statsData.shopAmountData.length === 0
) {
const chartDom = document.getElementById("shopAmountChart");
if (chartDom && this.shopAmountChart) {
this.shopAmountChart.clear();
}
return;
}

const chartDom = document.getElementById("shopAmountChart");
if (!chartDom) return;

if (this.shopAmountChart) {
this.shopAmountChart.dispose();
}

this.shopAmountChart = echarts.init(chartDom);

const data = this.statsData.shopAmountData.map((item) => ({
name: item.shopName,
value: item.amount,
percentage: item.percentage,
}));

const option = {
tooltip: {
trigger: "item",
formatter: (params) => {
return `${params.name}<br/>销售额: ¥${this.formatNumber(
params.value
)}<br/>占比: ${params.data.percentage}%`;
},
},
legend: {
type: "scroll",
orient: "vertical",
right: 10,
top: 20,
bottom: 20,
data: data.map((item) => item.name),
textStyle: {
color: "#666",
},
formatter: (name) => {
const item = data.find((p) => p.name === name);
const displayName =
name.length > 15 ? name.slice(0, 15) + "..." : name;

if (item && item.percentage !== undefined) {
return `${displayName} ${item.percentage}%`;
}
return displayName;
},
},
series: [
{
name: "销售金额",
type: "pie",
radius: ["50%", "70%"],
center: ["40%", "50%"],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
position: "center",
},
emphasis: {
label: {
show: true,
fontSize: "20",
fontWeight: "bold",
},
},
labelLine: {
show: false,
},
data: data,
color: [
"#5470C6",
"#91CC75",
"#FAC858",
"#EE6666",
"#73C0DE",
"#3BA272",
"#FC8452",
"#9A60B4",
"#EA7CCC",
],
},
],
};

this.shopAmountChart.setOption(option);
},

// 渲染店铺销售数量占比图表
renderShopQuantityChart() {
if (
!this.statsData.shopQuantityData ||
this.statsData.shopQuantityData.length === 0
) {
const chartDom = document.getElementById("shopQuantityChart");
if (chartDom && this.shopQuantityChart) {
this.shopQuantityChart.clear();
}
return;
}

const chartDom = document.getElementById("shopQuantityChart");
if (!chartDom) return;

if (this.shopQuantityChart) {
this.shopQuantityChart.dispose();
}

this.shopQuantityChart = echarts.init(chartDom);

const data = this.statsData.shopQuantityData.map((item) => ({
name: item.shopName,
value: item.quantity,
percentage: item.percentage,
}));

const option = {
tooltip: {
trigger: "item",
formatter: (params) => {
return `${params.name}<br/>销售数量: ${this.formatNumber(
params.value
)}<br/>占比: ${params.data.percentage}%`;
},
},
legend: {
type: "scroll",
orient: "vertical",
right: 10,
top: 20,
bottom: 20,
data: data.map((item) => item.name),
textStyle: {
color: "#666",
},
formatter: (name) => {
const item = data.find((p) => p.name === name);
const displayName =
name.length > 15 ? name.slice(0, 15) + "..." : name;

if (item && item.percentage !== undefined) {
return `${displayName} ${item.percentage}%`;
}
return displayName;
},
},
series: [
{
name: "销售数量",
type: "pie",
radius: ["50%", "70%"],
center: ["40%", "50%"],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: "#fff",
borderWidth: 2,
},
label: {
show: false,
position: "center",
},
emphasis: {
label: {
show: true,
fontSize: "20",
fontWeight: "bold",
},
},
labelLine: {
show: false,
},
data: data,
color: [
"#91CC75",
"#5470C6",
"#FAC858",
"#EE6666",
"#73C0DE",
"#3BA272",
"#FC8452",
"#9A60B4",
"#EA7CCC",
],
},
],
};

this.shopQuantityChart.setOption(option);
},

// 渲染品类销售趋势图表
renderCategoryTrendChart() {
if (
!this.statsData.categoryTrendData ||
this.statsData.categoryTrendData.length === 0
) {
const chartDom = document.getElementById("categoryTrendChart");
if (chartDom && this.categoryTrendChart) {
this.categoryTrendChart.clear();
}
return;
}

const chartDom = document.getElementById("categoryTrendChart");
if (!chartDom) return;

if (this.categoryTrendChart) {
this.categoryTrendChart.dispose();
}

this.categoryTrendChart = echarts.init(chartDom);

const categories = this.statsData.categoryTrendData.map(
(item) => item.category
);
const amounts = this.statsData.categoryTrendData.map(
(item) => item.amount
);
const quantities = this.statsData.categoryTrendData.map(
(item) => item.quantity
);

const option = {
tooltip: {
trigger: "axis",
axisPointer: {
type: "cross",
crossStyle: {
color: "#999",
},
},
formatter: (params) => {
let tooltipText = `${params[0].name}<br/>`;
params.forEach((param) => {
tooltipText += `${param.marker} ${param.seriesName}: `;
const value = param.value || 0;
if (param.seriesName === "销售额") {
tooltipText += `¥${this.formatNumber(value.toFixed(0))}`;
} else {
tooltipText += `${this.formatNumber(value)}`;
}
tooltipText += "<br/>";
});
return tooltipText;
},
},
legend: {
data: ["销售额", "销售数量"],
top: "top",
itemGap: 20,
textStyle: {
color: "#666",
},
},
grid: {
left: "3%",
right: "4%",
bottom: "25%",
containLabel: true,
},
xAxis: [
{
type: "category",
data: categories,
axisPointer: {
type: "shadow",
},
axisTick: {
show: false,
},
axisLine: {
show: false,
},
axisLabel: {
color: "#666",
interval: 0,
rotate: 45,
},
},
],
yAxis: [
{
type: "value",
name: "销售额",
axisLabel: {
formatter: "¥{value}",
color: "#666",
},
nameTextStyle: {
color: "#666",
padding: [0, 0, 0, 40],
},
splitLine: {
lineStyle: {
type: "dashed",
color: "#e0e6f1",
},
},
axisLine: { show: false },
axisTick: { show: false },
},
{
type: "value",
name: "销售数量",
axisLabel: {
formatter: "{value}",
color: "#666",
},
nameTextStyle: {
color: "#666",
padding: [0, 40, 0, 0],
},
splitLine: { show: false },
axisLine: { show: false },
axisTick: { show: false },
},
],
series: [
{
name: "销售额",
type: "bar",
barWidth: "40%",
data: amounts,
itemStyle: {
borderRadius: [5, 5, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#5470C6" },
{ offset: 1, color: "#80A4F3" },
]),
},
},
{
name: "销售数量",
type: "line",
yAxisIndex: 1,
smooth: true,
data: quantities,
symbol: "circle",
symbolSize: 8,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: "rgba(91, 204, 189, 0.5)",
},
{
offset: 1,
color: "rgba(91, 204, 189, 0)",
},
]),
},
itemStyle: {
color: "#5BCCBD",
},
},
],
dataZoom: [
{
type: "inside",
start: 0,
end: 100,
},
{
start: 0,
end: 100,
height: 20,
bottom: 5,
},
],
};

this.categoryTrendChart.setOption(option);
},



// 格式化数字
formatNumber(num) {
if (!num) return "0";
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
},


},
};
</script>

<style scoped>
.overall-analysis-page {
height: 100vh;
overflow-y: auto;
margin: -20px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.filter-card {
background-color: #fff;
padding: 20px;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.filter-section {
display: flex;
flex-direction: column;
gap: 10px;
}

.filter-row {
display: flex;
align-items: center;
gap: 10px;
}

.filter-item {
display: flex;
align-items: center;
gap: 4px;
}

.filter-item label {
font-weight: 500;
color: #333;
white-space: nowrap;
width: 70px;
}

.filter-item .el-select {
width: 180px;
}

.filter-item .el-input {
width: 180px;
}

.filter-actions {
display: flex;
gap: 10px;
}

.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}

.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 15px;
border: 1px solid #e8e8e8;
}

.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
background: #409eff;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
flex-shrink: 0;
}

.stat-content {
flex: 1;
}

.stat-title {
font-size: 16px;
color: #505050;
margin-bottom: 5px;
}

.stat-value {
font-size: 32px;
font-weight: 500;
color: #333;
margin-bottom: 5px;
}

.stat-value small {
font-size: 15px;
color: #505050;
}

.stat-desc {
font-size: 12px;
color: #999;
}

.charts-section {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}

.chart-card {
background: white;
}

.chart-container {
width: 100%;
}

.card-header {
display: flex;
align-items: center;
font-weight: 500;
font-size: 18px;
padding: 20px;
justify-content: center;
}



.chart-card {
background-color: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.chart-container {
padding: 10px 0;
}
</style>

+ 656
- 0
src/views/sales-analysis/shop-customer/ShopCustomer.vue Ver fichero

@@ -0,0 +1,656 @@
<template>
<div class="shop-customer-container app-container">
<div class="filter-container">
<div class="search-filters">
<el-input
v-model="searchForm.shopName"
placeholder="按店铺名称搜索"
clearable
@clear="handleSearch"
@keyup.enter.native="handleSearch"
class="filter-item"
size="small"
></el-input>
<el-input
v-model="searchForm.customerName"
placeholder="按客户名称搜索"
clearable
@clear="handleSearch"
@keyup.enter.native="handleSearch"
class="filter-item"
size="small"
></el-input>
<el-button
type="primary"
icon="el-icon-search"
@click="handleSearch"
size="small"
style="margin-right: 0"
></el-button>
<el-button
icon="el-icon-refresh"
@click="resetSearch"
size="small"
style="margin-left: 0"
></el-button>

<el-button
type="primary"
icon="el-icon-plus"
@click="handleAdd"
size="small"
style="margin-left: auto"
>新增关联</el-button
>
<el-divider direction="vertical"></el-divider>
<el-upload
ref="upload"
:action="uploadUrl"
:before-upload="beforeUpload"
:on-success="onUploadSuccess"
:on-error="onUploadError"
:show-file-list="false"
accept=".xlsx,.xls"
size="small"
>
<el-button type="success" plain icon="el-icon-upload2" size="small"
>导入Excel</el-button
>
</el-upload>
<el-button
type="warning"
plain
icon="el-icon-download"
@click="handleExport"
:disabled="!relationList.length"
size="small"
>导出Excel</el-button
>
<el-divider direction="vertical"></el-divider>
<el-button
type="danger"
icon="el-icon-delete"
@click="handleBatchDelete"
:disabled="!selectedRows.length"
plain
size="small"
>批量删除</el-button
>
</div>
</div>

<el-table
v-loading="loading"
:data="relationList"
style="width: 100%"
@selection-change="handleSelectionChange"
row-key="id"
size="small"
>
<el-table-column
type="selection"
width="55"
align="center"
></el-table-column>
<el-table-column
prop="id"
label="ID"
width="80"
align="center"
></el-table-column>
<el-table-column
prop="shopName"
label="店铺名称"
min-width="220"
show-overflow-tooltip
sortable
>
<template slot-scope="scope">
<i
class="el-icon-s-shop"
style="margin-right: 5px; color: #409eff"
></i>
<span>{{ scope.row.shopName }}</span>
</template>
</el-table-column>
<el-table-column
prop="customerName"
label="客户名称"
min-width="220"
show-overflow-tooltip
sortable
>
<template slot-scope="scope">
<i
class="el-icon-user-solid"
style="margin-right: 5px; color: #67c23a"
></i>
<span>{{ scope.row.customerName }}</span>
</template>
</el-table-column>
<el-table-column
prop="createdAt"
label="创建时间"
width="180"
align="center"
sortable
>
<template slot-scope="scope">
<i class="el-icon-time" style="margin-right: 5px"></i>
<span>{{ formatDateTime(scope.row.createdAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right" align="center">
<template slot-scope="scope">
<el-button
type="primary"
size="mini"
@click="handleEdit(scope.row)"
plain
>编辑</el-button
>
<el-popconfirm
title="确定删除此关联吗?"
@confirm="handleDelete(scope.row)"
style="margin-left: 10px"
>
<el-button slot="reference" type="danger" size="mini" plain
>删除</el-button
>
</el-popconfirm>
</template>
</el-table-column>
</el-table>

<pagination
v-show="pagination.total > 0"
:total="pagination.total"
:page.sync="pagination.page"
:limit.sync="pagination.pageSize"
@pagination="fetchRelationList"
/>

<!-- 新增/编辑对话框 -->
<el-dialog
:title="dialogTitle"
:visible.sync="dialogVisible"
width="600px"
:close-on-click-modal="false"
@close="handleDialogClose"
custom-class="relation-dialog"
>
<el-form
ref="relationForm"
:model="relationForm"
:rules="relationRules"
label-width="100px"
label-position="top"
>
<el-form-item label="店铺名称" prop="shopName">
<el-input
v-model="relationForm.shopName"
placeholder="请输入店铺名称"
:disabled="isEdit"
clearable
></el-input>
<div class="form-tips" v-if="isEdit">
<i class="el-icon-info"></i>
关联关系创建后不可修改,如需调整请删除后重新创建。
</div>
</el-form-item>
<el-form-item label="客户名称" prop="customerName">
<el-input
v-model="relationForm.customerName"
placeholder="请输入客户名称"
:disabled="isEdit"
clearable
></el-input>
</el-form-item>
</el-form>

<div
v-if="dialogTitle === '新增店铺客户关联'"
slot="footer"
class="dialog-footer"
>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit"
>确定</el-button
>
</div>
</el-dialog>
</div>
</template>

<script>
import { formatDateTime } from "@/utils/filters";
import {
getShopCustomerList,
deleteShopCustomer,
batchDeleteShopCustomer,
exportShopCustomer,
ShopCustomerAdd,
ShopCustomerUpdate,
} from "@/api/sales-analysis";

export default {
name: "ShopCustomer",
data() {
return {
loading: false,
submitting: false,
relationList: [],
selectedRows: [],
searchForm: {
shopName: "",
customerName: "",
},
pagination: {
page: 1,
pageSize: 50,
total: 0,
},
dialogVisible: false,
isEdit: false,
relationForm: {
id: null,
shopName: "",
customerName: "",
},
relationRules: {
shopName: [
{ required: true, message: "请输入店铺名称", trigger: "blur" },
{
min: 1,
max: 100,
message: "店铺名称长度在 1 到 100 个字符",
trigger: "blur",
},
],
customerName: [
{ required: true, message: "请输入客户名称", trigger: "blur" },
{
min: 1,
max: 100,
message: "客户名称长度在 1 到 100 个字符",
trigger: "blur",
},
],
},
};
},
computed: {
dialogTitle() {
return this.isEdit ? "编辑店铺客户关联" : "新增店铺客户关联";
},
uploadUrl() {
return process.env.VUE_APP_BASE_API + "/shop-customer/import";
},
},
created() {
this.fetchRelationList();
},
methods: {
formatDateTime,

// 获取关联列表
async fetchRelationList() {
try {
this.loading = true;
const params = {
page: this.pagination.page,
pageSize: this.pagination.pageSize,
shopName: this.searchForm.shopName,
customerName: this.searchForm.customerName,
};

const response = await getShopCustomerList(params);

if (response.code === 200) {
this.relationList = response.data.list || [];
this.pagination.total = response.data.pagination.total;
} else {
this.$message.error(response.message || "获取关联列表失败");
}
} catch (error) {
console.error("获取关联列表失败:", error);
this.$message.error("获取关联列表失败");
} finally {
this.loading = false;
}
},

// 搜索
handleSearch() {
this.pagination.page = 1;
this.fetchRelationList();
},

// 重置搜索
resetSearch() {
this.searchForm.shopName = "";
this.searchForm.customerName = "";
this.handleSearch();
},

// 分页大小改变
handleSizeChange(val) {
this.pagination.pageSize = val;
this.pagination.page = 1;
this.fetchRelationList();
},

// 当前页改变
handleCurrentChange(val) {
this.pagination.page = val;
this.fetchRelationList();
},

// 表格选择改变
handleSelectionChange(selection) {
this.selectedRows = selection;
},

// 新增关联
handleAdd() {
this.isEdit = false;
this.relationForm = {
id: null,
shopName: "",
customerName: "",
};
this.dialogVisible = true;
},

// 编辑关联
handleEdit(row) {
this.isEdit = true;
this.relationForm = {
id: row.id,
shopName: row.shopName,
customerName: row.customerName,
};
this.dialogVisible = true;
},

// 删除关联
handleDelete(row) {
this.$confirm(
`确定要删除店铺"${row.shopName}"与客户"${row.customerName}"的关联吗?`,
"提示",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
)
.then(async () => {
try {
const response = await deleteShopCustomer(row.id);

if (response.code === 200) {
this.$message.success("删除成功");
this.fetchRelationList();
} else {
this.$message.error(response.message || "删除失败");
}
} catch (error) {
console.error("删除失败:", error);
this.$message.error("删除失败");
}
})
.catch(() => {});
},

// 批量删除
handleBatchDelete() {
if (!this.selectedRows.length) {
this.$message.warning("请选择要删除的数据");
return;
}

const ids = this.selectedRows.map((row) => row.id).join(",");

this.$confirm(
`确定要删除选中的${this.selectedRows.length}条关联吗?`,
"提示",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
)
.then(async () => {
try {
const response = await batchDeleteShopCustomer(ids);

if (response.code === 200) {
this.$message.success("批量删除成功");
this.fetchRelationList();
} else {
this.$message.error(response.message || "批量删除失败");
}
} catch (error) {
console.error("批量删除失败:", error);
this.$message.error("批量删除失败");
}
})
.catch(() => {});
},

// 导出Excel
async handleExport() {
const response = await exportShopCustomer();
const blob = new Blob([response], {
type: "application/vnd.ms-excel",
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `shop-customer-${new Date().toLocaleDateString()}.xlsx`;
a.click();
},

// 上传前验证
beforeUpload(file) {
const isExcel = /\.(xlsx|xls)$/.test(file.name.toLowerCase());
if (!isExcel) {
this.$message.error("只能上传Excel文件!");
return false;
}

const isLt10M = file.size / 1024 / 1024 < 10;
if (!isLt10M) {
this.$message.error("上传文件大小不能超过10MB!");
return false;
}

return true;
},

// 上传成功
onUploadSuccess(response) {
if (response.code === 200) {
const data = response.data || {};
let message = response.message || "导入成功";

// 如果有错误详情,显示详细信息
if (data.errors > 0) {
message += `\n${data.errors}条数据有问题已跳过`;
if (data.errorDetails && data.errorDetails.length > 0) {
this.$alert(
`导入完成!\n\n统计信息:\n- 总共处理:${
data.total
}条\n- 成功导入:${data.imported}条\n- 跳过错误:${
data.errors
}条\n\n错误详情(前10条):\n${data.errorDetails
.slice(0, 10)
.join("\n")}`,
"导入结果",
{
confirmButtonText: "确定",
type: "warning",
}
);
} else {
this.$message.warning(message);
}
} else {
this.$message.success(message);
}

this.fetchRelationList();
} else {
this.$message.error(response.message || "导入失败");
}
},

// 上传失败
onUploadError(error) {
console.error("上传失败:", error);
this.$message.error("文件上传失败");
},

// 提交表单
async handleSubmit() {
try {
await this.$refs.relationForm.validate();

this.submitting = true;

const response = this.isEdit
? await ShopCustomerUpdate(this.relationForm.id, this.relationForm)
: await ShopCustomerAdd(this.relationForm);

if (response.code === 200) {
this.$message.success(this.isEdit ? "编辑成功" : "新增成功");
this.dialogVisible = false;
this.fetchRelationList();
} else {
this.$message.error(response.message || "操作失败");
}
} catch (error) {
if (error.message !== "validation failed") {
console.error("提交失败:", error);
this.$message.error("操作失败");
}
} finally {
this.submitting = false;
}
},

// 对话框关闭
handleDialogClose() {
this.$refs.relationForm.resetFields();
},
},
};
</script>

<style scoped>
.shop-customer-container {
min-height: calc(100vh - 50px);
}

.page-header-container {
display: flex;
align-items: center;
background: #fff;
padding: 15px 20px;
border-radius: 6px;
margin-bottom: 20px;
border: 1px solid #e6ebf5;
}

.header-icon {
font-size: 28px;
margin-right: 15px;
color: #e6a23c;
}

.header-text h1 {
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0;
}

.header-text p {
font-size: 13px;
color: #909399;
margin-top: 4px;
}

.content-body {
display: flex;
flex-direction: column;
gap: 20px;
}

.action-panel,
.data-table-panel {
border-radius: 6px;
border: 1px solid #e6ebf5;
box-shadow: none;
}

.action-panel .el-card__body {
padding: 15px 20px;
}
.data-table-panel .el-card__body {
padding: 20px;
}

.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}

.search-filters {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}

.search-filters .el-input {
width: 200px;
}

.action-buttons {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}

.el-table {
border-radius: 4px;
}

.pagination-container {
padding-top: 20px;
text-align: right;
}

.el-dialog__wrapper >>> .relation-dialog {
border-radius: 8px;
}

.form-tips {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 5px;
}
.form-tips i {
margin-right: 4px;
}

.dialog-footer {
text-align: right;
}
</style>

+ 2
- 2
src/views/system/role/authUser.vue Ver fichero

@@ -86,12 +86,12 @@
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column
label="用户名称"
label="员工编号"
prop="userName"
:show-overflow-tooltip="true"
/>
<el-table-column
label="用户昵称"
label="姓名"
prop="nickName"
:show-overflow-tooltip="true"
/>

+ 2
- 2
src/views/system/role/selectUser.vue Ver fichero

@@ -26,8 +26,8 @@
<el-row>
<el-table @row-click="clickRow" ref="table" :data="userList" @selection-change="handleSelectionChange" height="260px">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column label="用户名称" prop="userName" :show-overflow-tooltip="true" />
<el-table-column label="用户昵称" prop="nickName" :show-overflow-tooltip="true" />
<el-table-column label="员工编号" prop="userName" :show-overflow-tooltip="true" />
<el-table-column label="姓名" prop="nickName" :show-overflow-tooltip="true" />
<el-table-column label="邮箱" prop="email" :show-overflow-tooltip="true" />
<el-table-column label="手机" prop="phonenumber" :show-overflow-tooltip="true" />
<el-table-column label="状态" align="center" prop="status">

+ 2
- 2
src/views/system/user/authRole.vue Ver fichero

@@ -4,12 +4,12 @@
<el-form ref="form" :model="form" label-width="80px">
<el-row>
<el-col :span="8" :offset="2">
<el-form-item label="用户昵称" prop="nickName">
<el-form-item label="姓名" prop="nickName">
<el-input v-model="form.nickName" disabled />
</el-form-item>
</el-col>
<el-col :span="8" :offset="2">
<el-form-item label="登录账号" prop="userName">
<el-form-item label="员工编号" prop="userName">
<el-input v-model="form.userName" disabled />
</el-form-item>
</el-col>

+ 7
- 7
src/views/system/user/index.vue Ver fichero

@@ -171,7 +171,7 @@
<el-table-column
v-if="columns[1].visible"
key="userName"
label="用户名称"
label="员工编号"
align="center"
prop="userName"
:show-overflow-tooltip="true"
@@ -179,7 +179,7 @@
<el-table-column
v-if="columns[2].visible"
key="nickName"
label="用户昵称"
label="姓名"
align="center"
prop="nickName"
:show-overflow-tooltip="true"
@@ -289,10 +289,10 @@
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
<el-row>
<el-col :span="12">
<el-form-item label="用户昵称" prop="nickName">
<el-form-item label="姓名" prop="nickName">
<el-input
v-model="form.nickName"
placeholder="请输入用户昵称"
placeholder="请输入姓名"
maxlength="30"
/>
</el-form-item>
@@ -588,8 +588,8 @@ export default {
// 列信息
columns: [
{ key: 0, label: `用户编号`, visible: true },
{ key: 1, label: `用户名称`, visible: true },
{ key: 2, label: `用户昵称`, visible: true },
{ key: 1, label: `员工编号`, visible: true },
{ key: 2, label: `姓名`, visible: true },
{ key: 3, label: `部门`, visible: true },
{ key: 4, label: `手机号码`, visible: true },
{ key: 5, label: `状态`, visible: true },
@@ -607,7 +607,7 @@ export default {
}
],
nickName: [
{ required: true, message: '用户昵称不能为空', trigger: 'blur' }
{ required: true, message: '姓名不能为空', trigger: 'blur' }
],
password: [
{ required: true, message: '用户密码不能为空', trigger: 'blur' },

+ 2
- 2
src/views/system/user/profile/userInfo.vue Ver fichero

@@ -1,6 +1,6 @@
<template>
<el-form ref="form" :model="user" :rules="rules" label-width="80px">
<el-form-item label="用户昵称" prop="nickName">
<el-form-item label="姓名" prop="nickName">
<el-input v-model="user.nickName" maxlength="30" />
</el-form-item>
<el-form-item label="手机号码" prop="phonenumber">
@@ -36,7 +36,7 @@ export default {
// 表单校验
rules: {
nickName: [
{ required: true, message: "用户昵称不能为空", trigger: "blur" }
{ required: true, message: "姓名不能为空", trigger: "blur" }
],
email: [
{ required: true, message: "邮箱地址不能为空", trigger: "blur" },

Cargando…
Cancelar
Guardar