Merge branch 'master' into cs
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user