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>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="14">
<el-col :span="24">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
<span>转化排行</span>
<span class="card-hint">{{ dateRangeText }}</span>
<span class="card-hint">{{ dateRangeText }} · TOP 10</span>
</div>
</template>
<v-charts
v-if="rankingChartHasData"
class="stats-chart"
class="stats-chart stats-chart-wide"
:option="rankingChartOption"
autoresize
/>
<el-empty v-else description="暂无图表数据" />
</el-card>
</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">
<template #header>
<div class="card-header">
@@ -139,10 +142,7 @@
<el-empty v-else description="暂无金额数据" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :lg="10">
<el-col :xs="24" :lg="12">
<el-card class="!border-none" shadow="never">
<template #header>
<div class="card-header">
@@ -159,35 +159,82 @@
<el-empty v-else description="暂无加粉数据" />
</el-card>
</el-col>
<el-col :xs="24" :lg="14">
<el-card class="!border-none" shadow="never">
</el-row>
<el-row :gutter="20" class="mt-4">
<el-col :span="24">
<el-card class="!border-none stats-detail-card" shadow="never">
<template #header>
<div class="card-header">
<span>明细列表</span>
<span class="card-hint">{{ entityLabel }}维度</span>
<div class="card-header-main">
<span class="card-title">明细列表</span>
<span class="card-hint">{{ entityLabel }}维度 · {{ dateRangeText }}</span>
</div>
</div>
</template>
<el-table
:data="pager.lists"
:row-key="queryParams.dimension === 'dept' ? 'id' : undefined"
:row-key="rowKeyFn"
:tree-props="queryParams.dimension === 'dept' ? treeProps : undefined"
:default-expand-all="false"
size="large"
:indent="20"
:row-class-name="rowClassName"
size="default"
style="width: 100%"
max-height="560"
max-height="640"
border
>
<el-table-column
:label="entityLabel"
prop="name"
fixed="left"
min-width="280"
min-width="300"
class-name="dept-name-column"
show-overflow-tooltip
>
<template #default="{ row }">
<span class="dept-name-cell" :title="row.name">
{{ row.name }}
</span>
<div class="dept-name-cell" :title="row.name">
<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>
</el-table-column>
<el-table-column
@@ -215,6 +262,7 @@
<script setup lang="ts" name="conversionStatsPage">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import vCharts from 'vue-echarts'
import { getConversionStatsOverview } from '@/api/stats'
@@ -619,6 +667,27 @@ const renderMetric = (key: string, source: Record<string, any>, type = 'count',
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 () => {
await fetchOverview()
})
@@ -672,6 +741,27 @@ onMounted(async () => {
justify-content: space-between;
align-items: center;
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 {
@@ -680,19 +770,93 @@ onMounted(async () => {
}
.dept-name-cell {
display: inline-block;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 6px;
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;
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) {
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 {
height: 360px;
}
.stats-chart-wide {
height: 380px;
}
</style>
@@ -11,7 +11,7 @@ class ConversionController extends BaseAdminController
{
public function overview()
{
$result = ConversionLogic::overview($this->request->get());
$result = ConversionLogic::overview($this->request->get(), $this->adminId, $this->adminInfo);
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\validate\BaseValidate;
use think\facade\Config;
use think\facade\Log;
/**
* 登录验证
@@ -55,6 +56,27 @@ class LoginValidate extends BaseValidate
*/
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 = [
'login_restrictions' => ConfigService::get('admin_login', 'login_restrictions'),
@@ -100,4 +122,25 @@ class LoginValidate extends BaseValidate
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);
}
}