fix(stats/self_input): 修复保存不入库/路由 404,补全部门与自媒体来源下拉

- 模型去掉 defaultSoftDelete=0,避免 ThinkPHP 软删过滤导致新增记录"隐形"
- 菜单 paths 改相对路径并新增「自录数据统计」目录,迁移已有菜单
- Lists/Overview 补部门关联、支持按部门筛选
- 数据权限改由 self_input_stats_view_all_roles 白名单 + DataScope 控制,
  编辑/删除完全交给按钮权限校验(移除"仅本人可改"硬限制)
- 新增 /stats.self_input/mediaSourceOptions 接口,统计页/账户消耗页/录入弹窗
  共用 useMediaSourceOptions + MediaSourceSelect,去重动态加载

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-22 11:47:15 +08:00
co-authored by Cursor
parent 183b6a1acf
commit bba989c0af
19 changed files with 621 additions and 130 deletions
+5
View File
@@ -4,6 +4,11 @@ export function getSelfInputOverview(params: any) {
return request.get({ url: '/stats.self_input/overview', params })
}
/** 自媒体来源下拉(业绩+账户消耗已录入值去重) */
export function getSelfInputMediaSourceOptions() {
return request.get({ url: '/stats.self_input/mediaSourceOptions' })
}
export function personalYejiLists(params: any) {
return request.get({ url: '/stats.personal_yeji/lists', params })
}
@@ -0,0 +1,55 @@
<template>
<el-select
:model-value="modelValue"
:placeholder="placeholder"
:clearable="clearable"
:filterable="filterable"
:allow-create="allowCreate"
:default-first-option="allowCreate"
:disabled="disabled"
:loading="loading"
:class="selectClass"
:style="selectStyle"
@update:model-value="emit('update:modelValue', $event)"
@change="emit('change', $event)"
@visible-change="onVisibleChange"
>
<el-option v-for="item in options" :key="item" :label="item" :value="item" />
</el-select>
</template>
<script lang="ts" setup>
withDefaults(
defineProps<{
modelValue?: string
options: string[]
loading?: boolean
placeholder?: string
clearable?: boolean
filterable?: boolean
allowCreate?: boolean
disabled?: boolean
selectClass?: string
selectStyle?: string | Record<string, string>
}>(),
{
modelValue: '',
loading: false,
placeholder: '请选择自媒体来源',
clearable: true,
filterable: true,
allowCreate: false,
disabled: false,
}
)
const emit = defineEmits<{
'update:modelValue': [value: string]
change: [value: string]
'visible-change': [visible: boolean]
}>()
const onVisibleChange = (visible: boolean) => {
emit('visible-change', visible)
}
</script>
@@ -21,13 +21,31 @@
v-model:endTime="queryParams.end_date"
/>
</el-form-item>
<el-form-item label="自媒体来源">
<el-input
v-model="queryParams.media_source"
placeholder="筛选自媒体来源"
<el-form-item label="部门">
<el-tree-select
v-model="queryParams.dept_id"
:data="deptOptions"
node-key="id"
:props="deptTreeProps"
:default-expand-all="true"
check-strictly
placeholder="全部部门"
clearable
filterable
class="w-[220px]"
@keyup.enter="resetPage"
@change="resetPage"
/>
</el-form-item>
<el-form-item label="自媒体来源">
<media-source-select
v-model="queryParams.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="全部来源"
:allow-create="false"
select-class="w-[220px]"
@change="resetPage"
@visible-change="onMediaSourceDropdownVisible"
/>
</el-form-item>
<el-form-item class="w-[280px]" label="备注/录入人">
@@ -41,9 +59,7 @@
<el-form-item>
<el-button type="primary" @click="resetPage">查询</el-button>
<el-button @click="handleReset">重置</el-button>
<router-link to="/stats/self_input">
<el-button class="ml-2">返回统计</el-button>
</router-link>
<el-button v-if="selfInputPath" class="ml-2" @click="goSelfInput">返回统计</el-button>
</el-form-item>
</el-form>
</el-card>
@@ -66,12 +82,17 @@
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
<el-table-column label="录入人" prop="creator_name" min-width="100" />
<el-table-column label="部门" prop="dept_name" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.dept_name">{{ row.dept_name }}</span>
<span v-else class="text-gray-400">未分配</span>
</template>
</el-table-column>
<el-table-column label="最后修改人" prop="updater_name" min-width="100" />
<el-table-column label="更新时间" prop="update_time" min-width="180" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button
v-if="canMutateRow(row)"
v-perms="['stats.personal_account_cost/edit']"
type="primary"
link
@@ -80,7 +101,6 @@
编辑
</el-button>
<el-button
v-if="canMutateRow(row)"
v-perms="['stats.personal_account_cost/delete']"
type="danger"
link
@@ -97,26 +117,57 @@
</div>
</el-card>
<cost-edit-popup v-if="showEdit" ref="editRef" @success="getLists" @close="showEdit = false" />
<cost-edit-popup
v-if="showEdit"
ref="editRef"
:media-source-options="mediaSourceOptions"
@success="handleCostSuccess"
@close="showEdit = false"
/>
</div>
</template>
<script lang="ts" setup name="personalAccountCost">
import { personalAccountCostDelete, personalAccountCostLists } from '@/api/self_input_stats'
import { deptAll } from '@/api/org/department'
import { usePaging } from '@/hooks/usePaging'
import useUserStore from '@/stores/modules/user'
import { getRoutePath } from '@/router'
import feedback from '@/utils/feedback'
import CostEditPopup from './cost-edit.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
import { useMediaSourceOptions } from './useMediaSourceOptions'
const userStore = useUserStore()
const router = useRouter()
const editRef = shallowRef<InstanceType<typeof CostEditPopup>>()
const showEdit = ref(false)
const deptOptions = ref<any[]>([])
const {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
} = useMediaSourceOptions()
const deptTreeProps = {
value: 'id',
label: 'name',
children: 'children',
}
const selfInputPath = computed(() => getRoutePath('stats.self_input/overview') || '')
const goSelfInput = () => {
if (selfInputPath.value) {
router.push(selfInputPath.value)
}
}
const queryParams = reactive({
start_date: '',
end_date: '',
media_source: '',
dept_id: undefined as number | undefined,
remark: '',
})
@@ -125,23 +176,28 @@ const { pager, getLists, resetPage } = usePaging({
params: queryParams,
})
const currentAdminId = computed(() => Number(userStore.userInfo?.id || 0))
const isRoot = computed(() => Number(userStore.userInfo?.root || 0) === 1)
const canMutateRow = (row: Record<string, any>) => {
if (isRoot.value) return true
return Number(row.creator_id) === currentAdminId.value
}
const handleReset = () => {
queryParams.start_date = ''
queryParams.end_date = ''
queryParams.media_source = ''
queryParams.dept_id = undefined
queryParams.remark = ''
resetPage()
}
const onMediaSourceDropdownVisible = (visible: boolean) => {
if (visible) {
refreshMediaSourceOptions()
}
}
const handleCostSuccess = async () => {
await refreshMediaSourceOptions()
getLists()
}
const handleAdd = async () => {
await refreshMediaSourceOptions()
showEdit.value = true
await nextTick()
editRef.value?.open('add')
@@ -159,6 +215,22 @@ const handleDelete = async (id: number) => {
await personalAccountCostDelete({ id })
getLists()
}
const loadDeptOptions = async () => {
try {
const res = await deptAll({ apply_data_scope: 1 })
if (Array.isArray(res)) {
deptOptions.value = res
}
} catch (e) {
deptOptions.value = []
}
}
onMounted(() => {
loadDeptOptions()
loadMediaSourceOptions()
})
</script>
<style scoped lang="scss">
+16 -11
View File
@@ -20,12 +20,13 @@
/>
</el-form-item>
<el-form-item label="自媒体来源" prop="media_source">
<el-input
<media-source-select
v-model="formData.media_source"
placeholder="如:抖音-账号A"
maxlength="120"
show-word-limit
:options="mediaSourceOptions"
placeholder="请选择或输入自媒体来源"
:allow-create="true"
:disabled="mode === 'edit'"
select-class="w-full"
/>
</el-form-item>
<el-form-item label="账户消耗" prop="amount">
@@ -57,11 +58,21 @@ import type { FormInstance } from 'element-plus'
import {
personalAccountCostAdd,
personalAccountCostDetail,
personalAccountCostEdit,
} from '@/api/self_input_stats'
import Popup from '@/components/popup/index.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
const props = withDefaults(
defineProps<{
mediaSourceOptions?: string[]
}>(),
{
mediaSourceOptions: () => [],
}
)
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const popupRef = shallowRef<InstanceType<typeof Popup>>()
@@ -116,11 +127,6 @@ const setFormData = (data: Record<string, any>) => {
formData.remark = data.remark ?? ''
}
const getDetail = async (row: Record<string, any>) => {
const data = await personalAccountCostDetail({ id: row.id })
setFormData(data)
}
const handleClose = () => {
emit('close')
}
@@ -128,6 +134,5 @@ const handleClose = () => {
defineExpose({
open,
setFormData,
getDetail,
})
</script>
+98 -22
View File
@@ -25,13 +25,32 @@
/>
</el-form-item>
<el-form-item label="自媒体来源">
<el-input
v-model="queryParams.media_source"
placeholder="筛选自媒体来源"
<el-form-item label="部门">
<el-tree-select
v-model="queryParams.dept_id"
:data="deptOptions"
node-key="id"
:props="deptTreeProps"
:default-expand-all="true"
check-strictly
placeholder="全部部门"
clearable
filterable
style="width: 220px"
@keyup.enter="handleQuery"
@change="handleQuery"
/>
</el-form-item>
<el-form-item label="自媒体来源">
<media-source-select
v-model="queryParams.media_source"
:options="mediaSourceOptions"
:loading="mediaSourceLoading"
placeholder="全部来源"
:allow-create="false"
select-style="width: 220px"
@change="handleQuery"
@visible-change="onMediaSourceDropdownVisible"
/>
</el-form-item>
@@ -65,12 +84,13 @@
</template>
新增业绩
</el-button>
<router-link
<el-button
v-if="accountCostEntryPath"
v-perms="['stats.personal_account_cost/lists']"
to="/stats/self_input/account_cost"
@click="goAccountCostEntry"
>
<el-button>账户消耗录入</el-button>
</router-link>
账户消耗录入
</el-button>
</div>
</div>
</template>
@@ -85,6 +105,12 @@
<el-table-column label="日期" prop="yeji_date" min-width="110" fixed="left" />
<el-table-column label="自媒体来源" prop="media_source" min-width="140" show-overflow-tooltip />
<el-table-column label="录入人" prop="creator_name" min-width="100" />
<el-table-column label="部门" prop="dept_name" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.dept_name">{{ row.dept_name }}</span>
<span v-else class="text-gray-400">未分配</span>
</template>
</el-table-column>
<el-table-column
v-for="col in tableColumns"
:key="col.key"
@@ -100,7 +126,6 @@
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button
v-if="canMutateRow(row)"
v-perms="['stats.personal_yeji/edit']"
type="primary"
link
@@ -109,7 +134,6 @@
编辑
</el-button>
<el-button
v-if="canMutateRow(row)"
v-perms="['stats.personal_yeji/delete']"
type="danger"
link
@@ -126,7 +150,13 @@
</div>
</el-card>
<yeji-edit-popup v-if="showYejiEdit" ref="yejiEditRef" @success="fetchOverview" @close="showYejiEdit = false" />
<yeji-edit-popup
v-if="showYejiEdit"
ref="yejiEditRef"
:media-source-options="mediaSourceOptions"
@success="handleYejiSuccess"
@close="showYejiEdit = false"
/>
</div>
</template>
@@ -135,11 +165,14 @@ import {
getSelfInputOverview,
personalYejiDelete,
} from '@/api/self_input_stats'
import { deptAll } from '@/api/org/department'
import { usePaging } from '@/hooks/usePaging'
import useUserStore from '@/stores/modules/user'
import { getRoutePath } from '@/router'
import feedback from '@/utils/feedback'
import MediaSourceSelect from './MediaSourceSelect.vue'
import YejiEditPopup from './yeji-edit.vue'
import { useMediaSourceOptions } from './useMediaSourceOptions'
type MetricType = 'count' | 'money' | 'percent' | 'ratio'
@@ -156,10 +189,23 @@ interface MetricColumn {
minWidth?: number
}
const userStore = useUserStore()
const router = useRouter()
const yejiEditRef = shallowRef<InstanceType<typeof YejiEditPopup>>()
const showYejiEdit = ref(false)
const dateRange = ref<string[]>([])
const deptOptions = ref<any[]>([])
const {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
} = useMediaSourceOptions()
const deptTreeProps = {
value: 'id',
label: 'name',
children: 'children',
}
const overview = reactive<Record<string, any>>({
date_range: [],
@@ -168,11 +214,20 @@ const overview = reactive<Record<string, any>>({
const queryParams = reactive({
media_source: '',
dept_id: undefined as number | undefined,
time_type: 'today',
start_date: '',
end_date: '',
})
const accountCostEntryPath = computed(() => getRoutePath('stats.personal_account_cost/lists') || '')
const goAccountCostEntry = () => {
if (accountCostEntryPath.value) {
router.push(accountCostEntryPath.value)
}
}
const fetchOverviewList = async (params: Record<string, any>) => {
if (queryParams.time_type === 'custom') {
params.start_date = dateRange.value[0] || ''
@@ -183,6 +238,33 @@ const fetchOverviewList = async (params: Record<string, any>) => {
return res
}
const loadDeptOptions = async () => {
try {
const res = await deptAll({ apply_data_scope: 1 })
if (Array.isArray(res)) {
deptOptions.value = res
}
} catch (e) {
deptOptions.value = []
}
}
const onMediaSourceDropdownVisible = (visible: boolean) => {
if (visible) {
refreshMediaSourceOptions()
}
}
const handleYejiSuccess = async () => {
await refreshMediaSourceOptions()
await fetchOverview()
}
onMounted(() => {
loadDeptOptions()
loadMediaSourceOptions()
})
const { pager, getLists } = usePaging({
fetchFun: fetchOverviewList,
params: queryParams,
@@ -234,14 +316,6 @@ const dateRangeText = computed(() => {
return `${overview.date_range[0]}${overview.date_range[1]}`
})
const currentAdminId = computed(() => Number(userStore.userInfo?.id || 0))
const isRoot = computed(() => Number(userStore.userInfo?.root || 0) === 1)
const canMutateRow = (row: Record<string, any>) => {
if (isRoot.value) return true
return Number(row.creator_id) === currentAdminId.value
}
const fetchOverview = async () => {
try {
await getLists()
@@ -273,6 +347,7 @@ const handleQuery = () => {
const handleReset = () => {
queryParams.media_source = ''
queryParams.dept_id = undefined
queryParams.time_type = 'today'
dateRange.value = []
pager.page = 1
@@ -289,6 +364,7 @@ const renderMetric = (key: string, source: Record<string, any>, type = 'count')
}
const handleAddYeji = async () => {
await refreshMediaSourceOptions()
showYejiEdit.value = true
await nextTick()
yejiEditRef.value?.open('add')
@@ -0,0 +1,33 @@
import { getSelfInputMediaSourceOptions } from '@/api/self_input_stats'
/**
* 自录转化统计:自媒体来源下拉(从已录入业绩/消耗去重,两页共用同一接口)
*/
export function useMediaSourceOptions() {
const mediaSourceOptions = ref<string[]>([])
const mediaSourceLoading = ref(false)
const loadMediaSourceOptions = async (force = false) => {
if (!force && mediaSourceOptions.value.length > 0) {
return
}
mediaSourceLoading.value = true
try {
const res = await getSelfInputMediaSourceOptions()
mediaSourceOptions.value = Array.isArray(res) ? res : []
} catch {
mediaSourceOptions.value = []
} finally {
mediaSourceLoading.value = false
}
}
const refreshMediaSourceOptions = () => loadMediaSourceOptions(true)
return {
mediaSourceOptions,
mediaSourceLoading,
loadMediaSourceOptions,
refreshMediaSourceOptions,
}
}
+17 -11
View File
@@ -20,12 +20,13 @@
/>
</el-form-item>
<el-form-item label="自媒体来源" prop="media_source">
<el-input
<media-source-select
v-model="formData.media_source"
placeholder="如:抖音-账号A"
maxlength="120"
show-word-limit
:options="mediaSourceOptions"
placeholder="请选择或输入自媒体来源"
:allow-create="true"
:disabled="mode === 'edit'"
select-class="w-full"
/>
</el-form-item>
<el-row :gutter="16">
@@ -129,9 +130,20 @@
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import { personalYejiAdd, personalYejiDetail, personalYejiEdit } from '@/api/self_input_stats'
import { personalYejiAdd, personalYejiEdit } from '@/api/self_input_stats'
import Popup from '@/components/popup/index.vue'
import MediaSourceSelect from './MediaSourceSelect.vue'
const props = withDefaults(
defineProps<{
mediaSourceOptions?: string[]
}>(),
{
mediaSourceOptions: () => [],
}
)
const emit = defineEmits(['success', 'close'])
const formRef = shallowRef<FormInstance>()
const popupRef = shallowRef<InstanceType<typeof Popup>>()
@@ -207,11 +219,6 @@ const setFormData = (data: Record<string, any>) => {
formData.remark = data.remark ?? ''
}
const getDetail = async (row: Record<string, any>) => {
const data = await personalYejiDetail({ id: row.id })
setFormData(data)
}
const handleClose = () => {
emit('close')
}
@@ -219,6 +226,5 @@ const handleClose = () => {
defineExpose({
open,
setFormData,
getDetail,
})
</script>
@@ -15,4 +15,14 @@ class SelfInputController extends BaseAdminController
return $this->data($result);
}
/**
* 自媒体来源下拉:从已录入业绩/账户消耗去重提取(随录入动态变化)
*/
public function mediaSourceOptions()
{
$list = SelfInputLogic::mediaSourceOptions($this->adminId, $this->adminInfo);
return $this->data($list);
}
}
@@ -132,6 +132,19 @@ class AuthMiddleware
return true;
}
// 自录转化统计:自媒体来源下拉,复用总览或账户消耗列表权限
if ($accessUri === 'stats.selfinput/mediasourceoptions') {
$selfInputAliases = [
'stats.selfinput/overview',
'stats.self_input/overview',
'stats.personalaccountcost/lists',
'stats.personal_account_cost/lists',
];
if (count(array_intersect($selfInputAliases, $AdminUris)) > 0) {
return true;
}
}
// 处方库列表:消费者开方页/处方库页导入共用,复用开方或处方库菜单权限
if ($accessUri === 'tcm.prescriptionlibrary/lists'
&& $this->matchPrescriptionLibraryListsPermission($adminUris)) {
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\adminapi\lists\stats;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\stats\PersonalStatsScopeTrait;
use app\common\lists\ListsExtendInterface;
use app\common\lists\ListsSearchInterface;
use app\common\lists\Traits\HasDataScopeFilter;
@@ -13,6 +14,7 @@ use app\common\model\stats\PersonalAccountCost;
class PersonalAccountCostLists extends BaseAdminDataLists implements ListsSearchInterface, ListsExtendInterface
{
use HasDataScopeFilter;
use PersonalStatsScopeTrait;
public function setSearch(): array
{
@@ -24,7 +26,15 @@ class PersonalAccountCostLists extends BaseAdminDataLists implements ListsSearch
private function baseQuery()
{
$query = PersonalAccountCost::where($this->searchWhere);
$this->applyDataScopeByOwner($query, 'creator_id');
$visibleIds = $this->getDataScopeVisibleAdminIds();
$deptId = (int) ($this->params['dept_id'] ?? 0);
$finalIds = self::intersectVisibleByDept($visibleIds, $deptId);
if ($finalIds === []) {
$query->whereRaw('0 = 1');
} elseif ($finalIds !== null) {
$query->whereIn('creator_id', $finalIds);
}
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
$query->whereBetween('cost_date', [$this->params['start_date'], $this->params['end_date']]);
@@ -35,7 +45,7 @@ class PersonalAccountCostLists extends BaseAdminDataLists implements ListsSearch
}
if (!empty($this->params['media_source'])) {
$query->whereLike('media_source', '%' . trim((string) $this->params['media_source']) . '%');
$query->where('media_source', trim((string) $this->params['media_source']));
}
return $query;
@@ -43,11 +53,13 @@ class PersonalAccountCostLists extends BaseAdminDataLists implements ListsSearch
public function lists(): array
{
return $this->baseQuery()
$rows = $this->baseQuery()
->order(['cost_date' => 'desc', 'id' => 'desc'])
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
return self::attachDeptInfoToRows($rows);
}
public function count(): int
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\adminapi\lists\stats;
use app\adminapi\lists\BaseAdminDataLists;
use app\adminapi\logic\stats\PersonalStatsScopeTrait;
use app\common\lists\ListsSearchInterface;
use app\common\lists\Traits\HasDataScopeFilter;
use app\common\model\stats\PersonalYeji;
@@ -12,6 +13,7 @@ use app\common\model\stats\PersonalYeji;
class PersonalYejiLists extends BaseAdminDataLists implements ListsSearchInterface
{
use HasDataScopeFilter;
use PersonalStatsScopeTrait;
public function setSearch(): array
{
@@ -23,7 +25,15 @@ class PersonalYejiLists extends BaseAdminDataLists implements ListsSearchInterfa
private function baseQuery()
{
$query = PersonalYeji::where($this->searchWhere);
$this->applyDataScopeByOwner($query, 'creator_id');
$visibleIds = $this->getDataScopeVisibleAdminIds();
$deptId = (int) ($this->params['dept_id'] ?? 0);
$finalIds = self::intersectVisibleByDept($visibleIds, $deptId);
if ($finalIds === []) {
$query->whereRaw('0 = 1');
} elseif ($finalIds !== null) {
$query->whereIn('creator_id', $finalIds);
}
if (!empty($this->params['start_date']) && !empty($this->params['end_date'])) {
$query->whereBetween('yeji_date', [$this->params['start_date'], $this->params['end_date']]);
@@ -34,7 +44,7 @@ class PersonalYejiLists extends BaseAdminDataLists implements ListsSearchInterfa
}
if (!empty($this->params['media_source'])) {
$query->whereLike('media_source', '%' . trim((string) $this->params['media_source']) . '%');
$query->where('media_source', trim((string) $this->params['media_source']));
}
if (!empty($this->params['creator_id'])) {
@@ -46,11 +56,13 @@ class PersonalYejiLists extends BaseAdminDataLists implements ListsSearchInterfa
public function lists(): array
{
return $this->baseQuery()
$rows = $this->baseQuery()
->order(['yeji_date' => 'desc', 'id' => 'desc'])
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
return self::attachDeptInfoToRows($rows);
}
public function count(): int
@@ -64,12 +64,6 @@ class PersonalAccountCostLogic extends BaseLogic
return false;
}
if (!self::canMutateRecord($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('仅可编辑本人录入的账户消耗');
return false;
}
$model->amount = round((float) ($params['amount'] ?? 0), 2);
$model->remark = (string) ($params['remark'] ?? '');
$model->updater_id = $adminId;
@@ -100,12 +94,6 @@ class PersonalAccountCostLogic extends BaseLogic
return false;
}
if (!self::canMutateRecord($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('仅可删除本人录入的账户消耗');
return false;
}
$model->delete();
return true;
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace app\adminapi\logic\stats;
use app\adminapi\logic\dept\DeptLogic;
use app\common\model\auth\AdminDept;
use app\common\model\dept\Dept;
use app\common\model\stats\PersonalAccountCost;
use app\common\model\stats\PersonalYeji;
use app\common\service\DataScope\DataScopeService;
use think\facade\Config;
trait PersonalStatsScopeTrait
{
@@ -21,26 +24,124 @@ trait PersonalStatsScopeTrait
return (int) ($deptId ?: 0);
}
/**
* @param array<int, int> $creatorIds
* @return array{0: array<int, int>, 1: array<int, string>}
*/
protected static function loadAdminDeptMap(array $creatorIds): array
{
$creatorIds = array_values(array_unique(array_filter(array_map('intval', $creatorIds))));
if ($creatorIds === []) {
return [[], []];
}
$rows = AdminDept::whereIn('admin_id', $creatorIds)
->field('admin_id, dept_id')
->select()
->toArray();
$adminToDeptId = [];
foreach ($rows as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
$deptId = (int) ($row['dept_id'] ?? 0);
if ($adminId <= 0 || $deptId <= 0) {
continue;
}
if (!isset($adminToDeptId[$adminId])) {
$adminToDeptId[$adminId] = $deptId;
}
}
$deptNameMap = [];
$deptIds = array_values(array_unique($adminToDeptId));
if ($deptIds !== []) {
$deptNameMap = Dept::whereIn('id', $deptIds)
->column('name', 'id');
}
return [$adminToDeptId, $deptNameMap];
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
protected static function attachDeptInfoToRows(array $rows): array
{
if ($rows === []) {
return $rows;
}
$creatorIds = array_map(static fn (array $row): int => (int) ($row['creator_id'] ?? 0), $rows);
[$adminToDeptId, $deptNameMap] = self::loadAdminDeptMap($creatorIds);
foreach ($rows as &$row) {
$creatorId = (int) ($row['creator_id'] ?? 0);
$deptId = $adminToDeptId[$creatorId] ?? 0;
$row['dept_id'] = $deptId;
$row['dept_name'] = $deptId > 0 ? (string) ($deptNameMap[$deptId] ?? '') : '';
}
unset($row);
return $rows;
}
/**
* 根据 dept_id(包含其子部门)收窄可见 admin id 集合。
* 与 visibleAdminIds 取交集。
*
* @param array<int>|null $visibleAdminIds null 表示不限
* @return array<int>|null 返回 null 表示外部条件无需附加;返回 [] 表示无可见 admin
*/
protected static function intersectVisibleByDept(?array $visibleAdminIds, int $deptId): ?array
{
if ($deptId <= 0) {
return $visibleAdminIds;
}
$deptIds = DeptLogic::getSelfAndDescendantIds($deptId);
if ($deptIds === []) {
$deptIds = [$deptId];
}
$adminIds = AdminDept::whereIn('dept_id', $deptIds)->column('admin_id');
$adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds), static fn (int $v): bool => $v > 0)));
if ($visibleAdminIds === null) {
return $adminIds;
}
return array_values(array_intersect($visibleAdminIds, $adminIds));
}
protected static function normalizeMediaSource(string $mediaSource): string
{
return trim($mediaSource);
}
/**
* @return array<int>|null
* 与 project.self_input_stats_view_all_roles 一致:超管或白名单角色可见全部录入人数据。
*/
protected static function getVisibleCreatorIds(int $adminId, array $adminInfo): ?array
{
return DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
}
protected static function canMutateRecord(int $adminId, array $adminInfo, int $creatorId): bool
protected static function canViewAllSelfInputStats(array $adminInfo): bool
{
if ((int) ($adminInfo['root'] ?? 0) === 1) {
return true;
}
$allow = Config::get('project.self_input_stats_view_all_roles', []);
$allow = array_map('intval', is_array($allow) ? $allow : []);
if ($allow === []) {
return false;
}
$myRoles = array_map('intval', $adminInfo['role_id'] ?? []);
return $adminId > 0 && $adminId === $creatorId;
return count(array_intersect($myRoles, $allow)) > 0;
}
/**
* @return array<int>|null null=全部录入人;[]=无可见;int[]=可见 creator_id 集合
*/
protected static function getVisibleCreatorIds(int $adminId, array $adminInfo): ?array
{
if (self::canViewAllSelfInputStats($adminInfo)) {
return null;
}
return DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
}
protected static function assertRecordVisible(int $adminId, array $adminInfo, int $creatorId): bool
@@ -71,12 +71,6 @@ class PersonalYejiLogic extends BaseLogic
return false;
}
if (!self::canMutateRecord($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('仅可编辑本人录入的业绩');
return false;
}
$model->add_fans_count = (int) ($params['add_fans_count'] ?? 0);
$model->total_open_count = (int) ($params['total_open_count'] ?? 0);
$model->unreplied_count = (int) ($params['unreplied_count'] ?? 0);
@@ -114,12 +108,6 @@ class PersonalYejiLogic extends BaseLogic
return false;
}
if (!self::canMutateRecord($adminId, $adminInfo, (int) $model->creator_id)) {
self::setError('仅可删除本人录入的业绩');
return false;
}
$model->delete();
return true;
@@ -18,10 +18,13 @@ class SelfInputLogic extends BaseLogic
$pageNo = max(1, (int) ($params['page_no'] ?? 1));
$pageSize = min(100, max(1, (int) ($params['page_size'] ?? 15)));
$mediaSource = trim((string) ($params['media_source'] ?? ''));
$deptId = (int) ($params['dept_id'] ?? 0);
$yejiQuery = self::buildYejiQuery($startDate, $endDate, $adminId, $adminInfo);
$effectiveAdminIds = self::resolveEffectiveAdminIds($adminId, $adminInfo, $deptId);
$yejiQuery = self::buildYejiQuery($startDate, $endDate, $effectiveAdminIds);
if ($mediaSource !== '') {
$yejiQuery->whereLike('media_source', '%' . $mediaSource . '%');
$yejiQuery->where('media_source', $mediaSource);
}
$count = (int) (clone $yejiQuery)->count();
@@ -31,7 +34,7 @@ class SelfInputLogic extends BaseLogic
->select()
->toArray();
$costMap = self::loadAccountCostMap($startDate, $endDate, $adminId, $adminInfo, $mediaSource);
$costMap = self::loadAccountCostMap($startDate, $endDate, $effectiveAdminIds, $mediaSource);
$lists = [];
foreach ($rows as $row) {
@@ -44,10 +47,11 @@ class SelfInputLogic extends BaseLogic
$entity['account_cost'] = round((float) ($costMap[$costKey] ?? 0), 2);
$lists[] = self::finalizeMetrics($entity);
}
$lists = self::attachDeptInfoToRows($lists);
$allYejiRows = self::buildYejiQuery($startDate, $endDate, $adminId, $adminInfo);
$allYejiRows = self::buildYejiQuery($startDate, $endDate, $effectiveAdminIds);
if ($mediaSource !== '') {
$allYejiRows->whereLike('media_source', '%' . $mediaSource . '%');
$allYejiRows->where('media_source', $mediaSource);
}
$allYeji = $allYejiRows->select()->toArray();
@@ -85,39 +89,83 @@ class SelfInputLogic extends BaseLogic
];
}
private static function buildYejiQuery(string $startDate, string $endDate, int $adminId, array $adminInfo)
/**
* 从已录入的业绩/账户消耗中提取去重后的自媒体来源(与列表数据权限一致)。
*
* @return string[]
*/
public static function mediaSourceOptions(int $adminId, array $adminInfo): array
{
$effectiveAdminIds = self::resolveEffectiveAdminIds($adminId, $adminInfo, 0);
if ($effectiveAdminIds === []) {
return [];
}
$map = [];
foreach ([PersonalYeji::class, PersonalAccountCost::class] as $modelClass) {
$query = $modelClass::where('media_source', '<>', '');
if ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
}
$rows = $query->group('media_source')->column('media_source');
foreach ($rows as $raw) {
$norm = self::normalizeMediaSource((string) $raw);
if ($norm !== '') {
$map[$norm] = true;
}
}
}
$list = array_keys($map);
sort($list, SORT_STRING);
return $list;
}
/**
* @return array<int>|null null = 不限;[] = 无可见
*/
private static function resolveEffectiveAdminIds(int $adminId, array $adminInfo, int $deptId): ?array
{
$visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo);
return self::intersectVisibleByDept($visibleIds, $deptId);
}
/**
* @param array<int>|null $effectiveAdminIds
*/
private static function buildYejiQuery(string $startDate, string $endDate, ?array $effectiveAdminIds)
{
$query = PersonalYeji::whereBetween('yeji_date', [$startDate, $endDate]);
$visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo);
if ($visibleIds === []) {
if ($effectiveAdminIds === []) {
$query->whereRaw('0 = 1');
} elseif ($visibleIds !== null) {
$query->whereIn('creator_id', $visibleIds);
} elseif ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
}
return $query;
}
/**
* @param array<int>|null $effectiveAdminIds
* @return array<string, float>
*/
private static function loadAccountCostMap(
string $startDate,
string $endDate,
int $adminId,
array $adminInfo,
?array $effectiveAdminIds,
string $mediaSource
): array {
$query = PersonalAccountCost::whereBetween('cost_date', [$startDate, $endDate]);
$visibleIds = self::getVisibleCreatorIds($adminId, $adminInfo);
if ($visibleIds === []) {
if ($effectiveAdminIds === []) {
return [];
}
if ($visibleIds !== null) {
$query->whereIn('creator_id', $visibleIds);
if ($effectiveAdminIds !== null) {
$query->whereIn('creator_id', $effectiveAdminIds);
}
if ($mediaSource !== '') {
$query->whereLike('media_source', '%' . $mediaSource . '%');
$query->where('media_source', $mediaSource);
}
$rows = $query
@@ -15,8 +15,6 @@ class PersonalAccountCost extends BaseModel
protected $deleteTime = 'delete_time';
protected $defaultSoftDelete = 0;
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
@@ -15,8 +15,6 @@ class PersonalYeji extends BaseModel
protected $deleteTime = 'delete_time';
protected $defaultSoftDelete = 0;
protected $autoWriteTimestamp = true;
protected $createTime = 'create_time';
+5
View File
@@ -123,6 +123,11 @@ return [
// 订单管理列表(order/index):以下角色 ID + root 可查看全部订单,其余仅本人创建
'order_list_view_all_roles' => [0, 3, 4, 9, 6],
// 自录转化统计(stats.self_input / personal_yeji / personal_account_cost):
// 以下角色 ID + root 在列表与总览中可见全部录入人的业绩/账户消耗(不受角色 data_scope 收窄)。
// 编辑/删除由菜单按钮权限(stats.personal_yeji/edit 等)控制,不在此配置。
'self_input_stats_view_all_roles' => [0, 3, 4, 9, 6],
// 业务订单列表金额统计:医助角色 ID;非支付审核白名单的医助仅统计其诊单 assistant_id=本人 的业绩
'prescription_order_stats_assistant_role_id' => 2,
@@ -1,7 +1,11 @@
-- 自录转化统计菜单(挂在「数据统计」目录下)
-- 主页面: /stats/self_input
-- 账户消耗: /stats/self_input/account_cost
-- 自录数据统计菜单(挂在「数据统计」目录下)
-- 目录: /stats/self_input(自录数据统计)
-- 子菜单:
-- 自录转化统计 -> /stats/self_input
-- 账户消耗录入 -> /stats/self_input/account_cost
-- 子菜单 paths 为相对父级目录的路径片段。
-- ========== 已执行旧版 SQL 的环境:迁移到「自录数据统计」目录 ==========
SET @stats_root_id = (
SELECT `id` FROM `zyt_system_menu`
WHERE `paths` = '/stats' AND `type` = 'M'
@@ -12,9 +16,69 @@ SET @stats_root_id = (
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@stats_root_id, 'C', '自录转化统计', '', 5, 'stats.self_input/overview', '/stats/self_input', '/stats/self_input/index', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
@stats_root_id, 'M', '自录数据统计', '', 5, '', 'self_input', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @stats_root_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu`
WHERE `pid` = @stats_root_id AND `type` = 'M' AND `name` = '自录数据统计'
);
SET @self_input_group_id = (
SELECT `id` FROM `zyt_system_menu`
WHERE `pid` = @stats_root_id AND `type` = 'M' AND `name` = '自录数据统计'
ORDER BY `id` ASC
LIMIT 1
);
UPDATE `zyt_system_menu`
SET `pid` = @self_input_group_id,
`paths` = '',
`component` = 'stats/self_input/index',
`sort` = 1
WHERE `perms` = 'stats.self_input/overview'
AND @self_input_group_id IS NOT NULL;
UPDATE `zyt_system_menu`
SET `pid` = @self_input_group_id,
`paths` = 'account_cost',
`component` = 'stats/self_input/account_cost',
`sort` = 2
WHERE `perms` = 'stats.personal_account_cost/lists'
AND @self_input_group_id IS NOT NULL;
-- ========== 全新安装:目录 + 子菜单 + 按钮权限 ==========
SET @stats_root_id = (
SELECT `id` FROM `zyt_system_menu`
WHERE `paths` = '/stats' AND `type` = 'M'
ORDER BY `id` ASC
LIMIT 1
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@stats_root_id, 'M', '自录数据统计', '', 5, '', 'self_input', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @stats_root_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu`
WHERE `pid` = @stats_root_id AND `type` = 'M' AND `name` = '自录数据统计'
);
SET @self_input_group_id = (
SELECT `id` FROM `zyt_system_menu`
WHERE `pid` = @stats_root_id AND `type` = 'M' AND `name` = '自录数据统计'
ORDER BY `id` ASC
LIMIT 1
);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@self_input_group_id, 'C', '自录转化统计', '', 1, 'stats.self_input/overview', '', 'stats/self_input/index', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @self_input_group_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu`
WHERE `perms` = 'stats.self_input/overview'
@@ -49,9 +113,9 @@ FROM DUAL WHERE @self_input_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `z
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT
@stats_root_id, 'C', '账户消耗录入', '', 6, 'stats.personal_account_cost/lists', '/stats/self_input/account_cost', '/stats/self_input/account_cost', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
@self_input_group_id, 'C', '账户消耗录入', '', 2, 'stats.personal_account_cost/lists', 'account_cost', 'stats/self_input/account_cost', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @stats_root_id IS NOT NULL
WHERE @self_input_group_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM `zyt_system_menu`
WHERE `perms` = 'stats.personal_account_cost/lists'
@@ -77,3 +141,5 @@ INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT @personal_cost_menu_id, 'A', '删除消耗', '', 3, 'stats.personal_account_cost/delete', '', '', '', '', 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL WHERE @personal_cost_menu_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'stats.personal_account_cost/delete');
-- 角色需勾选新目录「自录数据统计」后,子菜单权限才会一并生效(与 likeadmin 菜单树授权一致)