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>