4 Commits

Author SHA1 Message Date
danaisuiyuan
e2d52ba4cc docs(czrt6): 0511 迁移说明写入 16 位用户 uid/id 范围
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:25:03 +08:00
danaisuiyuan
d8ad6cde20 feat(integral-external): 新增寄卖外部免认证三件套页面 2026-05-02 06:13:41 +08:00
danaisuiyuan
49900919c6 chore(admin): 恢复 crmeb-admin application-byjyw149.yml
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 04:30:02 +08:00
danaisuiyuan
5cbca4ba76 revert(env,uniapp): 保留 sxsy80 业务合并,恢复 byjyw149 环境与合同 PDF
- 恢复 admin/front application.yml、admin 端 env、WaUserController 合同模板与域名
- 恢复 single_uniapp22miao 中与域名/签约相关的页面与 config
- 移除合并中新增的多客户 profile 与 sxsy80/czcf82 合同 PDF 资源

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 04:26:38 +08:00
101 changed files with 3812 additions and 7619 deletions

View File

@@ -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'

View File

@@ -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'

View File

@@ -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,
});
}

View File

@@ -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: '今日抢单用户' },
},
],
};

View File

@@ -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({

View File

@@ -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'] },

View 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>

View File

@@ -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>

View 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="卖家ID0=平台)"
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>

View File

@@ -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()
// 除上面外的所有请求全部需要鉴权认证

View File

@@ -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));
}
}

View File

@@ -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);
}
// ========================================================================
// 今日抢单用户列表
// ========================================================================
/**
* 今日抢单用户列表(免认证)
* 过滤口径:今日购买总金额 &gt; 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);
}
}

View File

@@ -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的密码

View File

@@ -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的密码

View File

@@ -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的密码

View File

@@ -38,7 +38,7 @@ server:
spring:
profiles:
active: sxsy80
active: byjyw149
servlet:
multipart:
max-file-size: 50MB #设置单个文件大小

View File

@@ -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;
}

View File

@@ -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 = "团队长 IDwa_users.id为空时按团队长分组返回所有团队")
private Integer leaderId;
@ApiModelProperty(value = "查询日期 Dyyyy-MM-dd缺省为昨天")
private String date;
@ApiModelProperty(value = "是否包含禁用成员,默认 false")
private Boolean includeDisabled = Boolean.FALSE;
@ApiModelProperty(value = "限定成员 ID 列表(前端勾选过滤)")
private List<Integer> memberIds;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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("查询日期 Dyyyy-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;
}

View File

@@ -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("查询日期 Dyyyy-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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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的密码

View File

@@ -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的密码

View File

@@ -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的密码

View File

@@ -32,7 +32,7 @@ server:
spring:
profiles:
active: sxsy80
active: byjyw149
servlet:
multipart:
max-file-size: 50MB #设置单个文件大小

View File

@@ -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>

View File

@@ -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 JOINHAVING 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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 {
/**
* 分页查询当日已发生买单且金额 &gt; 0 的用户列表。
* 排序:今日购买总金额 DESC相同时按 id DESC。
*
* @param request 搜索条件
* @param pageParamRequest 分页参数
* @return PageInfo&lt;ExternalGrabUserResponse&gt;
*/
PageInfo<ExternalGrabUserResponse> list(ExternalGrabUserRequest request,
PageParamRequest pageParamRequest);
}

View File

@@ -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;
}

View File

@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
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;
}
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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 ? "正常" : "禁用";
}
}

View File

@@ -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;
}
}

View File

@@ -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 &gt;= #{todayStart}
AND pay_time &lt;= #{todayEnd}
GROUP BY buyer_id
HAVING SUM(total_money) &gt; 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 &gt;= #{todayStart}
AND pay_time &lt;= #{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 &gt;= #{yesterdayStart}
AND pay_time &lt;= #{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>

View File

@@ -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 &gt;= #{prevStart}
AND pay_time &lt;= #{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 &gt;= #{dStart}
AND pay_time &lt;= #{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 &gt;= #{dStart}
AND pay_time &lt;= #{dEnd}
GROUP BY buyer_id
) buy ON buy.uid = u.id
<where>
<choose>
<when test="leaderId != null">
u.pid = #{leaderId}
</when>
<otherwise>
u.pid &gt; 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>

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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

View File

@@ -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?

View File

@@ -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...
},
},
])
```

View File

@@ -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,
},
},
])

View File

@@ -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>

View File

@@ -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"
]
}
}

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

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 天交易趋势图" />
}

View File

@@ -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>
)
}

View File

@@ -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')
}

View File

@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
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>`
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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',
},
],
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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[]
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -1,4 +0,0 @@
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)

View File

@@ -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"`,
},
})
}),
]

View File

@@ -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%')
})
})

View File

@@ -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)}%`
}

View File

@@ -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"]
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -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"]
}

View File

@@ -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,
},
},
},
})

View 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文件

Binary file not shown.

Binary file not shown.

Binary file not shown.

59
restart-backend.command Executable file
View 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 11brew 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"

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
},

View File

@@ -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
},

View File

@@ -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) : '')

Some files were not shown because too many files have changed in this diff Show More