refactor(shop):优化分销商推荐关系模块

- 移除文章API中的MODULES_API_URL前缀,统一使用相对路径
- 调整MODULES_API_URL配置逻辑,支持本地存储自定义接口地址
-修复生产环境判断逻辑错误,确保自定义接口地址正确加载
- 新增下划线与驼峰命名转换工具函数
- 扩展ShopDealerReferee模型字段,增加推荐人和被推荐人的详细信息
- 新增推荐关系树状图展示组件RefereeTree.vue- 修改推荐关系列表页面布局,优化用户信息展示方式- 移除推荐层级筛选条件及部分冗余操作按钮
- 简化编辑弹窗标题及表单项,移除不必要的字段输入
- 调整表格列配置,优化推荐关系可视化展示效果
- 移除详情查看和解除推荐关系功能,简化操作流程
- 修复分页查询参数默认值处理问题,增强代码健壮性
This commit is contained in:
2025-10-21 20:52:50 +08:00
parent bf38e7e6d9
commit eebd164be4
11 changed files with 356 additions and 172 deletions

View File

@@ -1,14 +1,13 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { CmsArticle, CmsArticleParam } from './model';
import {MODULES_API_URL} from '@/config/setting';
import type {ApiResult, PageResult} from '@/api';
import type {CmsArticle, CmsArticleParam} from './model';
/**
* 分页查询文章
*/
export async function pageCmsArticle(params: CmsArticleParam) {
const res = await request.get<ApiResult<PageResult<CmsArticle>>>(
MODULES_API_URL + '/cms/cms-article/page',
'/cms/cms-article/page',
{
params
}
@@ -24,7 +23,7 @@ export async function pageCmsArticle(params: CmsArticleParam) {
*/
export async function listCmsArticle(params?: CmsArticleParam) {
const res = await request.get<ApiResult<CmsArticle[]>>(
MODULES_API_URL + '/cms/cms-article',
'/cms/cms-article',
{
params
}
@@ -40,7 +39,7 @@ export async function listCmsArticle(params?: CmsArticleParam) {
*/
export async function addCmsArticle(data: CmsArticle) {
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/cms/cms-article',
'/cms/cms-article',
data
);
if (res.data.code === 0) {
@@ -54,7 +53,7 @@ export async function addCmsArticle(data: CmsArticle) {
*/
export async function updateCmsArticle(data: CmsArticle) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/cms/cms-article',
'/cms/cms-article',
data
);
if (res.data.code === 0) {
@@ -68,7 +67,7 @@ export async function updateCmsArticle(data: CmsArticle) {
*/
export async function updateBatchCmsArticle(data: any) {
const res = await request.put<ApiResult<unknown>>(
MODULES_API_URL + '/cms/cms-article/batch',
'/cms/cms-article/batch',
data
);
if (res.data.code === 0) {
@@ -82,7 +81,7 @@ export async function updateBatchCmsArticle(data: any) {
*/
export async function removeCmsArticle(id?: number) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/cms/cms-article/' + id
'/cms/cms-article/' + id
);
if (res.data.code === 0) {
return res.data.message;
@@ -95,7 +94,7 @@ export async function removeCmsArticle(id?: number) {
*/
export async function removeBatchCmsArticle(data: (number | undefined)[]) {
const res = await request.delete<ApiResult<unknown>>(
MODULES_API_URL + '/cms/cms-article/batch',
'/cms/cms-article/batch',
{
data
}
@@ -111,7 +110,7 @@ export async function removeBatchCmsArticle(data: (number | undefined)[]) {
*/
export async function getCmsArticle(id: number) {
const res = await request.get<ApiResult<CmsArticle>>(
MODULES_API_URL + '/cms/cms-article/' + id
'/cms/cms-article/' + id
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
@@ -133,7 +132,7 @@ export async function getByCode(code: string) {
}
export async function getCount(params: CmsArticleParam) {
const res = await request.get(MODULES_API_URL + '/cms/cms-article/data', {
const res = await request.get('/cms/cms-article/data', {
params
});
if (res.data.code === 0) {
@@ -150,7 +149,7 @@ export async function importArticles(file: File) {
const formData = new FormData();
formData.append('file', file);
const res = await request.post<ApiResult<unknown>>(
MODULES_API_URL + '/cms/cms-article/import',
'/cms/cms-article/import',
formData
);
if (res.data.code === 0) {

View File

@@ -148,4 +148,4 @@ export async function importCmsNavigation(file: File) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
}

View File

@@ -8,8 +8,22 @@ export interface ShopDealerReferee {
id?: number;
// 分销商用户ID
dealerId?: number;
// 分销商名称
dealerName?: string;
// 分销商头像
dealerAvatar?: string;
// 分销商手机号
dealerPhone?: string;
// 用户id(被推荐人)
userId?: number;
// 昵称
nickname?: string;
// 头像
avatar?: string;
// 别名
alias?: string;
// 手机号
phone?: string;
// 推荐关系层级(1,2,3)
level?: number;
// 商城ID
@@ -31,4 +45,4 @@ export interface ShopDealerRefereeParam extends PageParam {
startTime?: string;
endTime?: string;
keywords?: string;
}
}

View File

@@ -9,7 +9,7 @@ export const domain = import.meta.env.VITE_DOMAIN || 'https://your-domain.com';
// 主节点
export const SERVER_API_URL = import.meta.env.VITE_SERVER_API_URL || 'https://your-api.com/api';
// 模块节点
export const MODULES_API_URL = import.meta.env.VITE_API_URL;
export const MODULES_API_URL = localStorage.getItem('ApiUrl') || import.meta.env.VITE_API_URL;
// 文件服务器地址
export const FILE_SERVER = import.meta.env.VITE_FILE_SERVER || 'https://your-file-server.com';

View File

@@ -246,7 +246,7 @@ const reload = () => {
});
}
// 检查是否启动自定义接口
if(import.meta.env.PROD){
if(!import.meta.env.PROD){
getCmsWebsiteFieldByCode('ApiUrl').then(res => {
if(res){
localStorage.setItem('ApiUrl', `${res.value}`);

View File

@@ -563,3 +563,31 @@ export const getTokenBySpm = () => {
return `${token}`;
}
};
/**
* 下划线转驼峰命名
*/
export function toCamelCase(str: string): string {
return str.replace(/_([a-z])/g, function (_, letter) {
return letter.toUpperCase();
});
}
/**
* 下划线转大驼峰命名
*/
export function toCamelCaseUpper(str: string): string {
return toCamelCase(str).replace(/^[a-z]/, function (letter) {
return letter.toUpperCase();
});
}
/**
* 转为短下划线
*/
export function toShortUnderline(str: string): string {
return str.replace(/[A-Z]/g, function (letter) {
return '_' + letter.toLowerCase();
}).replace(/^_/, '');
}

View File

@@ -4,7 +4,6 @@
<a-tag>{{ website?.appName }}</a-tag>
</a-descriptions>
<a-descriptions-item label="后台管理">
{{ website.apiUrl }}
<a-tag>https://mp.websoft.top</a-tag>
</a-descriptions-item>
<a-descriptions-item label="API">

View File

@@ -0,0 +1,205 @@
<template>
<a-modal
:visible="visible"
:title="title"
:width="1000"
:footer="null"
@cancel="handleCancel"
>
<div class="tree-container">
<v-chart
ref="chartRef"
class="chart"
:option="chartOption"
:loading="loading"
autoresize
/>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { TreeChart } from 'echarts/charts';
import {
TooltipComponent,
TitleComponent
} from 'echarts/components';
import VChart from 'vue-echarts';
import type { ShopDealerReferee } from '@/api/shop/shopDealerReferee/model';
// 注册 echarts 组件
use([
CanvasRenderer,
TreeChart,
TooltipComponent,
TitleComponent
]);
// 定义组件属性
const props = withDefaults(defineProps<{
visible: boolean;
data?: ShopDealerReferee[];
title?: string;
}>(), {
visible: false,
data: () => [],
title: '推荐关系树'
});
// 定义事件
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'cancel'): void;
}>();
// 图表引用
const chartRef = ref<InstanceType<typeof VChart> | null>(null);
// 加载状态
const loading = ref(false);
// 处理取消事件
const handleCancel = () => {
emit('update:visible', false);
emit('cancel');
};
// 转换数据为树形结构
const transformToTreeData = (data: ShopDealerReferee[]) => {
if (!data || data.length === 0) {
return { name: '暂无数据', children: [] };
}
// 构建节点映射
const nodeMap = new Map<number, any>();
const rootNodes: any[] = [];
// 创建所有节点
data.forEach(item => {
// 推荐人节点
if (item.dealerId && !nodeMap.has(item.dealerId)) {
nodeMap.set(item.dealerId, {
id: item.dealerId,
name: `推荐人\nID:${item.dealerId}\n${item.dealerName || ''}`,
level: 'dealer',
children: []
});
}
// 被推荐人节点
if (item.userId && !nodeMap.has(item.userId)) {
nodeMap.set(item.userId, {
id: item.userId,
name: `被推荐人\nID:${item.userId}\n${item.nickname || ''}`,
level: 'user',
children: []
});
}
});
// 构建关系树
data.forEach(item => {
if (!item.dealerId || !item.userId) return;
const dealerNode = nodeMap.get(item.dealerId);
const userNode = nodeMap.get(item.userId);
if (dealerNode && userNode) {
// 添加层级标签
const levelText = { 1: '一级', 2: '二级', 3: '三级' }[item.level || 0] || `${item.level || 0}`;
userNode.name += `\n${levelText}推荐`;
dealerNode.children.push(userNode);
}
});
// 查找根节点(没有被推荐关系的节点)
const referencedIds = new Set(data.map(item => item.userId).filter(id => id));
data.forEach(item => {
if (item.dealerId && !referencedIds.has(item.dealerId)) {
const rootNode = nodeMap.get(item.dealerId);
if (rootNode) {
rootNodes.push(rootNode);
}
}
});
// 如果没有明确的根节点,使用第一个节点作为根
if (rootNodes.length === 0 && data.length > 0 && data[0].dealerId) {
const firstNode = nodeMap.get(data[0].dealerId);
if (firstNode) {
rootNodes.push(firstNode);
}
}
// 如果还是没有根节点,返回默认节点
if (rootNodes.length === 0) {
return { name: '暂无数据', children: [] };
}
return rootNodes[0];
};
// 图表配置
const chartOption = computed(() => {
const treeData = transformToTreeData(props.data || []);
return {
tooltip: {
trigger: 'item',
triggerOn: 'mousemove'
},
series: [
{
type: 'tree',
data: [treeData],
top: '1%',
left: '7%',
bottom: '1%',
right: '20%',
symbolSize: 12,
symbol: 'circle',
orient: 'LR', // 从左到右
expandAndCollapse: true,
label: {
position: 'left',
verticalAlign: 'middle',
align: 'right',
fontSize: 12,
backgroundColor: '#fff',
padding: [2, 4],
borderRadius: 4,
borderWidth: 1,
borderColor: '#ccc'
},
leaves: {
label: {
position: 'right',
verticalAlign: 'middle',
align: 'left'
}
},
emphasis: {
focus: 'descendant'
},
animationDuration: 500,
animationDurationUpdate: 750
}
]
};
});
</script>
<style lang="less" scoped>
.tree-container {
height: 600px;
width: 100%;
}
.chart {
height: 100%;
width: 100%;
}
</style>

View File

@@ -1,6 +1,5 @@
<!-- 搜索表单 -->
<template>
<div class="search-container">
<!-- 搜索表单 -->
<a-form
:model="searchForm"
@@ -26,19 +25,6 @@
/>
</a-form-item>
<a-form-item label="推荐层级">
<a-select
v-model:value="searchForm.level"
placeholder="全部层级"
allow-clear
style="width: 120px"
>
<a-select-option :value="1">一级推荐</a-select-option>
<a-select-option :value="2">二级推荐</a-select-option>
<a-select-option :value="3">三级推荐</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="建立时间">
<a-range-picker
v-model:value="searchForm.dateRange"
@@ -62,29 +48,28 @@
</a-form>
<!-- 操作按钮 -->
<div class="action-buttons">
<a-space>
<a-button type="primary" @click="add" class="ele-btn-icon">
<template #icon>
<PlusOutlined/>
</template>
建立推荐关系
</a-button>
<a-button @click="viewTree" class="ele-btn-icon">
<template #icon>
<ApartmentOutlined/>
</template>
推荐关系树
</a-button>
<a-button @click="exportData" class="ele-btn-icon">
<template #icon>
<ExportOutlined/>
</template>
导出数据
</a-button>
</a-space>
</div>
</div>
<!-- <div class="action-buttons">-->
<!-- <a-space>-->
<!-- <a-button type="primary" @click="add" class="ele-btn-icon">-->
<!-- <template #icon>-->
<!-- <PlusOutlined/>-->
<!-- </template>-->
<!-- 建立推荐关系-->
<!-- </a-button>-->
<!-- <a-button @click="viewTree" class="ele-btn-icon">-->
<!-- <template #icon>-->
<!-- <ApartmentOutlined/>-->
<!-- </template>-->
<!-- 推荐关系树-->
<!-- </a-button>-->
<!-- <a-button @click="exportData" class="ele-btn-icon">-->
<!-- <template #icon>-->
<!-- <ExportOutlined/>-->
<!-- </template>-->
<!-- 导出数据-->
<!-- </a-button>-->
<!-- </a-space>-->
<!-- </div>-->
</template>
<script lang="ts" setup>

View File

@@ -5,7 +5,7 @@
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '编辑分销商推荐关系表' : '添加分销商推荐关系'"
:title="isUpdate ? '编辑推荐' : '添加推荐关系'"
:body-style="{ paddingBottom: '28px' }"
@update:visible="updateVisible"
@ok="save"
@@ -19,34 +19,20 @@
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
"
>
<a-form-item label="分销商用户ID" name="dealerId">
<a-form-item label="推荐人信息" name="dealerId">
<a-input
allow-clear
placeholder="请输入分销商用户ID"
v-model:value="form.dealerId"
/>
</a-form-item>
<a-form-item label="用户id(被推荐人)" name="userId">
<a-form-item label="被推荐人信息" name="userId">
<a-input
allow-clear
placeholder="请输入用户id(被推荐人)"
v-model:value="form.userId"
/>
</a-form-item>
<a-form-item label="推荐关系层级(1,2,3)" name="level">
<a-input
allow-clear
placeholder="请输入推荐关系层级(1,2,3)"
v-model:value="form.level"
/>
</a-form-item>
<a-form-item label="修改时间" name="updateTime">
<a-input
allow-clear
placeholder="请输入修改时间"
v-model:value="form.updateTime"
/>
</a-form-item>
</a-form>
</ele-modal>
</template>
@@ -54,14 +40,13 @@
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { Form, message } from 'ant-design-vue';
import { assignObject, uuid } from 'ele-admin-pro';
import { assignObject } from 'ele-admin-pro';
import { addShopDealerReferee, updateShopDealerReferee } from '@/api/shop/shopDealerReferee';
import { ShopDealerReferee } from '@/api/shop/shopDealerReferee/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import { FormInstance } from 'ant-design-vue/es/form';
import { FileRecord } from '@/api/system/file/model';
// 是否是修改
const isUpdate = ref(false);
@@ -98,10 +83,7 @@
level: undefined,
tenantId: undefined,
createTime: undefined,
updateTime: undefined,
status: 0,
comments: '',
sortNumber: 100
updateTime: undefined
});
/* 更新visible */
@@ -121,20 +103,6 @@
]
});
const chooseImage = (data: FileRecord) => {
images.value.push({
uid: data.id,
url: data.path,
status: 'done'
});
form.image = data.path;
};
const onDeleteItem = (index: number) => {
images.value.splice(index, 1);
form.image = '';
};
const { resetFields } = useForm(form, rules);
/* 保存编辑 */

View File

@@ -3,7 +3,7 @@
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="shopDealerRefereeId"
row-key="id"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
@@ -21,16 +21,21 @@
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dealerInfo'">
<div class="user-info">
<div class="user-id">ID: {{ record.dealerId }}</div>
<div class="user-id">{{ record.dealerName }}({{ record.dealerId }})</div>
<div class="user-id"></div>
<div class="user-role">
<a-tag color="blue">推荐人</a-tag>
</div>
</div>
</template>
<template v-if="column.key === 'relationship'">
<ArrowRightOutlined />
</template>
<template v-if="column.key === 'userInfo'">
<div class="user-info">
<div class="user-id">ID: {{ record.userId }}</div>
<div class="user-id">{{ record.nickname }}({{ record.userId }})</div>
<div class="user-role">
<a-tag color="green">被推荐人</a-tag>
</div>
@@ -39,10 +44,10 @@
<template v-if="column.key === 'level'">
<a-tag
:color="getLevelColor(record.level)"
:color="getLevelColor(record.level || 0)"
class="level-tag"
>
{{ getLevelText(record.level) }}
{{ getLevelText(record.level || 0) }}
</a-tag>
</template>
@@ -59,23 +64,19 @@
<template v-if="column.key === 'action'">
<a-space>
<a @click="viewDetail(record)" class="ele-text-info">
<EyeOutlined /> 详情
</a>
<a-divider type="vertical" />
<a @click="openEdit(record)" class="ele-text-primary">
<EditOutlined /> 编辑
</a>
<a-divider type="vertical" />
<a-popconfirm
title="确定要解除此推荐关系吗?"
@confirm="remove(record)"
placement="topRight"
>
<a class="ele-text-danger">
<DisconnectOutlined /> 解除
</a>
</a-popconfirm>
<!-- <a-divider type="vertical" />-->
<!-- <a-popconfirm-->
<!-- title="确定要解除此推荐关系吗?"-->
<!-- @confirm="remove(record)"-->
<!-- placement="topRight"-->
<!-- >-->
<!-- <a class="ele-text-danger">-->
<!-- <DisconnectOutlined /> 解除-->
<!-- </a>-->
<!-- </a-popconfirm>-->
</a-space>
</template>
</template>
@@ -84,6 +85,14 @@
<!-- 编辑弹窗 -->
<ShopDealerRefereeEdit v-model:visible="showEdit" :data="current" @done="reload" />
<!-- 树状图弹窗 -->
<RefereeTree
v-model:visible="showTree"
:data="treeData"
:title="'推荐关系树'"
@cancel="showTree = false"
/>
</a-page-header>
</template>
@@ -93,9 +102,8 @@
import {
ExclamationCircleOutlined,
TeamOutlined,
EyeOutlined,
EditOutlined,
DisconnectOutlined
ArrowRightOutlined
} from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
@@ -106,6 +114,7 @@
import Search from './components/search.vue';
import {getPageTitle} from '@/utils/common';
import ShopDealerRefereeEdit from './components/shopDealerRefereeEdit.vue';
import RefereeTree from './components/RefereeTree.vue';
import { pageShopDealerReferee, removeShopDealerReferee, removeBatchShopDealerReferee } from '@/api/shop/shopDealerReferee';
import type { ShopDealerReferee, ShopDealerRefereeParam } from '@/api/shop/shopDealerReferee/model';
@@ -118,8 +127,10 @@
const current = ref<ShopDealerReferee | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示批量移动弹窗
const showMove = ref(false);
// 是否显示树状图弹窗
const showTree = ref(false);
// 树状图数据
const treeData = ref<ShopDealerReferee[]>([]);
// 加载状态
const loading = ref(true);
@@ -150,29 +161,29 @@
align: 'left',
width: 150
},
{
title: '',
key: 'relationship',
align: 'center',
width: 50
},
{
title: '被推荐人信息',
key: 'userInfo',
align: 'left',
width: 150
},
{
title: '推荐层级',
key: 'level',
align: 'center',
width: 120,
filters: [
{ text: '一级推荐', value: 1 },
{ text: '二级推荐', value: 2 },
{ text: '三级推荐', value: 3 }
]
},
{
title: '关系链',
key: 'relationChain',
align: 'center',
width: 120
},
// {
// title: '推荐层级',
// key: 'level',
// align: 'center',
// width: 120,
// filters: [
// { text: '一级推荐', value: 1 },
// { text: '二级推荐', value: 2 },
// { text: '三级推荐', value: 3 }
// ]
// },
{
title: '建立时间',
dataIndex: 'createTime',
@@ -212,30 +223,6 @@
return texts[level] || `${level}级推荐`;
};
/* 查看详情 */
const viewDetail = (record: ShopDealerReferee) => {
Modal.info({
title: '推荐关系详情',
width: 600,
content: createVNode('div', { class: 'referee-detail' }, [
createVNode('div', { class: 'detail-section' }, [
createVNode('h4', null, '推荐关系信息'),
createVNode('p', null, `推荐人ID: ${record.dealerId}`),
createVNode('p', null, `被推荐人ID: ${record.userId}`),
createVNode('p', null, [
'推荐层级: ',
createVNode('span', {
class: 'level-badge',
style: `color: ${getLevelColor(record.level)}; font-weight: bold;`
}, getLevelText(record.level))
]),
createVNode('p', null, `建立时间: ${toDateString(record.createTime, 'yyyy-MM-dd HH:mm:ss')}`),
])
]),
okText: '关闭'
});
};
/* 查看关系链 */
const viewRelationChain = (record: ShopDealerReferee) => {
// 这里可以调用API获取完整的推荐关系链
@@ -253,7 +240,7 @@
createVNode('div', { class: 'chain-node user' }, [
createVNode('div', { class: 'node-title' }, '被推荐人'),
createVNode('div', { class: 'node-id' }, `用户ID: ${record.userId}`),
createVNode('div', { class: 'node-level' }, getLevelText(record.level))
createVNode('div', { class: 'node-level' }, getLevelText(record.level || 0))
])
]),
createVNode('div', { class: 'chain-info' }, [
@@ -267,14 +254,18 @@
/* 查看推荐树 */
const viewRefereeTree = () => {
Modal.info({
title: '推荐关系树',
width: 1000,
content: createVNode('div', null, [
createVNode('p', null, '推荐关系树功能开发中,将展示完整的推荐网络结构')
]),
okText: '关闭'
});
// 加载所有数据用于树状图展示
loading.value = true;
pageShopDealerReferee({ page: 1, limit: 10000 }) // 获取所有数据
.then((result) => {
treeData.value = result?.list || [];
showTree.value = true;
loading.value = false;
})
.catch((e) => {
loading.value = false;
message.error('加载数据失败: ' + e.message);
});
};
/* 导出数据 */
@@ -299,15 +290,10 @@
showEdit.value = true;
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
/* 删除单个 */
const remove = (row: ShopDealerReferee) => {
const hide = message.loading('请求中..', 0);
removeShopDealerReferee(row.shopDealerRefereeId)
removeShopDealerReferee(row.id)
.then((msg) => {
hide();
message.success(msg);
@@ -332,7 +318,7 @@
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchShopDealerReferee(selection.value.map((d) => d.shopDealerRefereeId))
removeBatchShopDealerReferee(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);