feat: some
All checks were successful
构建并部署前端到测试环境 / deploy (push) Successful in 55s

This commit is contained in:
luo
2026-09-22 12:14:47 +08:00
parent c136f68ab8
commit 0a28b7d8ef
19 changed files with 608 additions and 117 deletions

View File

@@ -9,6 +9,17 @@
返回
</ElButton>
<h2 class="detail-title">核销申请详情</h2>
<ElButton
v-if="
application?.recoverable &&
hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationRecoverApproval)
"
type="warning"
:loading="recovering"
@click="recoverApproval"
>
恢复审批
</ElButton>
</div>
<DetailPage v-if="application" :sections="detailSections" :data="application" />
@@ -112,6 +123,7 @@
ElCard,
ElIcon,
ElMessage,
ElMessageBox,
ElTable,
ElTableColumn,
ElTimeline,
@@ -146,6 +158,7 @@
const { hasAuth } = useAuth()
const loading = ref(false)
const recovering = ref(false)
const detail = ref<EmployeeCollectionApplicationDetailData | null>(null)
const application = computed<EmployeeCollectionApplication | null>(
@@ -186,6 +199,20 @@
prop: 'status',
formatter: (value, data) => data.status_name || getApplicationStatusLabel(value)
},
{
label: '审批提交状态',
formatter: (_, data) => data.approval_submission_name || '-'
},
{
label: '提交失败摘要',
formatter: (_, data) => data.approval_failure_summary || '-',
fullWidth: true
},
{
label: '最近恢复时间',
formatter: (_, data) =>
data.last_recovery_at ? formatDateTime(data.last_recovery_at) : '-'
},
{
label: '审批实例ID',
prop: 'latest_approval_instance_id',
@@ -241,6 +268,38 @@
router.push({ path: RoutesAlias.EmployeeCollectionApplications })
}
const recoverApproval = async () => {
if (
!application.value?.recoverable ||
!hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationRecoverApproval) ||
recovering.value
) {
return
}
try {
await ElMessageBox.confirm('确定要恢复该核销申请的原审批提交吗?', '恢复审批', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
} catch {
return
}
recovering.value = true
try {
const res = await EmployeeCollectionService.recoverApplicationApproval(application.value.id)
ElMessage[res.code === 0 ? 'success' : 'error'](
res.msg || (res.code === 0 ? '恢复审批成功' : '恢复审批失败')
)
await loadDetail()
} catch (error) {
ElMessage.error(getErrorMessage(error, '恢复审批失败'))
await loadDetail()
} finally {
recovering.value = false
}
}
onMounted(() => void loadDetail())
</script>

View File

@@ -58,7 +58,7 @@
<script setup lang="ts">
import { h, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElButton, ElMessage, ElTag } from 'element-plus'
import { ElButton, ElMessage, ElMessageBox, ElTag } from 'element-plus'
import { EmployeeCollectionService } from '@/api/modules'
import type {
EmployeeCollectionApplication,
@@ -141,6 +141,8 @@
{ label: '收款方式', prop: 'payment_method_name' },
{ label: '付款金额', prop: 'paid_amount' },
{ label: '状态', prop: 'status' },
{ label: '提交状态', prop: 'approval_submission_name' },
{ label: '可恢复', prop: 'recoverable' },
{ label: '提交时间', prop: 'created_at' }
]
@@ -184,6 +186,21 @@
() => row.status_name || getApplicationStatusLabel(row.status)
)
},
{
prop: 'approval_submission_name',
label: '提交状态',
width: 130,
formatter: (row: EmployeeCollectionApplication) => row.approval_submission_name || '-'
},
{
prop: 'recoverable',
label: '可恢复',
width: 90,
formatter: (row: EmployeeCollectionApplication) =>
h(ElTag, { type: row.recoverable ? 'warning' : 'info' }, () =>
row.recoverable ? '是' : '否'
)
},
{
prop: 'created_at',
label: '提交时间',
@@ -271,6 +288,30 @@
getTableData()
}
const handleRecoverApproval = async (row: EmployeeCollectionApplication) => {
if (
!row.recoverable ||
!hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationRecoverApproval)
) {
return
}
try {
await ElMessageBox.confirm('确定要恢复该核销申请的原审批提交吗?', '恢复审批', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
const res = await EmployeeCollectionService.recoverApplicationApproval(row.id)
if (res.code === 0) ElMessage.success(res.msg || '恢复审批成功')
else ElMessage.error(res.msg || '恢复审批失败')
await getTableData()
} catch (error) {
if (error !== 'cancel' && error !== 'close') {
ElMessage.error(getErrorMessage(error, '恢复审批失败'))
}
}
}
const getActions = (row: EmployeeCollectionApplication) => {
const actions: any[] = []
if (
@@ -279,6 +320,16 @@
) {
actions.push({ label: '修改并重新提交', handler: () => handleResubmit(row), type: 'primary' })
}
if (
row.recoverable &&
hasAuth(AUGUST_PERMISSIONS.employeeCollection.applicationRecoverApproval)
) {
actions.push({
label: '恢复审批',
handler: () => handleRecoverApproval(row),
type: 'warning'
})
}
return actions
}

