141 lines
18 KiB
Vue
141 lines
18 KiB
Vue
<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>
|