Merge branch 'master' into cs

This commit is contained in:
Your Name
2026-05-05 13:30:23 +08:00
4 changed files with 1304 additions and 73 deletions
+187 -23
View File
@@ -105,24 +105,27 @@
</div> </div>
<el-row :gutter="20" class="mt-4"> <el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="14"> <el-col :span="24">
<el-card class="!border-none" shadow="never"> <el-card class="!border-none" shadow="never">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>转化排行</span> <span>转化排行</span>
<span class="card-hint">{{ dateRangeText }}</span> <span class="card-hint">{{ dateRangeText }} · TOP 10</span>
</div> </div>
</template> </template>
<v-charts <v-charts
v-if="rankingChartHasData" v-if="rankingChartHasData"
class="stats-chart" class="stats-chart stats-chart-wide"
:option="rankingChartOption" :option="rankingChartOption"
autoresize autoresize
/> />
<el-empty v-else description="暂无图表数据" /> <el-empty v-else description="暂无图表数据" />
</el-card> </el-card>
</el-col> </el-col>
<el-col :xs="24" :lg="10"> </el-row>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="12">
<el-card class="!border-none" shadow="never"> <el-card class="!border-none" shadow="never">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
@@ -139,10 +142,7 @@
<el-empty v-else description="暂无金额数据" /> <el-empty v-else description="暂无金额数据" />
</el-card> </el-card>
</el-col> </el-col>
</el-row> <el-col :xs="24" :lg="12">
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="10">
<el-card class="!border-none" shadow="never"> <el-card class="!border-none" shadow="never">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
@@ -159,35 +159,82 @@
<el-empty v-else description="暂无加粉数据" /> <el-empty v-else description="暂无加粉数据" />
</el-card> </el-card>
</el-col> </el-col>
<el-col :xs="24" :lg="14"> </el-row>
<el-card class="!border-none" shadow="never">
<el-row :gutter="20" class="mt-4">
<el-col :span="24">
<el-card class="!border-none stats-detail-card" shadow="never">
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>明细列表</span> <div class="card-header-main">
<span class="card-hint">{{ entityLabel }}维度</span> <span class="card-title">明细列表</span>
<span class="card-hint">{{ entityLabel }}维度 · {{ dateRangeText }}</span>
</div>
</div> </div>
</template> </template>
<el-table <el-table
:data="pager.lists" :data="pager.lists"
:row-key="queryParams.dimension === 'dept' ? 'id' : undefined" :row-key="rowKeyFn"
:tree-props="queryParams.dimension === 'dept' ? treeProps : undefined" :tree-props="queryParams.dimension === 'dept' ? treeProps : undefined"
:default-expand-all="false" :default-expand-all="false"
size="large" :indent="20"
:row-class-name="rowClassName"
size="default"
style="width: 100%" style="width: 100%"
max-height="560" max-height="640"
border
> >
<el-table-column <el-table-column
:label="entityLabel" :label="entityLabel"
prop="name" prop="name"
fixed="left" fixed="left"
min-width="280" min-width="300"
class-name="dept-name-column" class-name="dept-name-column"
show-overflow-tooltip
> >
<template #default="{ row }"> <template #default="{ row }">
<span class="dept-name-cell" :title="row.name"> <div class="dept-name-cell" :title="row.name">
{{ row.name }} <span
</span> v-if="isMemberRow(row)"
class="member-icon"
:class="{
'is-leader': row.is_leader,
'is-unbound': row.type === 'unbound'
}"
>
<el-icon><User /></el-icon>
</span>
<span class="entity-name">{{ row.name }}</span>
<el-tag
v-if="row.type === 'member' && row.is_leader"
size="small"
type="warning"
effect="light"
round
class="role-tag"
>
组长
</el-tag>
<el-tag
v-if="row.type === 'member' && row.role"
size="small"
:type="row.role === '医生' ? 'success' : 'info'"
effect="plain"
round
class="role-tag"
>
{{ row.role }}
</el-tag>
<el-tag
v-if="row.type === 'unbound'"
size="small"
type="danger"
effect="plain"
round
class="role-tag"
>
未绑定
</el-tag>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column
@@ -215,6 +262,7 @@
<script setup lang="ts" name="conversionStatsPage"> <script setup lang="ts" name="conversionStatsPage">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import vCharts from 'vue-echarts' import vCharts from 'vue-echarts'
import { getConversionStatsOverview } from '@/api/stats' import { getConversionStatsOverview } from '@/api/stats'
@@ -619,6 +667,27 @@ const renderMetric = (key: string, source: Record<string, any>, type = 'count',
return String(value ?? 0) return String(value ?? 0)
} }
const rowKeyFn = (row: Record<string, any>) => {
if (row?.id !== undefined && row?.id !== null) {
return String(row.id)
}
return ''
}
const isMemberRow = (row: Record<string, any>) => {
return row?.type === 'member' || row?.type === 'unbound'
}
const rowClassName = ({ row }: { row: Record<string, any> }) => {
if (row?.type === 'unbound') {
return 'member-row member-row--unbound'
}
if (row?.type === 'member') {
return row.is_leader ? 'member-row member-row--leader' : 'member-row'
}
return ''
}
onMounted(async () => { onMounted(async () => {
await fetchOverview() await fetchOverview()
}) })
@@ -672,6 +741,27 @@ onMounted(async () => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
flex-wrap: wrap;
}
.card-header-main {
display: flex;
align-items: baseline;
gap: 10px;
}
.card-header-tip {
display: flex;
align-items: center;
gap: 6px;
color: #909399;
font-size: 12px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
} }
.card-hint { .card-hint {
@@ -680,19 +770,93 @@ onMounted(async () => {
} }
.dept-name-cell { .dept-name-cell {
display: inline-block; display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
max-width: 100%; max-width: 100%;
white-space: nowrap; width: 100%;
line-height: 1.4;
overflow: hidden;
}
.entity-name {
flex: 1 1 0;
min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
vertical-align: middle; white-space: nowrap;
color: inherit;
}
.member-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #e6f0ff;
color: #4a78ff;
font-size: 13px;
flex-shrink: 0;
}
.member-icon.is-leader {
background: #fff4e0;
color: #f59e0b;
}
.member-icon.is-unbound {
background: #fde8e8;
color: #ef4444;
}
.role-tag {
margin-left: 0;
flex-shrink: 0;
} }
:deep(.dept-name-column .cell) { :deep(.dept-name-column .cell) {
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: clip;
display: flex;
align-items: center;
}
:deep(.member-row) td {
background-color: #fafbff !important;
}
:deep(.member-row--leader) td {
background-color: #fff8ec !important;
}
:deep(.member-row--leader .entity-name) {
color: #d97706;
font-weight: 600;
}
:deep(.member-row--unbound) td {
background-color: #fff5f5 !important;
}
:deep(.member-row--unbound .entity-name) {
color: #ef4444;
font-family: monospace;
font-size: 12px;
}
.stats-detail-card :deep(.el-card__body) {
padding-top: 0;
} }
.stats-chart { .stats-chart {
height: 360px; height: 360px;
} }
.stats-chart-wide {
height: 380px;
}
</style> </style>
@@ -11,7 +11,7 @@ class ConversionController extends BaseAdminController
{ {
public function overview() public function overview()
{ {
$result = ConversionLogic::overview($this->request->get()); $result = ConversionLogic::overview($this->request->get(), $this->adminId, $this->adminInfo);
return $this->data($result); return $this->data($result);
} }
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,7 @@ use app\common\cache\AdminAccountSafeCache;
use app\common\service\ConfigService; use app\common\service\ConfigService;
use app\common\validate\BaseValidate; use app\common\validate\BaseValidate;
use think\facade\Config; use think\facade\Config;
use think\facade\Log;
/** /**
* 登录验证 * 登录验证
@@ -55,6 +56,27 @@ class LoginValidate extends BaseValidate
*/ */
public function password($password, $other, $data) public function password($password, $other, $data)
{ {
// 本地调试:免密登录开关。须同时满足 APP_DEBUG=1 + LOCAL_DEBUG.LOGIN_SKIP_PASSWORD=true
// 任一不满足都走原始密码校验。每次免密登录都会记一条 warning 日志,便于发现误开。
if (self::isLocalDebugLoginBypassEnabled()) {
$adminInfo = Admin::where('account', '=', $data['account'])
->field(['password,disable'])
->findOrEmpty();
if ($adminInfo->isEmpty()) {
return '账号不存在';
}
if ((int)($adminInfo['disable'] ?? 0) === 1) {
return '账号已禁用';
}
Log::warning(sprintf(
'[LOCAL_DEBUG] login bypass password: account=%s, ip=%s',
(string)($data['account'] ?? ''),
(string)request()->ip()
));
return true;
}
// 登录限制 // 登录限制
$config = [ $config = [
'login_restrictions' => ConfigService::get('admin_login', 'login_restrictions'), 'login_restrictions' => ConfigService::get('admin_login', 'login_restrictions'),
@@ -100,4 +122,25 @@ class LoginValidate extends BaseValidate
return true; return true;
} }
/**
* 是否启用"本地调试免密登录"。
*
* 双重闸门:
* 1. APP_DEBUG 必须为 true(即应用本身处于调试模式)
* 2. LOCAL_DEBUG.LOGIN_SKIP_PASSWORD 必须显式为 true / 1 / yes / on
*
* 任何一条不满足都返回 false,强制走原始密码校验。这样即使误把
* LOGIN_SKIP_PASSWORD 配进生产,只要 APP_DEBUG=0 仍然安全。
*/
private static function isLocalDebugLoginBypassEnabled(): bool
{
$appDebug = strtolower((string) env('app_debug', '0'));
if (!in_array($appDebug, ['1', 'true', 'yes', 'on'], true)) {
return false;
}
$flag = strtolower((string) env('local_debug.login_skip_password', ''));
return in_array($flag, ['1', 'true', 'yes', 'on'], true);
}
} }