Files
kra-new/web/src/view/sw/components/BusinessManagement.vue
T

141 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="sw-page">
<el-tabs v-model="tab"><el-tab-pane label="商务列表" name="accounts" /><el-tab-pane label="小组列表" name="groups" /><el-tab-pane label="绑定列表" name="bindings" /></el-tabs>
<business-bindings v-if="tab === 'bindings'" @changed="refresh" />
<template v-else>
<div class="kra-search-box">
<el-form inline @submit.prevent="search">
<template v-if="tab === 'accounts'">
<el-form-item label="商务名称"><el-input v-model="filters.name" clearable placeholder="商务名称 / 负责人" /></el-form-item>
<el-form-item label="商务账号"><el-input v-model="filters.account" clearable /></el-form-item>
<el-form-item label="商务等级"><el-select v-model="filters.level" clearable style="width:130px"><el-option v-for="(label, value) in businessLevels" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
<el-form-item label="小组"><el-select v-model="filters.group" clearable style="width:180px"><el-option label="未分组" value="0" /><el-option v-for="group in catalog.groups" :key="group.id" :label="`${group.name}(${group.memberIds.length} 人)`" :value="String(group.id)" /></el-select></el-form-item>
<el-form-item label="在职状态"><el-select v-model="filters.employment" clearable style="width:130px"><el-option v-for="(label, value) in employmentLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
</template>
<template v-else>
<el-form-item label="小组或成员"><el-input v-model="groupFilters.keyword" clearable /></el-form-item>
<el-form-item label="与我的关系"><el-select v-model="groupFilters.relation" clearable style="width:160px"><el-option label="我所在的小组" value="mine" /><el-option label="下级的小组" value="sub" /></el-select></el-form-item>
<el-form-item label="在职状态"><el-select v-model="groupFilters.employment" clearable style="width:150px"><el-option v-for="(label, value) in employmentLabels" :key="value" :label="`含${label}`" :value="value" /></el-select></el-form-item>
</template>
</el-form>
</div>
<div class="kra-table-box">
<div class="kra-btn-list"><el-button v-if="tab === 'accounts' && canAdd" type="primary" @click="openCreate">添加子商务</el-button><el-button v-if="catalog.canManage" plain type="primary" @click="teamSettings = true">小组设置</el-button></div>
<el-alert v-if="error || metricError" :title="error || metricError" type="error" :closable="false" class="mb-3" />
<el-table scrollbar-always-on v-if="tab === 'accounts'" v-loading="loading" :data="pagedMembers" row-key="userId" @sort-change="sortMembers">
<el-table-column prop="userId" label="ID" width="80" class-name="sw-col-meta" />
<el-table-column prop="userName" label="商务账号" class-name="sw-col-identity" min-width="130" show-overflow-tooltip />
<el-table-column label="商务等级" width="105"><template #default="{ row }"><level-tag :level="row.level" /></template></el-table-column>
<el-table-column prop="parentUserName" label="上级商务" min-width="120" show-overflow-tooltip />
<el-table-column label="商务名称 / 负责人" min-width="150"><template #default="{ row }"><strong class="sw-business-name" :title="displayMember(row)">{{ displayMember(row) }}</strong><div class="muted">{{ row.realName || '—' }}</div></template></el-table-column>
<el-table-column label="SDK商务" width="100"><template #default="{ row }"><el-button v-if="row.bindCount" link type="primary" @click="showBindings(row)">{{ row.bindCount }}</el-button><span v-else class="muted">0</span></template></el-table-column>
<el-table-column label="旗下渠道数" width="115"><template #default="{ row }"><el-button v-if="row.promoteCount != null" link type="primary" @click="showChannels(row)">{{ row.promoteCount }}</el-button><span v-else>未接入</span></template></el-table-column>
<el-table-column v-for="metric in ['open', 'effective']" :key="metric" :label="metric === 'open' ? '开端数' : '有效端数'" width="105"><template #header><el-tooltip :content="periodTip"><span>{{ metric === 'open' ? '开端数' : '有效端数' }}</span></el-tooltip></template><template #default="{ row }"><el-button v-if="row.metrics?.[metric]" link :class="metric" @click="details(row, metric)">{{ row.metrics[metric] }}</el-button><span v-else :class="metric">{{ metricLoading ? '…' : row.metrics ? 0 : '—' }}</span></template></el-table-column>
<el-table-column label="有效率" width="90"><template #default="{ row }"><span :class="row.rate != null && Number(row.rate) < 50 ? 'low-rate' : 'effective'">{{ row.rate == null ? '—' : `${row.rate}%` }}</span></template></el-table-column>
<el-table-column prop="hireDate" label="入职日期" width="115" class-name="sw-col-meta"><template #default="{ row }">{{ row.hireDate || '—' }}</template></el-table-column>
<el-table-column prop="groupName" label="小组" width="125" sortable="custom"><template #default="{ row }">{{ row.groupName || '—' }}</template></el-table-column>
<el-table-column label="在职状态" width="100"><template #default="{ row }"><el-tag size="small" :type="employmentTypes[row.employment]">{{ employmentLabels[row.employment] }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="195" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="showPortrait(row)">画像</el-button><el-button v-if="row.canEdit" link type="primary" @click="openEdit(row)">编辑</el-button><el-button v-if="row.canSetGoal" link type="primary" @click="openGoal('person', row.userId, displayMember(row))">设目标</el-button></template></el-table-column>
</el-table>
<el-table scrollbar-always-on v-else v-loading="loading" :data="groupRows" row-key="id" @sort-change="sortGroups">
<el-table-column prop="name" label="小组" min-width="145" sortable="custom"><template #default="{ row }">{{ row.name }} <el-tag v-if="row.mine" size="small">本组</el-tag></template></el-table-column>
<el-table-column prop="relation" label="与我的关系" min-width="145" />
<el-table-column prop="memberCount" label="成员数" width="110" sortable="custom" />
<el-table-column label="成员" min-width="230"><template #default="{ row }"><span :title="row.memberNames.join('、')">{{ row.memberNames.slice(0, 4).join('、') }}{{ row.memberCount > 4 ? ` 等 ${row.memberCount} 人` : '' }}</span></template></el-table-column>
<el-table-column label="在职状态" min-width="170"><template #default="{ row }">{{ Object.entries(row.employments).map(([key, value]) => `${employmentLabels[key]} ${value}`).join(' · ') }}</template></el-table-column>
<el-table-column prop="channelCount" label="旗下渠道数" width="135" sortable="custom" align="right"><template #default="{ row }"><metric-value :value="row.channelCount" /></template></el-table-column>
<el-table-column label="操作" width="180"><template #default="{ row }"><el-button v-if="row.canSetGoal" link type="primary" @click="openGoal('group', row.id, row.name)">设目标</el-button><el-button link type="primary" @click="viewMembers(row)">查看成员</el-button></template></el-table-column>
</el-table>
<div v-if="tab === 'accounts'" class="kra-pagination"><el-pagination v-model:current-page="page" v-model:page-size="size" :total="filteredMembers.length" :page-sizes="[10,20,50,100]" layout="total, sizes, prev, pager, next" /></div>
</div>
</template>
<team-settings-dialog v-model="teamSettings" :catalog="catalog" @saved="teamsSaved" />
<goal-drawer v-model="goalOpen" :target="goalTarget" @saved="loadMetrics" />
<performance-detail-drawer v-model="detailOpen" :query="detailQuery" :title="detailTitle" />
<el-drawer class="sw-dialog" v-model="channelsOpen" append-to-body title="旗下渠道" size="520px" destroy-on-close><business-channels :user-id="selectedID" /></el-drawer>
<el-drawer v-model="bindingsOpen" append-to-body title="SDK商务绑定详情" size="85%" destroy-on-close><business-bindings :user-id="selectedID" /></el-drawer>
<business-profile-drawer v-model="portraitOpen" :user-id="selectedID" :name="selectedName" />
<el-drawer v-model="memberDrawer" append-to-body :title="editing ? '编辑商务' : '添加子商务'" size="min(480px,96vw)" destroy-on-close>
<el-form ref="memberForm" :model="form" :rules="rules" label-position="top" :disabled="saving">
<el-form-item label="商务账号" prop="userName"><el-input v-model="form.userName" :disabled="editing" maxlength="30" /></el-form-item>
<template v-if="editing"><el-form-item label="商务名称"><el-input :model-value="form.nickName" disabled /></el-form-item><el-form-item label="等级 / 上级"><span>{{ businessLevels[form.level] }} · {{ form.parentUserName || '无上级' }}</span></el-form-item></template>
<el-form-item :label="editing ? '登录密码(留空不修改)' : '登录密码'" prop="password"><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="50" /></el-form-item>
<el-form-item label="负责人姓名" prop="realName"><el-input v-model="form.realName" maxlength="30" /></el-form-item>
<el-form-item label="联系电话" prop="phoneNumber"><el-input v-model="form.phoneNumber" maxlength="20" /></el-form-item>
<el-form-item label="入职日期" prop="hireDate"><el-date-picker v-model="form.hireDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item v-if="editing" label="在职状态"><el-select v-model="form.employment"><el-option v-for="(label,value) in employmentLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="memberDrawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></template>
</el-drawer>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user'
import { useBtnAuth } from '@/utils/btnAuth'
import { createBusiness } from '@/api/business'
import { getTeams, getManagement, updateMember } from '@/api/performance'
import LevelTag from './LevelTag.vue'
import MetricValue from './MetricValue.vue'
import { businessLevels, employmentLabels, employmentTypes, displayMember } from '../performance-display'
import TeamSettingsDialog from './TeamSettings.vue'
import GoalDrawer from './GoalDrawer.vue'
import PerformanceDetailDrawer from './PerformanceDetailDrawer.vue'
import BusinessBindings from './BusinessBindings.vue'
import BusinessChannels from './BusinessChannels.vue'
import BusinessProfileDrawer from './BusinessProfileDrawer.vue'
const user = useUserStore(), buttons = useBtnAuth()
const canAdd = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) < 3 && !!buttons['sw:business:add'])
const tab = ref('accounts'), catalog = ref({ groups: [], members: [], revision: 0 }), metrics = ref(null), loading = ref(false), metricLoading = ref(false), error = ref(''), metricError = ref('')
const filters = reactive({ name: '', account: '', level: '', group: '', employment: '' }), applied = ref({ ...filters }), groupFilters = reactive({ keyword: '', relation: '', employment: '' })
const page = ref(1), size = ref(10), groupOrder = ref(null), groupSort = ref({})
const metricsByID = computed(() => new Map((metrics.value?.rows || []).map(row => [row.member.userId, row])))
const filteredMembers = computed(() => {
const f = applied.value, order = new Map(catalog.value.groups.map((g, i) => [g.id, i]))
return catalog.value.members.map(member => ({ ...member, metrics: metricsByID.value.get(member.userId)?.total, rate: metricsByID.value.get(member.userId)?.rate })).filter(m => (!f.name || `${displayMember(m)} ${m.realName}`.includes(f.name)) && (!f.account || m.userName.toLowerCase().includes(f.account.toLowerCase())) && (!f.level || m.level === f.level) && (!f.group || String(m.groupId) === f.group) && (!f.employment || m.employment === f.employment)).sort((a, b) => {
if (!!a.groupId !== !!b.groupId) return a.groupId ? -1 : 1
if (groupOrder.value) { const c = a.groupName.localeCompare(b.groupName, 'zh-CN'); if (c) return groupOrder.value === 'ascending' ? c : -c }
return (order.get(a.groupId) ?? Infinity) - (order.get(b.groupId) ?? Infinity) || 0
})
})
const pagedMembers = computed(() => filteredMembers.value.slice((page.value - 1) * size.value, page.value * size.value))
const groupRows = computed(() => {
const members = new Map(catalog.value.members.map(m => [m.userId, m]))
const rows = catalog.value.groups.filter(g => g.memberIds.length).map(group => { const people = group.memberIds.map(id => members.get(id)).filter(Boolean), employments = {}; for (const m of people) employments[m.employment] = (employments[m.employment] || 0) + 1; return { ...group, memberNames: people.map(displayMember), memberCount: people.length, employments } }).filter(g => (!groupFilters.keyword || `${g.name} ${g.memberNames.join(' ')}`.includes(groupFilters.keyword)) && (!groupFilters.relation || (groupFilters.relation === 'mine' ? g.mine : !g.mine)) && (!groupFilters.employment || g.employments[groupFilters.employment]))
return rows.sort((a, b) => { const { prop, order } = groupSort.value; if (!order) return Number(b.mine) - Number(a.mine); const result = prop === 'name' ? a.name.localeCompare(b.name, 'zh-CN') : a[prop] - b[prop]; return order === 'ascending' ? result : -result })
})
const periodTip = computed(() => metrics.value && metrics.value.begin <= metrics.value.end ? `${metrics.value.begin} 至 ${metrics.value.end},本人及全部下级商务,按渠道去重统计。` : '本年尚无已结束月份。')
let teamVersion = 0, metricVersion = 0
const loadTeams = async () => { const v = ++teamVersion; loading.value = true; error.value = ''; try { const res = await getTeams(); if (v !== teamVersion) return; if (res.code === 0) catalog.value = res.data; else error.value = res.msg || '小组及成员读取失败' } catch { if (v === teamVersion) error.value = '小组及成员读取失败,请重试' } finally { if (v === teamVersion) loading.value = false } }
const loadMetrics = async () => { const v = ++metricVersion; metricLoading.value = true; metricError.value = ''; try { const res = await getManagement({}); if (v !== metricVersion) return; if (res.code === 0) metrics.value = res.data; else metricError.value = res.msg || '绩效读取失败' } catch { if (v === metricVersion) metricError.value = '绩效读取失败,请重试' } finally { if (v === metricVersion) metricLoading.value = false } }
const refresh = () => Promise.all([loadTeams(), loadMetrics()])
const search = () => { applied.value = { ...filters }; page.value = 1 }
const reset = () => { Object.assign(filters, { name: '', account: '', level: '', group: '', employment: '' }); search() }
const sortMembers = ({ order }) => { groupOrder.value = order; page.value = 1 }
const sortGroups = value => { groupSort.value = value }
const viewMembers = group => { reset(); filters.group = String(group.id); search(); tab.value = 'accounts' }
const teamSettings = ref(false), goalOpen = ref(false), goalTarget = ref(null), detailOpen = ref(false), detailQuery = ref(null), detailTitle = ref('')
const teamsSaved = value => { catalog.value = value; if (filters.group && filters.group !== '0' && !value.groups.some(g => String(g.id) === filters.group)) { filters.group = ''; search() } loadMetrics() }
const openGoal = (kind, id, name) => { goalTarget.value = { kind, id, name }; goalOpen.value = true }
const details = (row, metric) => { detailQuery.value = { kind: 'person', targetId: row.userId, metric, begin: metrics.value.begin, end: metrics.value.end }; detailTitle.value = `${displayMember(row)} · ${metric === 'open' ? '开端' : '有效端'}明细`; detailOpen.value = true }
const channelsOpen = ref(false), bindingsOpen = ref(false), portraitOpen = ref(false), selectedID = ref(0), selectedName = ref('')
const showChannels = row => { selectedID.value = row.userId; channelsOpen.value = true }
const showBindings = row => { selectedID.value = row.userId; bindingsOpen.value = true }
const showPortrait = row => { selectedID.value = row.userId; selectedName.value = displayMember(row); portraitOpen.value = true }
const memberDrawer = ref(false), editing = ref(false), memberForm = ref(), saving = ref(false), form = ref({})
const rules = computed(() => ({ userName: [{ required: true, message: '请输入商务账号' }], realName: [{ required: true, message: '请输入负责人姓名' }], hireDate: [{ required: true, message: '请选择入职日期' }], phoneNumber: [{ pattern: /^[0-9 -]*$/, message: '联系电话只允许数字、空格和短横线', trigger: 'blur' }], password: [{ validator: (_r, value, cb) => cb((editing.value && !value || value?.length >= 5 && value.length <= 50) ? undefined : new Error('密码为5至50个字符')) }] }))
const openCreate = () => { editing.value = false; form.value = { userName: '', realName: '', phoneNumber: '', hireDate: '', password: '' }; memberDrawer.value = true }
const openEdit = row => { editing.value = true; form.value = { ...row, password: '' }; memberDrawer.value = true }
const saveMember = async () => { if (saving.value || !await memberForm.value.validate().catch(() => false)) return; saving.value = true; try { const data = editing.value ? { userId: form.value.userId, realName: form.value.realName, phoneNumber: form.value.phoneNumber, hireDate: form.value.hireDate, employment: form.value.employment, password: form.value.password } : { ...form.value, nickName: form.value.realName, status: '0' }; const res = await (editing.value ? updateMember(data) : createBusiness(data)); if (res.code === 0) { ElMessage.success('保存成功'); memberDrawer.value = false; await refresh() } } finally { saving.value = false } }
onMounted(refresh)
</script>
<style scoped>
.sw-business-name { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.muted { color: var(--el-text-color-secondary); font-size: 12px; }
.open { color: var(--el-color-primary); }
.effective { color: var(--el-color-success); }
.low-rate { color: var(--el-color-warning); }
</style>