Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2d52ba4cc | ||
|
|
d8ad6cde20 | ||
|
|
49900919c6 | ||
|
|
5cbca4ba76 |
@@ -8,11 +8,8 @@ ENV = 'development'
|
||||
# VUE_APP_BASE_API = 'http://jfanyueadmin.szxingming.com'
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.wenjinhui.com'
|
||||
# VUE_APP_BASE_API = 'http://jfadmin-bsy.bosenyuan.com'
|
||||
# sxsy80 项目(太原树英商贸)
|
||||
VUE_APP_BASE_API = 'https://sxsy-jf.cichude.com'
|
||||
|
||||
# czcf82 项目(池州春芳商贸)
|
||||
# VUE_APP_BASE_API = 'https://czcf-jf.uj345.com'
|
||||
# byjyw149 项目(宝应金雅文商贸)
|
||||
VUE_APP_BASE_API = 'https://jf.jinyawen.com'
|
||||
|
||||
# hapr191 项目(淮安鹏然商贸)
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.hapengran.com'
|
||||
|
||||
@@ -8,11 +8,8 @@ ENV = 'production'
|
||||
# miao33 项目
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.xiashengjun.com'
|
||||
|
||||
# sxsy80 项目(太原树英商贸)
|
||||
VUE_APP_BASE_API = 'https://sxsy-jf.cichude.com'
|
||||
|
||||
# czcf82 项目(池州春芳商贸)
|
||||
# VUE_APP_BASE_API = 'https://czcf-jf.uj345.com'
|
||||
# byjyw149 项目(宝应金雅文商贸)
|
||||
VUE_APP_BASE_API = 'https://jf.jinyawen.com'
|
||||
|
||||
# hapr191 项目(淮安鹏然商贸)
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.hapengran.com'
|
||||
|
||||
@@ -39,3 +39,59 @@ export function getExternalIntegralLog(data) {
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 寄卖订单 列表(免认证)
|
||||
*/
|
||||
export function getExternalWaOrderList(params) {
|
||||
return requestNoAuth({
|
||||
url: 'external/integral/wa-order/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 寄卖订单 详情(免认证)
|
||||
*/
|
||||
export function getExternalWaOrderInfo(id) {
|
||||
return requestNoAuth({
|
||||
url: 'external/integral/wa-order/info',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队每日对账报表(免认证)
|
||||
*/
|
||||
export function getExternalTeamDailyReport(params) {
|
||||
return requestNoAuth({
|
||||
url: 'external/integral/team-report/daily',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队每日对账报表 - Excel 导出(免认证)
|
||||
*/
|
||||
export function exportExternalTeamDailyReport(params) {
|
||||
return requestNoAuth({
|
||||
url: 'external/integral/team-report/daily/export',
|
||||
method: 'get',
|
||||
params,
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表(免认证)
|
||||
*/
|
||||
export function getExternalGrabUserList(params) {
|
||||
return requestNoAuth({
|
||||
url: 'external/integral/grab-user/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,24 @@ const integralExternalRouter = {
|
||||
name: 'IntegralExternalUserDetail',
|
||||
meta: { title: '用户积分明细' },
|
||||
},
|
||||
{
|
||||
path: 'wa-order',
|
||||
component: () => import('@/views/integral-external/wa-order/index'),
|
||||
name: 'IntegralExternalWaOrder',
|
||||
meta: { title: '寄卖订单' },
|
||||
},
|
||||
{
|
||||
path: 'team-report',
|
||||
component: () => import('@/views/integral-external/team-report/index'),
|
||||
name: 'IntegralExternalTeamReport',
|
||||
meta: { title: '团队日报' },
|
||||
},
|
||||
{
|
||||
path: 'grab-user',
|
||||
component: () => import('@/views/integral-external/grab-user/index'),
|
||||
name: 'IntegralExternalGrabUser',
|
||||
meta: { title: '今日抢单用户' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ service.interceptors.request.use(
|
||||
// 响应拦截器 — 不拦截 401 跳转
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
// Blob / arraybuffer 等二进制响应直接透传,不做业务码拆包
|
||||
const responseType = response.config && response.config.responseType;
|
||||
if (responseType === 'blob' || responseType === 'arraybuffer') {
|
||||
return response.data;
|
||||
}
|
||||
const res = response.data;
|
||||
if (res.code !== 0 && res.code !== 200) {
|
||||
Message({
|
||||
|
||||
@@ -121,6 +121,27 @@ export default {
|
||||
url: '/integral-external/user/integral-detail',
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
bgColor: '#F56C6C',
|
||||
icon: 'icondingdanguanli',
|
||||
title: '寄卖订单',
|
||||
url: '/integral-external/wa-order',
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
bgColor: '#67C23A',
|
||||
icon: 'iconfenxiaoguanli',
|
||||
title: '团队日报',
|
||||
url: '/integral-external/team-report',
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
bgColor: '#E6A23C',
|
||||
icon: 'iconhuiyuanguanli',
|
||||
title: '今日抢单用户',
|
||||
url: '/integral-external/grab-user',
|
||||
alwaysShow: true,
|
||||
},
|
||||
],
|
||||
statisticData: [
|
||||
{ title: '待发货订单', num: 0, path: '/order/index', perms: ['admin:order:list'] },
|
||||
|
||||
297
backend-adminend/src/views/integral-external/grab-user/index.vue
Normal file
297
backend-adminend/src/views/integral-external/grab-user/index.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="divBox relative">
|
||||
<!-- 顶部搜索区 -->
|
||||
<el-card class="box-card">
|
||||
<div class="container">
|
||||
<el-form
|
||||
ref="searchForm"
|
||||
:model="tableFrom"
|
||||
inline
|
||||
size="small"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="用户ID:">
|
||||
<el-input
|
||||
v-model="tableFrom.uid"
|
||||
placeholder="请输入用户ID"
|
||||
class="selWidth"
|
||||
clearable
|
||||
@keyup.enter.native="seachList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系方式:">
|
||||
<el-input
|
||||
v-model="tableFrom.mobile"
|
||||
placeholder="请输入手机号 / 账号"
|
||||
class="selWidth"
|
||||
clearable
|
||||
@keyup.enter.native="seachList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上级ID:">
|
||||
<el-input
|
||||
v-model="tableFrom.pid"
|
||||
placeholder="请输入上级ID"
|
||||
class="selWidth"
|
||||
clearable
|
||||
@keyup.enter.native="seachList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" @click="seachList">查询</el-button>
|
||||
<el-button icon="el-icon-refresh-left" @click="resetHandler">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 主表 -->
|
||||
<el-card class="box-card mt10">
|
||||
<div class="header-actions">
|
||||
<el-button size="mini" icon="el-icon-refresh" circle @click="getList" />
|
||||
<el-button size="mini" icon="el-icon-upload2" circle @click="exportCsv" />
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="listLoading"
|
||||
:data="tableData.data"
|
||||
size="mini"
|
||||
class="table"
|
||||
highlight-current-row
|
||||
:header-cell-style="{ fontWeight: 'bold' }"
|
||||
>
|
||||
<el-table-column prop="id" label="用户ID" min-width="80" />
|
||||
<el-table-column prop="username" label="账号" min-width="130">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.username || scope.row.mobile || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nickname" label="昵称" min-width="100" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.nickname || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="昨日卖/今日买" min-width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.prevSellCnt || 0 }}/{{ scope.row.todayBuyCnt || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="合同" min-width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-link
|
||||
v-if="scope.row.contract"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openContract(scope.row.contract)"
|
||||
>查看</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mobile" label="联系方式" min-width="130" />
|
||||
<el-table-column label="上级ID" min-width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.pid || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最高可抢单数" min-width="110" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.maxOrder ? scope.row.maxOrder : '未设置' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="todayBuyAmount" label="今日购买总金额" min-width="130" align="right" />
|
||||
<el-table-column prop="todaySellAmount" label="今日卖出总金额" min-width="130" align="right" />
|
||||
<el-table-column label="用户等级" min-width="100" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span :style="{ color: levelColor(scope.row.level) }">
|
||||
{{ scope.row.levelName || '普通用户' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="money" label="余额" min-width="100" align="right" />
|
||||
<el-table-column prop="coupon" label="优惠券" min-width="100" align="right" />
|
||||
<el-table-column prop="selfBonus" label="个人奖金" min-width="100" align="right" />
|
||||
<el-table-column prop="shareBonus" label="推广奖金" min-width="100" align="right" />
|
||||
<el-table-column label="状态" min-width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'" size="mini">
|
||||
{{ scope.row.statusStr || (scope.row.status === 1 ? '正常' : '禁用') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="更新时间" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="操作" min-width="160" fixed="right" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="small" @click="onEditPid(scope.row)">修改上级</el-button>
|
||||
<el-button type="text" size="small" @click="onContractRenew(scope.row)">合同重签</el-button>
|
||||
<el-button type="text" size="small" @click="onEdit(scope.row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="block mt20">
|
||||
<el-pagination
|
||||
:page-sizes="[15, 30, 45, 60]"
|
||||
:page-size="tableFrom.limit"
|
||||
:current-page="tableFrom.page"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="tableData.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 写操作提示对话框(外部页面默认不直接修改资金类字段) -->
|
||||
<el-dialog title="提示" :visible.sync="writeTipVisible" width="380px" append-to-body>
|
||||
<span>{{ writeTipText }}</span>
|
||||
<span slot="footer">
|
||||
<el-button type="primary" size="small" @click="writeTipVisible = false">知道了</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getExternalGrabUserList } from '@/api/integralExternal';
|
||||
|
||||
export default {
|
||||
name: 'IntegralExternalGrabUser',
|
||||
data() {
|
||||
return {
|
||||
listLoading: false,
|
||||
tableData: { data: [], total: 0 },
|
||||
tableFrom: {
|
||||
uid: '',
|
||||
mobile: '',
|
||||
pid: '',
|
||||
page: 1,
|
||||
limit: 15,
|
||||
},
|
||||
writeTipVisible: false,
|
||||
writeTipText: '',
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.getList();
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
this.listLoading = true;
|
||||
const params = { ...this.tableFrom };
|
||||
// 空字段不发送
|
||||
Object.keys(params).forEach((k) => {
|
||||
if (params[k] === '' || params[k] === null) delete params[k];
|
||||
});
|
||||
getExternalGrabUserList(params)
|
||||
.then((res) => {
|
||||
this.tableData.data = res.list || [];
|
||||
this.tableData.total = res.total || 0;
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.listLoading = false;
|
||||
});
|
||||
},
|
||||
seachList() {
|
||||
this.tableFrom.page = 1;
|
||||
this.getList();
|
||||
},
|
||||
resetHandler() {
|
||||
this.tableFrom = { uid: '', mobile: '', pid: '', page: 1, limit: 15 };
|
||||
this.getList();
|
||||
},
|
||||
handleSizeChange(val) {
|
||||
this.tableFrom.limit = val;
|
||||
this.getList();
|
||||
},
|
||||
handleCurrentChange(val) {
|
||||
this.tableFrom.page = val;
|
||||
this.getList();
|
||||
},
|
||||
openContract(url) {
|
||||
if (!url) return;
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
levelColor(level) {
|
||||
switch (level) {
|
||||
case 2:
|
||||
return '#E6A23C';
|
||||
case 3:
|
||||
return '#67C23A';
|
||||
default:
|
||||
return '#409EFF';
|
||||
}
|
||||
},
|
||||
onEditPid() {
|
||||
this.writeTipText = '请在管理后台「用户管理」中修改上级;本页面不支持写操作。';
|
||||
this.writeTipVisible = true;
|
||||
},
|
||||
onContractRenew() {
|
||||
this.writeTipText = '请在管理后台「用户管理」中进行合同重签;本页面不支持写操作。';
|
||||
this.writeTipVisible = true;
|
||||
},
|
||||
onEdit() {
|
||||
this.writeTipText = '请在管理后台「用户管理」中编辑;本页面不支持写操作。';
|
||||
this.writeTipVisible = true;
|
||||
},
|
||||
/**
|
||||
* 简易 CSV 导出(仅当前页)
|
||||
* 生产环境如需全量导出建议改为后端 /grab-user/list/export
|
||||
*/
|
||||
exportCsv() {
|
||||
const rows = this.tableData.data || [];
|
||||
if (rows.length === 0) {
|
||||
this.$message.info('当前页没有数据可导出');
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
'用户ID', '账号', '昵称', '昨日卖/今日买', '联系方式', '上级ID',
|
||||
'最高可抢单数', '今日购买总金额', '今日卖出总金额', '用户等级',
|
||||
'余额', '优惠券', '个人奖金', '推广奖金', '状态', '更新时间',
|
||||
];
|
||||
const lines = [headers.join(',')];
|
||||
rows.forEach((r) => {
|
||||
const fields = [
|
||||
r.id,
|
||||
r.username || r.mobile || '',
|
||||
r.nickname || '',
|
||||
`${r.prevSellCnt || 0}/${r.todayBuyCnt || 0}`,
|
||||
r.mobile || '',
|
||||
r.pid || '',
|
||||
r.maxOrder || '',
|
||||
r.todayBuyAmount || '0',
|
||||
r.todaySellAmount || '0',
|
||||
r.levelName || '普通用户',
|
||||
r.money || '0',
|
||||
r.coupon || '0',
|
||||
r.selfBonus || '0',
|
||||
r.shareBonus || '0',
|
||||
r.statusStr || (r.status === 1 ? '正常' : '禁用'),
|
||||
r.updatedAt || '',
|
||||
].map((v) => `"${String(v).replace(/"/g, '""')}"`);
|
||||
lines.push(fields.join(','));
|
||||
});
|
||||
const blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `今日抢单用户_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mt10 { margin-top: 10px; }
|
||||
.mt20 { margin-top: 20px; }
|
||||
.selWidth { width: 220px; }
|
||||
.header-actions {
|
||||
text-align: right;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.block { text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<div class="divBox relative">
|
||||
<!-- 条件区 -->
|
||||
<el-card class="box-card">
|
||||
<el-form
|
||||
ref="searchForm"
|
||||
:model="tableFrom"
|
||||
inline
|
||||
size="small"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="团队长 ID:">
|
||||
<el-input
|
||||
v-model.number="tableFrom.leaderId"
|
||||
placeholder="留空=全部"
|
||||
class="leaderIdWidth"
|
||||
clearable
|
||||
@keyup.enter.native="seachList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期 D:">
|
||||
<el-date-picker
|
||||
v-model="tableFrom.date"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
placeholder="缺省=昨天"
|
||||
class="dateWidth"
|
||||
:picker-options="pickerOptions"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="含禁用成员:">
|
||||
<el-switch v-model="tableFrom.includeDisabled" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" @click="seachList">查询</el-button>
|
||||
<el-button icon="el-icon-refresh-left" @click="resetHandler">重置</el-button>
|
||||
<el-button
|
||||
icon="el-icon-download"
|
||||
:disabled="!tableFrom.leaderId"
|
||||
@click="exportExcel"
|
||||
>导出 Excel</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div v-if="!tableFrom.leaderId" class="hint">
|
||||
Tips: 团队长 ID 留空时返回所有团队的日报,导出 Excel 仅支持单团队。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 跨团队总计(仅在多团队模式下显示) -->
|
||||
<el-card v-if="report && report.teams && report.teams.length > 1" class="box-card mt10">
|
||||
<div class="report-header">
|
||||
<span class="title">
|
||||
全部团队总计 · {{ report.teamCount }} 个团队 · 共 {{ report.totalMemberCount }} 人 · {{ report.date }}
|
||||
</span>
|
||||
<span class="rate-info">
|
||||
服务费率 {{ report.serviceRate }} · E 积分率 {{ report.eScoreRate }}
|
||||
</span>
|
||||
</div>
|
||||
<el-row :gutter="16" class="summary-row">
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ report.previousDate }} 买单合计</div>
|
||||
<div class="summary-value">{{ formatNum(report.grandSummary.prevBuy) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ report.date }} 卖单合计</div>
|
||||
<div class="summary-value">{{ formatNum(report.grandSummary.todaySell) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ report.date }} 买单合计</div>
|
||||
<div class="summary-value">{{ formatNum(report.grandSummary.todayBuy) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">实际收付合计</div>
|
||||
<div
|
||||
class="summary-value"
|
||||
:class="actualClass(report.grandSummary.actual)"
|
||||
>{{ formatNum(report.grandSummary.actual) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 各团队子报表 -->
|
||||
<template v-if="report && report.teams && report.teams.length">
|
||||
<el-card
|
||||
v-for="team in report.teams"
|
||||
:key="team.leaderId"
|
||||
class="box-card mt10 team-card"
|
||||
>
|
||||
<div class="report-header">
|
||||
<span class="title">
|
||||
团队长 {{ team.leaderNickname || team.teamCode || '-' }}
|
||||
· {{ team.memberCount }} 人 · {{ team.date }}
|
||||
</span>
|
||||
<el-button
|
||||
v-if="!tableFrom.leaderId"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-download"
|
||||
@click="exportTeamExcel(team)"
|
||||
>导出该团队</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 团队级 4 卡片摘要(仅在单团队模式 / 想看每个团队也可以看;保留显示) -->
|
||||
<el-row v-if="report.teams.length === 1" :gutter="16" class="summary-row">
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ team.previousDate }} 买单合计</div>
|
||||
<div class="summary-value">{{ formatNum(team.summary.prevBuy) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ team.date }} 卖单合计</div>
|
||||
<div class="summary-value">{{ formatNum(team.summary.todaySell) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ team.date }} 买单合计</div>
|
||||
<div class="summary-value">{{ formatNum(team.summary.todayBuy) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">实际收付合计</div>
|
||||
<div
|
||||
class="summary-value"
|
||||
:class="actualClass(team.summary.actual)"
|
||||
>{{ formatNum(team.summary.actual) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table
|
||||
:data="team.rows"
|
||||
size="mini"
|
||||
class="table"
|
||||
border
|
||||
highlight-current-row
|
||||
:header-cell-style="headerCellStyle"
|
||||
:show-summary="true"
|
||||
:summary-method="(meta) => getTeamSummaries(meta, team)"
|
||||
>
|
||||
<el-table-column label="昵称" prop="nickname" min-width="120">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.nickname || '-' }}</span>
|
||||
<el-tag v-if="scope.row.status === 0" size="mini" type="info" style="margin-left: 4px">已禁用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="prevBuyLabel(team)" prop="prevBuy" min-width="110" align="right">
|
||||
<template slot-scope="scope">{{ formatNum(scope.row.prevBuy) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="todaySellLabel(team)" prop="todaySell" min-width="110" align="right">
|
||||
<template slot-scope="scope">{{ formatNum(scope.row.todaySell) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="todayBuyLabel(team)" prop="todayBuy" min-width="110" align="right">
|
||||
<template slot-scope="scope">{{ formatNum(scope.row.todayBuy) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="serviceFeeLabel(team)" prop="serviceFee" min-width="120" align="right">
|
||||
<template slot-scope="scope">{{ formatNum(scope.row.serviceFee) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="E积分" prop="eScore" min-width="90" align="right">
|
||||
<template slot-scope="scope">{{ formatNum(scope.row.eScore) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实际收付" prop="actual" min-width="110" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span :class="actualClass(scope.row.actual)">{{ formatNum(scope.row.actual) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="团队" min-width="120" align="center">
|
||||
<template>
|
||||
<span>{{ team.leaderNickname || team.teamCode || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="180">
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-model="scope.row.remark"
|
||||
size="mini"
|
||||
placeholder="可填写(仅当前会话有效)"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!team.rows || team.rows.length === 0" class="empty-tip">该团队当日无成员数据</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-card v-else-if="report" class="box-card mt10">
|
||||
<div class="empty-tip">未查询到任何团队数据。</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { saveAs } from 'file-saver';
|
||||
import {
|
||||
getExternalTeamDailyReport,
|
||||
exportExternalTeamDailyReport,
|
||||
} from '@/api/integralExternal';
|
||||
|
||||
export default {
|
||||
name: 'IntegralExternalTeamReport',
|
||||
data() {
|
||||
return {
|
||||
listLoading: false,
|
||||
report: null,
|
||||
tableFrom: {
|
||||
leaderId: '',
|
||||
date: this.yesterdayStr(),
|
||||
includeDisabled: false,
|
||||
},
|
||||
pickerOptions: {
|
||||
disabledDate(t) {
|
||||
return t.getTime() > Date.now();
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
// 进入页面默认拉取所有团队(不传 leaderId)
|
||||
this.seachList();
|
||||
},
|
||||
methods: {
|
||||
yesterdayStr() {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1);
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
},
|
||||
headerCellStyle() {
|
||||
return {
|
||||
background: '#C6EFCE',
|
||||
color: '#000',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
};
|
||||
},
|
||||
seachList() {
|
||||
this.listLoading = true;
|
||||
const params = {
|
||||
date: this.tableFrom.date,
|
||||
includeDisabled: this.tableFrom.includeDisabled,
|
||||
};
|
||||
if (this.tableFrom.leaderId) {
|
||||
params.leaderId = this.tableFrom.leaderId;
|
||||
}
|
||||
getExternalTeamDailyReport(params)
|
||||
.then((res) => {
|
||||
this.report = res || null;
|
||||
})
|
||||
.catch(() => {
|
||||
this.report = null;
|
||||
})
|
||||
.finally(() => {
|
||||
this.listLoading = false;
|
||||
});
|
||||
},
|
||||
resetHandler() {
|
||||
this.tableFrom = {
|
||||
leaderId: '',
|
||||
date: this.yesterdayStr(),
|
||||
includeDisabled: false,
|
||||
};
|
||||
this.seachList();
|
||||
},
|
||||
formatNum(v) {
|
||||
if (v === null || v === undefined || v === '') return '0';
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return v;
|
||||
const sign = n < 0 ? '-' : '';
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(2);
|
||||
const [intPart, decPart] = fixed.split('.');
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return `${sign}${withSep}.${decPart}`;
|
||||
},
|
||||
actualClass(v) {
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return '';
|
||||
if (n < 0) return 'text-danger';
|
||||
if (n === 0) return 'text-muted';
|
||||
return '';
|
||||
},
|
||||
prevBuyLabel(team) {
|
||||
return team && team.previousDate ? `${team.previousDate.slice(5)} 买单` : 'D-1 买单';
|
||||
},
|
||||
todaySellLabel(team) {
|
||||
return team && team.date ? `${team.date.slice(5)} 卖单` : 'D 卖单';
|
||||
},
|
||||
todayBuyLabel(team) {
|
||||
return team && team.date ? `${team.date.slice(5)} 买单` : 'D 买单';
|
||||
},
|
||||
serviceFeeLabel(team) {
|
||||
return team && team.serviceRate
|
||||
? `服务费*${team.serviceRate}`
|
||||
: `服务费*${this.report ? this.report.serviceRate : '0.02'}`;
|
||||
},
|
||||
/** 团队子表的小计行(直接取 team.summary,不在前端逐行累加) */
|
||||
getTeamSummaries({ columns }, team) {
|
||||
const result = new Array(columns.length).fill('');
|
||||
if (!team || !team.summary) return result;
|
||||
const s = team.summary;
|
||||
const map = {
|
||||
nickname: '小计',
|
||||
prevBuy: s.prevBuy,
|
||||
todaySell: s.todaySell,
|
||||
todayBuy: s.todayBuy,
|
||||
serviceFee:s.serviceFee,
|
||||
eScore: s.eScore,
|
||||
actual: s.actual,
|
||||
};
|
||||
columns.forEach((col, idx) => {
|
||||
const v = map[col.property];
|
||||
if (v !== undefined && v !== null) {
|
||||
result[idx] = col.property === 'nickname' ? v : this.formatNum(v);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
},
|
||||
/** 工具栏里的"导出 Excel"按钮:使用当前 tableFrom 的 leaderId */
|
||||
exportExcel() {
|
||||
if (!this.tableFrom.leaderId) {
|
||||
this.$message.warning('Excel 导出需要指定团队长 ID');
|
||||
return;
|
||||
}
|
||||
this.doExport(this.tableFrom.leaderId, '');
|
||||
},
|
||||
/** 多团队卡片右上角的"导出该团队"按钮 */
|
||||
exportTeamExcel(team) {
|
||||
if (!team || !team.leaderId) return;
|
||||
this.doExport(team.leaderId, team.leaderNickname || team.teamCode || '');
|
||||
},
|
||||
doExport(leaderId, nickname) {
|
||||
this.listLoading = true;
|
||||
exportExternalTeamDailyReport({
|
||||
leaderId,
|
||||
date: this.tableFrom.date,
|
||||
includeDisabled: this.tableFrom.includeDisabled,
|
||||
})
|
||||
.then((blob) => {
|
||||
const file = blob instanceof Blob ? blob : new Blob([blob], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
const date = this.tableFrom.date || this.yesterdayStr();
|
||||
const name = nickname || `leader${leaderId}`;
|
||||
saveAs(file, `团队${name}_${date}.xlsx`);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.listLoading = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mt10 { margin-top: 10px; }
|
||||
.selWidth { width: 240px; }
|
||||
.leaderIdWidth { width: 100px; }
|
||||
.dateWidth { width: 160px; }
|
||||
.hint {
|
||||
margin-top: 4px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.team-card {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.rate-info {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.summary-row {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.summary-card {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.summary-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.summary-value {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
.text-danger { color: #C00000; font-weight: bold; }
|
||||
.text-muted { color: #909399; }
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
padding: 30px 0;
|
||||
}
|
||||
</style>
|
||||
747
backend-adminend/src/views/integral-external/wa-order/index.vue
Normal file
747
backend-adminend/src/views/integral-external/wa-order/index.vue
Normal file
@@ -0,0 +1,747 @@
|
||||
<template>
|
||||
<div class="divBox relative">
|
||||
<!-- 顶部搜索区 -->
|
||||
<el-card class="box-card">
|
||||
<el-form size="small" inline label-width="100px">
|
||||
<el-form-item label="订单状态:">
|
||||
<el-radio-group v-model="uiStatus" type="button" size="mini" @change="onStatusChange">
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="unPaid">待付款</el-radio-button>
|
||||
<el-radio-button label="paid">已支付</el-radio-button>
|
||||
<el-radio-button label="complete">交易完成</el-radio-button>
|
||||
<el-radio-button label="cancel">已取消</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="时间字段:">
|
||||
<el-select
|
||||
v-model="timeField"
|
||||
placeholder="选择时间维度"
|
||||
style="width: 140px"
|
||||
size="mini"
|
||||
@change="seachList"
|
||||
>
|
||||
<el-option label="抢购时间" value="buyTime" />
|
||||
<el-option label="支付时间" value="payTime" />
|
||||
<el-option label="完成时间" value="confirmTime" />
|
||||
<el-option label="创建时间" value="createdAt" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期范围:">
|
||||
<el-date-picker
|
||||
v-model="timeVal"
|
||||
value-format="yyyy-MM-dd"
|
||||
format="yyyy-MM-dd"
|
||||
size="mini"
|
||||
type="daterange"
|
||||
placeholder="自定义时间"
|
||||
style="width: 220px"
|
||||
@change="onTimeChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号:">
|
||||
<el-input
|
||||
v-model="tableFrom.orderSn"
|
||||
placeholder="请输入订单号"
|
||||
clearable
|
||||
class="selWidth"
|
||||
size="mini"
|
||||
@keyup.enter.native="seachList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="买家ID:">
|
||||
<el-input
|
||||
v-model.number="tableFrom.buyerId"
|
||||
placeholder="买家用户ID"
|
||||
clearable
|
||||
class="selWidth"
|
||||
size="mini"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="卖家ID:">
|
||||
<el-input
|
||||
v-model.number="tableFrom.sellerId"
|
||||
placeholder="卖家ID(0=平台)"
|
||||
clearable
|
||||
class="selWidth"
|
||||
size="mini"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否转拍:">
|
||||
<el-select
|
||||
v-model="tableFrom.isResell"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
size="mini"
|
||||
@change="seachList"
|
||||
>
|
||||
<el-option :value="1" label="是" />
|
||||
<el-option :value="0" label="否" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="seachList">查询</el-button>
|
||||
<el-button icon="el-icon-refresh-left" size="mini" @click="resetHandler">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作按钮区 -->
|
||||
<el-card class="box-card mt10">
|
||||
<div class="header-actions">
|
||||
<el-popover
|
||||
placement="bottom-end"
|
||||
width="300"
|
||||
trigger="click"
|
||||
v-model="columnPopoverVisible"
|
||||
>
|
||||
<div class="column-setting">
|
||||
<div class="column-setting-actions">
|
||||
<el-button size="mini" type="text" @click="selectAllColumns">全选</el-button>
|
||||
<el-button size="mini" type="text" @click="invertColumns">反选</el-button>
|
||||
<el-button size="mini" type="text" @click="resetColumns">恢复默认</el-button>
|
||||
</div>
|
||||
<el-checkbox-group v-model="visibleColumns" class="column-checkbox-group">
|
||||
<el-checkbox
|
||||
v-for="col in columnsConfig"
|
||||
:key="col.prop"
|
||||
:label="col.prop"
|
||||
:disabled="col.fixed"
|
||||
>{{ col.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
<el-button slot="reference" size="mini" icon="el-icon-setting">列设置</el-button>
|
||||
</el-popover>
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="getList" />
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="listLoading"
|
||||
:data="tableData.data"
|
||||
size="mini"
|
||||
class="table"
|
||||
highlight-current-row
|
||||
:header-cell-style="{ fontWeight: 'bold' }"
|
||||
>
|
||||
<template v-for="col in columnsConfig">
|
||||
<el-table-column
|
||||
v-if="col.prop !== 'actions' && visibleColumns.includes(col.prop)"
|
||||
:key="col.prop"
|
||||
:label="col.label"
|
||||
:min-width="col.width"
|
||||
:show-overflow-tooltip="col.tooltip"
|
||||
:align="col.align || 'left'"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<!-- 商品图片单元格使用 el-image,其他文本走 formatCell -->
|
||||
<el-image
|
||||
v-if="col.prop === 'merchandiseImage' && scope.row.merchandiseImage"
|
||||
:src="scope.row.merchandiseImage"
|
||||
:preview-src-list="[scope.row.merchandiseImage]"
|
||||
style="width:48px;height:48px;border-radius:4px"
|
||||
fit="cover"
|
||||
/>
|
||||
<span v-else-if="col.prop === 'merchandiseImage'">-</span>
|
||||
<span v-else v-html="formatCell(col.prop, scope.row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
<el-table-column label="操作" min-width="120" fixed="right" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="small" @click="openDetail(scope.row.id)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="block mt20">
|
||||
<el-pagination
|
||||
:page-sizes="[15, 30, 45, 60]"
|
||||
:page-size="tableFrom.limit"
|
||||
:current-page="tableFrom.page"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="tableData.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer
|
||||
:visible.sync="drawerVisible"
|
||||
direction="rtl"
|
||||
size="680px"
|
||||
:destroy-on-close="true"
|
||||
:with-header="false"
|
||||
append-to-body
|
||||
custom-class="wa-order-drawer"
|
||||
@close="onDrawerClose"
|
||||
>
|
||||
<div v-loading="detailLoading" class="drawer-body">
|
||||
<!-- 顶部摘要:订单号 + 状态徽标 + 关闭 -->
|
||||
<div class="drawer-header">
|
||||
<div class="header-left">
|
||||
<div class="order-sn-row">
|
||||
<span class="order-sn-label">订单号</span>
|
||||
<span class="order-sn">{{ detail ? detail.orderSn : '-' }}</span>
|
||||
<el-button
|
||||
v-if="detail"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-document-copy"
|
||||
@click="copyOrderSn"
|
||||
>复制</el-button>
|
||||
</div>
|
||||
<div class="tag-row">
|
||||
<el-tag v-if="detail" :type="statusTagType(detail)" size="small">{{ statusLabel(detail) }}</el-tag>
|
||||
<el-tag v-if="detail && detail.isResell === 1" type="warning" size="small" effect="plain">转拍</el-tag>
|
||||
<el-tag v-if="detail && detail.isCancel === 1" type="danger" size="small" effect="plain">已取消</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
class="close-btn"
|
||||
icon="el-icon-close"
|
||||
type="text"
|
||||
@click="drawerVisible = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="detail" class="drawer-content">
|
||||
<!-- 时间链路 -->
|
||||
<el-card shadow="never" class="block-card">
|
||||
<div slot="header" class="block-title">订单时间链路</div>
|
||||
<el-steps :active="stepActive(detail)" finish-status="success" align-center>
|
||||
<el-step title="抢购下单" :description="detail.buyTime || '-'" />
|
||||
<el-step
|
||||
v-if="detail.isResell === 1"
|
||||
title="转拍生成"
|
||||
:description="detail.createdAt || '-'"
|
||||
/>
|
||||
<el-step title="支付" :description="detail.payTime || '-'" />
|
||||
<el-step title="完成" :description="detail.confirmTime || '-'" />
|
||||
</el-steps>
|
||||
</el-card>
|
||||
|
||||
<!-- 订单基础 -->
|
||||
<el-card shadow="never" class="block-card">
|
||||
<div slot="header" class="block-title">订单基础</div>
|
||||
<el-descriptions :column="2" :colon="false" size="small" class="info-desc">
|
||||
<el-descriptions-item label="订单ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单金额">
|
||||
<span class="price">¥{{ detail.totalMoney }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="商品ID">{{ detail.merchandiseId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品名称">{{ detail.merchandiseTitle || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否显示">{{ detail.isShow === 1 ? '显示' : '隐藏' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="原订单ID">{{ detail.oldId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品图片" :span="2">
|
||||
<el-image
|
||||
v-if="detail.merchandiseImage"
|
||||
:src="detail.merchandiseImage"
|
||||
:preview-src-list="[detail.merchandiseImage]"
|
||||
style="width:80px;height:80px;border-radius:4px"
|
||||
fit="cover"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 买家 -->
|
||||
<el-card shadow="never" class="block-card">
|
||||
<div slot="header" class="block-title">买家信息</div>
|
||||
<el-descriptions :column="2" :colon="false" size="small" class="info-desc">
|
||||
<el-descriptions-item label="买家ID">{{ detail.buyerId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="买家昵称">{{ detail.buyerName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货人">{{ detail.consignee || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货电话">{{ detail.phone || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货地区" :span="2">{{ joinArea(detail) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="详细地址" :span="2">{{ detail.address || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 卖家 -->
|
||||
<el-card shadow="never" class="block-card">
|
||||
<div slot="header" class="block-title">卖家信息</div>
|
||||
<el-descriptions :column="2" :colon="false" size="small" class="info-desc">
|
||||
<el-descriptions-item label="卖家ID">
|
||||
{{ detail.sellerId === 0 ? '0(平台)' : detail.sellerId }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="卖家昵称">
|
||||
{{ detail.sellerId === 0 ? '平台' : (detail.sellerName || '-') }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 支付与日志 -->
|
||||
<el-card shadow="never" class="block-card">
|
||||
<div slot="header" class="block-title">支付与日志</div>
|
||||
<el-descriptions :column="2" :colon="false" size="small" class="info-desc">
|
||||
<el-descriptions-item label="抢购时间">{{ detail.buyTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支付时间">{{ detail.payTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="完成时间">{{ detail.confirmTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ detail.createdAt || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下单IP">{{ detail.buyIp || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.isCancel === 1" label="取消IP">
|
||||
{{ detail.cancelIp || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付凭证" :span="2">
|
||||
<el-image
|
||||
v-if="detail.payImg"
|
||||
:src="detail.payImg"
|
||||
:preview-src-list="[detail.payImg]"
|
||||
style="width:80px;height:80px;border-radius:4px"
|
||||
fit="cover"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="drawerVisible = false">关 闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getExternalWaOrderList,
|
||||
getExternalWaOrderInfo,
|
||||
} from '@/api/integralExternal';
|
||||
|
||||
const STORAGE_KEY = 'waOrderColumns:external';
|
||||
|
||||
const COLUMN_DEFS = [
|
||||
{ prop: 'id', label: '订单ID', width: 80, defaultVisible: true, fixed: true, tooltip: false, align: 'left' },
|
||||
{ prop: 'orderSn', label: '订单号', width: 210, defaultVisible: true, fixed: true, tooltip: true },
|
||||
{ prop: 'merchandiseId', label: '商品ID', width: 90, defaultVisible: true, align: 'left' },
|
||||
{ prop: 'merchandiseTitle', label: '商品名称', width: 200, defaultVisible: true, tooltip: true },
|
||||
{ prop: 'merchandiseImage', label: '商品图片', width: 90, defaultVisible: false, align: 'center' },
|
||||
{ prop: 'totalMoney', label: '订单金额', width: 110, defaultVisible: true, align: 'right' },
|
||||
{ prop: 'statusStr', label: '订单状态', width: 110, defaultVisible: true, align: 'center' },
|
||||
{ prop: 'isResell', label: '是否转拍', width: 90, defaultVisible: false, align: 'center' },
|
||||
{ prop: 'buyerName', label: '买家昵称', width: 120, defaultVisible: true, tooltip: true },
|
||||
{ prop: 'buyerId', label: '买家ID', width: 90, defaultVisible: false },
|
||||
{ prop: 'sellerName', label: '卖家昵称', width: 120, defaultVisible: true, tooltip: true },
|
||||
{ prop: 'sellerId', label: '卖家ID', width: 90, defaultVisible: false },
|
||||
{ prop: 'consignee', label: '收货人', width: 100, defaultVisible: false },
|
||||
{ prop: 'phone', label: '收货电话', width: 130, defaultVisible: false },
|
||||
{ prop: 'areaText', label: '收货地区', width: 180, defaultVisible: false, tooltip: true },
|
||||
{ prop: 'address', label: '详细地址', width: 220, defaultVisible: false, tooltip: true },
|
||||
{ prop: 'buyTime', label: '抢购时间', width: 160, defaultVisible: true },
|
||||
{ prop: 'createdAt', label: '转拍/创建时间', width: 160, defaultVisible: false },
|
||||
{ prop: 'payTime', label: '支付时间', width: 160, defaultVisible: true },
|
||||
{ prop: 'confirmTime', label: '完成时间', width: 160, defaultVisible: true },
|
||||
{ prop: 'updatedAt', label: '更新时间', width: 160, defaultVisible: false },
|
||||
{ prop: 'buyIp', label: '下单IP', width: 130, defaultVisible: false },
|
||||
{ prop: 'cancelIp', label: '取消IP', width: 130, defaultVisible: false },
|
||||
{ prop: 'isShow', label: '显示', width: 80, defaultVisible: false, align: 'center' },
|
||||
];
|
||||
|
||||
export default {
|
||||
name: 'IntegralExternalWaOrder',
|
||||
data() {
|
||||
return {
|
||||
listLoading: false,
|
||||
tableData: { data: [], total: 0 },
|
||||
tableFrom: {
|
||||
orderSn: '',
|
||||
buyerId: '',
|
||||
sellerId: '',
|
||||
status: null,
|
||||
isCancel: null,
|
||||
isResell: null,
|
||||
buyTimeStart: '',
|
||||
buyTimeEnd: '',
|
||||
confirmTimeStart: '',
|
||||
confirmTimeEnd: '',
|
||||
page: 1,
|
||||
limit: 15,
|
||||
},
|
||||
uiStatus: 'all',
|
||||
timeField: 'buyTime',
|
||||
timeVal: [],
|
||||
columnsConfig: COLUMN_DEFS.concat([{ prop: 'actions', label: '操作', fixed: true }]),
|
||||
visibleColumns: this.loadColumns(),
|
||||
columnPopoverVisible: false,
|
||||
drawerVisible: false,
|
||||
detail: null,
|
||||
detailLoading: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
// 支持 ?detailId=xxx 直接打开抽屉
|
||||
if (this.$route && this.$route.query && this.$route.query.detailId) {
|
||||
this.openDetail(this.$route.query.detailId);
|
||||
}
|
||||
this.getList();
|
||||
},
|
||||
methods: {
|
||||
loadColumns() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const arr = JSON.parse(raw);
|
||||
if (Array.isArray(arr) && arr.length) {
|
||||
const validProps = COLUMN_DEFS.map((c) => c.prop);
|
||||
// 1) 与最新字段定义做交集;2) 锁定列必须存在;3) 新增的 defaultVisible 列自动并入
|
||||
const next = arr.filter((p) => validProps.includes(p));
|
||||
COLUMN_DEFS.forEach((c) => {
|
||||
if ((c.fixed || c.defaultVisible) && !next.includes(c.prop)) {
|
||||
next.push(c.prop);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return COLUMN_DEFS.filter((c) => c.defaultVisible || c.fixed).map((c) => c.prop);
|
||||
},
|
||||
persistColumns() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.visibleColumns));
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
selectAllColumns() {
|
||||
this.visibleColumns = COLUMN_DEFS.map((c) => c.prop);
|
||||
this.persistColumns();
|
||||
},
|
||||
invertColumns() {
|
||||
const all = COLUMN_DEFS.map((c) => c.prop);
|
||||
const next = all.filter((p) => !this.visibleColumns.includes(p));
|
||||
// 锁定列必须保留
|
||||
COLUMN_DEFS.filter((c) => c.fixed).forEach((c) => {
|
||||
if (!next.includes(c.prop)) next.push(c.prop);
|
||||
});
|
||||
this.visibleColumns = next;
|
||||
this.persistColumns();
|
||||
},
|
||||
resetColumns() {
|
||||
this.visibleColumns = COLUMN_DEFS.filter((c) => c.defaultVisible || c.fixed).map((c) => c.prop);
|
||||
this.persistColumns();
|
||||
},
|
||||
onStatusChange() {
|
||||
this.applyUiStatus();
|
||||
this.seachList();
|
||||
},
|
||||
applyUiStatus() {
|
||||
// UI 标签 → status / isCancel 组合
|
||||
switch (this.uiStatus) {
|
||||
case 'unPaid':
|
||||
this.tableFrom.status = 0;
|
||||
this.tableFrom.isCancel = 0;
|
||||
break;
|
||||
case 'paid':
|
||||
this.tableFrom.status = 1;
|
||||
this.tableFrom.isCancel = 0;
|
||||
break;
|
||||
case 'complete':
|
||||
this.tableFrom.status = 2;
|
||||
this.tableFrom.isCancel = 0;
|
||||
break;
|
||||
case 'cancel':
|
||||
this.tableFrom.status = null;
|
||||
this.tableFrom.isCancel = 1;
|
||||
break;
|
||||
default:
|
||||
this.tableFrom.status = null;
|
||||
this.tableFrom.isCancel = null;
|
||||
}
|
||||
},
|
||||
onTimeChange(val) {
|
||||
// 清掉所有时间字段
|
||||
this.tableFrom.buyTimeStart = '';
|
||||
this.tableFrom.buyTimeEnd = '';
|
||||
this.tableFrom.confirmTimeStart = '';
|
||||
this.tableFrom.confirmTimeEnd = '';
|
||||
if (val && val.length === 2) {
|
||||
const startKey = `${this.timeField}Start`;
|
||||
const endKey = `${this.timeField}End`;
|
||||
// WaOrderSearchRequest 仅支持 buyTime / confirmTime;其余维度回退到 buyTime
|
||||
if (startKey in this.tableFrom) {
|
||||
this.tableFrom[startKey] = `${val[0]} 00:00:00`;
|
||||
this.tableFrom[endKey] = `${val[1]} 23:59:59`;
|
||||
} else {
|
||||
this.tableFrom.buyTimeStart = `${val[0]} 00:00:00`;
|
||||
this.tableFrom.buyTimeEnd = `${val[1]} 23:59:59`;
|
||||
}
|
||||
}
|
||||
this.seachList();
|
||||
},
|
||||
seachList() {
|
||||
this.tableFrom.page = 1;
|
||||
this.getList();
|
||||
},
|
||||
resetHandler() {
|
||||
this.tableFrom = {
|
||||
orderSn: '', buyerId: '', sellerId: '',
|
||||
status: null, isCancel: null, isResell: null,
|
||||
buyTimeStart: '', buyTimeEnd: '', confirmTimeStart: '', confirmTimeEnd: '',
|
||||
page: 1, limit: 15,
|
||||
};
|
||||
this.uiStatus = 'all';
|
||||
this.timeField = 'buyTime';
|
||||
this.timeVal = [];
|
||||
this.getList();
|
||||
},
|
||||
getList() {
|
||||
this.listLoading = true;
|
||||
const params = { ...this.tableFrom };
|
||||
// null/空字段不发
|
||||
Object.keys(params).forEach((k) => {
|
||||
if (params[k] === '' || params[k] === null) delete params[k];
|
||||
});
|
||||
getExternalWaOrderList(params)
|
||||
.then((res) => {
|
||||
this.tableData.data = res.list || [];
|
||||
this.tableData.total = res.total || 0;
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.listLoading = false;
|
||||
});
|
||||
},
|
||||
handleSizeChange(val) {
|
||||
this.tableFrom.limit = val;
|
||||
this.getList();
|
||||
},
|
||||
handleCurrentChange(val) {
|
||||
this.tableFrom.page = val;
|
||||
this.getList();
|
||||
},
|
||||
formatCell(prop, row) {
|
||||
if (!row) return '-';
|
||||
switch (prop) {
|
||||
case 'totalMoney':
|
||||
return row.totalMoney != null ? `¥${row.totalMoney}` : '-';
|
||||
case 'statusStr': {
|
||||
const status = this.statusLabel(row);
|
||||
const type = this.statusTagType(row);
|
||||
return `<span class="el-tag el-tag--${type} el-tag--mini">${status}</span>`;
|
||||
}
|
||||
case 'isResell':
|
||||
return row.isResell === 1
|
||||
? '<span style="color:#E6A23C">是</span>'
|
||||
: '否';
|
||||
case 'sellerId':
|
||||
return row.sellerId === 0 ? '0(平台)' : row.sellerId;
|
||||
case 'sellerName':
|
||||
return row.sellerId === 0 ? '平台' : (row.sellerName || '-');
|
||||
case 'areaText':
|
||||
return this.joinArea(row);
|
||||
case 'isShow':
|
||||
return row.isShow === 1 ? '是' : '否';
|
||||
case 'merchandiseTitle':
|
||||
return row.merchandiseTitle || row.productName || '-';
|
||||
default: {
|
||||
const v = row[prop];
|
||||
return v == null || v === '' ? '-' : v;
|
||||
}
|
||||
}
|
||||
},
|
||||
statusLabel(row) {
|
||||
if (!row) return '-';
|
||||
if (row.isCancel === 1) return '已取消';
|
||||
if (row.isResell === 1 && row.status === 0) return '转拍中';
|
||||
switch (row.status) {
|
||||
case 0: return '待付款';
|
||||
case 1: return '已支付';
|
||||
case 2: return '交易完成';
|
||||
default: return '-';
|
||||
}
|
||||
},
|
||||
statusTagType(row) {
|
||||
if (!row) return 'info';
|
||||
if (row.isCancel === 1) return 'info';
|
||||
switch (row.status) {
|
||||
case 0: return 'warning';
|
||||
case 1: return 'primary';
|
||||
case 2: return 'success';
|
||||
default: return 'info';
|
||||
}
|
||||
},
|
||||
stepActive(row) {
|
||||
if (!row) return 0;
|
||||
if (row.confirmTime) return row.isResell === 1 ? 4 : 3;
|
||||
if (row.payTime) return row.isResell === 1 ? 3 : 2;
|
||||
if (row.isResell === 1 && row.createdAt) return 2;
|
||||
if (row.buyTime) return 1;
|
||||
return 0;
|
||||
},
|
||||
joinArea(row) {
|
||||
if (!row) return '-';
|
||||
const parts = [row.province, row.city, row.area].filter(Boolean);
|
||||
return parts.length ? parts.join(' / ') : '-';
|
||||
},
|
||||
openDetail(id) {
|
||||
if (!id) return;
|
||||
this.drawerVisible = true;
|
||||
this.detailLoading = true;
|
||||
this.detail = null;
|
||||
getExternalWaOrderInfo(id)
|
||||
.then((res) => {
|
||||
this.detail = res || null;
|
||||
})
|
||||
.catch(() => {
|
||||
this.$message.error('订单详情加载失败');
|
||||
this.drawerVisible = false;
|
||||
})
|
||||
.finally(() => {
|
||||
this.detailLoading = false;
|
||||
});
|
||||
},
|
||||
onDrawerClose() {
|
||||
this.detail = null;
|
||||
// 清掉 URL 上的 detailId
|
||||
if (this.$route && this.$route.query && this.$route.query.detailId) {
|
||||
const q = { ...this.$route.query };
|
||||
delete q.detailId;
|
||||
this.$router.replace({ path: this.$route.path, query: q });
|
||||
}
|
||||
},
|
||||
copyOrderSn() {
|
||||
if (!this.detail || !this.detail.orderSn) return;
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = this.detail.orderSn;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); this.$message.success('已复制订单号'); }
|
||||
catch (e) { this.$message.error('复制失败'); }
|
||||
document.body.removeChild(ta);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
visibleColumns: {
|
||||
deep: true,
|
||||
handler() { this.persistColumns(); },
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mt10 { margin-top: 10px; }
|
||||
.mt20 { margin-top: 20px; }
|
||||
.selWidth { width: 180px; }
|
||||
.header-actions {
|
||||
text-align: right;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.column-setting {
|
||||
.column-setting-actions {
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px dashed #ebeef5;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
.column-checkbox-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px 0;
|
||||
}
|
||||
.block { text-align: right; }
|
||||
|
||||
/* ===== 抽屉详情样式 ===== */
|
||||
.drawer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.drawer-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px 20px;
|
||||
background: #f5f7fa;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
.header-left { flex: 1; min-width: 0; }
|
||||
.order-sn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.order-sn-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.order-sn {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
.tag-row {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.close-btn {
|
||||
font-size: 18px;
|
||||
color: #909399;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
.drawer-content {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.drawer-footer {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
background: #fafafa;
|
||||
}
|
||||
.block-card {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
::v-deep .el-card__header {
|
||||
padding: 10px 14px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
::v-deep .el-card__body {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
}
|
||||
.block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
position: relative;
|
||||
padding-left: 8px;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 3px;
|
||||
width: 3px; height: 14px;
|
||||
background: #409EFF;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
.info-desc {
|
||||
::v-deep .el-descriptions__label {
|
||||
color: #909399;
|
||||
font-weight: normal;
|
||||
width: 90px;
|
||||
}
|
||||
::v-deep .el-descriptions__content {
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
.price {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
::v-deep .wa-order-drawer .el-drawer__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -148,8 +148,6 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
|
||||
.antMatchers("/api/admin/store/product/copy/**").permitAll()
|
||||
.antMatchers("/api/admin/merchandise/select").permitAll()
|
||||
.antMatchers("/api/admin/merchandise/update").permitAll()
|
||||
// 老板驾驶舱独立 H5 页面接口,本机演示和报表归档使用
|
||||
.antMatchers("/api/admin/dashboard/**").permitAll()
|
||||
// 积分模块外部免认证只读接口(供 /integral-external/* 页面调用)
|
||||
.antMatchers("/api/external/integral/**").permitAll()
|
||||
// 除上面外的所有请求全部需要鉴权认证
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package com.zbkj.admin.controller;
|
||||
|
||||
import com.zbkj.common.response.dashboard.BossDashboardResponse;
|
||||
import com.zbkj.common.result.CommonResult;
|
||||
import com.zbkj.service.service.BossDashboardService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 老板经营驾驶舱
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("api/admin/dashboard")
|
||||
@Api(tags = "老板经营驾驶舱")
|
||||
public class BossDashboardController {
|
||||
|
||||
@Autowired
|
||||
private BossDashboardService bossDashboardService;
|
||||
|
||||
@ApiOperation(value = "老板驾驶舱概览")
|
||||
@RequestMapping(value = "/overview", method = RequestMethod.GET)
|
||||
public CommonResult<BossDashboardResponse> overview(@RequestParam(value = "date", required = false) String date) {
|
||||
return CommonResult.success(bossDashboardService.overview(date));
|
||||
}
|
||||
|
||||
@ApiOperation(value = "生成经营日报归档 HTML")
|
||||
@RequestMapping(value = "/daily-report/archive", method = RequestMethod.GET)
|
||||
public ResponseEntity<byte[]> dailyReportArchive(@RequestParam(value = "date", required = false) String date) {
|
||||
BossDashboardResponse overview = bossDashboardService.overview(date);
|
||||
String html = bossDashboardService.dailyReportArchiveHtml(date);
|
||||
String filename = "dashboard-daily-report-" + overview.getBusinessDate() + ".html";
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(new MediaType("text", "html", StandardCharsets.UTF_8));
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"");
|
||||
return ResponseEntity.ok().headers(headers).body(html.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,35 @@ package com.zbkj.admin.controller;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.zbkj.common.page.CommonPage;
|
||||
import com.zbkj.common.request.*;
|
||||
import com.zbkj.common.response.ExternalGrabUserResponse;
|
||||
import com.zbkj.common.response.StoreOrderDetailResponse;
|
||||
import com.zbkj.common.response.TeamDailyMultiReportResponse;
|
||||
import com.zbkj.common.response.TeamDailyReportResponse;
|
||||
import com.zbkj.common.response.UserIntegralRecordResponse;
|
||||
import com.zbkj.common.response.UserResponse;
|
||||
import com.zbkj.common.response.WaOrderResponse;
|
||||
import com.zbkj.common.result.CommonResult;
|
||||
import com.zbkj.service.service.ExternalGrabUserService;
|
||||
import com.zbkj.service.service.StoreOrderService;
|
||||
import com.zbkj.service.service.TeamReportExternalService;
|
||||
import com.zbkj.service.service.UserIntegralRecordService;
|
||||
import com.zbkj.service.service.UserService;
|
||||
import com.zbkj.service.service.WaOrderAdminService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 积分模块外部免认证接口 Controller
|
||||
* 供管理后台外部页面(/integral-external/*)调用,跳过登录验证。
|
||||
@@ -40,6 +55,15 @@ public class ExternalIntegralController {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private WaOrderAdminService waOrderAdminService;
|
||||
|
||||
@Autowired
|
||||
private TeamReportExternalService teamReportExternalService;
|
||||
|
||||
@Autowired
|
||||
private ExternalGrabUserService externalGrabUserService;
|
||||
|
||||
/**
|
||||
* 积分明细分页列表(免认证)
|
||||
* 复用 UserIntegralRecordService.findAdminList,与 /admin/user/integral/list 逻辑完全一致。
|
||||
@@ -93,4 +117,90 @@ public class ExternalIntegralController {
|
||||
}
|
||||
return CommonResult.success(restPage);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 寄卖订单管理(wa_order)
|
||||
// 复用 WaOrderAdminService(仅读路径),不使用 @PreAuthorize。
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 寄卖订单分页列表(免认证)
|
||||
*/
|
||||
@ApiOperation(value = "寄卖订单分页列表(免认证)")
|
||||
@GetMapping(value = "/wa-order/list")
|
||||
public CommonResult<CommonPage<WaOrderResponse>> waOrderList(
|
||||
@ModelAttribute @Validated WaOrderSearchRequest request,
|
||||
@Validated PageParamRequest pageParamRequest) {
|
||||
CommonPage<WaOrderResponse> restPage =
|
||||
CommonPage.restPage(waOrderAdminService.getAdminList(request, pageParamRequest));
|
||||
return CommonResult.success(restPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 寄卖订单详情(免认证)
|
||||
*/
|
||||
@ApiOperation(value = "寄卖订单详情(免认证)")
|
||||
@GetMapping(value = "/wa-order/info")
|
||||
public CommonResult<WaOrderResponse> waOrderInfo(@RequestParam Integer id) {
|
||||
return CommonResult.success(waOrderAdminService.getDetailById(id));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 团队每日对账日报
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 团队每日对账日报(免认证)。
|
||||
* - leaderId 非空:返回该团队对账(teams 仅 1 项)
|
||||
* - leaderId 为空:按团队长分组返回所有团队对账 + 跨团队总计
|
||||
*/
|
||||
@ApiOperation(value = "团队每日对账(免认证)")
|
||||
@GetMapping(value = "/team-report/daily")
|
||||
public CommonResult<TeamDailyMultiReportResponse> teamDailyReport(
|
||||
@ModelAttribute @Validated TeamDailyReportRequest request) {
|
||||
return CommonResult.success(teamReportExternalService.getMultiDailyReport(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 - Excel 导出(免认证;仅支持单团队,必须传 leaderId)
|
||||
*/
|
||||
@ApiOperation(value = "团队每日对账 Excel 导出(免认证)")
|
||||
@GetMapping(value = "/team-report/daily/export")
|
||||
public ResponseEntity<byte[]> teamDailyReportExport(
|
||||
@ModelAttribute @Validated TeamDailyReportRequest request) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
TeamDailyReportResponse data = teamReportExternalService.exportDailyReport(request, out);
|
||||
|
||||
String fileName = String.format("团队%s_%s.xlsx",
|
||||
data.getTeamCode() == null ? "" : data.getTeamCode(),
|
||||
data.getDate());
|
||||
String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name())
|
||||
.replaceAll("\\+", "%20");
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded);
|
||||
|
||||
return new ResponseEntity<>(out.toByteArray(), headers, org.springframework.http.HttpStatus.OK);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 今日抢单用户列表
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表(免认证)
|
||||
* 过滤口径:今日购买总金额 > 0;已在 SQL 层落实。
|
||||
*/
|
||||
@ApiOperation(value = "今日抢单用户分页列表(免认证)")
|
||||
@GetMapping(value = "/grab-user/list")
|
||||
public CommonResult<CommonPage<ExternalGrabUserResponse>> grabUserList(
|
||||
@ModelAttribute @Validated ExternalGrabUserRequest request,
|
||||
@Validated PageParamRequest pageParamRequest) {
|
||||
CommonPage<ExternalGrabUserResponse> restPage =
|
||||
CommonPage.restPage(externalGrabUserService.list(request, pageParamRequest));
|
||||
return CommonResult.success(restPage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# CRMEB 相关配置
|
||||
crmeb:
|
||||
captchaOn: false # 是否开启行为验证码
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30032
|
||||
|
||||
sync:
|
||||
source-id: shop_13
|
||||
target-mer-id: 13
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://121.43.134.82:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 121.43.134.82 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 2 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -1,59 +0,0 @@
|
||||
# CRMEB 相关配置
|
||||
crmeb:
|
||||
captchaOn: false # 是否开启行为验证码
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30032
|
||||
|
||||
sync:
|
||||
source-id: shop_12
|
||||
target-mer-id: 12
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://101.37.101.6:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 101.37.101.6 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 2 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -1,59 +0,0 @@
|
||||
# CRMEB 相关配置
|
||||
crmeb:
|
||||
captchaOn: false # 是否开启行为验证码
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30032
|
||||
|
||||
sync:
|
||||
source-id: shop_14
|
||||
target-mer-id: 14
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://106.14.132.80:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 106.14.132.80 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 2 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -38,7 +38,7 @@ server:
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: sxsy80
|
||||
active: byjyw149
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB #设置单个文件大小
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.zbkj.common.request;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 查询请求(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "ExternalGrabUserRequest 对象", description = "今日抢单用户列表查询请求")
|
||||
public class ExternalGrabUserRequest implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "用户ID(精确)")
|
||||
private Integer uid;
|
||||
|
||||
@ApiModelProperty(value = "联系方式(模糊匹配 mobile / username)")
|
||||
private String mobile;
|
||||
|
||||
@ApiModelProperty(value = "上级ID(精确)")
|
||||
private Integer pid;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.zbkj.common.request;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 查询请求(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "TeamDailyReportRequest 对象", description = "团队每日对账查询请求")
|
||||
public class TeamDailyReportRequest implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "团队长 ID(wa_users.id);为空时按团队长分组返回所有团队")
|
||||
private Integer leaderId;
|
||||
|
||||
@ApiModelProperty(value = "查询日期 D(yyyy-MM-dd),缺省为昨天")
|
||||
private String date;
|
||||
|
||||
@ApiModelProperty(value = "是否包含禁用成员,默认 false")
|
||||
private Boolean includeDisabled = Boolean.FALSE;
|
||||
|
||||
@ApiModelProperty(value = "限定成员 ID 列表(前端勾选过滤)")
|
||||
private List<Integer> memberIds;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.zbkj.common.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 响应对象(外部免认证)
|
||||
* 字段保留 3 位小数(与上传参考图一致),由 Service 层格式化为字符串。
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "ExternalGrabUserResponse 对象", description = "今日抢单用户列表响应")
|
||||
public class ExternalGrabUserResponse implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "用户ID")
|
||||
private Integer id;
|
||||
|
||||
@ApiModelProperty(value = "账号 / 用户名")
|
||||
private String username;
|
||||
|
||||
@ApiModelProperty(value = "昵称")
|
||||
private String nickname;
|
||||
|
||||
@ApiModelProperty(value = "手机号 / 联系方式")
|
||||
private String mobile;
|
||||
|
||||
@ApiModelProperty(value = "合同 URL(为空表示未上传)")
|
||||
private String contract;
|
||||
|
||||
@ApiModelProperty(value = "上级ID")
|
||||
private Integer pid;
|
||||
|
||||
@ApiModelProperty(value = "最高可抢单数")
|
||||
private Integer maxOrder;
|
||||
|
||||
@ApiModelProperty(value = "用户等级(数值)")
|
||||
private Integer level;
|
||||
|
||||
@ApiModelProperty(value = "用户等级文案")
|
||||
private String levelName;
|
||||
|
||||
@ApiModelProperty(value = "余额(保留 3 位小数)")
|
||||
private String money;
|
||||
|
||||
@ApiModelProperty(value = "优惠券(保留 3 位小数)")
|
||||
private String coupon;
|
||||
|
||||
@ApiModelProperty(value = "个人奖金(保留 3 位小数)")
|
||||
private String selfBonus;
|
||||
|
||||
@ApiModelProperty(value = "推广奖金(保留 3 位小数)")
|
||||
private String shareBonus;
|
||||
|
||||
@ApiModelProperty(value = "状态:0=禁用,1=正常")
|
||||
private Integer status;
|
||||
|
||||
@ApiModelProperty(value = "状态文案")
|
||||
private String statusStr;
|
||||
|
||||
@ApiModelProperty(value = "更新时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private Date updatedAt;
|
||||
|
||||
@ApiModelProperty(value = "今日购买总金额(保留 3 位小数)")
|
||||
private String todayBuyAmount;
|
||||
|
||||
@ApiModelProperty(value = "今日卖出总金额(保留 3 位小数)")
|
||||
private String todaySellAmount;
|
||||
|
||||
@ApiModelProperty(value = "今日买单数")
|
||||
private Integer todayBuyCnt;
|
||||
|
||||
@ApiModelProperty(value = "昨日卖单数")
|
||||
private Integer prevSellCnt;
|
||||
|
||||
/** 内部使用:SQL 聚合后由 Service 二次处理为格式化字符串;不输出到 JSON */
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal todayBuyAmountRaw;
|
||||
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal todaySellAmountRaw;
|
||||
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal moneyRaw;
|
||||
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal couponRaw;
|
||||
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal selfBonusRaw;
|
||||
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal shareBonusRaw;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.zbkj.common.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 - 单成员行
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "TeamDailyMemberRow 对象", description = "团队每日对账 - 单成员行")
|
||||
public class TeamDailyMemberRow implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "成员用户 ID")
|
||||
private Integer userId;
|
||||
|
||||
@ApiModelProperty(value = "成员昵称")
|
||||
private String nickname;
|
||||
|
||||
@ApiModelProperty(value = "团队代号")
|
||||
private String teamCode;
|
||||
|
||||
@ApiModelProperty(value = "成员状态:1=启用,0=禁用")
|
||||
private Integer status;
|
||||
|
||||
/** 内部使用:所属团队长 ID(即 wa_users.pid);不输出到 JSON */
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private Integer leaderId;
|
||||
|
||||
@ApiModelProperty(value = "D-1 买单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal prevBuy;
|
||||
|
||||
@ApiModelProperty(value = "D 卖单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal todaySell;
|
||||
|
||||
@ApiModelProperty(value = "D 买单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal todayBuy;
|
||||
|
||||
@ApiModelProperty(value = "服务费 = D买单 × service_rate")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal serviceFee;
|
||||
|
||||
@ApiModelProperty(value = "E 积分 = D买单 × e_score_rate")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal eScore;
|
||||
|
||||
@ApiModelProperty(value = "实际收付 = D卖单 − D买单 − 服务费 − E积分")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal actual;
|
||||
|
||||
@ApiModelProperty(value = "备注(前端会话级,非持久化)")
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.zbkj.common.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 — 多团队聚合响应(外部免认证)。
|
||||
*
|
||||
* 当前端不传 leaderId 时返回此结构,按团队长分组列出全部团队的报表,
|
||||
* 并附带跨团队的总计。当 leaderId 传入时 teams 仅含 1 项。
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "TeamDailyMultiReportResponse 对象", description = "多团队每日对账响应")
|
||||
public class TeamDailyMultiReportResponse implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("查询日期 D(yyyy-MM-dd)")
|
||||
private String date;
|
||||
|
||||
@ApiModelProperty("D-1 日期(yyyy-MM-dd)")
|
||||
private String previousDate;
|
||||
|
||||
@ApiModelProperty("服务费率")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal serviceRate;
|
||||
|
||||
@ApiModelProperty("E 积分率")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal eScoreRate;
|
||||
|
||||
@ApiModelProperty("团队数")
|
||||
private Integer teamCount;
|
||||
|
||||
@ApiModelProperty("成员合计(跨团队)")
|
||||
private Integer totalMemberCount;
|
||||
|
||||
@ApiModelProperty("各团队报表(按 leaderId 分组)")
|
||||
private List<TeamDailyReportResponse> teams;
|
||||
|
||||
@ApiModelProperty("跨团队总计")
|
||||
private TeamDailySummary grandSummary;
|
||||
|
||||
@ApiModelProperty("警告信息(如非法 memberIds)")
|
||||
private List<String> warnings;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.zbkj.common.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 响应(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "TeamDailyReportResponse 对象", description = "团队每日对账日报响应")
|
||||
public class TeamDailyReportResponse implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("团队长 ID")
|
||||
private Integer leaderId;
|
||||
|
||||
@ApiModelProperty("团队长昵称")
|
||||
private String leaderNickname;
|
||||
|
||||
@ApiModelProperty("团队代号")
|
||||
private String teamCode;
|
||||
|
||||
@ApiModelProperty("成员人数")
|
||||
private Integer memberCount;
|
||||
|
||||
@ApiModelProperty("查询日期 D(yyyy-MM-dd)")
|
||||
private String date;
|
||||
|
||||
@ApiModelProperty("D-1 日期(yyyy-MM-dd)")
|
||||
private String previousDate;
|
||||
|
||||
@ApiModelProperty("服务费率")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal serviceRate;
|
||||
|
||||
@ApiModelProperty("E 积分率")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal eScoreRate;
|
||||
|
||||
@ApiModelProperty("成员行")
|
||||
private List<TeamDailyMemberRow> rows;
|
||||
|
||||
@ApiModelProperty("小计")
|
||||
private TeamDailySummary summary;
|
||||
|
||||
@ApiModelProperty("警告信息(如非法 memberIds)")
|
||||
private List<String> warnings;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.zbkj.common.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 - 小计
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "TeamDailySummary 对象", description = "团队每日对账 - 小计")
|
||||
public class TeamDailySummary implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("D-1 买单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal prevBuy = BigDecimal.ZERO;
|
||||
|
||||
@ApiModelProperty("D 卖单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal todaySell = BigDecimal.ZERO;
|
||||
|
||||
@ApiModelProperty("D 买单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal todayBuy = BigDecimal.ZERO;
|
||||
|
||||
@ApiModelProperty("服务费合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal serviceFee = BigDecimal.ZERO;
|
||||
|
||||
@ApiModelProperty("E 积分合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal eScore = BigDecimal.ZERO;
|
||||
|
||||
@ApiModelProperty("实际收付合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal actual = BigDecimal.ZERO;
|
||||
}
|
||||
@@ -93,6 +93,12 @@ public class WaOrderResponse implements Serializable {
|
||||
@ApiModelProperty(value = "寄售商品ID")
|
||||
private Integer merchandiseId;
|
||||
|
||||
@ApiModelProperty(value = "寄售商品标题")
|
||||
private String merchandiseTitle;
|
||||
|
||||
@ApiModelProperty(value = "寄售商品图片")
|
||||
private String merchandiseImage;
|
||||
|
||||
@ApiModelProperty(value = "确认收货时间")
|
||||
private Date confirmTime;
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package com.zbkj.common.response.dashboard;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 老板驾驶舱响应对象
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(value = "BossDashboardResponse", description = "老板驾驶舱响应对象")
|
||||
public class BossDashboardResponse {
|
||||
|
||||
@ApiModelProperty(value = "业务日期")
|
||||
private String businessDate;
|
||||
|
||||
@ApiModelProperty(value = "生成时间")
|
||||
private String generatedAt;
|
||||
|
||||
@ApiModelProperty(value = "经营摘要")
|
||||
private String summary;
|
||||
|
||||
@ApiModelProperty(value = "核心指标")
|
||||
private List<KpiMetric> kpis = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "资金池指标")
|
||||
private List<KpiMetric> fundPool = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "今日节点快报")
|
||||
private List<TodaySnapshot> snapshots = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "近 7 天趋势")
|
||||
private List<TrendPoint> trends = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "高价值用户排行")
|
||||
private List<RankItem> userRanks = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "团队贡献排行")
|
||||
private List<RankItem> teamRanks = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "高货值未成交商品排行")
|
||||
private List<RankItem> productRanks = new ArrayList<>();
|
||||
|
||||
@ApiModelProperty(value = "风险预警")
|
||||
private List<RiskAlert> risks = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class KpiMetric {
|
||||
private String key;
|
||||
private String title;
|
||||
private Object value;
|
||||
private String unit;
|
||||
private String trendLabel;
|
||||
private BigDecimal trendValue;
|
||||
private String status = "normal";
|
||||
private Boolean featured = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TodaySnapshot {
|
||||
private String slot;
|
||||
private String title;
|
||||
private String status;
|
||||
private String generatedAt;
|
||||
private String message;
|
||||
private Integer purchaseUsers = 0;
|
||||
private Integer orderCount = 0;
|
||||
private BigDecimal dealAmount = BigDecimal.ZERO;
|
||||
private BigDecimal paidAmount = BigDecimal.ZERO;
|
||||
private Integer newMerchandiseCount = 0;
|
||||
private BigDecimal selfBonusChange = BigDecimal.ZERO;
|
||||
private BigDecimal shareBonusChange = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TrendPoint {
|
||||
private String date;
|
||||
private BigDecimal amount = BigDecimal.ZERO;
|
||||
private Integer orders = 0;
|
||||
private Integer newUsers = 0;
|
||||
private BigDecimal bonus = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RankItem {
|
||||
private String id;
|
||||
private String name;
|
||||
private BigDecimal value = BigDecimal.ZERO;
|
||||
private String description;
|
||||
private String badge;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class RiskAlert {
|
||||
private String id;
|
||||
private String level;
|
||||
private String type;
|
||||
private String title;
|
||||
private String description;
|
||||
private String discoveredAt;
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ public class WaUserController {
|
||||
FileInputStream fileInputStream = null;
|
||||
try {
|
||||
// 读取模板PDF文件
|
||||
Resource resource = new ClassPathResource("pdf/sign_contract_sxsy80.pdf");
|
||||
Resource resource = new ClassPathResource("pdf/sign_contract_byjyw149.pdf");
|
||||
InputStream pdfInputStream = resource.getInputStream();
|
||||
document = PDDocument.load(pdfInputStream);
|
||||
pdfInputStream.close();
|
||||
@@ -199,7 +199,7 @@ public class WaUserController {
|
||||
// user.setContract("https://anyue.szxingming.com/"+pdfResultVo.getUrl());
|
||||
// user.setContract("https://xiashengjun.com/"+pdfResultVo.getUrl());
|
||||
// user.setContract("https://ccd.cichude.com/"+pdfResultVo.getUrl());
|
||||
user.setContract("https://sxsy.cichude.com/"+pdfResultVo.getUrl());
|
||||
user.setContract("https://jinyawen.com/"+pdfResultVo.getUrl());
|
||||
waUsersDao.updateById(user);
|
||||
}
|
||||
return CommonResult.success(pdfResultVo);
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
crmeb:
|
||||
imagePath: /www/wwwroot/czchunfang.com/ # 池州春芳商贸服务器图片路径 斜杠结尾
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30031
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://121.43.134.82:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 121.43.134.82 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 3 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -1,54 +0,0 @@
|
||||
crmeb:
|
||||
imagePath: /www/wwwroot/czruitang.com/ # 池州瑞棠商贸服务器图片路径 斜杠结尾
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30031
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://101.37.101.6:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 101.37.101.6 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 3 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -1,54 +0,0 @@
|
||||
crmeb:
|
||||
imagePath: /www/wwwroot/sxsy.cichude.com/ # 太原树英商贸 服务器图片路径 斜杠结尾
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30031
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://106.14.132.80:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 106.14.132.80 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 3 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -32,7 +32,7 @@ server:
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: sxsy80
|
||||
active: byjyw149
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB #设置单个文件大小
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -22,6 +22,13 @@
|
||||
<artifactId>crmeb-common</artifactId>
|
||||
<version>${crmeb-common}</version>
|
||||
</dependency>
|
||||
<!-- 单元测试(仅 test 作用域;用于 util 类公式校验等纯函数测试) -->
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>4.12</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.jayway.jsonpath</groupId>
|
||||
<artifactId>json-path</artifactId>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.zbkj.service.dao.consignment;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.zbkj.common.model.consignment.WaUsers;
|
||||
import com.zbkj.common.response.ExternalGrabUserResponse;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 今日抢单用户 DAO(外部免认证)
|
||||
* 通过自定义 SQL 一次性聚合:
|
||||
* - 今日买单合计 / 笔数(INNER JOIN,HAVING SUM>0)
|
||||
* - 今日卖单合计
|
||||
* - 昨日卖单笔数
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Mapper
|
||||
public interface ExternalGrabUserDao extends BaseMapper<WaUsers> {
|
||||
|
||||
/**
|
||||
* 列表查询。
|
||||
*
|
||||
* @param todayStart 今天 00:00:00
|
||||
* @param todayEnd 今天 23:59:59
|
||||
* @param yesterdayStart 昨天 00:00:00
|
||||
* @param yesterdayEnd 昨天 23:59:59
|
||||
* @param uid 用户 ID(精确,可空)
|
||||
* @param mobile 模糊匹配 mobile / username(可空)
|
||||
* @param pid 上级 ID(精确,可空)
|
||||
*/
|
||||
List<ExternalGrabUserResponse> selectGrabUserList(
|
||||
@Param("todayStart") Date todayStart,
|
||||
@Param("todayEnd") Date todayEnd,
|
||||
@Param("yesterdayStart") Date yesterdayStart,
|
||||
@Param("yesterdayEnd") Date yesterdayEnd,
|
||||
@Param("uid") Integer uid,
|
||||
@Param("mobile") String mobile,
|
||||
@Param("pid") Integer pid);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.zbkj.service.dao.consignment;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.zbkj.common.model.consignment.WaUsers;
|
||||
import com.zbkj.common.response.TeamDailyMemberRow;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 DAO(外部免认证)
|
||||
* 通过自定义 SQL 一次性聚合每个直推下级的:D-1 买单 / D 卖单 / D 买单。
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Mapper
|
||||
public interface TeamReportDao extends BaseMapper<WaUsers> {
|
||||
|
||||
/**
|
||||
* 查询团队成员每日对账原始数据。
|
||||
*
|
||||
* @param leaderId 团队长 ID;为 null 时返回所有有 pid 的成员,由 Service 按 leader_id 分组
|
||||
*/
|
||||
List<TeamDailyMemberRow> selectTeamMemberAggregates(
|
||||
@Param("leaderId") Integer leaderId,
|
||||
@Param("dStart") Date dStart,
|
||||
@Param("dEnd") Date dEnd,
|
||||
@Param("prevStart") Date prevStart,
|
||||
@Param("prevEnd") Date prevEnd,
|
||||
@Param("includeDisabled") Boolean includeDisabled,
|
||||
@Param("memberIds") List<Integer> memberIds);
|
||||
|
||||
/**
|
||||
* 读取 wa_setting 中某 key 的字符串值(不存在时返回 null)。
|
||||
* KV 表无独立 Model,使用注解直接查询。
|
||||
*/
|
||||
@Select("SELECT `value` FROM wa_setting WHERE `name` = #{name} LIMIT 1")
|
||||
String selectSettingValue(@Param("name") String name);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.zbkj.service.service;
|
||||
|
||||
import com.zbkj.common.response.dashboard.BossDashboardResponse;
|
||||
|
||||
/**
|
||||
* 老板经营驾驶舱服务
|
||||
*/
|
||||
public interface BossDashboardService {
|
||||
|
||||
/**
|
||||
* 获取老板经营驾驶舱数据
|
||||
*
|
||||
* @param date 业务日期,格式 yyyy-MM-dd,为空时默认上一个工作日
|
||||
* @return BossDashboardResponse
|
||||
*/
|
||||
BossDashboardResponse overview(String date);
|
||||
|
||||
/**
|
||||
* 生成经营日报归档 HTML
|
||||
*
|
||||
* @param date 业务日期,格式 yyyy-MM-dd,为空时默认上一个工作日
|
||||
* @return standalone HTML
|
||||
*/
|
||||
String dailyReportArchiveHtml(String date);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.zbkj.service.service;
|
||||
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.zbkj.common.request.ExternalGrabUserRequest;
|
||||
import com.zbkj.common.request.PageParamRequest;
|
||||
import com.zbkj.common.response.ExternalGrabUserResponse;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 Service(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
public interface ExternalGrabUserService {
|
||||
|
||||
/**
|
||||
* 分页查询当日已发生买单且金额 > 0 的用户列表。
|
||||
* 排序:今日购买总金额 DESC,相同时按 id DESC。
|
||||
*
|
||||
* @param request 搜索条件
|
||||
* @param pageParamRequest 分页参数
|
||||
* @return PageInfo<ExternalGrabUserResponse>
|
||||
*/
|
||||
PageInfo<ExternalGrabUserResponse> list(ExternalGrabUserRequest request,
|
||||
PageParamRequest pageParamRequest);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.zbkj.service.service;
|
||||
|
||||
import com.zbkj.common.request.TeamDailyReportRequest;
|
||||
import com.zbkj.common.response.TeamDailyMultiReportResponse;
|
||||
import com.zbkj.common.response.TeamDailyReportResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 Service(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
public interface TeamReportExternalService {
|
||||
|
||||
/**
|
||||
* 查询某团队某日对账数据(要求 leaderId 非空)。
|
||||
*/
|
||||
TeamDailyReportResponse getDailyReport(TeamDailyReportRequest request);
|
||||
|
||||
/**
|
||||
* 查询多团队对账数据:
|
||||
* - leaderId 为空:按团队长分组返回所有团队
|
||||
* - leaderId 非空:teams 仅含该团队
|
||||
*
|
||||
* @return 多团队报表(含 grandSummary 与每个团队的子报表)
|
||||
*/
|
||||
TeamDailyMultiReportResponse getMultiDailyReport(TeamDailyReportRequest request);
|
||||
|
||||
/**
|
||||
* Excel 导出(仅支持单团队,要求 leaderId 非空)。
|
||||
*/
|
||||
TeamDailyReportResponse exportDailyReport(TeamDailyReportRequest request, OutputStream out) throws IOException;
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
package com.zbkj.service.service.impl;
|
||||
|
||||
import cn.hutool.core.date.DateTime;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.zbkj.common.model.consignment.WaMerchandise;
|
||||
import com.zbkj.common.model.consignment.WaOrder;
|
||||
import com.zbkj.common.model.consignment.WaSelfbonusLog;
|
||||
import com.zbkj.common.model.consignment.WaSharebonusLog;
|
||||
import com.zbkj.common.model.consignment.WaUsers;
|
||||
import com.zbkj.common.model.consignment.WaWithdraw;
|
||||
import com.zbkj.common.response.dashboard.BossDashboardResponse;
|
||||
import com.zbkj.service.dao.consignment.WaMerchandiseDao;
|
||||
import com.zbkj.service.dao.consignment.WaOrderDao;
|
||||
import com.zbkj.service.dao.consignment.WaSelfbonusLogDao;
|
||||
import com.zbkj.service.dao.consignment.WaSharebonusLogDao;
|
||||
import com.zbkj.service.dao.consignment.WaUsersDao;
|
||||
import com.zbkj.service.dao.consignment.WaWithdrawDao;
|
||||
import com.zbkj.service.service.BossDashboardService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 老板经营驾驶舱服务实现
|
||||
*/
|
||||
@Service
|
||||
public class BossDashboardServiceImpl implements BossDashboardService {
|
||||
|
||||
@Resource
|
||||
private WaOrderDao waOrderDao;
|
||||
|
||||
@Resource
|
||||
private WaMerchandiseDao waMerchandiseDao;
|
||||
|
||||
@Resource
|
||||
private WaUsersDao waUsersDao;
|
||||
|
||||
@Resource
|
||||
private WaSelfbonusLogDao waSelfbonusLogDao;
|
||||
|
||||
@Resource
|
||||
private WaSharebonusLogDao waSharebonusLogDao;
|
||||
|
||||
@Resource
|
||||
private WaWithdrawDao waWithdrawDao;
|
||||
|
||||
@Override
|
||||
public BossDashboardResponse overview(String date) {
|
||||
DateTime businessDate = StrUtil.isBlank(date) ? previousWorkday(DateUtil.date()) : DateUtil.parseDate(date);
|
||||
DateTime previousDate = previousWorkday(businessDate);
|
||||
DateRange businessRange = dayRange(businessDate);
|
||||
DateRange previousRange = dayRange(previousDate);
|
||||
|
||||
DailyMetrics metrics = buildDailyMetrics(businessRange);
|
||||
DailyMetrics previousMetrics = buildDailyMetrics(previousRange);
|
||||
|
||||
BossDashboardResponse response = new BossDashboardResponse();
|
||||
response.setBusinessDate(businessDate.toString("yyyy-MM-dd"));
|
||||
response.setGeneratedAt(DateUtil.formatDateTime(new Date()));
|
||||
response.setSummary(buildSummary(metrics));
|
||||
response.getKpis().add(metric("dealAmount", "上个工作日成交额", metrics.dealAmount, "元", "较上一工作日", ratio(metrics.dealAmount, previousMetrics.dealAmount), statusByRatio(metrics.dealAmount, previousMetrics.dealAmount), true));
|
||||
response.getKpis().add(metric("orderCount", "上个工作日订单数", metrics.orderCount, "单", "较上一工作日", ratio(metrics.orderCount, previousMetrics.orderCount), statusByRatio(metrics.orderCount, previousMetrics.orderCount), false));
|
||||
response.getKpis().add(metric("purchaseUsers", "采购用户", metrics.purchaseUsers, "人", "较上一工作日", ratio(metrics.purchaseUsers, previousMetrics.purchaseUsers), statusByRatio(metrics.purchaseUsers, previousMetrics.purchaseUsers), false));
|
||||
response.getKpis().add(metric("newUsers", "新增用户", metrics.newUsers, "人", "较上一工作日", ratio(metrics.newUsers, previousMetrics.newUsers), statusByRatio(metrics.newUsers, previousMetrics.newUsers), false));
|
||||
response.getKpis().add(metric("newMerchandise", "新增寄售商品", metrics.newMerchandiseCount, "件", "较上一工作日", ratio(metrics.newMerchandiseCount, previousMetrics.newMerchandiseCount), statusByRatio(metrics.newMerchandiseCount, previousMetrics.newMerchandiseCount), false));
|
||||
response.getKpis().add(metric("selfBonus", "个人奖金发放", metrics.selfBonus, "元", "较上一工作日", ratio(metrics.selfBonus, previousMetrics.selfBonus), "normal", false));
|
||||
response.getKpis().add(metric("shareBonus", "推广奖金发放", metrics.shareBonus, "元", "较上一工作日", ratio(metrics.shareBonus, previousMetrics.shareBonus), "normal", false));
|
||||
response.getKpis().add(metric("pendingAmount", "待支付/待结算", metrics.pendingAmount, "元", "需关注", null, metrics.pendingAmount.compareTo(BigDecimal.ZERO) > 0 ? "warning" : "normal", false));
|
||||
|
||||
buildFundPool(response);
|
||||
buildSnapshots(response);
|
||||
buildTrends(response, businessDate);
|
||||
buildRanks(response);
|
||||
buildRisks(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String dailyReportArchiveHtml(String date) {
|
||||
BossDashboardResponse data = overview(date);
|
||||
StringBuilder html = new StringBuilder();
|
||||
html.append("<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">");
|
||||
html.append("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">");
|
||||
html.append("<title>经营日报归档 - ").append(escape(data.getBusinessDate())).append("</title>");
|
||||
html.append("<style>");
|
||||
html.append(":root{color:#132033;background:#fff6f1;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top left,rgba(255,91,54,.18),transparent 30rem),#fff6f1}.page{max-width:820px;margin:0 auto;padding:28px 18px 40px}.hero{color:#fff;padding:26px;border-radius:0 0 28px 28px;background:linear-gradient(145deg,#ff5b36,#ff8b52),radial-gradient(circle at 90% 10%,rgba(255,176,0,.42),transparent 18rem);box-shadow:0 16px 40px rgba(255,91,54,.14)}.eyebrow{margin:0;color:rgba(255,255,255,.76);font-size:12px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.hero h1{margin:12px 0 8px;font-size:32px;line-height:1.1}.hero p{margin:0;color:rgba(255,255,255,.82);line-height:1.7}.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}.meta span{padding:7px 12px;border-radius:999px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.2);font-size:12px;font-weight:700}.section{margin-top:16px;padding:18px;background:#fff;border:1px solid rgba(19,32,51,.08);border-radius:24px;box-shadow:0 10px 28px rgba(22,47,80,.08)}.section h2{margin:0 0 14px;font-size:20px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.card{padding:14px;border-radius:18px;background:#f6f9fb}.card small{display:block;color:#6b7a90}.card strong{display:block;margin-top:6px;font-size:22px}.list{display:grid;gap:10px}.item{padding:12px;border-radius:16px;background:#f6f9fb}.item strong{display:block}.item small,.item p{color:#6b7a90}.risk-red strong{color:#dc2626}.risk-yellow strong{color:#ffb000}.footer{margin-top:18px;color:#6b7a90;font-size:12px;text-align:center}@media(max-width:560px){.grid{grid-template-columns:1fr}.hero h1{font-size:28px}}");
|
||||
html.append("</style></head><body><main class=\"page\">");
|
||||
html.append("<header class=\"hero\"><p class=\"eyebrow\">Daily Report Archive</p><h1>经营日报归档</h1>");
|
||||
html.append("<p>").append(escape(data.getSummary())).append("</p><div class=\"meta\">");
|
||||
html.append("<span>数据日期:").append(escape(data.getBusinessDate())).append("</span>");
|
||||
html.append("<span>生成时间:").append(escape(data.getGeneratedAt())).append("</span>");
|
||||
html.append("<span>归档类型:Standalone HTML</span></div></header>");
|
||||
appendMetricsSection(html, "核心经营指标", data.getKpis());
|
||||
appendTrendSection(html, data);
|
||||
appendMetricsSection(html, "资金池摘要", data.getFundPool());
|
||||
appendRankSection(html, "高价值用户", data.getUserRanks());
|
||||
appendRankSection(html, "团队贡献排行", data.getTeamRanks());
|
||||
appendRankSection(html, "高货值未成交商品", data.getProductRanks());
|
||||
appendRiskSection(html, data);
|
||||
html.append("<p class=\"footer\">本归档由经营驾驶舱实时数据生成,可独立保存和打开。</p>");
|
||||
html.append("</main></body></html>");
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private DailyMetrics buildDailyMetrics(DateRange range) {
|
||||
DailyMetrics metrics = new DailyMetrics();
|
||||
metrics.dealAmount = sumOrderAmount(range.start, range.end, true, null);
|
||||
metrics.orderCount = countOrders(range.start, range.end, null);
|
||||
metrics.purchaseUsers = distinctBuyerCount(range.start, range.end, null);
|
||||
metrics.newUsers = countUsers(range.start, range.end);
|
||||
metrics.newMerchandiseCount = countMerchandise(range.start, range.end);
|
||||
metrics.selfBonus = sumSelfBonus(range.start, range.end);
|
||||
metrics.shareBonus = sumShareBonus(range.start, range.end);
|
||||
metrics.pendingAmount = sumOrderAmount(range.start, range.end, false, null);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
private void buildFundPool(BossDashboardResponse response) {
|
||||
BigDecimal money = sumUsersDecimal("money");
|
||||
BigDecimal coupon = sumUsersDecimal("coupon");
|
||||
BigDecimal selfBonus = sumUsersDecimal("self_bonus");
|
||||
BigDecimal shareBonus = sumUsersDecimal("share_bonus");
|
||||
BigDecimal score = sumUsersDecimal("score");
|
||||
BigDecimal pendingWithdraw = sumWithdrawAmount(0);
|
||||
Integer pendingWithdrawCount = countWithdraw(0);
|
||||
|
||||
response.getFundPool().add(metric("balance", "余额总额", money, "元", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("coupon", "优惠券总额", coupon, "元", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("selfBonusPool", "个人奖金总额", selfBonus, "元", null, null, selfBonus.compareTo(BigDecimal.ZERO) > 0 ? "warning" : "normal", false));
|
||||
response.getFundPool().add(metric("shareBonusPool", "推广奖金总额", shareBonus, "元", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("integral", "积分总额", score, "分", null, null, "normal", false));
|
||||
response.getFundPool().add(metric("withdrawPending", "待审核提现", pendingWithdraw, "元", pendingWithdrawCount + " 笔", null, pendingWithdrawCount > 0 ? "danger" : "normal", false));
|
||||
}
|
||||
|
||||
private void buildSnapshots(BossDashboardResponse response) {
|
||||
DateTime today = DateUtil.date();
|
||||
DateRange morningRange = range(today, "00:00:00", "10:15:59");
|
||||
DateRange afternoonRange = range(today, "10:16:00", "14:55:59");
|
||||
|
||||
DailyMetrics morningMetrics = buildDailyMetrics(morningRange);
|
||||
BossDashboardResponse.TodaySnapshot morning = snapshot("1015", "10:15 上午快报", morningRange.end, "上午抢购节点已完成,上一日寄卖商品消化情况请关注成交额、付款和采购用户。", morningMetrics, "success");
|
||||
response.getSnapshots().add(morning);
|
||||
|
||||
String afternoonStatus = new Date().after(afternoonRange.end) ? "success" : "pending";
|
||||
String afternoonMessage = "success".equals(afternoonStatus)
|
||||
? "下午寄卖/转卖节点已完成,请关注用户抢购商品的再次上架与转卖承接。"
|
||||
: "下午寄卖/转卖节点尚未生成,预计 14:55 后可查看用户抢购商品的再次上架情况。";
|
||||
DailyMetrics afternoonMetrics = "success".equals(afternoonStatus) ? buildDailyMetrics(afternoonRange) : new DailyMetrics();
|
||||
response.getSnapshots().add(snapshot("1455", "14:55 下午快报", afternoonRange.end, afternoonMessage, afternoonMetrics, afternoonStatus));
|
||||
}
|
||||
|
||||
private void buildTrends(BossDashboardResponse response, DateTime businessDate) {
|
||||
for (int i = 6; i >= 0; i--) {
|
||||
DateTime date = DateUtil.offsetDay(businessDate, -i);
|
||||
DailyMetrics metrics = buildDailyMetrics(dayRange(date));
|
||||
BossDashboardResponse.TrendPoint point = new BossDashboardResponse.TrendPoint();
|
||||
point.setDate(date.toString("MM-dd"));
|
||||
point.setAmount(metrics.dealAmount);
|
||||
point.setOrders(metrics.orderCount);
|
||||
point.setNewUsers(metrics.newUsers);
|
||||
point.setBonus(metrics.selfBonus.add(metrics.shareBonus));
|
||||
response.getTrends().add(point);
|
||||
}
|
||||
}
|
||||
|
||||
private void buildRanks(BossDashboardResponse response) {
|
||||
QueryWrapper<WaUsers> userWrapper = new QueryWrapper<WaUsers>()
|
||||
.select("id", "nickname", "mobile", "self_bonus", "share_bonus", "coupon", "score")
|
||||
.orderByDesc("IFNULL(self_bonus,0) + IFNULL(share_bonus,0) + IFNULL(coupon,0)")
|
||||
.last("limit 3");
|
||||
List<WaUsers> users = waUsersDao.selectList(userWrapper);
|
||||
for (int i = 0; i < users.size(); i++) {
|
||||
WaUsers user = users.get(i);
|
||||
BigDecimal value = defaultDecimal(user.getSelfBonus()).add(defaultDecimal(user.getShareBonus())).add(defaultDecimal(user.getCoupon()));
|
||||
response.getUserRanks().add(rank("u" + user.getId(), displayName(user), value, maskMobile(user.getMobile()), i == 0 ? "高价值" : null));
|
||||
}
|
||||
|
||||
QueryWrapper<WaUsers> teamWrapper = new QueryWrapper<WaUsers>()
|
||||
.select("pid as id", "COUNT(id) as memberCount", "IFNULL(SUM(self_bonus),0) as selfBonus", "IFNULL(SUM(share_bonus),0) as shareBonus")
|
||||
.isNotNull("pid")
|
||||
.gt("pid", 0)
|
||||
.groupBy("pid")
|
||||
.orderByDesc("IFNULL(SUM(self_bonus),0) + IFNULL(SUM(share_bonus),0)")
|
||||
.last("limit 3");
|
||||
List<Map<String, Object>> teams = waUsersDao.selectMaps(teamWrapper);
|
||||
for (int i = 0; i < teams.size(); i++) {
|
||||
Map<String, Object> team = teams.get(i);
|
||||
BigDecimal selfBonus = decimal(team.get("selfBonus"));
|
||||
BigDecimal shareBonus = decimal(team.get("shareBonus"));
|
||||
String leaderId = stringValue(team.get("id"));
|
||||
WaUsers leader = waUsersDao.selectById(leaderId);
|
||||
String leaderName = leader == null ? "团队 " + leaderId : displayName(leader);
|
||||
response.getTeamRanks().add(rank("t" + leaderId, leaderName, selfBonus.add(shareBonus), "成员 " + intValue(team.get("memberCount")) + " 人", i == 0 ? "TOP1" : null));
|
||||
}
|
||||
|
||||
QueryWrapper<WaMerchandise> productWrapper = new QueryWrapper<WaMerchandise>()
|
||||
.select("id", "title", "price", "created_at")
|
||||
.eq("status", 1)
|
||||
.orderByDesc("price")
|
||||
.last("limit 3");
|
||||
List<WaMerchandise> products = waMerchandiseDao.selectList(productWrapper);
|
||||
for (int i = 0; i < products.size(); i++) {
|
||||
WaMerchandise product = products.get(i);
|
||||
response.getProductRanks().add(rank("p" + product.getId(), StrUtil.isBlank(product.getTitle()) ? "未命名商品" : product.getTitle(), defaultDecimal(product.getPrice()), "高货值待成交", i == 0 ? "滞销" : null));
|
||||
}
|
||||
}
|
||||
|
||||
private void buildRisks(BossDashboardResponse response) {
|
||||
BigDecimal pendingWithdraw = sumWithdrawAmount(0);
|
||||
if (pendingWithdraw.compareTo(BigDecimal.ZERO) > 0) {
|
||||
response.getRisks().add(risk("r1", "red", "资金", "待审核提现", "当前待审核提现 " + pendingWithdraw + " 元,建议今日处理。"));
|
||||
}
|
||||
|
||||
Integer pendingOrders = countPendingOrders();
|
||||
if (pendingOrders > 0) {
|
||||
response.getRisks().add(risk("r2", "yellow", "订单", "待支付订单未处理", "当前存在 " + pendingOrders + " 笔待支付订单,请关注付款转化。"));
|
||||
}
|
||||
|
||||
Integer hiddenProducts = countHiddenMerchandise();
|
||||
if (hiddenProducts > 0) {
|
||||
response.getRisks().add(risk("r3", "gray", "商品", "隐藏寄售商品", "当前存在 " + hiddenProducts + " 个隐藏寄售商品,可按需核查。"));
|
||||
}
|
||||
}
|
||||
|
||||
private BossDashboardResponse.KpiMetric metric(String key, String title, Object value, String unit, String trendLabel, BigDecimal trendValue, String status, Boolean featured) {
|
||||
BossDashboardResponse.KpiMetric metric = new BossDashboardResponse.KpiMetric();
|
||||
metric.setKey(key);
|
||||
metric.setTitle(title);
|
||||
metric.setValue(value);
|
||||
metric.setUnit(unit);
|
||||
metric.setTrendLabel(trendLabel);
|
||||
metric.setTrendValue(trendValue);
|
||||
metric.setStatus(status);
|
||||
metric.setFeatured(featured);
|
||||
return metric;
|
||||
}
|
||||
|
||||
private BossDashboardResponse.TodaySnapshot snapshot(String slot, String title, Date generatedAt, String message, DailyMetrics metrics, String status) {
|
||||
BossDashboardResponse.TodaySnapshot snapshot = new BossDashboardResponse.TodaySnapshot();
|
||||
snapshot.setSlot(slot);
|
||||
snapshot.setTitle(title);
|
||||
snapshot.setStatus(status);
|
||||
snapshot.setGeneratedAt(DateUtil.formatDateTime(generatedAt));
|
||||
snapshot.setMessage(message);
|
||||
snapshot.setPurchaseUsers(metrics.purchaseUsers);
|
||||
snapshot.setOrderCount(metrics.orderCount);
|
||||
snapshot.setDealAmount(metrics.dealAmount);
|
||||
snapshot.setPaidAmount(metrics.dealAmount);
|
||||
snapshot.setNewMerchandiseCount(metrics.newMerchandiseCount);
|
||||
snapshot.setSelfBonusChange(metrics.selfBonus);
|
||||
snapshot.setShareBonusChange(metrics.shareBonus);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private BossDashboardResponse.RankItem rank(String id, String name, BigDecimal value, String description, String badge) {
|
||||
BossDashboardResponse.RankItem rank = new BossDashboardResponse.RankItem();
|
||||
rank.setId(id);
|
||||
rank.setName(name);
|
||||
rank.setValue(value);
|
||||
rank.setDescription(description);
|
||||
rank.setBadge(badge);
|
||||
return rank;
|
||||
}
|
||||
|
||||
private BossDashboardResponse.RiskAlert risk(String id, String level, String type, String title, String description) {
|
||||
BossDashboardResponse.RiskAlert risk = new BossDashboardResponse.RiskAlert();
|
||||
risk.setId(id);
|
||||
risk.setLevel(level);
|
||||
risk.setType(type);
|
||||
risk.setTitle(title);
|
||||
risk.setDescription(description);
|
||||
risk.setDiscoveredAt(DateUtil.format(new Date(), "HH:mm"));
|
||||
return risk;
|
||||
}
|
||||
|
||||
private void appendMetricsSection(StringBuilder html, String title, List<BossDashboardResponse.KpiMetric> metrics) {
|
||||
html.append("<section class=\"section\"><h2>").append(escape(title)).append("</h2><div class=\"grid\">");
|
||||
for (BossDashboardResponse.KpiMetric metric : metrics) {
|
||||
html.append("<article class=\"card\"><small>").append(escape(metric.getTitle())).append("</small>");
|
||||
html.append("<strong>").append(formatMetric(metric.getValue(), metric.getUnit())).append("</strong>");
|
||||
if (StrUtil.isNotBlank(metric.getTrendLabel()) || metric.getTrendValue() != null) {
|
||||
html.append("<small>").append(escape(metric.getTrendLabel()));
|
||||
if (metric.getTrendValue() != null) {
|
||||
html.append(" ").append(metric.getTrendValue()).append("%");
|
||||
}
|
||||
html.append("</small>");
|
||||
}
|
||||
html.append("</article>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private void appendTrendSection(StringBuilder html, BossDashboardResponse data) {
|
||||
html.append("<section class=\"section\"><h2>最近 7 天趋势</h2><div class=\"list\">");
|
||||
for (BossDashboardResponse.TrendPoint point : data.getTrends()) {
|
||||
html.append("<div class=\"item\"><strong>").append(escape(point.getDate())).append(":").append(formatMoney(point.getAmount())).append("</strong>");
|
||||
html.append("<small>").append(point.getOrders()).append(" 单 / 新增用户 ").append(point.getNewUsers()).append(" / 奖金 ").append(formatMoney(point.getBonus())).append("</small></div>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private void appendRankSection(StringBuilder html, String title, List<BossDashboardResponse.RankItem> ranks) {
|
||||
html.append("<section class=\"section\"><h2>").append(escape(title)).append("</h2><div class=\"list\">");
|
||||
if (ranks.isEmpty()) {
|
||||
html.append("<div class=\"item\"><strong>暂无数据</strong><small>当前实时数据未生成该排行。</small></div>");
|
||||
}
|
||||
for (int i = 0; i < ranks.size(); i++) {
|
||||
BossDashboardResponse.RankItem rank = ranks.get(i);
|
||||
html.append("<div class=\"item\"><strong>").append(i + 1).append(". ").append(escape(rank.getName())).append(" · ").append(formatMoney(rank.getValue())).append("</strong>");
|
||||
html.append("<small>").append(escape(rank.getDescription()));
|
||||
if (StrUtil.isNotBlank(rank.getBadge())) {
|
||||
html.append(" / ").append(escape(rank.getBadge()));
|
||||
}
|
||||
html.append("</small></div>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private void appendRiskSection(StringBuilder html, BossDashboardResponse data) {
|
||||
html.append("<section class=\"section\"><h2>风险预警</h2><div class=\"list\">");
|
||||
if (data.getRisks().isEmpty()) {
|
||||
html.append("<div class=\"item\"><strong>暂无风险</strong><small>当前实时数据未触发风险预警。</small></div>");
|
||||
}
|
||||
for (BossDashboardResponse.RiskAlert risk : data.getRisks()) {
|
||||
html.append("<div class=\"item risk-").append(escape(risk.getLevel())).append("\"><strong>");
|
||||
html.append(escape(risk.getType())).append(" / ").append(escape(risk.getTitle())).append("</strong>");
|
||||
html.append("<p>").append(escape(risk.getDescription())).append("</p>");
|
||||
html.append("<small>发现时间:").append(escape(risk.getDiscoveredAt())).append("</small></div>");
|
||||
}
|
||||
html.append("</div></section>");
|
||||
}
|
||||
|
||||
private BigDecimal sumOrderAmount(Date start, Date end, Boolean paidOnly, Boolean isResell) {
|
||||
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().select("IFNULL(SUM(total_money),0) as total").between("buy_time", start, end);
|
||||
wrapper.eq("is_cancel", 0);
|
||||
if (Boolean.TRUE.equals(paidOnly)) {
|
||||
wrapper.ge("status", 1);
|
||||
} else if (Boolean.FALSE.equals(paidOnly)) {
|
||||
wrapper.eq("status", 0);
|
||||
}
|
||||
if (isResell != null) {
|
||||
wrapper.eq("is_resell", isResell ? 1 : 0);
|
||||
}
|
||||
return aggregateDecimal(waOrderDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private Integer countOrders(Date start, Date end, Boolean isResell) {
|
||||
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().between("buy_time", start, end).eq("is_cancel", 0);
|
||||
if (isResell != null) {
|
||||
wrapper.eq("is_resell", isResell ? 1 : 0);
|
||||
}
|
||||
return waOrderDao.selectCount(wrapper);
|
||||
}
|
||||
|
||||
private Integer distinctBuyerCount(Date start, Date end, Boolean isResell) {
|
||||
QueryWrapper<WaOrder> wrapper = new QueryWrapper<WaOrder>().select("COUNT(DISTINCT buyer_id) as total").between("buy_time", start, end).eq("is_cancel", 0);
|
||||
if (isResell != null) {
|
||||
wrapper.eq("is_resell", isResell ? 1 : 0);
|
||||
}
|
||||
return aggregateInt(waOrderDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private Integer countUsers(Date start, Date end) {
|
||||
return waUsersDao.selectCount(new QueryWrapper<WaUsers>().between("join_time", start, end));
|
||||
}
|
||||
|
||||
private Integer countMerchandise(Date start, Date end) {
|
||||
return waMerchandiseDao.selectCount(new QueryWrapper<WaMerchandise>().between("created_at", start, end));
|
||||
}
|
||||
|
||||
private BigDecimal sumSelfBonus(Date start, Date end) {
|
||||
QueryWrapper<WaSelfbonusLog> wrapper = new QueryWrapper<WaSelfbonusLog>().select("IFNULL(SUM(money),0) as total").eq("type", 1).between("created_at", start, end);
|
||||
return aggregateDecimal(waSelfbonusLogDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private BigDecimal sumShareBonus(Date start, Date end) {
|
||||
QueryWrapper<WaSharebonusLog> wrapper = new QueryWrapper<WaSharebonusLog>().select("IFNULL(SUM(money),0) as total").eq("type", 1).between("created_at", start, end);
|
||||
return aggregateDecimal(waSharebonusLogDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private BigDecimal sumUsersDecimal(String column) {
|
||||
QueryWrapper<WaUsers> wrapper = new QueryWrapper<WaUsers>().select("IFNULL(SUM(" + column + "),0) as total");
|
||||
return aggregateDecimal(waUsersDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private BigDecimal sumWithdrawAmount(Integer status) {
|
||||
QueryWrapper<WaWithdraw> wrapper = new QueryWrapper<WaWithdraw>().select("IFNULL(SUM(money),0) as total").eq("status", status);
|
||||
return aggregateDecimal(waWithdrawDao.selectMaps(wrapper), "total");
|
||||
}
|
||||
|
||||
private Integer countWithdraw(Integer status) {
|
||||
return waWithdrawDao.selectCount(new QueryWrapper<WaWithdraw>().eq("status", status));
|
||||
}
|
||||
|
||||
private Integer countPendingOrders() {
|
||||
return waOrderDao.selectCount(new QueryWrapper<WaOrder>().eq("status", 0).eq("is_cancel", 0));
|
||||
}
|
||||
|
||||
private Integer countHiddenMerchandise() {
|
||||
return waMerchandiseDao.selectCount(new QueryWrapper<WaMerchandise>().eq("is_show", 0));
|
||||
}
|
||||
|
||||
private BigDecimal ratio(BigDecimal current, BigDecimal previous) {
|
||||
if (previous == null || previous.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return current != null && current.compareTo(BigDecimal.ZERO) > 0 ? BigDecimal.valueOf(100) : BigDecimal.ZERO;
|
||||
}
|
||||
return current.subtract(previous).multiply(BigDecimal.valueOf(100)).divide(previous, 1, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private BigDecimal ratio(Integer current, Integer previous) {
|
||||
return ratio(BigDecimal.valueOf(current == null ? 0 : current), BigDecimal.valueOf(previous == null ? 0 : previous));
|
||||
}
|
||||
|
||||
private String statusByRatio(BigDecimal current, BigDecimal previous) {
|
||||
BigDecimal value = ratio(current, previous);
|
||||
return value.compareTo(BigDecimal.ZERO) >= 0 ? "success" : "warning";
|
||||
}
|
||||
|
||||
private String statusByRatio(Integer current, Integer previous) {
|
||||
return statusByRatio(BigDecimal.valueOf(current == null ? 0 : current), BigDecimal.valueOf(previous == null ? 0 : previous));
|
||||
}
|
||||
|
||||
private String buildSummary(DailyMetrics metrics) {
|
||||
if (metrics.dealAmount.compareTo(BigDecimal.ZERO) == 0 && metrics.orderCount == 0) {
|
||||
return "当前日期暂无成交数据,请关注抢购与寄卖节点是否正常生成。";
|
||||
}
|
||||
return "上个工作日成交 " + metrics.dealAmount + " 元,订单 " + metrics.orderCount + " 单,采购用户 " + metrics.purchaseUsers + " 人;请重点关注待支付、提现和寄售供给。";
|
||||
}
|
||||
|
||||
private DateRange dayRange(DateTime date) {
|
||||
return new DateRange(DateUtil.beginOfDay(date), DateUtil.endOfDay(date));
|
||||
}
|
||||
|
||||
private DateTime previousWorkday(DateTime referenceDate) {
|
||||
DateTime date = DateUtil.offsetDay(referenceDate, -1);
|
||||
while (isWeekend(date)) {
|
||||
date = DateUtil.offsetDay(date, -1);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
private boolean isWeekend(DateTime date) {
|
||||
int dayOfWeek = DateUtil.dayOfWeek(date);
|
||||
return dayOfWeek == Calendar.SATURDAY || dayOfWeek == Calendar.SUNDAY;
|
||||
}
|
||||
|
||||
private DateRange range(DateTime date, String startTime, String endTime) {
|
||||
String day = date.toString("yyyy-MM-dd");
|
||||
return new DateRange(DateUtil.parse(day + " " + startTime), DateUtil.parse(day + " " + endTime));
|
||||
}
|
||||
|
||||
private BigDecimal aggregateDecimal(List<Map<String, Object>> maps, String key) {
|
||||
if (maps == null || maps.isEmpty()) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return decimal(maps.get(0).get(key));
|
||||
}
|
||||
|
||||
private Integer aggregateInt(List<Map<String, Object>> maps, String key) {
|
||||
if (maps == null || maps.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return intValue(maps.get(0).get(key));
|
||||
}
|
||||
|
||||
private BigDecimal decimal(Object value) {
|
||||
if (value == null) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
if (value instanceof BigDecimal) {
|
||||
return (BigDecimal) value;
|
||||
}
|
||||
return new BigDecimal(String.valueOf(value));
|
||||
}
|
||||
|
||||
private BigDecimal defaultDecimal(BigDecimal value) {
|
||||
return value == null ? BigDecimal.ZERO : value;
|
||||
}
|
||||
|
||||
private Integer intValue(Object value) {
|
||||
if (value == null) {
|
||||
return 0;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return ((Number) value).intValue();
|
||||
}
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String displayName(WaUsers user) {
|
||||
if (StrUtil.isNotBlank(user.getNickname())) {
|
||||
return user.getNickname();
|
||||
}
|
||||
if (StrUtil.isNotBlank(user.getUsername())) {
|
||||
return user.getUsername();
|
||||
}
|
||||
return "用户 " + user.getId();
|
||||
}
|
||||
|
||||
private String maskMobile(String mobile) {
|
||||
if (StrUtil.isBlank(mobile) || mobile.length() < 7) {
|
||||
return "手机号未完善";
|
||||
}
|
||||
return mobile.substring(0, 3) + "****" + mobile.substring(mobile.length() - 4);
|
||||
}
|
||||
|
||||
private String formatMetric(Object value, String unit) {
|
||||
if ("元".equals(unit)) {
|
||||
return formatMoney(decimal(value));
|
||||
}
|
||||
if (value == null) {
|
||||
return "--";
|
||||
}
|
||||
return escape(String.valueOf(value)) + (unit == null ? "" : escape(unit));
|
||||
}
|
||||
|
||||
private String formatMoney(BigDecimal value) {
|
||||
return "¥" + defaultDecimal(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
private String escape(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
private static class DailyMetrics {
|
||||
private BigDecimal dealAmount = BigDecimal.ZERO;
|
||||
private Integer orderCount = 0;
|
||||
private Integer purchaseUsers = 0;
|
||||
private Integer newUsers = 0;
|
||||
private Integer newMerchandiseCount = 0;
|
||||
private BigDecimal selfBonus = BigDecimal.ZERO;
|
||||
private BigDecimal shareBonus = BigDecimal.ZERO;
|
||||
private BigDecimal pendingAmount = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
private static class DateRange {
|
||||
private Date start;
|
||||
private Date end;
|
||||
|
||||
private DateRange(Date start, Date end) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.zbkj.service.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.zbkj.common.request.ExternalGrabUserRequest;
|
||||
import com.zbkj.common.request.PageParamRequest;
|
||||
import com.zbkj.common.response.ExternalGrabUserResponse;
|
||||
import com.zbkj.service.dao.consignment.ExternalGrabUserDao;
|
||||
import com.zbkj.service.service.ExternalGrabUserService;
|
||||
import com.zbkj.service.util.GrabUserFormatter;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 Service 实现(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Service
|
||||
public class ExternalGrabUserServiceImpl implements ExternalGrabUserService {
|
||||
|
||||
private static final ZoneId ZONE_CN = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
@Resource
|
||||
private ExternalGrabUserDao externalGrabUserDao;
|
||||
|
||||
@Override
|
||||
public PageInfo<ExternalGrabUserResponse> list(ExternalGrabUserRequest request,
|
||||
PageParamRequest pageParamRequest) {
|
||||
if (request == null) {
|
||||
request = new ExternalGrabUserRequest();
|
||||
}
|
||||
|
||||
// 时间窗(业务时区 CST)
|
||||
LocalDate today = LocalDate.now(ZONE_CN);
|
||||
Date todayStart = toDate(today.atStartOfDay());
|
||||
Date todayEnd = toDate(today.atTime(LocalTime.MAX));
|
||||
LocalDate yesterday = today.minusDays(1);
|
||||
Date yesterdayStart = toDate(yesterday.atStartOfDay());
|
||||
Date yesterdayEnd = toDate(yesterday.atTime(LocalTime.MAX));
|
||||
|
||||
// 分页(PageHelper 在执行下一条 SQL 时生效)
|
||||
int page = pageParamRequest != null && pageParamRequest.getPage() > 0 ? pageParamRequest.getPage() : 1;
|
||||
int limit = pageParamRequest != null && pageParamRequest.getLimit() > 0 ? Math.min(pageParamRequest.getLimit(), 100) : 15;
|
||||
PageHelper.startPage(page, limit);
|
||||
|
||||
List<ExternalGrabUserResponse> rows = externalGrabUserDao.selectGrabUserList(
|
||||
todayStart, todayEnd, yesterdayStart, yesterdayEnd,
|
||||
request.getUid(),
|
||||
StrUtil.trimToNull(request.getMobile()),
|
||||
request.getPid());
|
||||
|
||||
// 后处理:金额格式化、状态/等级文案
|
||||
for (ExternalGrabUserResponse row : rows) {
|
||||
row.setMoney(GrabUserFormatter.formatAmount(row.getMoneyRaw()));
|
||||
row.setCoupon(GrabUserFormatter.formatAmount(row.getCouponRaw()));
|
||||
row.setSelfBonus(GrabUserFormatter.formatAmount(row.getSelfBonusRaw()));
|
||||
row.setShareBonus(GrabUserFormatter.formatAmount(row.getShareBonusRaw()));
|
||||
row.setTodayBuyAmount(GrabUserFormatter.formatAmount(row.getTodayBuyAmountRaw()));
|
||||
row.setTodaySellAmount(GrabUserFormatter.formatAmount(row.getTodaySellAmountRaw()));
|
||||
row.setStatusStr(GrabUserFormatter.mapStatus(row.getStatus()));
|
||||
row.setLevelName(GrabUserFormatter.mapLevelName(row.getLevel()));
|
||||
}
|
||||
|
||||
return new PageInfo<>(rows);
|
||||
}
|
||||
|
||||
private static Date toDate(LocalDateTime dt) {
|
||||
return Date.from(dt.atZone(ZONE_CN).toInstant());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package com.zbkj.service.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.zbkj.common.exception.CrmebException;
|
||||
import com.zbkj.common.model.consignment.WaUsers;
|
||||
import com.zbkj.common.request.TeamDailyReportRequest;
|
||||
import com.zbkj.common.response.TeamDailyMemberRow;
|
||||
import com.zbkj.common.response.TeamDailyMultiReportResponse;
|
||||
import com.zbkj.common.response.TeamDailyReportResponse;
|
||||
import com.zbkj.common.response.TeamDailySummary;
|
||||
import com.zbkj.service.dao.consignment.TeamReportDao;
|
||||
import com.zbkj.service.dao.consignment.WaUsersDao;
|
||||
import com.zbkj.service.service.TeamReportExternalService;
|
||||
import com.zbkj.service.util.TeamReportFormula;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 Service 实现(外部免认证)
|
||||
*
|
||||
* 关键设计:
|
||||
* 1) 费率从 wa_setting 读取(service_rate / e_score_rate),缺省 0.02 / 0.005;
|
||||
* 2) BigDecimal 全程 HALF_UP 保留 2 位;
|
||||
* 3) 小计的服务费 / E积分 / 实际收付 由"成员级别已舍入数值"再求和,避免逐行二次舍入;
|
||||
* 4) 时区:业务时区 Asia/Shanghai。
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Service
|
||||
public class TeamReportExternalServiceImpl implements TeamReportExternalService {
|
||||
|
||||
private static final ZoneId ZONE_CN = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
private static final BigDecimal DEFAULT_SERVICE_RATE = new BigDecimal("0.02");
|
||||
private static final BigDecimal DEFAULT_E_SCORE_RATE = new BigDecimal("0.005");
|
||||
private static final int SCALE = 2;
|
||||
|
||||
@Resource
|
||||
private TeamReportDao teamReportDao;
|
||||
|
||||
@Resource
|
||||
private WaUsersDao waUsersDao;
|
||||
|
||||
@Override
|
||||
public TeamDailyReportResponse getDailyReport(TeamDailyReportRequest request) {
|
||||
if (request == null || request.getLeaderId() == null) {
|
||||
throw new CrmebException("团队长 ID 不能为空");
|
||||
}
|
||||
return assembleSingleTeam(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TeamDailyMultiReportResponse getMultiDailyReport(TeamDailyReportRequest request) {
|
||||
if (request == null) request = new TeamDailyReportRequest();
|
||||
|
||||
Ctx ctx = prepare(request);
|
||||
|
||||
// 拉取原始聚合(leaderId 为空时一次拿所有团队成员)
|
||||
List<TeamDailyMemberRow> rows = teamReportDao.selectTeamMemberAggregates(
|
||||
request.getLeaderId(), ctx.dStart, ctx.dEnd, ctx.pStart, ctx.pEnd,
|
||||
request.getIncludeDisabled(),
|
||||
(request.getMemberIds() != null && !request.getMemberIds().isEmpty())
|
||||
? request.getMemberIds() : null);
|
||||
if (rows == null) rows = new ArrayList<>();
|
||||
|
||||
// 公式计算
|
||||
for (TeamDailyMemberRow r : rows) {
|
||||
TeamReportFormula.applyFormula(r, ctx.serviceRate, ctx.eScoreRate);
|
||||
r.setRemark("");
|
||||
}
|
||||
|
||||
// 按 leaderId 分组(保持稳定顺序)
|
||||
Map<Integer, List<TeamDailyMemberRow>> groups = new LinkedHashMap<>();
|
||||
Set<Integer> leaderIds = new HashSet<>();
|
||||
for (TeamDailyMemberRow r : rows) {
|
||||
Integer lid = r.getLeaderId();
|
||||
if (lid == null || lid <= 0) continue;
|
||||
groups.computeIfAbsent(lid, k -> new ArrayList<>()).add(r);
|
||||
leaderIds.add(lid);
|
||||
}
|
||||
|
||||
// 批量拉取团队长信息
|
||||
Map<Integer, WaUsers> leaderMap = new HashMap<>();
|
||||
if (!leaderIds.isEmpty()) {
|
||||
for (WaUsers u : waUsersDao.selectBatchIds(leaderIds)) {
|
||||
leaderMap.put(u.getId(), u);
|
||||
}
|
||||
}
|
||||
|
||||
// 组装每个团队子报表
|
||||
List<TeamDailyReportResponse> teams = new ArrayList<>(groups.size());
|
||||
TeamDailySummary grand = new TeamDailySummary();
|
||||
int totalMembers = 0;
|
||||
for (Map.Entry<Integer, List<TeamDailyMemberRow>> e : groups.entrySet()) {
|
||||
Integer lid = e.getKey();
|
||||
List<TeamDailyMemberRow> teamRows = e.getValue();
|
||||
WaUsers leader = leaderMap.get(lid);
|
||||
String leaderNickname = leader != null ? leader.getNickname() : null;
|
||||
String teamCode = leader != null && StrUtil.isNotBlank(leader.getInvite())
|
||||
? leader.getInvite()
|
||||
: String.valueOf(lid);
|
||||
|
||||
TeamDailySummary teamSummary = TeamReportFormula.aggregateSummary(teamRows);
|
||||
|
||||
teams.add(new TeamDailyReportResponse()
|
||||
.setLeaderId(lid)
|
||||
.setLeaderNickname(leaderNickname)
|
||||
.setTeamCode(teamCode)
|
||||
.setMemberCount(teamRows.size())
|
||||
.setDate(ctx.d.format(DATE_FMT))
|
||||
.setPreviousDate(ctx.prev.format(DATE_FMT))
|
||||
.setServiceRate(ctx.serviceRate)
|
||||
.setEScoreRate(ctx.eScoreRate)
|
||||
.setRows(teamRows)
|
||||
.setSummary(teamSummary));
|
||||
|
||||
// 累加 grand summary
|
||||
grand.setPrevBuy(grand.getPrevBuy().add(teamSummary.getPrevBuy()));
|
||||
grand.setTodaySell(grand.getTodaySell().add(teamSummary.getTodaySell()));
|
||||
grand.setTodayBuy(grand.getTodayBuy().add(teamSummary.getTodayBuy()));
|
||||
grand.setServiceFee(grand.getServiceFee().add(teamSummary.getServiceFee()));
|
||||
grand.setEScore(grand.getEScore().add(teamSummary.getEScore()));
|
||||
grand.setActual(grand.getActual().add(teamSummary.getActual()));
|
||||
totalMembers += teamRows.size();
|
||||
}
|
||||
|
||||
return new TeamDailyMultiReportResponse()
|
||||
.setDate(ctx.d.format(DATE_FMT))
|
||||
.setPreviousDate(ctx.prev.format(DATE_FMT))
|
||||
.setServiceRate(ctx.serviceRate)
|
||||
.setEScoreRate(ctx.eScoreRate)
|
||||
.setTeamCount(teams.size())
|
||||
.setTotalMemberCount(totalMembers)
|
||||
.setTeams(teams)
|
||||
.setGrandSummary(grand);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TeamDailyReportResponse exportDailyReport(TeamDailyReportRequest request, OutputStream out) throws IOException {
|
||||
if (request == null || request.getLeaderId() == null) {
|
||||
throw new CrmebException("Excel 导出仅支持单团队,必须指定团队长 ID");
|
||||
}
|
||||
TeamDailyReportResponse data = assembleSingleTeam(request);
|
||||
writeExcel(data, out);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 主流程:单团队装配
|
||||
// ------------------------------------------------------------------
|
||||
private TeamDailyReportResponse assembleSingleTeam(TeamDailyReportRequest request) {
|
||||
// 校验团队长存在
|
||||
WaUsers leader = waUsersDao.selectById(request.getLeaderId());
|
||||
if (leader == null) {
|
||||
throw new CrmebException("团队长不存在");
|
||||
}
|
||||
|
||||
Ctx ctx = prepare(request);
|
||||
|
||||
// 拉取原始聚合
|
||||
List<TeamDailyMemberRow> rows = teamReportDao.selectTeamMemberAggregates(
|
||||
request.getLeaderId(), ctx.dStart, ctx.dEnd, ctx.pStart, ctx.pEnd,
|
||||
request.getIncludeDisabled(),
|
||||
(request.getMemberIds() != null && !request.getMemberIds().isEmpty())
|
||||
? request.getMemberIds() : null);
|
||||
if (rows == null) rows = new ArrayList<>();
|
||||
|
||||
for (TeamDailyMemberRow r : rows) {
|
||||
TeamReportFormula.applyFormula(r, ctx.serviceRate, ctx.eScoreRate);
|
||||
r.setRemark("");
|
||||
}
|
||||
TeamDailySummary summary = TeamReportFormula.aggregateSummary(rows);
|
||||
|
||||
String teamCode = StrUtil.isNotBlank(leader.getInvite())
|
||||
? leader.getInvite()
|
||||
: String.valueOf(leader.getId());
|
||||
|
||||
return new TeamDailyReportResponse()
|
||||
.setLeaderId(leader.getId())
|
||||
.setLeaderNickname(leader.getNickname())
|
||||
.setTeamCode(teamCode)
|
||||
.setMemberCount(rows.size())
|
||||
.setDate(ctx.d.format(DATE_FMT))
|
||||
.setPreviousDate(ctx.prev.format(DATE_FMT))
|
||||
.setServiceRate(ctx.serviceRate)
|
||||
.setEScoreRate(ctx.eScoreRate)
|
||||
.setRows(rows)
|
||||
.setSummary(summary);
|
||||
}
|
||||
|
||||
/** 单/多团队共用的上下文(日期、时间窗、费率) */
|
||||
private Ctx prepare(TeamDailyReportRequest request) {
|
||||
LocalDate today = LocalDate.now(ZONE_CN);
|
||||
LocalDate d;
|
||||
if (StrUtil.isNotBlank(request.getDate())) {
|
||||
d = LocalDate.parse(request.getDate(), DATE_FMT);
|
||||
} else {
|
||||
d = today.minusDays(1);
|
||||
}
|
||||
if (d.isAfter(today)) {
|
||||
throw new CrmebException("不能查询未来日期");
|
||||
}
|
||||
LocalDate prev = d.minusDays(1);
|
||||
Ctx ctx = new Ctx();
|
||||
ctx.d = d;
|
||||
ctx.prev = prev;
|
||||
ctx.dStart = toDate(d, true);
|
||||
ctx.dEnd = toDate(d, false);
|
||||
ctx.pStart = toDate(prev, true);
|
||||
ctx.pEnd = toDate(prev, false);
|
||||
ctx.serviceRate = readRate("service_rate", DEFAULT_SERVICE_RATE);
|
||||
ctx.eScoreRate = readRate("e_score_rate", DEFAULT_E_SCORE_RATE);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Service 内部上下文 */
|
||||
private static class Ctx {
|
||||
LocalDate d;
|
||||
LocalDate prev;
|
||||
Date dStart;
|
||||
Date dEnd;
|
||||
Date pStart;
|
||||
Date pEnd;
|
||||
BigDecimal serviceRate;
|
||||
BigDecimal eScoreRate;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 工具方法
|
||||
// ------------------------------------------------------------------
|
||||
private static Date toDate(LocalDate date, boolean start) {
|
||||
return Date.from((start ? date.atStartOfDay() : date.atTime(LocalTime.MAX))
|
||||
.atZone(ZONE_CN).toInstant());
|
||||
}
|
||||
|
||||
private BigDecimal readRate(String name, BigDecimal fallback) {
|
||||
String v = teamReportDao.selectSettingValue(name);
|
||||
if (StrUtil.isBlank(v)) return fallback;
|
||||
try {
|
||||
return new BigDecimal(v.trim());
|
||||
} catch (Exception e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Excel 导出(Apache POI)
|
||||
// ------------------------------------------------------------------
|
||||
private void writeExcel(TeamDailyReportResponse data, OutputStream out) throws IOException {
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("团队日报");
|
||||
|
||||
// 表头横幅:团队长 昵称 · N 人 · 日期
|
||||
Row banner = sheet.createRow(0);
|
||||
Cell bannerCell = banner.createCell(0);
|
||||
String leaderShown = data.getLeaderNickname() != null && !data.getLeaderNickname().isEmpty()
|
||||
? data.getLeaderNickname()
|
||||
: (data.getTeamCode() == null ? "" : data.getTeamCode());
|
||||
bannerCell.setCellValue(String.format("团队长 %s · %d 人 · %s",
|
||||
leaderShown, data.getMemberCount(), data.getDate()));
|
||||
CellStyle bannerStyle = wb.createCellStyle();
|
||||
Font bannerFont = wb.createFont();
|
||||
bannerFont.setBold(true);
|
||||
bannerFont.setFontHeightInPoints((short) 14);
|
||||
bannerStyle.setFont(bannerFont);
|
||||
bannerStyle.setAlignment(HorizontalAlignment.CENTER);
|
||||
bannerCell.setCellStyle(bannerStyle);
|
||||
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 8));
|
||||
|
||||
// 表头
|
||||
String[] headers = {
|
||||
"昵称",
|
||||
data.getPreviousDate() + " 买单",
|
||||
data.getDate() + " 卖单",
|
||||
data.getDate() + " 买单",
|
||||
"服务费*" + data.getServiceRate().toPlainString(),
|
||||
"E积分",
|
||||
"实际收付",
|
||||
"团队",
|
||||
"备注"
|
||||
};
|
||||
CellStyle headStyle = wb.createCellStyle();
|
||||
Font headFont = wb.createFont();
|
||||
headFont.setBold(true);
|
||||
headStyle.setFont(headFont);
|
||||
headStyle.setAlignment(HorizontalAlignment.CENTER);
|
||||
headStyle.setFillForegroundColor(IndexedColors.LIGHT_GREEN.getIndex());
|
||||
headStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
applyBorders(headStyle);
|
||||
Row headRow = sheet.createRow(1);
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
Cell cell = headRow.createCell(i);
|
||||
cell.setCellValue(headers[i]);
|
||||
cell.setCellStyle(headStyle);
|
||||
}
|
||||
|
||||
// 小计行(紧跟表头)
|
||||
CellStyle summaryStyle = wb.createCellStyle();
|
||||
Font summaryFont = wb.createFont();
|
||||
summaryFont.setBold(true);
|
||||
summaryFont.setColor(IndexedColors.RED.getIndex());
|
||||
summaryStyle.setFont(summaryFont);
|
||||
applyBorders(summaryStyle);
|
||||
|
||||
CellStyle moneyStyle = wb.createCellStyle();
|
||||
moneyStyle.setDataFormat(wb.createDataFormat().getFormat("#,##0.00;-#,##0.00"));
|
||||
applyBorders(moneyStyle);
|
||||
|
||||
CellStyle moneyBoldRedStyle = wb.createCellStyle();
|
||||
moneyBoldRedStyle.cloneStyleFrom(moneyStyle);
|
||||
moneyBoldRedStyle.setFont(summaryFont);
|
||||
|
||||
Row summaryRow = sheet.createRow(2);
|
||||
TeamDailySummary s = data.getSummary();
|
||||
summaryRow.createCell(0).setCellValue("小计");
|
||||
summaryRow.getCell(0).setCellStyle(summaryStyle);
|
||||
writeMoney(summaryRow, 1, s.getPrevBuy(), moneyBoldRedStyle);
|
||||
writeMoney(summaryRow, 2, s.getTodaySell(), moneyBoldRedStyle);
|
||||
writeMoney(summaryRow, 3, s.getTodayBuy(), moneyBoldRedStyle);
|
||||
writeMoney(summaryRow, 4, s.getServiceFee(), moneyBoldRedStyle);
|
||||
writeMoney(summaryRow, 5, s.getEScore(), moneyBoldRedStyle);
|
||||
writeMoney(summaryRow, 6, s.getActual(), moneyBoldRedStyle);
|
||||
summaryRow.createCell(7).setCellStyle(summaryStyle);
|
||||
summaryRow.createCell(8).setCellStyle(summaryStyle);
|
||||
|
||||
// 数据行(团队列填团队长昵称)
|
||||
int rowIdx = 3;
|
||||
for (TeamDailyMemberRow r : data.getRows()) {
|
||||
Row row = sheet.createRow(rowIdx++);
|
||||
row.createCell(0).setCellValue(r.getNickname() == null ? "" : r.getNickname());
|
||||
writeMoney(row, 1, r.getPrevBuy(), moneyStyle);
|
||||
writeMoney(row, 2, r.getTodaySell(), moneyStyle);
|
||||
writeMoney(row, 3, r.getTodayBuy(), moneyStyle);
|
||||
writeMoney(row, 4, r.getServiceFee(), moneyStyle);
|
||||
writeMoney(row, 5, r.getEScore(), moneyStyle);
|
||||
writeMoney(row, 6, r.getActual(), moneyStyle);
|
||||
row.createCell(7).setCellValue(leaderShown);
|
||||
// 备注列保留空白(运营手填)
|
||||
row.createCell(8).setCellValue("");
|
||||
}
|
||||
|
||||
// 列宽
|
||||
int[] widths = { 14, 14, 14, 14, 16, 12, 14, 8, 20 };
|
||||
for (int i = 0; i < widths.length; i++) {
|
||||
sheet.setColumnWidth(i, widths[i] * 256);
|
||||
}
|
||||
|
||||
wb.write(out);
|
||||
out.flush();
|
||||
}
|
||||
}
|
||||
|
||||
private void writeMoney(Row row, int col, BigDecimal value, CellStyle style) {
|
||||
Cell cell = row.createCell(col);
|
||||
cell.setCellValue(value == null ? 0d : value.doubleValue());
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
|
||||
private void applyBorders(CellStyle style) {
|
||||
style.setBorderTop(BorderStyle.THIN);
|
||||
style.setBorderBottom(BorderStyle.THIN);
|
||||
style.setBorderLeft(BorderStyle.THIN);
|
||||
style.setBorderRight(BorderStyle.THIN);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,265 @@
|
||||
package com.zbkj.service.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.zbkj.common.model.consignment.WaSelfbonusLog;
|
||||
import com.zbkj.common.model.user.User;
|
||||
import com.zbkj.common.model.user.UserIntegralRecord;
|
||||
import com.zbkj.common.utils.CrmebUtil;
|
||||
import com.zbkj.service.dao.UserDao;
|
||||
import com.zbkj.service.dao.UserIntegralRecordDao;
|
||||
import com.zbkj.service.dao.consignment.WaSelfbonusLogDao;
|
||||
import com.zbkj.service.service.UserService;
|
||||
import com.zbkj.service.service.WaSelfbonusSyncService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 保留并发安全服务 bean 名称兼容历史调用。
|
||||
* 实际逻辑统一委托到 waSelfbonusSyncService,避免双实现规则漂移。
|
||||
* 用户积分并发安全服务实现类
|
||||
* 专门处理高并发场景下的积分更新,避免数据库锁等待超时
|
||||
*/
|
||||
@Slf4j
|
||||
@Service("userIntegralConcurrencyService")
|
||||
public class UserIntegralConcurrencyServiceImpl {
|
||||
|
||||
@Autowired
|
||||
private WaSelfbonusSyncService waSelfbonusSyncService;
|
||||
private WaSelfbonusLogDao waSelfbonusLogDao;
|
||||
|
||||
@Autowired
|
||||
private UserIntegralRecordDao userIntegralRecordDao;
|
||||
|
||||
@Autowired
|
||||
private UserDao userDao;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
// 使用ConcurrentHashMap来缓存正在处理的用户ID,防止重复处理
|
||||
private final ConcurrentHashMap<Integer, Object> processingUsers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 同步个人奖金变动到用户积分 - 并发安全版本
|
||||
* 根据个人奖金变动记录,为对应的用户增加积分(奖金金额的50%)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> syncSelfbonusToIntegral() {
|
||||
return waSelfbonusSyncService.syncSelfbonusToIntegral();
|
||||
log.info("开始同步个人奖金变动到用户积分(并发安全版)");
|
||||
|
||||
int successCount = 0;
|
||||
int skipCount = 0;
|
||||
int failCount = 0;
|
||||
|
||||
try {
|
||||
// 查询最新的个人奖金变动记录
|
||||
LambdaQueryWrapper<WaSelfbonusLog> bonusLogWrapper = new LambdaQueryWrapper<>();
|
||||
bonusLogWrapper.orderByDesc(WaSelfbonusLog::getCreatedAt); // 按创建时间倒序
|
||||
bonusLogWrapper.last("LIMIT 500"); // 限制查询500条,避免一次性处理过多
|
||||
List<WaSelfbonusLog> bonusLogList = waSelfbonusLogDao.selectList(bonusLogWrapper);
|
||||
|
||||
for (WaSelfbonusLog bonusLog : bonusLogList) {
|
||||
try {
|
||||
// 检查该奖金记录是否已经处理过
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, bonusLog.getId());
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
|
||||
if (existCount != null && existCount > 0) {
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取用户ID
|
||||
Integer ebUserId = bonusLog.getUserId();
|
||||
|
||||
// 使用同步块确保同一用户不会被重复处理
|
||||
Object lock = processingUsers.computeIfAbsent(ebUserId, k -> new Object());
|
||||
synchronized (lock) {
|
||||
try {
|
||||
// 再次检查积分记录是否已存在(双重检查)
|
||||
existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
if (existCount != null && existCount > 0) {
|
||||
log.debug("奖金记录已在其他线程处理,跳过: bonusLogId={}", bonusLog.getId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 验证奖金类型和金额
|
||||
if (!isValidBonusLog(bonusLog)) {
|
||||
log.debug("奖金记录不符合处理条件,跳过: bonusLogId={}, type={}, amount={}",
|
||||
bonusLog.getId(), bonusLog.getType(), bonusLog.getMoney());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算积分值
|
||||
BigDecimal integralValue = calculateIntegralValue(bonusLog.getMoney());
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0或负数,跳过: bonusLogId={}, integralValue={}",
|
||||
bonusLog.getId(), integralValue);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用CAS方式更新积分,避免锁竞争
|
||||
Boolean updateResult = updateIntegralWithRetry(user.getUid(), integralValue, "add", 3);
|
||||
|
||||
if (!updateResult) {
|
||||
log.error("更新用户积分失败(重试后): userId={}, integralValue={}", user.getUid(), integralValue);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 插入积分记录
|
||||
UserIntegralRecord integralRecord = createUserIntegralRecord(user.getUid(), bonusLog, integralValue);
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
log.error("插入积分记录失败: userId={}, bonusLogId={}", user.getUid(), bonusLog.getId());
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
successCount++;
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusLog.getMoney(), integralValue);
|
||||
|
||||
} finally {
|
||||
// 清理锁对象
|
||||
processingUsers.remove(ebUserId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
failCount++;
|
||||
log.error("处理奖金记录失败: bonusLogId={}, error={}", bonusLog.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("total", bonusLogList.size());
|
||||
result.put("successCount", successCount);
|
||||
result.put("skipCount", skipCount);
|
||||
result.put("failCount", failCount);
|
||||
|
||||
log.info("同步个人奖金变动到用户积分完成(并发安全版): 总数={}, 成功={}, 跳过={}, 失败={}",
|
||||
bonusLogList.size(), successCount, skipCount, failCount);
|
||||
|
||||
result.put("message", "同步完成");
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("同步个人奖金变动到用户积分异常", e);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("message", "同步失败: " + e.getMessage());
|
||||
result.put("error", e.getMessage());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证奖金记录是否符合处理条件
|
||||
*/
|
||||
private boolean isValidBonusLog(WaSelfbonusLog bonusLog) {
|
||||
// 只处理收入类型(type=1)
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证奖金金额有效
|
||||
if (bonusLog.getMoney() == null || bonusLog.getMoney().compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算积分值
|
||||
*/
|
||||
private BigDecimal calculateIntegralValue(BigDecimal bonusAmount) {
|
||||
// 计算积分:奖金金额 * 50%,向下取整
|
||||
BigDecimal integralDecimal = bonusAmount.multiply(new BigDecimal("0.5"));
|
||||
return integralDecimal.setScale(3, RoundingMode.DOWN);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建积分记录对象
|
||||
*/
|
||||
private UserIntegralRecord createUserIntegralRecord(Integer userId, WaSelfbonusLog bonusLog, BigDecimal integralValue) {
|
||||
User user = userDao.selectById(userId); // 重新查询用户获取最新积分
|
||||
Integer newIntegral = user != null && user.getIntegral() != null ? user.getIntegral().intValue() : 0;
|
||||
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(userId);
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId())); // 关联奖金记录ID
|
||||
integralRecord.setLinkType("selfbonus"); // 关联类型:个人奖金
|
||||
integralRecord.setType(1); // 类型:1-增加
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(newIntegral); // 实际上应该是更新后的积分,这里可能需要调整
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%d",
|
||||
bonusLog.getMoney(), integralValue.intValue()));
|
||||
integralRecord.setStatus(3); // 状态:3-完成
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId()); // 关联个人奖金记录ID
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
|
||||
return integralRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试机制的积分更新
|
||||
*/
|
||||
private Boolean updateIntegralWithRetry(Integer uid, BigDecimal integral, String type, int maxRetries) {
|
||||
int attempts = 0;
|
||||
Exception lastException = null;
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
attempts++;
|
||||
|
||||
// 直接更新积分,不再依赖乐观锁
|
||||
Boolean result = userService.operationIntegral(uid, integral, BigDecimal.ZERO, type);
|
||||
|
||||
if (result) {
|
||||
return true;
|
||||
} else {
|
||||
log.warn("积分更新失败,准备重试 (attempt {}/{})", attempts, maxRetries);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
log.warn("积分更新异常,准备重试 (attempt {}/{}), error: {}", attempts, maxRetries, e.getMessage());
|
||||
|
||||
// 如果是数据库锁等待超时,等待一段时间再重试
|
||||
if (e.getMessage() != null && e.getMessage().contains("Lock wait timeout")) {
|
||||
try {
|
||||
Thread.sleep(100 * attempts); // 指数退避
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.error("积分更新达到最大重试次数仍然失败,最后一次异常: ", lastException);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.zbkj.common.exception.CrmebException;
|
||||
import com.zbkj.common.model.consignment.WaMerchandise;
|
||||
import com.zbkj.common.model.consignment.WaOrder;
|
||||
import com.zbkj.common.model.consignment.WaUsers;
|
||||
import com.zbkj.common.page.CommonPage;
|
||||
@@ -13,6 +14,7 @@ import com.zbkj.common.request.PageParamRequest;
|
||||
import com.zbkj.common.request.WaOrderSearchRequest;
|
||||
import com.zbkj.common.request.WaOrderUpdateRequest;
|
||||
import com.zbkj.common.response.WaOrderResponse;
|
||||
import com.zbkj.service.dao.consignment.WaMerchandiseDao;
|
||||
import com.zbkj.service.dao.consignment.WaOrderDao;
|
||||
import com.zbkj.service.dao.consignment.WaUsersDao;
|
||||
import com.zbkj.service.service.WaOrderAdminService;
|
||||
@@ -20,7 +22,11 @@ import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -44,6 +50,9 @@ public class WaOrderAdminServiceImpl extends ServiceImpl<WaOrderDao, WaOrder> im
|
||||
@Resource
|
||||
private WaUsersDao waUsersDao;
|
||||
|
||||
@Resource
|
||||
private WaMerchandiseDao waMerchandiseDao;
|
||||
|
||||
/**
|
||||
* 分页列表查询
|
||||
*/
|
||||
@@ -108,31 +117,58 @@ public class WaOrderAdminServiceImpl extends ServiceImpl<WaOrderDao, WaOrder> im
|
||||
wrapper.orderByDesc(WaOrder::getCreatedAt);
|
||||
|
||||
List<WaOrder> list = waOrderDao.selectList(wrapper);
|
||||
|
||||
// 批量收集所需关联 ID,避免循环 N+1 查询
|
||||
Set<Integer> userIds = new HashSet<>();
|
||||
Set<Integer> merchandiseIds = new HashSet<>();
|
||||
for (WaOrder o : list) {
|
||||
if (o.getSellerId() != null && o.getSellerId() > 0) userIds.add(o.getSellerId());
|
||||
if (o.getBuyerId() != null && o.getBuyerId() > 0) userIds.add(o.getBuyerId());
|
||||
if (o.getMerchandiseId() != null && o.getMerchandiseId() > 0) merchandiseIds.add(o.getMerchandiseId());
|
||||
}
|
||||
Map<Integer, WaUsers> userMap = new HashMap<>();
|
||||
if (!userIds.isEmpty()) {
|
||||
for (WaUsers u : waUsersDao.selectBatchIds(userIds)) {
|
||||
userMap.put(u.getId(), u);
|
||||
}
|
||||
}
|
||||
Map<Integer, WaMerchandise> merchandiseMap = new HashMap<>();
|
||||
if (!merchandiseIds.isEmpty()) {
|
||||
for (WaMerchandise m : waMerchandiseDao.selectBatchIds(merchandiseIds)) {
|
||||
merchandiseMap.put(m.getId(), m);
|
||||
}
|
||||
}
|
||||
|
||||
List<WaOrderResponse> responseList = list.stream().map(item -> {
|
||||
WaOrderResponse response = new WaOrderResponse();
|
||||
BeanUtils.copyProperties(item, response);
|
||||
|
||||
// 获取卖家名称
|
||||
|
||||
// 卖家名称
|
||||
if (item.getSellerId() != null && item.getSellerId() > 0) {
|
||||
WaUsers seller = waUsersDao.selectById(item.getSellerId());
|
||||
if (seller != null) {
|
||||
response.setSellerName(seller.getNickname());
|
||||
}
|
||||
WaUsers seller = userMap.get(item.getSellerId());
|
||||
if (seller != null) response.setSellerName(seller.getNickname());
|
||||
} else {
|
||||
response.setSellerName("平台");
|
||||
}
|
||||
|
||||
// 获取买家名称
|
||||
|
||||
// 买家名称
|
||||
if (item.getBuyerId() != null && item.getBuyerId() > 0) {
|
||||
WaUsers buyer = waUsersDao.selectById(item.getBuyerId());
|
||||
if (buyer != null) {
|
||||
response.setBuyerName(buyer.getNickname());
|
||||
WaUsers buyer = userMap.get(item.getBuyerId());
|
||||
if (buyer != null) response.setBuyerName(buyer.getNickname());
|
||||
}
|
||||
|
||||
// 商品名称 / 图片
|
||||
if (item.getMerchandiseId() != null && item.getMerchandiseId() > 0) {
|
||||
WaMerchandise mh = merchandiseMap.get(item.getMerchandiseId());
|
||||
if (mh != null) {
|
||||
response.setMerchandiseTitle(mh.getTitle());
|
||||
response.setMerchandiseImage(mh.getImage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return response;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
|
||||
return CommonPage.copyPageInfo((PageInfo<WaOrder>) new PageInfo<>(list), responseList);
|
||||
}
|
||||
|
||||
@@ -166,7 +202,16 @@ public class WaOrderAdminServiceImpl extends ServiceImpl<WaOrderDao, WaOrder> im
|
||||
response.setBuyerName(buyer.getNickname());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 商品名称 / 图片
|
||||
if (order.getMerchandiseId() != null && order.getMerchandiseId() > 0) {
|
||||
WaMerchandise mh = waMerchandiseDao.selectById(order.getMerchandiseId());
|
||||
if (mh != null) {
|
||||
response.setMerchandiseTitle(mh.getTitle());
|
||||
response.setMerchandiseImage(mh.getImage());
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import com.zbkj.service.service.UserService;
|
||||
import com.zbkj.service.service.WaSelfbonusSyncService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -44,14 +44,12 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private TransactionTemplate transactionTemplate;
|
||||
|
||||
/**
|
||||
* 同步个人奖金变动到用户积分
|
||||
* 根据个人奖金变动记录,为对应的用户增加积分(奖金金额的50%)
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Map<String, Object> syncSelfbonusToIntegral() {
|
||||
log.info("开始同步个人奖金变动到用户积分");
|
||||
|
||||
@@ -68,16 +66,101 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
|
||||
for (WaSelfbonusLog bonusLog : bonusLogList) {
|
||||
try {
|
||||
ProcessStatus processStatus = transactionTemplate.execute(status -> processBonusLogAtomically(bonusLog));
|
||||
if (processStatus == ProcessStatus.SKIP) {
|
||||
// 检查该奖金记录是否已经处理过(通过 waSelfbonusLogid 字段查询积分记录)
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, bonusLog.getId());
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
|
||||
if (existCount != null && existCount > 0) {
|
||||
// 已处理过,跳过
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
if (processStatus == ProcessStatus.SUCCESS) {
|
||||
successCount++;
|
||||
|
||||
// 根据 wa_users 的 user_id 查找对应的 eb_user 表的 uid
|
||||
// 注意:wa_users.id 对应 eb_user.uid(在同步时已建立关联)
|
||||
Integer ebUserId = bonusLog.getUserId(); // wa_users.id 就是 eb_user.uid
|
||||
|
||||
// 查询 eb_user 表中的用户
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
failCount++;
|
||||
|
||||
// 计算积分值:个人奖金变更金额的50%(只处理收入类型的奖金变动)
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
// 只处理收入类型(type=1),支出类型不处理
|
||||
log.debug("跳过非收入类型的奖金变动: bonusLogId={}, type={}", bonusLog.getId(), bonusLog.getType());
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 奖金金额(收入为正数)
|
||||
BigDecimal bonusAmount = bonusLog.getMoney();
|
||||
if (bonusAmount == null || bonusAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("奖金金额无效,跳过: bonusLogId={}, money={}", bonusLog.getId(), bonusAmount);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 计算积分:奖金金额 * 50%,向下取整
|
||||
BigDecimal integralDecimal = bonusAmount.multiply(new BigDecimal("0.5"));
|
||||
|
||||
BigDecimal integralValue = integralDecimal.setScale(3, RoundingMode.DOWN);
|
||||
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0,跳过: bonusLogId={}, integralValue={}", bonusLog.getId(), integralValue);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新用户积分 - 不再需要当前积分值作为乐观锁条件
|
||||
Boolean updateResult = userService.operationIntegral(
|
||||
user.getUid(),
|
||||
integralValue,
|
||||
BigDecimal.valueOf(0), // 不再使用当前积分作为乐观锁条件
|
||||
"add"
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
log.error("更新用户积分失败: userId={}, integralValue={}", user.getUid(), integralValue);
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 重新查询用户获取最新积分
|
||||
user = userDao.selectById(user.getUid());
|
||||
Integer newIntegral = user.getIntegral() != null ? user.getIntegral().intValue() : 0;
|
||||
|
||||
// 新增积分记录
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(user.getUid());
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId())); // 关联奖金记录ID
|
||||
integralRecord.setLinkType("selfbonus"); // 关联类型:个人奖金
|
||||
integralRecord.setType(1); // 类型:1-增加
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(newIntegral);
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%d",
|
||||
bonusAmount, integralValue.intValue()));
|
||||
integralRecord.setStatus(3); // 状态:3-完成
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId()); // 关联个人奖金记录ID
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
log.error("插入积分记录失败: userId={}, bonusLogId={}", user.getUid(), bonusLog.getId());
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
successCount++;
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusAmount, integralValue);
|
||||
|
||||
} catch (Exception e) {
|
||||
failCount++;
|
||||
@@ -105,104 +188,47 @@ public class WaSelfbonusServiceImpl implements WaSelfbonusSyncService {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 原子处理单条奖金日志:
|
||||
* 1) 先插入积分流水(受唯一索引保护)
|
||||
* 2) 插入成功后再更新用户总积分
|
||||
* 3) 回填本条流水的 balance
|
||||
* 带重试机制的用户积分更新
|
||||
*/
|
||||
private ProcessStatus processBonusLogAtomically(WaSelfbonusLog bonusLog) {
|
||||
if (isAlreadyProcessed(bonusLog.getId())) {
|
||||
log.debug("奖金记录已处理,跳过: bonusLogId={}, userId={}", bonusLog.getId(), bonusLog.getUserId());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
Integer ebUserId = bonusLog.getUserId();
|
||||
User user = userDao.selectById(ebUserId);
|
||||
if (user == null) {
|
||||
log.warn("未找到对应的系统用户,跳过: waUserId={}", bonusLog.getUserId());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
if (bonusLog.getType() == null || bonusLog.getType() != 1) {
|
||||
log.debug("跳过非收入类型的奖金变动: bonusLogId={}, type={}", bonusLog.getId(), bonusLog.getType());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
BigDecimal bonusAmount = bonusLog.getMoney();
|
||||
if (bonusAmount == null || bonusAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("奖金金额无效,跳过: bonusLogId={}, money={}", bonusLog.getId(), bonusAmount);
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
BigDecimal integralValue = bonusAmount.multiply(new BigDecimal("0.5")).setScale(3, RoundingMode.DOWN);
|
||||
if (integralValue.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
log.debug("计算出的积分为0,跳过: bonusLogId={}, integralValue={}", bonusLog.getId(), integralValue);
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
UserIntegralRecord integralRecord = buildIntegralRecord(user.getUid(), bonusLog, bonusAmount, integralValue);
|
||||
try {
|
||||
int insertResult = userIntegralRecordDao.insert(integralRecord);
|
||||
if (insertResult <= 0) {
|
||||
throw new IllegalStateException("插入积分记录失败");
|
||||
private Boolean updateUserIntegralWithRetry(Integer uid, BigDecimal integralValue, int maxRetries) {
|
||||
int attempts = 0;
|
||||
Exception lastException = null;
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
attempts++;
|
||||
|
||||
Boolean result = userService.operationIntegral(
|
||||
uid,
|
||||
integralValue,
|
||||
BigDecimal.ZERO, // 不再使用当前积分作为乐观锁条件
|
||||
"add"
|
||||
);
|
||||
|
||||
if (result) {
|
||||
return true;
|
||||
} else {
|
||||
log.warn("积分更新失败,准备重试 (attempt {}/{})", attempts, maxRetries);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
log.warn("积分更新异常,准备重试 (attempt {}/{}), error: {}", attempts, maxRetries, e.getMessage());
|
||||
|
||||
// 如果是数据库锁等待超时,等待一段时间再重试
|
||||
if (e.getMessage() != null && e.getMessage().contains("Lock wait timeout")) {
|
||||
try {
|
||||
Thread.sleep(100 * attempts); // 指数退避
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (DuplicateKeyException duplicateKeyException) {
|
||||
// 数据库唯一索引兜底,保证多入口并发只处理一次
|
||||
log.info("奖金记录并发重复处理,已跳过: bonusLogId={}, userId={}", bonusLog.getId(), user.getUid());
|
||||
return ProcessStatus.SKIP;
|
||||
}
|
||||
|
||||
Boolean updateResult = userService.operationIntegral(
|
||||
user.getUid(),
|
||||
integralValue,
|
||||
BigDecimal.ZERO,
|
||||
"add"
|
||||
);
|
||||
|
||||
if (!updateResult) {
|
||||
throw new IllegalStateException(String.format("更新用户积分失败: userId=%s, integralValue=%s", user.getUid(), integralValue));
|
||||
}
|
||||
|
||||
User latestUser = userDao.selectById(user.getUid());
|
||||
Integer latestIntegral = latestUser != null && latestUser.getIntegral() != null ? latestUser.getIntegral().intValue() : 0;
|
||||
integralRecord.setBalance(latestIntegral);
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
userIntegralRecordDao.updateById(integralRecord);
|
||||
|
||||
log.info("成功同步奖金到积分: bonusLogId={}, userId={}, bonusAmount={}, integralValue={}",
|
||||
bonusLog.getId(), user.getUid(), bonusAmount, integralValue);
|
||||
return ProcessStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private boolean isAlreadyProcessed(Integer waSelfbonusLogId) {
|
||||
LambdaQueryWrapper<UserIntegralRecord> checkWrapper = new LambdaQueryWrapper<>();
|
||||
checkWrapper.eq(UserIntegralRecord::getWaSelfbonusLogid, waSelfbonusLogId);
|
||||
Integer existCount = userIntegralRecordDao.selectCount(checkWrapper);
|
||||
return existCount != null && existCount > 0;
|
||||
}
|
||||
|
||||
private UserIntegralRecord buildIntegralRecord(Integer uid, WaSelfbonusLog bonusLog, BigDecimal bonusAmount, BigDecimal integralValue) {
|
||||
UserIntegralRecord integralRecord = new UserIntegralRecord();
|
||||
integralRecord.setUid(uid);
|
||||
integralRecord.setLinkId(String.valueOf(bonusLog.getId()));
|
||||
integralRecord.setLinkType("selfbonus");
|
||||
integralRecord.setType(1);
|
||||
integralRecord.setTitle("个人奖金奖励");
|
||||
integralRecord.setIntegral(integralValue);
|
||||
integralRecord.setBalance(0);
|
||||
integralRecord.setMark(String.format("个人奖金变动奖励,奖金金额:%.3f,积分:%.3f",
|
||||
bonusAmount, integralValue));
|
||||
integralRecord.setStatus(3);
|
||||
integralRecord.setWaSelfbonusLogid(bonusLog.getId());
|
||||
integralRecord.setCreateTime(new Date());
|
||||
integralRecord.setUpdateTime(new Date());
|
||||
return integralRecord;
|
||||
}
|
||||
|
||||
private enum ProcessStatus {
|
||||
SUCCESS,
|
||||
SKIP
|
||||
|
||||
log.error("积分更新达到最大重试次数仍然失败,最后一次异常: ", lastException);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.zbkj.service.util;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 — 字段格式化工具。
|
||||
* 抽出为单独类便于单元测试。
|
||||
*/
|
||||
public final class GrabUserFormatter {
|
||||
|
||||
public static final int AMOUNT_SCALE = 3;
|
||||
|
||||
private GrabUserFormatter() {}
|
||||
|
||||
/**
|
||||
* 金额格式化:保留 3 位小数(与上传参考图一致)。
|
||||
*/
|
||||
public static String formatAmount(BigDecimal raw) {
|
||||
BigDecimal v = raw == null ? BigDecimal.ZERO : raw;
|
||||
return v.setScale(AMOUNT_SCALE, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户等级映射;未知等级回退为「普通用户」。
|
||||
*/
|
||||
public static String mapLevelName(Integer level) {
|
||||
if (level == null) return "普通用户";
|
||||
switch (level) {
|
||||
case 0:
|
||||
case 1:
|
||||
return "普通用户";
|
||||
case 2:
|
||||
return "VIP";
|
||||
case 3:
|
||||
return "合伙人";
|
||||
default:
|
||||
return "等级" + level;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态文案:1=正常 / 其它=禁用。
|
||||
*/
|
||||
public static String mapStatus(Integer status) {
|
||||
return status != null && status == 1 ? "正常" : "禁用";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.zbkj.service.util;
|
||||
|
||||
import com.zbkj.common.response.TeamDailyMemberRow;
|
||||
import com.zbkj.common.response.TeamDailySummary;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 — 公式工具类。
|
||||
* 设计要点:
|
||||
* - 所有金额按 HALF_UP 舍入到 2 位;
|
||||
* - 小计的服务费 / E积分 / 实际收付 由"成员级别已舍入数值"再求和,
|
||||
* 避免逐行二次舍入。
|
||||
*
|
||||
* 抽出为单独类便于单元测试(无需 DB / Spring 上下文)。
|
||||
*/
|
||||
public final class TeamReportFormula {
|
||||
|
||||
public static final int SCALE = 2;
|
||||
|
||||
private TeamReportFormula() {}
|
||||
|
||||
/**
|
||||
* 单成员金额舍入与公式计算。
|
||||
* 修改入参对象的 prevBuy / todaySell / todayBuy / serviceFee / eScore / actual。
|
||||
*
|
||||
* @param row 成员行(prevBuy / todaySell / todayBuy 已由 SQL 填充)
|
||||
* @param serviceRate 服务费率
|
||||
* @param eScoreRate E 积分率
|
||||
*/
|
||||
public static void applyFormula(TeamDailyMemberRow row,
|
||||
BigDecimal serviceRate,
|
||||
BigDecimal eScoreRate) {
|
||||
if (row == null) return;
|
||||
row.setPrevBuy(scale(row.getPrevBuy()));
|
||||
row.setTodaySell(scale(row.getTodaySell()));
|
||||
row.setTodayBuy(scale(row.getTodayBuy()));
|
||||
|
||||
BigDecimal serviceFee = scale(row.getTodayBuy().multiply(serviceRate));
|
||||
BigDecimal eScore = scale(row.getTodayBuy().multiply(eScoreRate));
|
||||
BigDecimal actual = scale(row.getTodaySell()
|
||||
.subtract(row.getTodayBuy())
|
||||
.subtract(serviceFee)
|
||||
.subtract(eScore));
|
||||
|
||||
row.setServiceFee(serviceFee);
|
||||
row.setEScore(eScore);
|
||||
row.setActual(actual);
|
||||
}
|
||||
|
||||
/**
|
||||
* 累加每行已舍入数值得到小计;不再做二次舍入。
|
||||
*/
|
||||
public static TeamDailySummary aggregateSummary(List<TeamDailyMemberRow> rows) {
|
||||
TeamDailySummary s = new TeamDailySummary();
|
||||
if (rows == null) return s;
|
||||
for (TeamDailyMemberRow r : rows) {
|
||||
s.setPrevBuy(s.getPrevBuy().add(nz(r.getPrevBuy())));
|
||||
s.setTodaySell(s.getTodaySell().add(nz(r.getTodaySell())));
|
||||
s.setTodayBuy(s.getTodayBuy().add(nz(r.getTodayBuy())));
|
||||
s.setServiceFee(s.getServiceFee().add(nz(r.getServiceFee())));
|
||||
s.setEScore(s.getEScore().add(nz(r.getEScore())));
|
||||
s.setActual(s.getActual().add(nz(r.getActual())));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public static BigDecimal scale(BigDecimal v) {
|
||||
return v == null ? BigDecimal.ZERO.setScale(SCALE)
|
||||
: v.setScale(SCALE, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private static BigDecimal nz(BigDecimal v) {
|
||||
return v == null ? BigDecimal.ZERO : v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.zbkj.service.dao.consignment.ExternalGrabUserDao">
|
||||
|
||||
<resultMap id="GrabUserResultMap" type="com.zbkj.common.response.ExternalGrabUserResponse">
|
||||
<id property="id" column="id"/>
|
||||
<result property="username" column="username"/>
|
||||
<result property="nickname" column="nickname"/>
|
||||
<result property="mobile" column="mobile"/>
|
||||
<result property="contract" column="contract"/>
|
||||
<result property="pid" column="pid"/>
|
||||
<result property="maxOrder" column="max_order"/>
|
||||
<result property="level" column="level"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="updatedAt" column="updated_at"/>
|
||||
<result property="moneyRaw" column="money"/>
|
||||
<result property="couponRaw" column="coupon"/>
|
||||
<result property="selfBonusRaw" column="self_bonus"/>
|
||||
<result property="shareBonusRaw" column="share_bonus"/>
|
||||
<result property="todayBuyAmountRaw" column="today_buy_amount"/>
|
||||
<result property="todaySellAmountRaw" column="today_sell_amount"/>
|
||||
<result property="todayBuyCnt" column="today_buy_cnt"/>
|
||||
<result property="prevSellCnt" column="prev_sell_cnt"/>
|
||||
</resultMap>
|
||||
|
||||
<!--
|
||||
SQL 设计要点:
|
||||
1) INNER JOIN buy + HAVING SUM(total_money)>0 把"今日购买总金额>0"过滤直接落到 SQL 层;
|
||||
2) LEFT JOIN sell / prev 不影响过滤结果集;
|
||||
3) is_cancel=0 全程过滤已取消订单。
|
||||
-->
|
||||
<select id="selectGrabUserList" resultMap="GrabUserResultMap">
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.nickname,
|
||||
u.mobile,
|
||||
u.contract,
|
||||
u.pid,
|
||||
u.max_order,
|
||||
u.level,
|
||||
u.money,
|
||||
u.coupon,
|
||||
u.self_bonus,
|
||||
u.share_bonus,
|
||||
u.status,
|
||||
u.updated_at,
|
||||
COALESCE(buy.amt, 0) AS today_buy_amount,
|
||||
COALESCE(sell.amt, 0) AS today_sell_amount,
|
||||
COALESCE(buy.cnt, 0) AS today_buy_cnt,
|
||||
COALESCE(prev.cnt, 0) AS prev_sell_cnt
|
||||
FROM wa_users u
|
||||
INNER JOIN (
|
||||
SELECT buyer_id AS uid, SUM(total_money) AS amt, COUNT(*) AS cnt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{todayStart}
|
||||
AND pay_time <= #{todayEnd}
|
||||
GROUP BY buyer_id
|
||||
HAVING SUM(total_money) > 0
|
||||
) buy ON buy.uid = u.id
|
||||
LEFT JOIN (
|
||||
SELECT seller_id AS uid, SUM(total_money) AS amt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{todayStart}
|
||||
AND pay_time <= #{todayEnd}
|
||||
GROUP BY seller_id
|
||||
) sell ON sell.uid = u.id
|
||||
LEFT JOIN (
|
||||
SELECT seller_id AS uid, COUNT(*) AS cnt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{yesterdayStart}
|
||||
AND pay_time <= #{yesterdayEnd}
|
||||
GROUP BY seller_id
|
||||
) prev ON prev.uid = u.id
|
||||
<where>
|
||||
<if test="uid != null">
|
||||
AND u.id = #{uid}
|
||||
</if>
|
||||
<if test="mobile != null and mobile != ''">
|
||||
AND (u.mobile LIKE CONCAT('%', #{mobile}, '%')
|
||||
OR u.username LIKE CONCAT('%', #{mobile}, '%'))
|
||||
</if>
|
||||
<if test="pid != null">
|
||||
AND u.pid = #{pid}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY today_buy_amount DESC, u.id DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.zbkj.service.dao.consignment.TeamReportDao">
|
||||
|
||||
<resultMap id="MemberAggregateResultMap" type="com.zbkj.common.response.TeamDailyMemberRow">
|
||||
<id property="userId" column="user_id"/>
|
||||
<result property="nickname" column="nickname"/>
|
||||
<result property="teamCode" column="team_code"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="leaderId" column="leader_id"/>
|
||||
<result property="prevBuy" column="prev_buy"/>
|
||||
<result property="todaySell" column="today_sell"/>
|
||||
<result property="todayBuy" column="today_buy"/>
|
||||
</resultMap>
|
||||
|
||||
<!--
|
||||
SQL 设计:
|
||||
- 一次三 LEFT JOIN 聚合:prev / sell / buy
|
||||
- WHERE u.pid = leaderId 限定团队成员(直推下级)
|
||||
- includeDisabled=false 时仅取 status=1
|
||||
- memberIds 非空时再二次过滤
|
||||
-->
|
||||
<!--
|
||||
team-report 聚合查询:
|
||||
- leaderId 非空:仅查该团队长的下级(u.pid = leaderId)
|
||||
- leaderId 为空:查所有有上级的成员(u.pid > 0),后续在 Service 按 leader_id 分组
|
||||
-->
|
||||
<select id="selectTeamMemberAggregates" resultMap="MemberAggregateResultMap">
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.nickname AS nickname,
|
||||
u.invite AS team_code,
|
||||
u.status AS status,
|
||||
u.pid AS leader_id,
|
||||
COALESCE(prev.amt, 0) AS prev_buy,
|
||||
COALESCE(sell.amt, 0) AS today_sell,
|
||||
COALESCE(buy.amt, 0) AS today_buy
|
||||
FROM wa_users u
|
||||
LEFT JOIN (
|
||||
SELECT buyer_id AS uid, SUM(total_money) AS amt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{prevStart}
|
||||
AND pay_time <= #{prevEnd}
|
||||
GROUP BY buyer_id
|
||||
) prev ON prev.uid = u.id
|
||||
LEFT JOIN (
|
||||
SELECT seller_id AS uid, SUM(total_money) AS amt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{dStart}
|
||||
AND pay_time <= #{dEnd}
|
||||
GROUP BY seller_id
|
||||
) sell ON sell.uid = u.id
|
||||
LEFT JOIN (
|
||||
SELECT buyer_id AS uid, SUM(total_money) AS amt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{dStart}
|
||||
AND pay_time <= #{dEnd}
|
||||
GROUP BY buyer_id
|
||||
) buy ON buy.uid = u.id
|
||||
<where>
|
||||
<choose>
|
||||
<when test="leaderId != null">
|
||||
u.pid = #{leaderId}
|
||||
</when>
|
||||
<otherwise>
|
||||
u.pid > 0
|
||||
</otherwise>
|
||||
</choose>
|
||||
<if test="includeDisabled == null or !includeDisabled">
|
||||
AND u.status = 1
|
||||
</if>
|
||||
<if test="memberIds != null and memberIds.size() > 0">
|
||||
AND u.id IN
|
||||
<foreach collection="memberIds" item="mid" open="(" separator="," close=")">
|
||||
#{mid}
|
||||
</foreach>
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY u.pid ASC, u.id ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.zbkj.service.util;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 - 格式化工具单元测试。
|
||||
*/
|
||||
public class GrabUserFormatterTest {
|
||||
|
||||
@Test
|
||||
public void formatAmount_null_should_be_zero() {
|
||||
assertEquals("0.000", GrabUserFormatter.formatAmount(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatAmount_should_keep_three_decimals() {
|
||||
assertEquals("0.000", GrabUserFormatter.formatAmount(new BigDecimal("0")));
|
||||
assertEquals("226.383", GrabUserFormatter.formatAmount(new BigDecimal("226.383")));
|
||||
assertEquals("100.000", GrabUserFormatter.formatAmount(new BigDecimal("100")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatAmount_should_round_half_up() {
|
||||
// 0.0005 → 0.001
|
||||
assertEquals("0.001", GrabUserFormatter.formatAmount(new BigDecimal("0.0005")));
|
||||
// 0.0014 → 0.001
|
||||
assertEquals("0.001", GrabUserFormatter.formatAmount(new BigDecimal("0.0014")));
|
||||
// 0.0015 → 0.002
|
||||
assertEquals("0.002", GrabUserFormatter.formatAmount(new BigDecimal("0.0015")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatAmount_should_handle_negative() {
|
||||
assertEquals("-1.234", GrabUserFormatter.formatAmount(new BigDecimal("-1.2340")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mapLevelName_known_levels() {
|
||||
assertEquals("普通用户", GrabUserFormatter.mapLevelName(null));
|
||||
assertEquals("普通用户", GrabUserFormatter.mapLevelName(0));
|
||||
assertEquals("普通用户", GrabUserFormatter.mapLevelName(1));
|
||||
assertEquals("VIP", GrabUserFormatter.mapLevelName(2));
|
||||
assertEquals("合伙人", GrabUserFormatter.mapLevelName(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mapLevelName_unknown_levels_fallback() {
|
||||
assertEquals("等级5", GrabUserFormatter.mapLevelName(5));
|
||||
assertEquals("等级99", GrabUserFormatter.mapLevelName(99));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mapStatus_should_match_spec() {
|
||||
assertEquals("正常", GrabUserFormatter.mapStatus(1));
|
||||
assertEquals("禁用", GrabUserFormatter.mapStatus(0));
|
||||
assertEquals("禁用", GrabUserFormatter.mapStatus(null));
|
||||
assertEquals("禁用", GrabUserFormatter.mapStatus(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.zbkj.service.util;
|
||||
|
||||
import com.zbkj.common.response.TeamDailyMemberRow;
|
||||
import com.zbkj.common.response.TeamDailySummary;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 公式工具单元测试。
|
||||
* 覆盖:
|
||||
* - 上传参考图样例 4 名成员(王珍华 / 柯美燕 / 王兵启 / 胡晓彩)
|
||||
* - 仅有卖单 / 仅有买单 / 全空 等边界
|
||||
* - 小计精度:与逐行二次舍入对比
|
||||
*/
|
||||
public class TeamReportFormulaTest {
|
||||
|
||||
private static final BigDecimal SERVICE_RATE = new BigDecimal("0.02");
|
||||
private static final BigDecimal E_SCORE_RATE = new BigDecimal("0.005");
|
||||
|
||||
/** 王珍华:22151 - 21418 - 428.36 - 107.09 = 197.55 */
|
||||
@Test
|
||||
public void member_with_buy_and_sell_should_match_doc() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow()
|
||||
.setNickname("王珍华")
|
||||
.setPrevBuy(new BigDecimal("21506"))
|
||||
.setTodaySell(new BigDecimal("22151"))
|
||||
.setTodayBuy(new BigDecimal("21418"));
|
||||
TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE);
|
||||
|
||||
assertEquals(new BigDecimal("428.36"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("107.09"), r.getEScore());
|
||||
assertEquals(new BigDecimal("197.55"), r.getActual());
|
||||
}
|
||||
|
||||
/** 胡晓彩:28259 - 27708 - 554.16 - 138.54 = -141.70 */
|
||||
@Test
|
||||
public void member_with_negative_actual() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow()
|
||||
.setNickname("胡晓彩")
|
||||
.setPrevBuy(new BigDecimal("27436"))
|
||||
.setTodaySell(new BigDecimal("28259"))
|
||||
.setTodayBuy(new BigDecimal("27708"));
|
||||
TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE);
|
||||
|
||||
assertEquals(new BigDecimal("554.16"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("138.54"), r.getEScore());
|
||||
assertEquals(new BigDecimal("-141.70"), r.getActual());
|
||||
}
|
||||
|
||||
/** 柯美燕 / 王兵启:仅有卖单。服务费/E积分=0;实际=卖单。 */
|
||||
@Test
|
||||
public void member_only_sell_should_zero_fees() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow()
|
||||
.setNickname("柯美燕")
|
||||
.setPrevBuy(new BigDecimal("22519"))
|
||||
.setTodaySell(new BigDecimal("23195"))
|
||||
.setTodayBuy(BigDecimal.ZERO);
|
||||
TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE);
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("0.00"), r.getEScore());
|
||||
assertEquals(new BigDecimal("23195.00"), r.getActual());
|
||||
}
|
||||
|
||||
/** 仅有买单:实际收付为负值(-买 - 服务费 - E积分)。 */
|
||||
@Test
|
||||
public void member_only_buy_should_negative_actual() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow()
|
||||
.setTodaySell(BigDecimal.ZERO)
|
||||
.setTodayBuy(new BigDecimal("1000"));
|
||||
TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE);
|
||||
|
||||
assertEquals(new BigDecimal("20.00"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("5.00"), r.getEScore());
|
||||
assertEquals(new BigDecimal("-1025.00"), r.getActual());
|
||||
}
|
||||
|
||||
/** Null 字段:当作 0 处理,不抛异常。 */
|
||||
@Test
|
||||
public void member_with_null_amounts_should_be_zero() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow();
|
||||
TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE);
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), r.getPrevBuy());
|
||||
assertEquals(new BigDecimal("0.00"), r.getTodaySell());
|
||||
assertEquals(new BigDecimal("0.00"), r.getTodayBuy());
|
||||
assertEquals(new BigDecimal("0.00"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("0.00"), r.getEScore());
|
||||
assertEquals(new BigDecimal("0.00"), r.getActual());
|
||||
}
|
||||
|
||||
/** 小计:4 名成员全量样例对账。 */
|
||||
@Test
|
||||
public void summary_should_match_doc_team_F_sample() {
|
||||
List<TeamDailyMemberRow> rows = Arrays.asList(
|
||||
row("王珍华", "21506", "22151", "21418"),
|
||||
row("柯美燕", "22519", "23195", "0"),
|
||||
row("王兵启", "34266", "35294", "0"),
|
||||
row("胡晓彩", "27436", "28259", "27708")
|
||||
);
|
||||
rows.forEach(r -> TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE));
|
||||
TeamDailySummary s = TeamReportFormula.aggregateSummary(rows);
|
||||
|
||||
// 文档样例核对:D-1 买单合计 = 105727
|
||||
assertEquals(new BigDecimal("105727.00"), s.getPrevBuy());
|
||||
// D 卖单合计 = 108899
|
||||
assertEquals(new BigDecimal("108899.00"), s.getTodaySell());
|
||||
// D 买单合计 = 49126
|
||||
assertEquals(new BigDecimal("49126.00"), s.getTodayBuy());
|
||||
// 服务费合计:428.36 + 0 + 0 + 554.16 = 982.52
|
||||
assertEquals(new BigDecimal("982.52"), s.getServiceFee());
|
||||
// E积分合计:107.09 + 0 + 0 + 138.54 = 245.63
|
||||
assertEquals(new BigDecimal("245.63"), s.getEScore());
|
||||
// 实际收付合计:197.55 + 23195 + 35294 + (-141.70) = 58544.85
|
||||
assertEquals(new BigDecimal("58544.85"), s.getActual());
|
||||
}
|
||||
|
||||
/** 空成员列表:小计全 0,不抛异常。 */
|
||||
@Test
|
||||
public void summary_empty_rows_should_zero() {
|
||||
TeamDailySummary s = TeamReportFormula.aggregateSummary(Collections.emptyList());
|
||||
assertNotNull(s);
|
||||
assertEquals(BigDecimal.ZERO, s.getPrevBuy());
|
||||
assertEquals(BigDecimal.ZERO, s.getTodayBuy());
|
||||
assertEquals(BigDecimal.ZERO, s.getActual());
|
||||
}
|
||||
|
||||
/** 不同费率:service_rate=0.03 / e_score_rate=0.01。 */
|
||||
@Test
|
||||
public void custom_rates_should_be_applied() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow()
|
||||
.setTodaySell(new BigDecimal("0"))
|
||||
.setTodayBuy(new BigDecimal("1000"));
|
||||
TeamReportFormula.applyFormula(r, new BigDecimal("0.03"), new BigDecimal("0.01"));
|
||||
|
||||
assertEquals(new BigDecimal("30.00"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("10.00"), r.getEScore());
|
||||
assertEquals(new BigDecimal("-1040.00"), r.getActual());
|
||||
}
|
||||
|
||||
private static TeamDailyMemberRow row(String name, String prev, String sell, String buy) {
|
||||
return new TeamDailyMemberRow()
|
||||
.setNickname(name)
|
||||
.setPrevBuy(new BigDecimal(prev))
|
||||
.setTodaySell(new BigDecimal(sell))
|
||||
.setTodayBuy(new BigDecimal(buy));
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
-- Add strong idempotency guard for selfbonus -> integral conversion.
|
||||
-- This index guarantees one wa_selfbonus_log can map to at most one integral record.
|
||||
-- Prerequisite: clear duplicate wa_selfbonus_logid rows first.
|
||||
|
||||
-- Pre-checks
|
||||
SELECT wa_selfbonus_logid, COUNT(*) AS cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY wa_selfbonus_logid
|
||||
HAVING COUNT(*) > 1
|
||||
LIMIT 20;
|
||||
|
||||
SELECT COUNT(*) AS zero_cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE wa_selfbonus_logid = 0;
|
||||
|
||||
-- Apply unique index
|
||||
ALTER TABLE eb_user_integral_record
|
||||
ADD UNIQUE KEY uk_integral_selfbonus_log (wa_selfbonus_logid);
|
||||
|
||||
-- Verify index exists
|
||||
SHOW INDEX FROM eb_user_integral_record
|
||||
WHERE Key_name = 'uk_integral_selfbonus_log';
|
||||
@@ -1,130 +0,0 @@
|
||||
-- Purpose:
|
||||
-- 1) Find duplicate selfbonus integral rows generated from same wa_selfbonus_log
|
||||
-- 2) Backup affected data
|
||||
-- 3) Keep min(id), delete duplicate rows
|
||||
-- 4) Resync eb_user.integral from remaining ledger rows
|
||||
-- 5) Rebuild integer balance snapshot for affected users
|
||||
--
|
||||
-- Notes:
|
||||
-- - Designed for MySQL 5.7
|
||||
-- - Run during low traffic window
|
||||
-- - Review backup table names before execution
|
||||
|
||||
-- 0) Preview duplicate groups
|
||||
SELECT uid,
|
||||
wa_selfbonus_logid,
|
||||
link_id,
|
||||
COUNT(*) AS cnt,
|
||||
SUM(integral) AS total_integral,
|
||||
MIN(id) AS keep_id,
|
||||
GROUP_CONCAT(id ORDER BY id) AS record_ids
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 1) Backup duplicate rows and affected users
|
||||
DROP TABLE IF EXISTS backup_euir_selfbonus_dups_20260511_0959;
|
||||
CREATE TABLE backup_euir_selfbonus_dups_20260511_0959 AS
|
||||
SELECT e.*
|
||||
FROM eb_user_integral_record e
|
||||
JOIN (
|
||||
SELECT uid, wa_selfbonus_logid, link_id, MIN(id) AS keep_id, COUNT(*) AS cnt
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1
|
||||
) d
|
||||
ON d.uid = e.uid
|
||||
AND d.wa_selfbonus_logid = e.wa_selfbonus_logid
|
||||
AND d.link_id = e.link_id;
|
||||
|
||||
DROP TABLE IF EXISTS backup_eb_user_integral_before_fix_20260511_0959;
|
||||
CREATE TABLE backup_eb_user_integral_before_fix_20260511_0959 AS
|
||||
SELECT u.*
|
||||
FROM eb_user u
|
||||
WHERE u.uid IN (
|
||||
SELECT DISTINCT uid FROM backup_euir_selfbonus_dups_20260511_0959
|
||||
);
|
||||
|
||||
-- 2) Deduplicate + resync in one transaction
|
||||
START TRANSACTION;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_dup_groups;
|
||||
CREATE TEMPORARY TABLE tmp_dup_groups AS
|
||||
SELECT uid, wa_selfbonus_logid, link_id, MIN(id) AS keep_id
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS tmp_affected_uids;
|
||||
CREATE TEMPORARY TABLE tmp_affected_uids AS
|
||||
SELECT DISTINCT uid FROM tmp_dup_groups;
|
||||
|
||||
DELETE e
|
||||
FROM eb_user_integral_record e
|
||||
JOIN tmp_dup_groups d
|
||||
ON d.uid = e.uid
|
||||
AND d.wa_selfbonus_logid = e.wa_selfbonus_logid
|
||||
AND d.link_id = e.link_id
|
||||
WHERE e.id <> d.keep_id;
|
||||
|
||||
UPDATE eb_user u
|
||||
JOIN (
|
||||
SELECT r.uid, COALESCE(SUM(r.integral), 0) AS sum_integral
|
||||
FROM eb_user_integral_record r
|
||||
JOIN tmp_affected_uids t ON t.uid = r.uid
|
||||
GROUP BY r.uid
|
||||
) s ON s.uid = u.uid
|
||||
SET u.integral = s.sum_integral;
|
||||
|
||||
SET @run_uid := 0;
|
||||
SET @run_bal := 0;
|
||||
UPDATE eb_user_integral_record e
|
||||
JOIN (
|
||||
SELECT t.id, FLOOR(t.running) AS new_balance
|
||||
FROM (
|
||||
SELECT s.id,
|
||||
s.uid,
|
||||
(@run_bal := IF(@run_uid = s.uid, @run_bal + s.integral, s.integral)) AS running,
|
||||
(@run_uid := s.uid) AS uid_guard
|
||||
FROM (
|
||||
SELECT id, uid, integral
|
||||
FROM eb_user_integral_record
|
||||
WHERE uid IN (SELECT uid FROM tmp_affected_uids)
|
||||
ORDER BY uid, id
|
||||
) s
|
||||
) t
|
||||
) x ON x.id = e.id
|
||||
SET e.balance = x.new_balance;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 3) Post-checks
|
||||
SELECT COUNT(*) AS remaining_dup_groups
|
||||
FROM (
|
||||
SELECT 1
|
||||
FROM eb_user_integral_record
|
||||
WHERE link_type = 'selfbonus'
|
||||
AND type = 1
|
||||
AND wa_selfbonus_logid IS NOT NULL
|
||||
GROUP BY uid, wa_selfbonus_logid, link_id
|
||||
HAVING COUNT(*) > 1
|
||||
) a;
|
||||
|
||||
SELECT u.uid, u.integral, s.sum_integral
|
||||
FROM eb_user u
|
||||
JOIN (
|
||||
SELECT uid, SUM(integral) AS sum_integral
|
||||
FROM eb_user_integral_record
|
||||
GROUP BY uid
|
||||
) s ON s.uid = u.uid
|
||||
WHERE u.uid IN (SELECT DISTINCT uid FROM backup_euir_selfbonus_dups_20260511_0959)
|
||||
ORDER BY u.uid;
|
||||
@@ -1,5 +0,0 @@
|
||||
VITE_APP_ENV=development
|
||||
VITE_API_BASE_URL=/api/admin
|
||||
VITE_MOCK_ENABLED=false
|
||||
VITE_APP_TITLE=经营驾驶舱
|
||||
VITE_BUILD_VERSION=local
|
||||
24
dashboard-frontend/.gitignore
vendored
24
dashboard-frontend/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,122 +0,0 @@
|
||||
# Dashboard Frontend
|
||||
|
||||
独立 H5 经营驾驶舱前端项目。第一阶段只使用本地 Mock 数据,不对接后端 API。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- React 19
|
||||
- TypeScript
|
||||
- Vite
|
||||
- antd-mobile
|
||||
- TanStack Query
|
||||
- Zustand
|
||||
- Axios
|
||||
- ECharts
|
||||
- MSW
|
||||
- Vitest
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
nvm use --delete-prefix v24.14.1
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
默认访问:
|
||||
|
||||
```text
|
||||
http://localhost:5174/h5/dashboard/boss
|
||||
```
|
||||
|
||||
## 第一阶段范围
|
||||
|
||||
- H5 移动端老板驾驶舱首页
|
||||
- 昨日经营核心 KPI
|
||||
- 今日 10:15 / 14:55 节点快报 Mock
|
||||
- 近 7 天交易趋势
|
||||
- 用户、团队、商品排行
|
||||
- 风险预警摘要
|
||||
- 底部 Tab 导航
|
||||
- MSW Mock 数据
|
||||
|
||||
## 校验
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test -- --run
|
||||
pnpm build
|
||||
```
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -1,22 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'public/mockServiceWorker.js']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dashboard-frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"name": "dashboard-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"antd-mobile": "^5.42.3",
|
||||
"antd-mobile-icons": "^0.3.0",
|
||||
"axios": "^1.16.0",
|
||||
"echarts": "^6.0.0",
|
||||
"msw": "^2.14.5",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
3461
dashboard-frontend/pnpm-lock.yaml
generated
3461
dashboard-frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,349 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.14.5'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AppProviders } from './app/providers/AppProviders'
|
||||
import { MobileLayout } from './app/layouts/MobileLayout'
|
||||
import { BossDashboardPage } from './features/boss-dashboard/pages/BossDashboardPage'
|
||||
import { DailyReportPage, ProfilePage, RiskCenterPage, TodaySnapshotPage } from './features/boss-dashboard/pages/OperationsPages'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AppProviders>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/h5/dashboard/boss" replace />} />
|
||||
<Route element={<MobileLayout />}>
|
||||
<Route path="/h5/dashboard/boss" element={<BossDashboardPage />} />
|
||||
<Route path="/h5/dashboard/daily-report" element={<DailyReportPage />} />
|
||||
<Route path="/h5/dashboard/today-snapshot" element={<TodaySnapshotPage />} />
|
||||
<Route path="/h5/dashboard/risk-center" element={<RiskCenterPage />} />
|
||||
<Route path="/h5/dashboard/profile" element={<ProfilePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/h5/dashboard/boss" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AppProviders>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,34 +0,0 @@
|
||||
import { AppOutline, BellOutline, FileOutline, HistogramOutline, UserOutline } from 'antd-mobile-icons'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { SafeArea, TabBar } from 'antd-mobile'
|
||||
|
||||
const tabs = [
|
||||
{ key: '/h5/dashboard/boss', title: '首页', icon: <AppOutline /> },
|
||||
{ key: '/h5/dashboard/daily-report', title: '日报', icon: <FileOutline /> },
|
||||
{ key: '/h5/dashboard/today-snapshot', title: '快报', icon: <HistogramOutline /> },
|
||||
{ key: '/h5/dashboard/risk-center', title: '风险', icon: <BellOutline /> },
|
||||
{ key: '/h5/dashboard/profile', title: '我的', icon: <UserOutline /> },
|
||||
]
|
||||
|
||||
export function MobileLayout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const activeKey = tabs.find((tab) => location.pathname.startsWith(tab.key))?.key ?? tabs[0].key
|
||||
|
||||
return (
|
||||
<div className="mobile-shell">
|
||||
<main className="mobile-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
<nav className="bottom-nav" aria-label="Dashboard mobile navigation">
|
||||
<TabBar activeKey={activeKey} onChange={(key) => navigate(key)}>
|
||||
{tabs.map((tab) => (
|
||||
<TabBar.Item key={tab.key} icon={tab.icon} title={tab.title} />
|
||||
))}
|
||||
</TabBar>
|
||||
<SafeArea position="bottom" />
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ConfigProvider } from 'antd-mobile'
|
||||
import zhCN from 'antd-mobile/es/locales/zh-CN'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type AppProvidersProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: AppProvidersProps) {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import * as echarts from 'echarts/core'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import type { TrendPoint } from '../../features/boss-dashboard/types'
|
||||
|
||||
echarts.use([GridComponent, TooltipComponent, LineChart, BarChart, CanvasRenderer])
|
||||
|
||||
type MiniTrendChartProps = {
|
||||
data: TrendPoint[]
|
||||
}
|
||||
|
||||
export function MiniTrendChart({ data }: MiniTrendChartProps) {
|
||||
const chartRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const option = useMemo(
|
||||
() => ({
|
||||
color: ['#ff5b36', '#ffb000'],
|
||||
grid: { left: 8, right: 8, top: 24, bottom: 18, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
valueFormatter: (value: number | string) => Number(value).toLocaleString('zh-CN'),
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map((item) => item.date),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: [
|
||||
{ type: 'value', show: false },
|
||||
{ type: 'value', show: false },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '成交额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 0,
|
||||
data: data.map((item) => item.amount),
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { width: 3 },
|
||||
areaStyle: { opacity: 0.08 },
|
||||
},
|
||||
{
|
||||
name: '订单数',
|
||||
type: 'bar',
|
||||
yAxisIndex: 1,
|
||||
data: data.map((item) => item.orders),
|
||||
barWidth: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
],
|
||||
}),
|
||||
[data],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return undefined
|
||||
const chart = echarts.init(chartRef.current)
|
||||
chart.setOption(option)
|
||||
|
||||
const resize = () => chart.resize()
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize)
|
||||
chart.dispose()
|
||||
}
|
||||
}, [option])
|
||||
|
||||
return <div className="mini-trend-chart" ref={chartRef} aria-label="近 7 天交易趋势图" />
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Skeleton } from 'antd-mobile'
|
||||
import { formatMetricValue, formatTrend } from '../../utils/format'
|
||||
import type { KpiMetric } from '../../features/boss-dashboard/types'
|
||||
|
||||
type KpiCardProps = {
|
||||
metric: KpiMetric
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function KpiCard({ metric, loading }: KpiCardProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<article className="kpi-card">
|
||||
<Skeleton.Title animated />
|
||||
<Skeleton.Paragraph lineCount={1} animated />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={`kpi-card kpi-card--${metric.status} ${metric.featured ? 'kpi-card--featured' : ''}`}>
|
||||
<p className="kpi-title">{metric.title}</p>
|
||||
<strong className="kpi-value">{formatMetricValue(metric.value, metric.unit)}</strong>
|
||||
{(metric.trendLabel || metric.trendValue !== undefined) && (
|
||||
<p className="kpi-trend">
|
||||
{metric.trendLabel}
|
||||
{metric.trendValue !== undefined && <span>{formatTrend(metric.trendValue)}</span>}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getApiData, getBlob } from '../../services/http/client'
|
||||
import type { DashboardOverview } from './types'
|
||||
|
||||
export const dashboardQueryKeys = {
|
||||
overview: (date?: string) => ['dashboard', 'overview', date ?? 'default'] as const,
|
||||
}
|
||||
|
||||
export function useDashboardOverview(date?: string) {
|
||||
return useQuery({
|
||||
queryKey: dashboardQueryKeys.overview(date),
|
||||
queryFn: () => getApiData<DashboardOverview>(date ? `/dashboard/overview?date=${date}` : '/dashboard/overview'),
|
||||
})
|
||||
}
|
||||
|
||||
export function downloadDailyReportArchive(date?: string) {
|
||||
return getBlob(date ? `/dashboard/daily-report/archive?date=${date}` : '/dashboard/daily-report/archive')
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { formatMetricValue, formatMoney, formatNumber } from '../../utils/format'
|
||||
import type { DashboardOverview, MetricStatus, RankItem, RiskAlert, RiskLevel, SnapshotSlot, TodaySnapshot } from './types'
|
||||
|
||||
const snapshotTitle: Record<SnapshotSlot, string> = {
|
||||
'1015': '上午抢购快报',
|
||||
'1455': '下午寄卖/转卖快报',
|
||||
}
|
||||
|
||||
const snapshotDescription: Record<SnapshotSlot, string> = {
|
||||
'1015': '用户集中抢购上一天用户寄卖的商品,重点看成交、付款和采购用户是否达标。',
|
||||
'1455': '用户把上午抢到的商品继续寄卖或转卖,重点看新增寄售供给和奖金变化是否正常。',
|
||||
}
|
||||
|
||||
const metricStatusText: Record<MetricStatus, string> = {
|
||||
normal: '正常',
|
||||
success: '达标',
|
||||
warning: '关注',
|
||||
danger: '异常',
|
||||
}
|
||||
|
||||
const riskLevelText: Record<RiskLevel, string> = {
|
||||
red: '红色',
|
||||
yellow: '黄色',
|
||||
gray: '灰色',
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
function serializeStaticData(data: DashboardOverview): string {
|
||||
return JSON.stringify(data, null, 2).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e')
|
||||
}
|
||||
|
||||
function renderMetricGrid(metrics: DashboardOverview['kpis']): string {
|
||||
return metrics
|
||||
.map(
|
||||
(metric) => `
|
||||
<article class="card metric-card">
|
||||
<span>${escapeHtml(metricStatusText[metric.status])}</span>
|
||||
<h3>${escapeHtml(metric.title)}</h3>
|
||||
<strong>${escapeHtml(formatMetricValue(metric.value, metric.unit))}</strong>
|
||||
${metric.trendLabel ? `<p>${escapeHtml(metric.trendLabel)} ${escapeHtml(metric.trendValue ?? '')}%</p>` : ''}
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function renderSnapshots(snapshots: TodaySnapshot[]): string {
|
||||
return snapshots
|
||||
.map((snapshot) => {
|
||||
const bonusChange = Number(snapshot.selfBonusChange) + Number(snapshot.shareBonusChange)
|
||||
return `
|
||||
<article class="card snapshot-card">
|
||||
<div class="card-title-row">
|
||||
<div>
|
||||
<span>${escapeHtml(snapshot.slot)}</span>
|
||||
<h3>${escapeHtml(snapshotTitle[snapshot.slot])}</h3>
|
||||
</div>
|
||||
<mark>${escapeHtml(snapshot.status)}</mark>
|
||||
</div>
|
||||
<p>${escapeHtml(snapshotDescription[snapshot.slot])}</p>
|
||||
<p class="message">${escapeHtml(snapshot.message)}</p>
|
||||
${snapshot.generatedAt ? `<small>生成时间:${escapeHtml(snapshot.generatedAt)}</small>` : ''}
|
||||
<div class="snapshot-grid">
|
||||
<span>用户<strong>${escapeHtml(formatNumber(snapshot.purchaseUsers))}人</strong></span>
|
||||
<span>订单<strong>${escapeHtml(formatNumber(snapshot.orderCount))}单</strong></span>
|
||||
<span>成交额<strong>${escapeHtml(formatMoney(snapshot.dealAmount))}</strong></span>
|
||||
<span>已支付<strong>${escapeHtml(formatMoney(snapshot.paidAmount))}</strong></span>
|
||||
<span>商品<strong>${escapeHtml(formatNumber(snapshot.newMerchandiseCount))}件</strong></span>
|
||||
<span>奖金<strong>${escapeHtml(formatMoney(bonusChange))}</strong></span>
|
||||
</div>
|
||||
</article>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
function renderRanks(title: string, ranks: RankItem[]): string {
|
||||
return `
|
||||
<section class="section">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<div class="rank-list">
|
||||
${ranks
|
||||
.map(
|
||||
(rank, index) => `
|
||||
<div class="rank-item">
|
||||
<b>${index + 1}</b>
|
||||
<span>
|
||||
<strong>${escapeHtml(rank.name)}</strong>
|
||||
<small>${escapeHtml(rank.description)}</small>
|
||||
</span>
|
||||
<em>${escapeHtml(formatMoney(rank.value))}</em>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>`
|
||||
}
|
||||
|
||||
function renderRisks(risks: RiskAlert[]): string {
|
||||
return risks
|
||||
.map(
|
||||
(risk) => `
|
||||
<article class="risk risk--${risk.level}">
|
||||
<div>
|
||||
<mark>${escapeHtml(riskLevelText[risk.level])}</mark>
|
||||
<span>${escapeHtml(risk.type)}</span>
|
||||
<time>${escapeHtml(risk.discoveredAt)}</time>
|
||||
</div>
|
||||
<strong>${escapeHtml(risk.title)}</strong>
|
||||
<p>${escapeHtml(risk.description)}</p>
|
||||
</article>`,
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function buildDailyReportArchiveHtml(data: DashboardOverview): string {
|
||||
const generatedAt = new Date().toLocaleString('zh-CN', { hour12: false })
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>经营日报归档 - ${escapeHtml(data.businessDate)}</title>
|
||||
<style>
|
||||
:root { --bg: #fff6f1; --surface: #fff; --surface-soft: #f6f9fb; --text: #132033; --muted: #6b7a90; --border: rgba(19, 32, 51, .08); --primary: #ff5b36; --warning: #ffb000; --danger: #dc2626; --shadow: 0 16px 40px rgba(255, 91, 54, .14); --radius-xl: 28px; --radius-lg: 20px; --radius-md: 14px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { min-width: 320px; margin: 0; color: var(--text); background: radial-gradient(circle at top left, rgba(255, 91, 54, .2), transparent 28rem), var(--bg); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
|
||||
main { width: min(100%, 430px); min-height: 100svh; margin: 0 auto; padding: 14px 14px 24px; background: var(--bg); box-shadow: 0 0 0 1px rgba(19, 32, 51, .04); }
|
||||
.hero { position: relative; overflow: hidden; padding: 20px; color: #fff; background: linear-gradient(145deg, rgba(255, 91, 54, .98), rgba(255, 139, 82, .92)), radial-gradient(circle at 90% 10%, rgba(255, 176, 0, .42), transparent 18rem); border-radius: 0 0 var(--radius-xl) var(--radius-xl); box-shadow: var(--shadow); }
|
||||
.hero p { margin: 0; color: rgba(255, 255, 255, .76); line-height: 1.6; }
|
||||
.eyebrow, .card-title-row span, .metric-card > span { margin: 0; color: rgba(255, 255, 255, .68); font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
|
||||
h1, h2, h3 { margin: 0; }
|
||||
h1 { margin: 12px 0 8px; font-size: 28px; line-height: 1.12; }
|
||||
h2 { font-size: 18px; margin-bottom: 12px; }
|
||||
h3 { font-size: 16px; }
|
||||
.meta { display: grid; gap: 8px; margin-top: 16px; }
|
||||
.meta span { display: inline-flex; width: max-content; padding: 7px 12px; color: rgba(255, 255, 255, .86); font-size: 12px; font-weight: 700; background: rgba(255, 255, 255, .14); border: 1px solid rgba(255, 255, 255, .18); border-radius: 999px; }
|
||||
.section { margin-top: 14px; padding: 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-xl); box-shadow: 0 10px 28px rgba(22, 47, 80, .08); }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.card { padding: 14px; background: var(--surface-soft); border: 0; border-radius: var(--radius-lg); }
|
||||
.metric-card { min-height: 112px; background: var(--surface); border: 1px solid var(--border); box-shadow: 0 10px 28px rgba(22, 47, 80, .08); }
|
||||
.metric-card > span { color: var(--muted); }
|
||||
.metric-card strong { display: block; margin-top: 8px; color: var(--text); font-size: 22px; line-height: 1.08; word-break: break-all; }
|
||||
.metric-card p { margin: 8px 0 0; color: var(--muted); font-size: 12px; }
|
||||
.snapshot-stack, .trend-list, .rank-list, .risk-list { display: grid; gap: 10px; margin-top: 14px; }
|
||||
.card-title-row, .rank-item, .risk div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.card-title-row span { color: var(--muted); }
|
||||
mark { padding: 3px 8px; color: var(--primary); font-size: 12px; font-weight: 700; background: #fff0eb; border: 0; border-radius: 999px; }
|
||||
.snapshot-card p { margin: 12px 0 0; color: var(--muted); font-size: 13px; line-height: 1.55; }
|
||||
.snapshot-card .message { margin: 10px 0 8px; color: var(--text); font-weight: 700; line-height: 1.55; }
|
||||
.snapshot-card small { color: var(--muted); }
|
||||
.snapshot-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
|
||||
.snapshot-grid span, .trend-item, .rank-item, .risk { padding: 12px; background: var(--surface-soft); border: 0; border-radius: var(--radius-md); }
|
||||
.snapshot-grid span { color: var(--muted); font-size: 12px; }
|
||||
.snapshot-grid strong { display: block; margin-top: 4px; color: var(--text); font-size: 15px; }
|
||||
.trend-item { display: grid; gap: 4px; }
|
||||
.trend-item span { color: var(--muted); font-size: 13px; }
|
||||
.rank-item b { width: 28px; height: 28px; display: inline-grid; place-items: center; border-radius: 10px; color: #fff; background: var(--primary); }
|
||||
.rank-item span { flex: 1; }
|
||||
.rank-item small { display: block; margin-top: 3px; color: var(--muted); line-height: 1.45; }
|
||||
.rank-item em { color: var(--primary); font-size: 13px; font-style: normal; font-weight: 800; }
|
||||
.risk strong { display: block; margin-top: 10px; }
|
||||
.risk p { margin: 6px 0 0; color: var(--muted); line-height: 1.5; }
|
||||
.risk--red mark { color: #991b1b; background: #fee2e2; }
|
||||
.risk--yellow mark { color: #92400e; background: #fef3c7; }
|
||||
.risk--gray mark { color: #475569; background: #e2e8f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Daily Report Archive</p>
|
||||
<h1>经营日报归档 ${escapeHtml(data.businessDate)}</h1>
|
||||
<p>${escapeHtml(data.summary)}</p>
|
||||
<div class="meta">
|
||||
<span>业务日期:${escapeHtml(data.businessDate)}</span>
|
||||
<span>数据生成:${escapeHtml(data.generatedAt)}</span>
|
||||
<span>归档生成:${escapeHtml(generatedAt)}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section"><h2>核心指标</h2><div class="grid">${renderMetricGrid(data.kpis)}</div></section>
|
||||
<section class="section"><h2>资金池摘要</h2><div class="grid">${renderMetricGrid(data.fundPool)}</div></section>
|
||||
<section class="section"><h2>今日快报</h2><div class="snapshot-stack">${renderSnapshots(data.snapshots)}</div></section>
|
||||
<section class="section">
|
||||
<h2>最近趋势</h2>
|
||||
<div class="trend-list">
|
||||
${data.trends
|
||||
.map(
|
||||
(trend) => `
|
||||
<div class="trend-item">
|
||||
<strong>${escapeHtml(trend.date)}</strong>
|
||||
<span>成交 ${escapeHtml(formatMoney(trend.amount))}</span>
|
||||
<span>订单 ${escapeHtml(formatNumber(trend.orders))} 单</span>
|
||||
<span>奖金 ${escapeHtml(formatMoney(trend.bonus))}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
${renderRanks('高价值用户', data.userRanks)}
|
||||
${renderRanks('团队贡献排行', data.teamRanks)}
|
||||
${renderRanks('高货值未成交商品', data.productRanks)}
|
||||
<section class="section"><h2>风险预警</h2><div class="risk-list">${renderRisks(data.risks)}</div></section>
|
||||
<script id="dashboard-static-data" type="application/json">${serializeStaticData(data)}</script>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { RightOutline } from 'antd-mobile-icons'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import type { RankItem } from '../types'
|
||||
|
||||
type RankListProps = {
|
||||
title: string
|
||||
items: RankItem[]
|
||||
valueType?: 'money' | 'number'
|
||||
}
|
||||
|
||||
export function RankList({ title, items, valueType = 'money' }: RankListProps) {
|
||||
return (
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Top 3</p>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<button className="text-button" type="button">
|
||||
全部
|
||||
<RightOutline />
|
||||
</button>
|
||||
</div>
|
||||
<div className="rank-list">
|
||||
{items.map((item, index) => (
|
||||
<button className="rank-item" key={item.id} type="button">
|
||||
<span className="rank-index">{index + 1}</span>
|
||||
<span className="rank-content">
|
||||
<strong>{item.name}</strong>
|
||||
<small>{item.description}</small>
|
||||
</span>
|
||||
<span className="rank-value">{valueType === 'money' ? formatMoney(item.value) : formatNumber(item.value)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Tag } from 'antd-mobile'
|
||||
import type { RiskAlert, RiskLevel } from '../types'
|
||||
|
||||
type RiskAlertSectionProps = {
|
||||
risks: RiskAlert[]
|
||||
}
|
||||
|
||||
const levelMeta: Record<RiskLevel, { color: 'danger' | 'warning' | 'default'; label: string }> = {
|
||||
red: { color: 'danger', label: '红色' },
|
||||
yellow: { color: 'warning', label: '黄色' },
|
||||
gray: { color: 'default', label: '灰色' },
|
||||
}
|
||||
|
||||
export function RiskAlertSection({ risks }: RiskAlertSectionProps) {
|
||||
return (
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Risk</p>
|
||||
<h2>风险预警</h2>
|
||||
</div>
|
||||
<span className="risk-count">{risks.length} 条</span>
|
||||
</div>
|
||||
<div className="risk-list">
|
||||
{risks.map((risk) => {
|
||||
const meta = levelMeta[risk.level]
|
||||
return (
|
||||
<button className="risk-item" key={risk.id} type="button">
|
||||
<div className="risk-header">
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<span>{risk.type}</span>
|
||||
<time>{risk.discoveredAt}</time>
|
||||
</div>
|
||||
<strong>{risk.title}</strong>
|
||||
<p>{risk.description}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { CapsuleTabs, Tag } from 'antd-mobile'
|
||||
import { useState } from 'react'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import type { SnapshotSlot, TodaySnapshot } from '../types'
|
||||
|
||||
type TodaySnapshotSectionProps = {
|
||||
snapshots: TodaySnapshot[]
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
pending: { color: 'default', label: '待生成' },
|
||||
success: { color: 'success', label: '已生成' },
|
||||
failed: { color: 'danger', label: '生成失败' },
|
||||
temporary: { color: 'warning', label: '临时数据' },
|
||||
} as const
|
||||
|
||||
export function TodaySnapshotSection({ snapshots }: TodaySnapshotSectionProps) {
|
||||
const [activeSlot, setActiveSlot] = useState<SnapshotSlot>('1015')
|
||||
const activeSnapshot = snapshots.find((snapshot) => snapshot.slot === activeSlot) ?? snapshots[0]
|
||||
const status = statusMap[activeSnapshot.status]
|
||||
|
||||
return (
|
||||
<section className="section-block snapshot-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">今日节点</p>
|
||||
<h2>抢购 / 寄卖快报</h2>
|
||||
</div>
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
</div>
|
||||
|
||||
<CapsuleTabs activeKey={activeSlot} onChange={(key) => setActiveSlot(key as SnapshotSlot)}>
|
||||
{snapshots.map((snapshot) => (
|
||||
<CapsuleTabs.Tab title={snapshot.title.replace(' ', '')} key={snapshot.slot} />
|
||||
))}
|
||||
</CapsuleTabs>
|
||||
|
||||
<div className="snapshot-card">
|
||||
<p className="snapshot-message">{activeSnapshot.message}</p>
|
||||
{activeSnapshot.generatedAt && <p className="snapshot-time">生成时间:{activeSnapshot.generatedAt}</p>}
|
||||
<div className="snapshot-grid">
|
||||
<span>
|
||||
采购用户<strong>{formatNumber(activeSnapshot.purchaseUsers)}人</strong>
|
||||
</span>
|
||||
<span>
|
||||
订单数<strong>{formatNumber(activeSnapshot.orderCount)}单</strong>
|
||||
</span>
|
||||
<span>
|
||||
成交额<strong>{formatMoney(activeSnapshot.dealAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
支付额<strong>{formatMoney(activeSnapshot.paidAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
新增商品<strong>{formatNumber(activeSnapshot.newMerchandiseCount)}件</strong>
|
||||
</span>
|
||||
<span>
|
||||
奖金变化<strong>{formatMoney(Number(activeSnapshot.selfBonusChange) + Number(activeSnapshot.shareBonusChange))}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { DashboardOverview } from './types'
|
||||
|
||||
export const dashboardMock: DashboardOverview = {
|
||||
businessDate: '2026-05-10',
|
||||
generatedAt: '2026-05-11 00:10:12',
|
||||
summary: '昨日成交保持稳定,采购用户略有增长;资金风险主要集中在大额待提现和积分比例异常。',
|
||||
kpis: [
|
||||
{ key: 'dealAmount', title: '昨日成交额', value: 1289360.42, unit: '元', trendLabel: '较前日', trendValue: 8.6, status: 'success', featured: true },
|
||||
{ key: 'orderCount', title: '昨日订单数', value: 1842, unit: '单', trendLabel: '较前日', trendValue: 4.1, status: 'success' },
|
||||
{ key: 'purchaseUsers', title: '采购用户', value: 936, unit: '人', trendLabel: '较前日', trendValue: 2.7, status: 'success' },
|
||||
{ key: 'newUsers', title: '新增用户', value: 318, unit: '人', trendLabel: '较前日', trendValue: -3.2, status: 'warning' },
|
||||
{ key: 'newMerchandise', title: '新增寄售商品', value: 472, unit: '件', trendLabel: '较前日', trendValue: 12.4, status: 'success' },
|
||||
{ key: 'selfBonus', title: '个人奖金发放', value: 168230.36, unit: '元', trendLabel: '较前日', trendValue: 6.8, status: 'normal' },
|
||||
{ key: 'shareBonus', title: '推广奖金发放', value: 82460.18, unit: '元', trendLabel: '较前日', trendValue: 1.9, status: 'normal' },
|
||||
{ key: 'pendingAmount', title: '待支付/待结算', value: 95620.11, unit: '元', trendLabel: '需关注', status: 'warning' },
|
||||
],
|
||||
fundPool: [
|
||||
{ key: 'balance', title: '余额总额', value: 728903.22, unit: '元', status: 'normal' },
|
||||
{ key: 'coupon', title: '优惠券总额', value: 391082.88, unit: '元', status: 'normal' },
|
||||
{ key: 'selfBonusPool', title: '个人奖金总额', value: 836942.14, unit: '元', status: 'warning' },
|
||||
{ key: 'shareBonusPool', title: '推广奖金总额', value: 295402.77, unit: '元', status: 'normal' },
|
||||
{ key: 'integral', title: '积分总额', value: 418471.07, unit: '分', status: 'normal' },
|
||||
{ key: 'withdrawPending', title: '待审核提现', value: 63200, unit: '元', status: 'danger' },
|
||||
],
|
||||
snapshots: [
|
||||
{
|
||||
slot: '1015',
|
||||
title: '10:15 上午快报',
|
||||
status: 'success',
|
||||
generatedAt: '2026-05-11 10:15:08',
|
||||
message: '上午抢购节点已完成,上一日寄卖商品消化情况良好,采购用户和成交额略高于昨日同节点。',
|
||||
purchaseUsers: 421,
|
||||
orderCount: 756,
|
||||
dealAmount: 526880.2,
|
||||
paidAmount: 498320.5,
|
||||
newMerchandiseCount: 185,
|
||||
selfBonusChange: 64230.3,
|
||||
shareBonusChange: 31820.1,
|
||||
},
|
||||
{
|
||||
slot: '1455',
|
||||
title: '14:55 下午快报',
|
||||
status: 'pending',
|
||||
message: '下午寄卖/转卖节点尚未生成,预计 14:55 后可查看用户抢购商品的再次上架情况。',
|
||||
purchaseUsers: 0,
|
||||
orderCount: 0,
|
||||
dealAmount: 0,
|
||||
paidAmount: 0,
|
||||
newMerchandiseCount: 0,
|
||||
selfBonusChange: 0,
|
||||
shareBonusChange: 0,
|
||||
},
|
||||
],
|
||||
trends: [
|
||||
{ date: '05-04', amount: 948000, orders: 1390, newUsers: 226, bonus: 186000 },
|
||||
{ date: '05-05', amount: 1024000, orders: 1512, newUsers: 251, bonus: 194000 },
|
||||
{ date: '05-06', amount: 1119000, orders: 1604, newUsers: 287, bonus: 205000 },
|
||||
{ date: '05-07', amount: 1086000, orders: 1542, newUsers: 243, bonus: 198000 },
|
||||
{ date: '05-08', amount: 1198000, orders: 1731, newUsers: 302, bonus: 221000 },
|
||||
{ date: '05-09', amount: 1187200, orders: 1769, newUsers: 329, bonus: 229000 },
|
||||
{ date: '05-10', amount: 1289360, orders: 1842, newUsers: 318, bonus: 250690 },
|
||||
],
|
||||
userRanks: [
|
||||
{ id: 'u1', name: '刘先生', value: 96520, description: '个人奖金 + 推广奖金 + 积分折算', badge: '高价值' },
|
||||
{ id: 'u2', name: '陈女士', value: 81230, description: '昨日采购 12 单', badge: '活跃' },
|
||||
{ id: 'u3', name: '周先生', value: 75880, description: '团队新增 18 人' },
|
||||
],
|
||||
teamRanks: [
|
||||
{ id: 't1', name: '华东一队', value: 386200, description: '成交额第一,团队收益 4.8 万', badge: 'TOP1' },
|
||||
{ id: 't2', name: '苏州团队', value: 318760, description: '采购用户 182 人' },
|
||||
{ id: 't3', name: '扬州团队', value: 287500, description: '新增成员 36 人' },
|
||||
],
|
||||
productRanks: [
|
||||
{ id: 'p1', name: '高端礼盒 A 款', value: 128800, description: '上架 7 天未成交', badge: '滞销' },
|
||||
{ id: 'p2', name: '精选组合 B 款', value: 98600, description: '高货值待成交' },
|
||||
{ id: 'p3', name: '会员专享 C 款', value: 83500, description: '浏览高,成交低' },
|
||||
],
|
||||
risks: [
|
||||
{
|
||||
id: 'r1',
|
||||
level: 'red',
|
||||
type: '资金',
|
||||
title: '大额待审核提现',
|
||||
description: '当前待审核提现 6.32 万,建议今日处理。',
|
||||
discoveredAt: '11:00',
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
level: 'yellow',
|
||||
type: '积分',
|
||||
title: '积分与个人奖金比例异常',
|
||||
description: '发现 3 名用户积分未接近个人奖金的 1/2。',
|
||||
discoveredAt: '10:40',
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
level: 'gray',
|
||||
type: '数据',
|
||||
title: '用户资料不一致',
|
||||
description: 'wa_users 与 eb_user 有 5 条手机号不一致。',
|
||||
discoveredAt: '09:55',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Button, DotLoading, ErrorBlock } from 'antd-mobile'
|
||||
import { KpiCard } from '../../../components/kpi/KpiCard'
|
||||
import { MiniTrendChart } from '../../../components/charts/MiniTrendChart'
|
||||
import { formatMoney } from '../../../utils/format'
|
||||
import { useDashboardOverview } from '../api'
|
||||
import { RankList } from '../components/RankList'
|
||||
import { RiskAlertSection } from '../components/RiskAlertSection'
|
||||
import { TodaySnapshotSection } from '../components/TodaySnapshotSection'
|
||||
|
||||
export function BossDashboardPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="loading-page">
|
||||
<DotLoading color="primary" />
|
||||
<p>正在生成经营简报...</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<section className="error-page">
|
||||
<ErrorBlock status="default" title="驾驶舱加载失败" description="后端接口暂不可用,请确认服务、登录态或接口权限后重试。" />
|
||||
<Button color="primary" onClick={() => void refetch()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const coreKpis = data.kpis.slice(0, 4)
|
||||
const moreKpis = data.kpis.slice(4)
|
||||
|
||||
return (
|
||||
<section className="dashboard-page">
|
||||
<header className="dashboard-hero">
|
||||
<div className="hero-topline">
|
||||
<span>经营驾驶舱</span>
|
||||
<button type="button">上个工作日</button>
|
||||
</div>
|
||||
<p className="eyebrow">数据日期 {data.businessDate}</p>
|
||||
<h1>上个工作日经营简报</h1>
|
||||
<p className="hero-summary">{data.summary}</p>
|
||||
<div className="hero-metric">
|
||||
<span>上个工作日成交额</span>
|
||||
<strong>{formatMoney(data.kpis[0]?.value)}</strong>
|
||||
<small>生成时间:{data.generatedAt}</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="kpi-grid" aria-label="核心经营指标">
|
||||
{coreKpis.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">More</p>
|
||||
<h2>更多经营指标</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{moreKpis.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TodaySnapshotSection snapshots={data.snapshots} />
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Trend</p>
|
||||
<h2>近 7 天交易趋势</h2>
|
||||
</div>
|
||||
</div>
|
||||
<MiniTrendChart data={data.trends} />
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Fund</p>
|
||||
<h2>资金池摘要</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{data.fundPool.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RankList title="高价值用户" items={data.userRanks} />
|
||||
<RankList title="团队贡献排行" items={data.teamRanks} />
|
||||
<RankList title="高货值未成交商品" items={data.productRanks} />
|
||||
<RiskAlertSection risks={data.risks} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
import { Button, CapsuleTabs, DotLoading, ErrorBlock, Tag, Toast } from 'antd-mobile'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { MiniTrendChart } from '../../../components/charts/MiniTrendChart'
|
||||
import { KpiCard } from '../../../components/kpi/KpiCard'
|
||||
import { formatMoney, formatNumber } from '../../../utils/format'
|
||||
import { useDashboardOverview } from '../api'
|
||||
import { buildDailyReportArchiveHtml } from '../archive'
|
||||
import type { DashboardOverview, RiskLevel, SnapshotSlot, TodaySnapshot } from '../types'
|
||||
|
||||
const snapshotStatusMeta = {
|
||||
pending: { color: 'default', label: '待生成' },
|
||||
success: { color: 'success', label: '已生成' },
|
||||
failed: { color: 'danger', label: '失败' },
|
||||
temporary: { color: 'warning', label: '临时' },
|
||||
} as const
|
||||
|
||||
const riskLevelMeta: Record<RiskLevel, { color: 'danger' | 'warning' | 'default'; label: string }> = {
|
||||
red: { color: 'danger', label: '红色' },
|
||||
yellow: { color: 'warning', label: '黄色' },
|
||||
gray: { color: 'default', label: '灰色' },
|
||||
}
|
||||
|
||||
const snapshotSlotMeta: Record<
|
||||
SnapshotSlot,
|
||||
{
|
||||
title: string
|
||||
subtitle: string
|
||||
metricLabels: {
|
||||
primaryUsers: string
|
||||
primaryOrders: string
|
||||
amount: string
|
||||
paidAmount: string
|
||||
merchandise: string
|
||||
bonus: string
|
||||
}
|
||||
checklist: string[]
|
||||
}
|
||||
> = {
|
||||
'1015': {
|
||||
title: '上午抢购快报',
|
||||
subtitle: '用户集中抢购上一天用户寄卖的商品,重点看成交、付款和采购用户是否达标。',
|
||||
metricLabels: {
|
||||
primaryUsers: '抢购用户',
|
||||
primaryOrders: '抢购订单',
|
||||
amount: '抢购成交额',
|
||||
paidAmount: '已支付金额',
|
||||
merchandise: '成交商品',
|
||||
bonus: '相关奖金',
|
||||
},
|
||||
checklist: ['抢购成交额是否低于昨日同节点', '采购用户是否异常回落', '付款金额与成交额是否明显偏离', '高货值寄卖商品是否完成消化'],
|
||||
},
|
||||
'1455': {
|
||||
title: '下午寄卖/转卖快报',
|
||||
subtitle: '用户把上午抢到的商品继续寄卖或转卖,重点看新增寄售供给和奖金变化是否正常。',
|
||||
metricLabels: {
|
||||
primaryUsers: '寄卖用户',
|
||||
primaryOrders: '转卖订单',
|
||||
amount: '转卖成交额',
|
||||
paidAmount: '回款金额',
|
||||
merchandise: '新增寄售',
|
||||
bonus: '奖金变化',
|
||||
},
|
||||
checklist: ['抢购商品是否按预期转入寄卖', '新增寄售商品是否满足下午供给', '个人奖金与推广奖金是否同步变化', '转卖回款是否出现异常延迟'],
|
||||
},
|
||||
}
|
||||
|
||||
function QueryState({
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
title,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
refetch: () => void
|
||||
title: string
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="loading-page">
|
||||
<DotLoading color="primary" />
|
||||
<p>正在加载{title}...</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<section className="error-page">
|
||||
<ErrorBlock status="default" title={`${title}加载失败`} description="后端接口暂不可用,请确认服务、登录态或接口权限后重试。" />
|
||||
<Button color="primary" onClick={refetch}>
|
||||
重新加载
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function OperationsHeader({
|
||||
kicker,
|
||||
title,
|
||||
description,
|
||||
extra,
|
||||
}: {
|
||||
kicker: string
|
||||
title: string
|
||||
description: string
|
||||
extra?: string
|
||||
}) {
|
||||
return (
|
||||
<header className="operations-header">
|
||||
<p className="eyebrow">{kicker}</p>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
{extra && <span>{extra}</span>}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function SnapshotDetailCard({ snapshot }: { snapshot: TodaySnapshot }) {
|
||||
const status = snapshotStatusMeta[snapshot.status]
|
||||
const slotMeta = snapshotSlotMeta[snapshot.slot]
|
||||
|
||||
return (
|
||||
<article className="snapshot-detail-card">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">{snapshot.slot}</p>
|
||||
<h2>{slotMeta.title}</h2>
|
||||
</div>
|
||||
<Tag color={status.color}>{status.label}</Tag>
|
||||
</div>
|
||||
<p className="snapshot-detail-subtitle">{slotMeta.subtitle}</p>
|
||||
<p className="snapshot-detail-message">{snapshot.message}</p>
|
||||
{snapshot.generatedAt && <p className="snapshot-time">生成时间:{snapshot.generatedAt}</p>}
|
||||
<div className="snapshot-grid snapshot-grid--wide">
|
||||
<span>
|
||||
{slotMeta.metricLabels.primaryUsers}
|
||||
<strong>{formatNumber(snapshot.purchaseUsers)}人</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.primaryOrders}
|
||||
<strong>{formatNumber(snapshot.orderCount)}单</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.amount}
|
||||
<strong>{formatMoney(snapshot.dealAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.paidAmount}
|
||||
<strong>{formatMoney(snapshot.paidAmount)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.merchandise}
|
||||
<strong>{formatNumber(snapshot.newMerchandiseCount)}件</strong>
|
||||
</span>
|
||||
<span>
|
||||
{slotMeta.metricLabels.bonus}
|
||||
<strong>{formatMoney(Number(snapshot.selfBonusChange) + Number(snapshot.shareBonusChange))}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function buildDailyReports(data: DashboardOverview) {
|
||||
return data.trends
|
||||
.slice(-4)
|
||||
.reverse()
|
||||
.map((trend, index) => ({
|
||||
...trend,
|
||||
status: index === 0 ? '已生成' : '历史快照',
|
||||
bonusRate: Number(trend.amount) > 0 ? (Number(trend.bonus) / Number(trend.amount)) * 100 : 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export function DailyReportPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const [isArchiving, setIsArchiving] = useState(false)
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="经营日报" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
const reports = buildDailyReports(data)
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
setIsArchiving(true)
|
||||
const html = buildDailyReportArchiveHtml(data)
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `dashboard-daily-report-${data.businessDate}.html`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
Toast.show({ icon: 'success', content: '归档 HTML 已生成' })
|
||||
} catch {
|
||||
Toast.show({ icon: 'fail', content: '归档生成失败,请稍后重试' })
|
||||
} finally {
|
||||
setIsArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Daily Report"
|
||||
title="经营日报"
|
||||
description="按日沉淀成交、订单、用户与奖金变化,方便老板回看最近经营节奏。"
|
||||
extra={`最新数据:${data.businessDate}`}
|
||||
/>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Workday</p>
|
||||
<h2>上个工作日重点</h2>
|
||||
</div>
|
||||
<Tag color="success">已归档</Tag>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{data.kpis.slice(0, 4).map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Trend</p>
|
||||
<h2>最近 7 天趋势</h2>
|
||||
</div>
|
||||
</div>
|
||||
<MiniTrendChart data={data.trends} />
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Archive</p>
|
||||
<h2>日报归档</h2>
|
||||
</div>
|
||||
<button className="text-button" type="button" disabled={isArchiving} onClick={() => void handleArchive()}>
|
||||
{isArchiving ? '生成中...' : '生成归档'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="report-list">
|
||||
{reports.map((report) => (
|
||||
<button className="report-item" key={report.date} type="button">
|
||||
<span>
|
||||
<strong>{report.date}</strong>
|
||||
<small>{report.status}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{formatMoney(report.amount)}</strong>
|
||||
<small>
|
||||
{formatNumber(report.orders)} 单 / 奖金占比 {formatNumber(report.bonusRate, 1)}%
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function TodaySnapshotPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="今日快报" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Today Snapshot"
|
||||
title="今日快报"
|
||||
description="10:15 看上一日寄卖商品的抢购结果,14:55 看抢到商品的寄卖/转卖承接情况。"
|
||||
extra="节点状态随 Mock 场景切换"
|
||||
/>
|
||||
|
||||
<section className="section-block snapshot-page-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Timeline</p>
|
||||
<h2>节点快报</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="snapshot-stack">
|
||||
{data.snapshots.map((snapshot) => (
|
||||
<SnapshotDetailCard key={snapshot.slot} snapshot={snapshot} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Checklist</p>
|
||||
<h2>节点检查项</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="check-list">
|
||||
{data.snapshots.flatMap((snapshot) =>
|
||||
snapshotSlotMeta[snapshot.slot].checklist.map((item) => (
|
||||
<span key={`${snapshot.slot}-${item}`}>
|
||||
<strong>{snapshot.slot === '1015' ? '上午' : '下午'}</strong>
|
||||
{item}
|
||||
</span>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function RiskCenterPage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const [activeLevel, setActiveLevel] = useState<RiskLevel | 'all'>('all')
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="风险中心" />
|
||||
|
||||
const filteredRisks = useMemo(() => {
|
||||
if (!data) return []
|
||||
if (activeLevel === 'all') return data.risks
|
||||
return data.risks.filter((risk) => risk.level === activeLevel)
|
||||
}, [activeLevel, data])
|
||||
|
||||
if (!data) return state
|
||||
|
||||
const dangerousFunds = data.fundPool.filter((metric) => metric.status === 'warning' || metric.status === 'danger')
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Risk Center"
|
||||
title="风险中心"
|
||||
description="把资金、积分与数据一致性风险集中处理,优先看红色和黄色事项。"
|
||||
extra={`${data.risks.length} 条待关注`}
|
||||
/>
|
||||
|
||||
<section className="risk-summary-grid" aria-label="风险概览">
|
||||
{(['red', 'yellow', 'gray'] as const).map((level) => {
|
||||
const meta = riskLevelMeta[level]
|
||||
const count = data.risks.filter((risk) => risk.level === level).length
|
||||
return (
|
||||
<button className={`risk-summary-card risk-summary-card--${level}`} key={level} type="button" onClick={() => setActiveLevel(level)}>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<strong>{count}</strong>
|
||||
<span>条风险</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<CapsuleTabs activeKey={activeLevel} onChange={(key) => setActiveLevel(key as RiskLevel | 'all')}>
|
||||
<CapsuleTabs.Tab title="全部" key="all" />
|
||||
<CapsuleTabs.Tab title="红色" key="red" />
|
||||
<CapsuleTabs.Tab title="黄色" key="yellow" />
|
||||
<CapsuleTabs.Tab title="灰色" key="gray" />
|
||||
</CapsuleTabs>
|
||||
<div className="risk-list">
|
||||
{filteredRisks.map((risk) => {
|
||||
const meta = riskLevelMeta[risk.level]
|
||||
return (
|
||||
<button className="risk-item" key={risk.id} type="button">
|
||||
<div className="risk-header">
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<span>{risk.type}</span>
|
||||
<time>{risk.discoveredAt}</time>
|
||||
</div>
|
||||
<strong>{risk.title}</strong>
|
||||
<p>{risk.description}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block compact-section">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Fund Watch</p>
|
||||
<h2>资金池关注项</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi-grid kpi-grid--compact">
|
||||
{dangerousFunds.map((metric) => (
|
||||
<KpiCard key={metric.key} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfilePage() {
|
||||
const { data, isLoading, isError, refetch } = useDashboardOverview()
|
||||
const state = <QueryState isLoading={isLoading} isError={isError || !data} refetch={() => void refetch()} title="我的" />
|
||||
|
||||
if (!data) return state
|
||||
|
||||
return (
|
||||
<section className="operations-page">
|
||||
<OperationsHeader
|
||||
kicker="Profile"
|
||||
title="我的"
|
||||
description="展示当前驾驶舱权限、数据环境与演示版本,方便联调时确认口径。"
|
||||
extra="老板驾驶舱 H5"
|
||||
/>
|
||||
|
||||
<section className="profile-card">
|
||||
<div className="profile-avatar" aria-hidden="true">
|
||||
老
|
||||
</div>
|
||||
<div>
|
||||
<h2>老板视角</h2>
|
||||
<p>可查看经营概览、今日快报、排行与风险预警。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Environment</p>
|
||||
<h2>数据环境</h2>
|
||||
</div>
|
||||
<Tag color="warning">Mock</Tag>
|
||||
</div>
|
||||
<div className="info-list">
|
||||
<span>
|
||||
<small>数据日期</small>
|
||||
<strong>{data.businessDate}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>生成时间</small>
|
||||
<strong>{data.generatedAt}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>API 模式</small>
|
||||
<strong>{import.meta.env.VITE_MOCK_ENABLED === 'false' ? '真实接口' : 'Mock 演示'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<div className="section-title-row">
|
||||
<div>
|
||||
<p className="section-kicker">Permissions</p>
|
||||
<h2>权限模块</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="check-list">
|
||||
<span>经营概览:可见</span>
|
||||
<span>资金池摘要:可见</span>
|
||||
<span>风险预警:可见</span>
|
||||
<span>导出能力:待接入</span>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
export type MetricStatus = 'normal' | 'success' | 'warning' | 'danger'
|
||||
|
||||
export type SnapshotStatus = 'pending' | 'success' | 'failed' | 'temporary'
|
||||
|
||||
export type SnapshotSlot = '1015' | '1455'
|
||||
|
||||
export type KpiMetric = {
|
||||
key: string
|
||||
title: string
|
||||
value: number | string | null
|
||||
unit?: string
|
||||
trendLabel?: string
|
||||
trendValue?: number | string
|
||||
status: MetricStatus
|
||||
featured?: boolean
|
||||
}
|
||||
|
||||
export type TodaySnapshot = {
|
||||
slot: SnapshotSlot
|
||||
title: string
|
||||
status: SnapshotStatus
|
||||
generatedAt?: string
|
||||
message: string
|
||||
purchaseUsers: number
|
||||
orderCount: number
|
||||
dealAmount: number | string
|
||||
paidAmount: number | string
|
||||
newMerchandiseCount: number
|
||||
selfBonusChange: number | string
|
||||
shareBonusChange: number | string
|
||||
}
|
||||
|
||||
export type TrendPoint = {
|
||||
date: string
|
||||
amount: number | string
|
||||
orders: number
|
||||
newUsers: number
|
||||
bonus: number | string
|
||||
}
|
||||
|
||||
export type RankItem = {
|
||||
id: string
|
||||
name: string
|
||||
value: number | string
|
||||
description: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export type RiskLevel = 'red' | 'yellow' | 'gray'
|
||||
|
||||
export type RiskAlert = {
|
||||
id: string
|
||||
level: RiskLevel
|
||||
type: string
|
||||
title: string
|
||||
description: string
|
||||
discoveredAt: string
|
||||
}
|
||||
|
||||
export type DashboardOverview = {
|
||||
businessDate: string
|
||||
generatedAt: string
|
||||
summary: string
|
||||
kpis: KpiMetric[]
|
||||
fundPool: KpiMetric[]
|
||||
snapshots: TodaySnapshot[]
|
||||
trends: TrendPoint[]
|
||||
userRanks: RankItem[]
|
||||
teamRanks: RankItem[]
|
||||
productRanks: RankItem[]
|
||||
risks: RiskAlert[]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Empty } from 'antd-mobile'
|
||||
|
||||
type PlaceholderPageProps = {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
|
||||
return (
|
||||
<section className="placeholder-page">
|
||||
<div className="mobile-page-header">
|
||||
<p className="eyebrow">经营驾驶舱</p>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<Empty description={description} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,589 +0,0 @@
|
||||
:root {
|
||||
--bg: #fff6f1;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f6f9fb;
|
||||
--text: #132033;
|
||||
--muted: #6b7a90;
|
||||
--border: rgba(19, 32, 51, 0.08);
|
||||
--primary: #ff5b36;
|
||||
--primary-deep: #f04a2a;
|
||||
--primary-soft: #fff0eb;
|
||||
--success: #14a46c;
|
||||
--warning: #ffb000;
|
||||
--danger: #dc2626;
|
||||
--shadow: 0 16px 40px rgba(255, 91, 54, 0.14);
|
||||
--radius-xl: 28px;
|
||||
--radius-lg: 20px;
|
||||
--radius-md: 14px;
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--adm-color-primary: var(--primary);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 91, 54, 0.2), transparent 28rem),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid rgba(255, 91, 54, 0.72);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: min(100%, 430px);
|
||||
min-height: 100svh;
|
||||
margin: 0 auto;
|
||||
background: var(--bg);
|
||||
box-shadow: 0 0 0 1px rgba(19, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.mobile-shell {
|
||||
min-height: 100svh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-main {
|
||||
min-height: 100svh;
|
||||
padding-bottom: calc(74px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
width: min(100%, 430px);
|
||||
margin: 0 auto;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-top: 1px solid var(--border);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.dashboard-page {
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.dashboard-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 91, 54, 0.98), rgba(255, 139, 82, 0.92)),
|
||||
radial-gradient(circle at 90% 10%, rgba(255, 176, 0, 0.42), transparent 18rem);
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-topline,
|
||||
.section-title-row,
|
||||
.risk-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-topline span,
|
||||
.eyebrow,
|
||||
.section-kicker {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-topline button {
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.dashboard-hero h1 {
|
||||
margin: 18px 0 8px;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-summary {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-metric {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.hero-metric span,
|
||||
.hero-metric small {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-metric strong {
|
||||
display: block;
|
||||
margin: 6px 0;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.kpi-card--featured {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.kpi-title,
|
||||
.kpi-trend,
|
||||
.section-kicker,
|
||||
.snapshot-time,
|
||||
.rank-content small,
|
||||
.risk-item p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--text);
|
||||
font-size: 22px;
|
||||
line-height: 1.08;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.kpi-trend {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.kpi-trend span {
|
||||
margin-left: 6px;
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kpi-card--success .kpi-trend span {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.kpi-card--warning .kpi-trend span {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.kpi-card--danger .kpi-value,
|
||||
.kpi-card--danger .kpi-trend span {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.section-block {
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.section-title-row h2 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.compact-section .kpi-grid {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.snapshot-section .adm-capsule-tabs {
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.snapshot-card {
|
||||
padding: 14px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.snapshot-message {
|
||||
margin: 0 0 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.snapshot-grid span {
|
||||
min-height: 64px;
|
||||
padding: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.snapshot-grid strong {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.mini-trend-chart {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.text-button,
|
||||
.rank-item,
|
||||
.risk-item {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text-button:disabled {
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rank-list,
|
||||
.risk-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.rank-item,
|
||||
.risk-item {
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.rank-item {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rank-index {
|
||||
display: inline-grid;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
background: var(--text);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.rank-content strong,
|
||||
.rank-content small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rank-value {
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-count {
|
||||
color: var(--danger);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.risk-header {
|
||||
justify-content: flex-start;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-header time {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.risk-item strong {
|
||||
display: block;
|
||||
margin: 10px 0 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.risk-item p {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.placeholder-page,
|
||||
.loading-page,
|
||||
.error-page {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.mobile-page-header h1 {
|
||||
margin: 4px 0 24px;
|
||||
}
|
||||
|
||||
.operations-page {
|
||||
padding: 14px 14px 24px;
|
||||
}
|
||||
|
||||
.operations-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 91, 54, 0.98), rgba(255, 139, 82, 0.92)),
|
||||
radial-gradient(circle at 90% 10%, rgba(255, 176, 0, 0.42), transparent 18rem);
|
||||
border-radius: 0 0 var(--radius-xl) var(--radius-xl);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.operations-header h1 {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.operations-header p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.operations-header .eyebrow {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.operations-header span {
|
||||
display: inline-flex;
|
||||
margin-top: 16px;
|
||||
padding: 7px 12px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.report-list,
|
||||
.check-list,
|
||||
.info-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.report-item {
|
||||
display: grid;
|
||||
grid-template-columns: 82px 1fr;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
text-align: left;
|
||||
background: var(--surface-soft);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.report-item span,
|
||||
.info-list span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.report-item small,
|
||||
.info-list small,
|
||||
.profile-card p {
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.snapshot-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.snapshot-detail-card {
|
||||
padding: 14px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.snapshot-detail-subtitle {
|
||||
margin: 12px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.snapshot-detail-message {
|
||||
margin: 10px 0 8px;
|
||||
font-weight: 700;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.snapshot-grid--wide {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.check-list span,
|
||||
.info-list span {
|
||||
padding: 12px;
|
||||
background: var(--surface-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.check-list span {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.check-list strong {
|
||||
margin-right: 8px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.risk-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.risk-summary-card {
|
||||
display: grid;
|
||||
min-height: 104px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.risk-summary-card strong {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.risk-summary-card span:last-child {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.risk-summary-card--red strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.risk-summary-card--yellow strong {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.risk-summary-card--gray strong {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
display: grid;
|
||||
grid-template-columns: 58px 1fr;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 10px 28px rgba(22, 47, 80, 0.08);
|
||||
}
|
||||
|
||||
.profile-card h2,
|
||||
.profile-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
display: grid;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(145deg, var(--primary), var(--warning));
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.loading-page,
|
||||
.error-page {
|
||||
display: grid;
|
||||
min-height: 60svh;
|
||||
place-content: center;
|
||||
gap: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'antd-mobile/es/global'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
const startApp = async () => {
|
||||
if (import.meta.env.VITE_MOCK_ENABLED !== 'false') {
|
||||
const { worker } = await import('./services/mock/browser')
|
||||
await worker.start({ onUnhandledRequest: 'bypass' })
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
void startApp()
|
||||
@@ -1,26 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const httpClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? '',
|
||||
timeout: 8000,
|
||||
})
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
code: number
|
||||
message?: string
|
||||
msg?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export async function getApiData<T>(url: string): Promise<T> {
|
||||
const response = await httpClient.get<ApiResponse<T>>(url)
|
||||
if (response.data.code !== 0 && response.data.code !== 200) {
|
||||
throw new Error(response.data.msg ?? response.data.message ?? '接口请求失败')
|
||||
}
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export async function getBlob(url: string): Promise<Blob> {
|
||||
const response = await httpClient.get<Blob>(url, { responseType: 'blob' })
|
||||
return response.data
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { setupWorker } from 'msw/browser'
|
||||
import { handlers } from './handlers'
|
||||
|
||||
export const worker = setupWorker(...handlers)
|
||||
@@ -1,58 +0,0 @@
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { dashboardMock } from '../../features/boss-dashboard/mock'
|
||||
|
||||
function buildArchiveHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>经营日报归档 - ${dashboardMock.businessDate}</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #132033; background: #fff6f1; }
|
||||
main { max-width: 820px; margin: 0 auto; padding: 28px 18px 40px; }
|
||||
header { color: #fff; padding: 26px; border-radius: 0 0 28px 28px; background: linear-gradient(145deg, #ff5b36, #ff8b52); }
|
||||
section { margin-top: 16px; padding: 18px; background: #fff; border-radius: 24px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
article { padding: 14px; border-radius: 18px; background: #f6f9fb; }
|
||||
small { color: #6b7a90; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<p>Daily Report Archive</p>
|
||||
<h1>经营日报归档</h1>
|
||||
<p>${dashboardMock.summary}</p>
|
||||
<small>数据日期:${dashboardMock.businessDate} / 生成时间:${dashboardMock.generatedAt}</small>
|
||||
</header>
|
||||
<section>
|
||||
<h2>核心经营指标</h2>
|
||||
<div class="grid">
|
||||
${dashboardMock.kpis
|
||||
.map((metric) => `<article><small>${metric.title}</small><h3>${metric.value}${metric.unit ?? ''}</h3><small>${metric.trendLabel ?? ''}</small></article>`)
|
||||
.join('')}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export const handlers = [
|
||||
http.get('/api/admin/dashboard/overview', () => {
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: dashboardMock,
|
||||
})
|
||||
}),
|
||||
http.get('/api/admin/dashboard/daily-report/archive', () => {
|
||||
return new HttpResponse(buildArchiveHtml(), {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="dashboard-daily-report-${dashboardMock.businessDate}.html"`,
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatMetricValue, formatMoney, formatNumber, formatTrend } from './format'
|
||||
|
||||
describe('format helpers', () => {
|
||||
it('formats money with yuan symbol and two decimals', () => {
|
||||
expect(formatMoney(1289360.4)).toBe('¥1,289,360.40')
|
||||
})
|
||||
|
||||
it('formats metric values based on unit', () => {
|
||||
expect(formatMetricValue(418471.07, '分')).toBe('418,471.070')
|
||||
expect(formatMetricValue(936, '人')).toBe('936人')
|
||||
})
|
||||
|
||||
it('uses placeholder for empty values', () => {
|
||||
expect(formatNumber(null)).toBe('--')
|
||||
})
|
||||
|
||||
it('adds plus sign for positive trend values', () => {
|
||||
expect(formatTrend(8.6)).toBe('+8.6%')
|
||||
expect(formatTrend(-3.2)).toBe('-3.2%')
|
||||
})
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
export function formatMoney(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
return `¥${numberValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | string | null | undefined, digits = 0): string {
|
||||
if (value === null || value === undefined || value === '') return '--'
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
return numberValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatMetricValue(value: number | string | null, unit?: string): string {
|
||||
if (unit === '元') return formatMoney(value)
|
||||
if (unit === '分') return formatNumber(value, 3)
|
||||
return `${formatNumber(value)}${unit ?? ''}`
|
||||
}
|
||||
|
||||
export function formatTrend(value?: number | string): string {
|
||||
if (value === undefined || value === '') return ''
|
||||
const numberValue = Number(value)
|
||||
if (Number.isNaN(numberValue)) return String(value)
|
||||
const prefix = numberValue > 0 ? '+' : ''
|
||||
return `${prefix}${numberValue.toFixed(1)}%`
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:30032',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
51
docs/com-czrt6-data-imgration-0511.md
Normal file
51
docs/com-czrt6-data-imgration-0511.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 公司名称:池州瑞棠商贸
|
||||
|
||||
## mysql数据库配置信息
|
||||
|
||||
host ip: 101.37.101.6
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
|
||||
## 数据清理任务
|
||||
|
||||
- **数据范围**:用户 id 集(`eb_user.uid` 与 `wa_users.id` 一致):92738,92827,92861,92909,93090,93140,93150,93157,93175,93194,93201,93210,93211,93212,93219,93220
|
||||
|
||||
- wa_users表中id在用户id数据范围的
|
||||
- eb_user表中uid在用户id数据范围的
|
||||
|
||||
|
||||
- wa_order
|
||||
|
||||
|
||||
- wa_merchandise
|
||||
|
||||
|
||||
- wa_selfbonus_log
|
||||
|
||||
|
||||
- wa_sharebonus_log
|
||||
|
||||
|
||||
- wa_coupon_log
|
||||
|
||||
|
||||
- wa_withdraw
|
||||
|
||||
|
||||
- eb_store_order
|
||||
|
||||
|
||||
- eb_user_integral_record
|
||||
|
||||
|
||||
|
||||
## 执行脚本
|
||||
|
||||
|
||||
|
||||
## 相关文件
|
||||
|
||||
- 源数据dump文件
|
||||
|
||||
BIN
docs/今日抢单用户列表_开发说明文档.docx
Normal file
BIN
docs/今日抢单用户列表_开发说明文档.docx
Normal file
Binary file not shown.
BIN
docs/团队每日对账日报_开发说明文档.docx
Normal file
BIN
docs/团队每日对账日报_开发说明文档.docx
Normal file
Binary file not shown.
BIN
docs/寄卖外部免认证三件功能_开发计划.docx
Normal file
BIN
docs/寄卖外部免认证三件功能_开发计划.docx
Normal file
Binary file not shown.
BIN
docs/寄卖订单管理_开发说明文档.docx
Normal file
BIN
docs/寄卖订单管理_开发说明文档.docx
Normal file
Binary file not shown.
59
restart-backend.command
Executable file
59
restart-backend.command
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# 双击此文件即可在新终端启动 backend:
|
||||
# 1) 杀掉占用 20600 的旧进程
|
||||
# 2) 用 Maven 把 crmeb-common / crmeb-service 安装到本地 m2(这样 crmeb-admin 单模块运行时能找到依赖)
|
||||
# 3) 进入 crmeb-admin 目录,用完整 GAV 调用 spring-boot:run
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PROFILE="${BACKEND_PROFILE:-byjyw149}"
|
||||
|
||||
echo "🛑 Stopping any process listening on :20600 ..."
|
||||
PIDS=$(lsof -t -iTCP:20600 -sTCP:LISTEN 2>/dev/null || true)
|
||||
if [ -n "$PIDS" ]; then
|
||||
echo " killing PIDs: $PIDS"
|
||||
kill -9 $PIDS 2>/dev/null || true
|
||||
sleep 2
|
||||
else
|
||||
echo " no existing process on :20600"
|
||||
fi
|
||||
|
||||
# 自动定位 Java(沿用 start-backend.sh 的逻辑)
|
||||
find_java() {
|
||||
if /usr/libexec/java_home &>/dev/null; then
|
||||
echo "$(/usr/libexec/java_home)/bin/java"; return
|
||||
fi
|
||||
for p in /opt/homebrew/opt/openjdk*/bin/java /opt/homebrew/opt/openjdk/bin/java \
|
||||
/usr/local/opt/openjdk*/bin/java /usr/local/opt/openjdk/bin/java; do
|
||||
[ -x "$p" ] && echo "$p" && return
|
||||
done
|
||||
[ -n "$SDKMAN_DIR" ] && [ -x "$SDKMAN_DIR/candidates/java/current/bin/java" ] && \
|
||||
echo "$SDKMAN_DIR/candidates/java/current/bin/java" && return
|
||||
command -v java 2>/dev/null
|
||||
}
|
||||
JAVA_BIN=$(find_java)
|
||||
if [ -z "$JAVA_BIN" ]; then
|
||||
echo "❌ 未找到 Java,请先安装 JDK 11(brew install openjdk@11)"
|
||||
exit 1
|
||||
fi
|
||||
export JAVA_HOME="$(dirname "$(dirname "$JAVA_BIN")")"
|
||||
echo "☕ Java: $JAVA_BIN"
|
||||
"$JAVA_BIN" -version
|
||||
echo ""
|
||||
|
||||
cd backend
|
||||
|
||||
# 第一步:把依赖模块编译并安装到本地 m2(首次执行会下载依赖;只在源代码变更后需要重跑)
|
||||
echo "🔧 Step 1: install crmeb-common + crmeb-service to local m2 ..."
|
||||
echo ""
|
||||
./mvnw install -pl crmeb-common,crmeb-service -am -Dmaven.test.skip=true -q
|
||||
|
||||
# 第二步:进入 crmeb-admin 单模块跑 spring-boot:run(避免根 pom 触发 main class 错误)
|
||||
echo ""
|
||||
echo "🚀 Step 2: launch crmeb-admin (profile=$PROFILE) ..."
|
||||
echo ""
|
||||
cd crmeb-admin
|
||||
exec ../mvnw \
|
||||
org.springframework.boot:spring-boot-maven-plugin:2.3.0.RELEASE:run \
|
||||
-Dmaven.test.skip=true \
|
||||
-Dspring-boot.run.profiles="$PROFILE"
|
||||
@@ -6,7 +6,7 @@
|
||||
// let domain = 'https://jfanyue.szxingming.com'
|
||||
// let domain = 'https://jf.wenjinhui.com'
|
||||
// let domain = 'https://jjy-jf.fwxgpt.com'
|
||||
let domain = 'https://sxsy-jf.cichude.com'
|
||||
let domain = 'https://jf.jinyawen.com'
|
||||
// let domain = 'https://jf.hapengran.com'
|
||||
// let domain = 'https://jjy-jf.uj345.com'
|
||||
// let domain = 'https://ccd-jf.cichude.com'
|
||||
@@ -17,7 +17,7 @@ module.exports = {
|
||||
// HTTP_REQUEST_URL:'',
|
||||
HTTP_REQUEST_URL: domain,
|
||||
// H5商城地址
|
||||
HTTP_H5_URL: 'https://sxsy-jf.cichude.com',
|
||||
HTTP_H5_URL: 'https://jf.jinyawen.com',
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
HTTP_REQUEST_URL:domain,
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
pdfUrl: '/static/sign_contract_sxsy80.pdf',
|
||||
pdfUrl: '/static/sign_contract_byjyw149.pdf',
|
||||
userId: '',
|
||||
isMobile: false,
|
||||
usePdfJs: false,
|
||||
|
||||
@@ -338,7 +338,7 @@ export default {
|
||||
// 跳转到抢购页面
|
||||
goToRushBuy() {
|
||||
// #ifdef H5
|
||||
window.location.href = 'https://sxsy.cichude.com/?#/pages/personal/index'
|
||||
window.location.href = 'https://jinyawen.com/?#/pages/personal/index'
|
||||
// window.location.href = 'https://ccd.cichude.com/?#/pages/personal/index'
|
||||
// window.location.href = 'https://shop.wenjinhui.com/?#/pages/personal/index'
|
||||
//window.location.href = 'https://anyue.szxingming.com/?#/pages/personal/index'
|
||||
@@ -347,7 +347,7 @@ export default {
|
||||
// #endif
|
||||
// #ifndef H5
|
||||
uni.navigateTo({
|
||||
url: '/pages/web-view/index?url=' + encodeURIComponent('https://sxsy.cichude.com/?#/pages/personal/index')
|
||||
url: '/pages/web-view/index?url=' + encodeURIComponent('https://jinyawen.com/?#/pages/personal/index')
|
||||
})
|
||||
// #endif
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const url = options && options.url ? decodeURIComponent(options.url) : '/static/sign_contract_sxsy80.pdf'
|
||||
const url = options && options.url ? decodeURIComponent(options.url) : '/static/sign_contract_byjyw149.pdf'
|
||||
this.pdfUrl = url
|
||||
},
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ export default {
|
||||
});
|
||||
// 返回
|
||||
setTimeout(() => {
|
||||
window.location.href = 'https://sxsy.cichude.com/?#/pages/rushing/index' + (this.userId ? ('?user_id=' + this.userId) : '')
|
||||
window.location.href = 'https://jinyawen.com/?#/pages/rushing/index' + (this.userId ? ('?user_id=' + this.userId) : '')
|
||||
// window.location.href = 'https://shop.wenjinhui.com/?#/pages/rushing/index' + (this.userId ? ('?user_id=' + this.userId) : '')
|
||||
// window.location.href = 'https://anyue.szxingming.com/?#/pages/rushing/index' + (this.userId ? ('?user_id=' + this.userId) : '')
|
||||
// window.location.href = 'https://xiashengjun.com/?#/pages/rushing/index' + (this.userId ? ('?user_id=' + this.userId) : '')
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user