View File

@@ -5,7 +5,6 @@
v-model:filter="searchForm"
:items="searchFormItems"
show-expand
label-width="100px"
@reset="handleReset"
@search="handleSearch"
/>

View File

@@ -64,7 +64,13 @@
placeholder="1-64 字符,创建后不可修改"
maxlength="64"
show-word-limit
/>
>
<template #append>
<ElButton v-if="dialogType === 'add'" @click="handleGenerateGroupCode">
生成编码
</ElButton>
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="名称" prop="name">
<ElInput v-model="form.name" placeholder="请输入名称" maxlength="100" show-word-limit />
@@ -103,105 +109,106 @@
</ElDialog>
<!-- 成员管理弹窗 -->
<ElDialog
<ElDrawer
v-model="memberDialogVisible"
:title="`成员管理 - ${currentGroup?.name || ''}`"
width="680px"
:title="`成员管理 - ${currentGroup?.name || ''} - ${currentGroup?.enabled ? '启用' : '已停用'}`"
direction="rtl"
size="65%"
destroy-on-close
>
<div v-if="currentGroup" class="member-group-info">
<ElTag :type="currentGroup.enabled ? 'success' : 'danger'" size="small">
{{ currentGroup.enabled ? '启用' : '已停用' }}
</ElTag>
<span class="member-group-code">{{ currentGroup.code }}</span>
<span v-if="!currentGroup.enabled" class="member-group-tip">
停用组保留现有成员,不能作为批量目标
</span>
</div>
<ElDivider content-position="left">设置成员(整体替换)</ElDivider>
<ElAlert type="warning" :closable="false" show-icon class="member-alert">
保存后将以所选账号整体替换该组现有成员;账号必须是启用平台用户。
</ElAlert>
<ElSelect
v-model="memberAccountIds"
multiple
filterable
remote
:remote-method="searchMemberCandidates"
:loading="memberCandidatesLoading"
placeholder="搜索并选择平台账号"
style="width: 100%; margin-top: 12px"
>
<ElOption
v-for="candidate in memberCandidates"
:key="candidate.id"
:label="candidate.username"
:value="candidate.id"
>
<div class="member-option">
<span>{{ candidate.username }}</span>
<span class="member-option-phone">{{ candidate.phone_summary }}</span>
<div class="member-transfer-layout">
<div class="member-panel">
<div class="member-panel-header">
<div class="member-panel-title">候选账号</div>
<ElButton v-if="candidateHasMore" link type="primary" @click="loadMoreCandidates">
查看更多
</ElButton>
</div>
</ElOption>
</ElSelect>
<div class="member-actions">
<ElButton type="primary" :loading="settingMembers" @click="handleSetMembers">
保存成员
</ElButton>
</div>
<ElDivider content-position="left">移出成员(回未分组)</ElDivider>
<ElAlert type="info" :closable="false" show-icon class="member-alert">
选择要移出用户组的平台账号,清空后回未分组。
</ElAlert>
<ElSelect
v-model="clearAccountIds"
multiple
filterable
remote
:remote-method="searchMemberCandidates"
:loading="memberCandidatesLoading"
placeholder="搜索并选择要移出的平台账号"
style="width: 100%; margin-top: 12px"
>
<ElOption
v-for="candidate in memberCandidates"
:key="candidate.id"
:label="candidate.username"
:value="candidate.id"
>
<div class="member-option">
<span>{{ candidate.username }}</span>
<span class="member-option-phone">{{ candidate.phone_summary }}</span>
<ElInput
v-model="candidateKeyword"
placeholder="搜索用户名或手机号"
clearable
@input="searchMemberCandidates"
/>
<ElTable
:data="memberCandidates"
border
stripe
size="small"
height="300"
v-loading="memberCandidatesLoading"
@selection-change="candidateSelection = $event"
>
<ElTableColumn v-if="memberCandidates.length" type="selection" width="42" />
<ElTableColumn prop="username" label="用户名" min-width="120" />
<ElTableColumn prop="phone" label="手机号" min-width="110" />
<ElTableColumn label="操作提示" min-width="130" show-overflow-tooltip>
<template #default="scope">
<ElTag size="small" type="info">
{{ getMemberOperationLabel(scope.row.action || scope.row.operation) }}
</ElTag>
</template>
</ElTableColumn>
</ElTable>
<ElButton type="primary" :loading="settingMembers" @click="handleSetMembers">
添加/迁移所选账号
</ElButton>
</div>
<div class="member-panel">
<div class="member-panel-header">
<div class="member-panel-title">当前成员</div>
<ElButton
v-if="currentMembersHasMore"
link
type="primary"
@click="loadMoreCurrentMembers"
>
查看更多
</ElButton>
</div>
</ElOption>
</ElSelect>
<div class="member-actions">
<ElButton type="danger" :loading="clearingMembers" @click="handleClearMembers">
移出所选账号
</ElButton>
<ElInput v-model="currentMemberKeyword" placeholder="搜索当前成员" clearable />
<ElTable
:data="filteredCurrentMembers"
border
stripe
size="small"
height="300"
v-loading="currentMembersLoading"
empty-text="当前业务组暂无成员"
@selection-change="clearSelection = $event"
>
<ElTableColumn v-if="filteredCurrentMembers.length" type="selection" width="42" />
<ElTableColumn prop="username" label="用户名" min-width="120" />
<ElTableColumn prop="phone" label="手机号" min-width="110" />
<ElTableColumn prop="status_name" label="状态" width="90" />
</ElTable>
<ElButton type="danger" :loading="clearingMembers" @click="handleClearMembers">
移出所选成员
</ElButton>
</div>
</div>
</ElDialog>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { BusinessUserGroupService, ShopService } from '@/api/modules'
import { BusinessUserGroupService } from '@/api/modules'
import type {
BusinessUserGroupBusinessLine,
BusinessUserGroupItem,
BusinessUserGroupMember,
CreateBusinessUserGroupParams,
UpdateBusinessUserGroupParams,
ShopBusinessOwnerCandidate
BusinessUserGroupMemberCandidate
} from '@/types/api'
import type { FormInstance, FormRules } from 'element-plus'
import type { SearchFormItem } from '@/types'
import { useCheckedColumns } from '@/composables/useCheckedColumns'
import { useAuth } from '@/composables/useAuth'
import { formatDateTime } from '@/utils/business/format'
import { generateShopCode } from '@/utils/codeGenerator'
import {
BUSINESS_USER_GROUP_BUSINESS_LINE_MAP,
BUSINESS_USER_GROUP_BUSINESS_LINE_OPTIONS,
@@ -436,6 +443,12 @@
dialogVisible.value = true
}
const handleGenerateGroupCode = () => {
form.code = generateShopCode()
formRef.value?.clearValidate('code')
ElMessage.success('编码生成成功')
}
const openEditDialog = (row: BusinessUserGroupItem) => {
dialogType.value = 'edit'
currentGroup.value = row
@@ -512,22 +525,34 @@
// ==================== 成员管理 ====================
const memberDialogVisible = ref(false)
const memberCandidates = ref<ShopBusinessOwnerCandidate[]>([])
const memberCandidates = ref<BusinessUserGroupMemberCandidate[]>([])
const currentMembers = ref<BusinessUserGroupMember[]>([])
const currentMembersLoading = ref(false)
const memberCandidatesLoading = ref(false)
const memberAccountIds = ref<number[]>([])
const clearAccountIds = ref<number[]>([])
const memberCandidatesPage = ref(1)
const memberCandidatesTotal = ref(0)
const candidateKeyword = ref('')
const currentMembersPage = ref(1)
const currentMembersTotal = ref(0)
const currentMemberKeyword = ref('')
const candidateSelection = ref<BusinessUserGroupMemberCandidate[]>([])
const clearSelection = ref<BusinessUserGroupMember[]>([])
const settingMembers = ref(false)
const clearingMembers = ref(false)
const searchMemberCandidates = async (keyword?: string) => {
const searchMemberCandidates = async (keyword = '') => {
if (!currentGroup.value) return
memberCandidatesPage.value = 1
memberCandidatesLoading.value = true
try {
const res = await ShopService.getBusinessOwnerCandidates({
page: 1,
page_size: 50,
keyword: keyword?.trim() || undefined
const res = await BusinessUserGroupService.getMemberCandidates(currentGroup.value.id, {
page: memberCandidatesPage.value,
page_size: 20,
keyword: keyword.trim() || undefined,
scope: 'all'
})
memberCandidates.value = res.data.items || []
memberCandidatesTotal.value = res.data.total || 0
} catch {
memberCandidates.value = []
} finally {
@@ -535,12 +560,88 @@
}
}
const candidateHasMore = computed(
() => memberCandidates.value.length < memberCandidatesTotal.value
)
const loadMoreCandidates = async () => {
if (!currentGroup.value || memberCandidatesLoading.value) return
memberCandidatesPage.value += 1
memberCandidatesLoading.value = true
try {
const res = await BusinessUserGroupService.getMemberCandidates(currentGroup.value.id, {
page: memberCandidatesPage.value,
page_size: 20,
scope: 'all'
})
if (res.code === 0) memberCandidates.value.push(...(res.data?.items || []))
} finally {
memberCandidatesLoading.value = false
}
}
const getMemberOperationLabel = (operation?: string | null) => {
if (operation === 'add') return '可新增'
if (operation === 'already_member') return '已是当前成员'
if (operation === 'move') return '可迁移'
return operation || '-'
}
const loadCurrentMembers = async (groupId: number) => {
currentMembersLoading.value = true
try {
const res = await BusinessUserGroupService.getGroupMembers(groupId, {
page: 1,
page_size: 20
})
if (res.code === 0) {
currentMembers.value = res.data?.items || []
currentMembersPage.value = 1
currentMembersTotal.value = res.data?.total || 0
}
} finally {
currentMembersLoading.value = false
}
}
const currentMembersHasMore = computed(
() => currentMembers.value.length < currentMembersTotal.value
)
const filteredCurrentMembers = computed(() => {
const keyword = currentMemberKeyword.value.trim().toLowerCase()
if (!keyword) return currentMembers.value
return currentMembers.value.filter((member) =>
[member.username, member.phone]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword))
)
})
const loadMoreCurrentMembers = async () => {
if (!currentGroup.value || currentMembersLoading.value) return
currentMembersPage.value += 1
currentMembersLoading.value = true
try {
const res = await BusinessUserGroupService.getGroupMembers(currentGroup.value.id, {
page: currentMembersPage.value,
page_size: 20
})
if (res.code === 0) currentMembers.value.push(...(res.data?.items || []))
} finally {
currentMembersLoading.value = false
}
}
const openMemberDialog = (row: BusinessUserGroupItem) => {
currentGroup.value = row
memberAccountIds.value = []
clearAccountIds.value = []
candidateSelection.value = []
clearSelection.value = []
currentMemberKeyword.value = ''
memberCandidates.value = []
candidateKeyword.value = ''
memberDialogVisible.value = true
void loadCurrentMembers(row.id)
setTimeout(() => {
searchMemberCandidates()
}, 0)
@@ -548,14 +649,15 @@
const handleSetMembers = async () => {
if (!currentGroup.value) return
if (!memberAccountIds.value.length) {
const accountIds = candidateSelection.value.map((member) => member.account_id)
if (!accountIds.length) {
ElMessage.warning('请至少选择一个平台账号')
return
}
settingMembers.value = true
try {
const res = await BusinessUserGroupService.setGroupMembers(currentGroup.value.id, {
account_ids: memberAccountIds.value
const res = await BusinessUserGroupService.addGroupMembers(currentGroup.value.id, {
account_ids: accountIds
})
ElMessage.success(`已维护 ${res.data?.account_ids?.length ?? 0} 个账号归属`)
memberDialogVisible.value = false
@@ -566,14 +668,15 @@
}
const handleClearMembers = async () => {
if (!clearAccountIds.value.length) {
const accountIds = clearSelection.value.map((member) => member.account_id)
if (!accountIds.length) {
ElMessage.warning('请至少选择一个平台账号')
return
}
clearingMembers.value = true
try {
const res = await BusinessUserGroupService.clearGroupMembers({
account_ids: clearAccountIds.value
const res = await BusinessUserGroupService.removeGroupMembers(currentGroup.value?.id || 0, {
account_ids: accountIds
})
ElMessage.success(`已清空 ${res.data?.account_ids?.length ?? 0} 个账号归属`)
memberDialogVisible.value = false
@@ -604,6 +707,12 @@
font-size: 12px;
color: var(--el-text-color-secondary);
}
.member-layout-tip {
margin-left: auto;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.member-alert {
@@ -616,6 +725,28 @@
margin-top: 12px;
}
.member-transfer-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
}
.member-panel {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.member-panel :deep(.el-table) {
width: 100%;
}
.member-panel-title {
font-weight: 600;
color: var(--el-text-color-primary);
}
.member-option {
display: flex;
justify-content: space-between;