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

@@ -149,6 +149,7 @@
| 申请详情 | `/finance/employee-collection/applications/detail/:id` | `applicationDetail` | `employee_collection:application_detail` |
| 修改并重新提交 | — | `applicationUpdate` | `employee_collection:application_update` |
| 查看凭证 | — | `applicationViewVoucher` | `employee_collection:application_view_voucher` |
| 恢复审批 | — | `applicationRecoverApproval` | `employee_collection:application_recover_approval` |
## 财务管理:收款方式管理

View File

@@ -0,0 +1,19 @@
# Change: 对齐业务用户组成员增量管理接口
## Why
《产品迭代8月份/修改.md》已将业务用户组成员维护从“整体替换”调整为增量增加/迁移和按组移除。当前前端仍调用旧 PUT/全局 DELETE 接口,无法展示候选成员归属和操作提示。
## What Changes
- 对齐成员查询、成员候选、增量增加/迁移、按组移除接口。
- 成员管理弹窗展示当前成员及成员关系信息。
- 候选账号展示 `add`、`already_member`、`move` 操作提示。
- 停用组允许查询和移除,但新增/迁移由后端拒绝并提示。
- 用户组列表/详情使用实时成员数。
## Impact
- Affected specs: `business-user-group-management`
- Affected code: business user group types/API/page
- Breaking API alignment: 移除前端对旧整体替换 PUT 与全局清空 DELETE 的调用。

View File

@@ -0,0 +1,17 @@
## ADDED Requirements
### Requirement: Incremental Business User Group Membership
The frontend SHALL use the member query, candidate query, incremental POST add/move, and scoped DELETE remove contracts from the modification document.
#### Scenario: Add or move members
- **WHEN** the user selects candidate accounts and confirms adding members
- **THEN** the frontend MUST POST the account IDs to `/api/admin/business-user-groups/{id}/members`
- **AND** MUST refresh current members and member counts
#### Scenario: Remove current members
- **WHEN** the user selects members currently belonging to the group and confirms removal
- **THEN** the frontend MUST DELETE `/api/admin/business-user-groups/{id}/members` with the account IDs
- **AND** MUST refresh current members

View File

@@ -0,0 +1,14 @@
## 1. Contract
- [ ] 1.1 更新成员、候选和成员数类型。
- [ ] 1.2 更新 `BusinessUserGroupService` 方法与 HTTP 方法/路径。
## 2. UI
- [ ] 2.1 展示当前成员列表和成员状态。
- [ ] 2.2 使用候选接口展示归属筛选和操作提示。
- [ ] 2.3 将“整体替换”改为增量添加/迁移,将移除限定为当前组成员。
## 3. Verification
- [ ] 3.1 运行类型检查、lint、编码检查、构建和 OpenSpec 校验。

View File

@@ -0,0 +1,9 @@
## Context
核销申请的审批提交状态与业务状态独立维护。恢复操作只能复用最新审批尝试和原审批实例,不能创建新的申请或审批尝试。
## Decisions
- 资格以响应中的 `recoverable` 为准,前端不根据状态字段自行推导。
- 列表提供快速入口,详情提供主要操作入口;两处共用同一恢复权限编码。
- 恢复接口只传申请 ID,结果由后端返回并触发重新加载。

View File

@@ -0,0 +1,25 @@
# Change: 补齐核销申请恢复审批
## Why
《产品迭代8月份/修改.md》新增核销申请恢复原审批接口和恢复资格字段。当前前端类型、列表和详情尚未提供恢复入口,提交失败或结果未知的核销申请无法由运营人员处理。
## What Changes
- 扩展核销申请响应类型,支持 `recoverable`、提交状态、失败摘要和最近恢复时间。
- 新增 `EmployeeCollectionService.recoverApplicationApproval(id)`。
- 核销申请列表和详情在 `recoverable = true` 且具备权限时显示“恢复审批”。
- 恢复成功或冲突后刷新详情/列表,并展示后端错误消息。
- 新增权限 `employee_collection:application_recover_approval` 并写入权限文档。
## Impact
- Affected specs: `employee-collection`
- Affected code:
- `src/types/api/employeeCollection.ts`
- `src/api/modules/employeeCollection.ts`
- `src/config/constants/augustIteration.ts`
- `src/views/finance/employee-collection/applications/index.vue`
- `src/views/finance/employee-collection/applications/detail.vue`
- `docs/权限.md`
- Out of scope: 后端接口、数据库和企业微信审批引擎实现。

View File

@@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: Recover Employee Collection Approval
The frontend SHALL expose recovery for an employee collection application only when the backend returns `recoverable = true` and the user has the recovery permission.
#### Scenario: Recover approval from application detail
- **GIVEN** a核销申请返回 `recoverable = true`
- **WHEN** the user confirms “恢复审批”
- **THEN** the frontend MUST call `POST /api/admin/employee-collection-applications/{id}/recover-approval`
- **AND** MUST reload the application data after success or conflict

View File

@@ -0,0 +1,12 @@
## 1. Implementation
- [x] 1.1 扩展核销申请类型与恢复接口响应。
- [x] 1.2 增加恢复审批 API service 和唯一权限编码。
- [x] 1.3 列表增加提交状态字段和恢复审批操作。
- [x] 1.4 详情增加提交状态字段和恢复审批操作。
- [x] 1.5 更新 `docs/权限.md`。
## 2. Verification
- [x] 2.1 运行类型检查、lint、编码检查和构建。
- [x] 2.2 运行 OpenSpec 严格校验。

View File

@@ -6,6 +6,9 @@ import { BaseService } from '../BaseService'
import type {
BusinessUserGroupItem,
BusinessUserGroupMembersResult,
BusinessUserGroupMembersPageResult,
BusinessUserGroupMembersQueryParams,
BusinessUserGroupMemberCandidatesQueryParams,
BusinessUserGroupQueryParams,
CreateBusinessUserGroupParams,
DeleteBusinessUserGroupParams,
@@ -67,25 +70,47 @@ export class BusinessUserGroupService extends BaseService {
* 批量设置平台用户业务用户组归属(整体替换)
* PUT /api/admin/business-user-groups/{id}/members
*/
static setGroupMembers(
static addGroupMembers(
id: number,
data: SetBusinessUserGroupMembersParams
): Promise<BaseResponse<BusinessUserGroupMembersResult>> {
return this.put<BaseResponse<BusinessUserGroupMembersResult>>(
return this.post<BaseResponse<BusinessUserGroupMembersResult>>(
`/api/admin/business-user-groups/${id}/members`,
data
)
}
static getMemberCandidates(
id: number,
params?: BusinessUserGroupMemberCandidatesQueryParams
): Promise<BaseResponse<BusinessUserGroupMembersPageResult>> {
return this.get<BaseResponse<BusinessUserGroupMembersPageResult>>(
`/api/admin/business-user-groups/${id}/member-candidates`,
params
)
}
/** 查询业务用户组现有成员 */
static getGroupMembers(
id: number,
params?: BusinessUserGroupMembersQueryParams
): Promise<BaseResponse<BusinessUserGroupMembersPageResult>> {
return this.get<BaseResponse<BusinessUserGroupMembersPageResult>>(
`/api/admin/business-user-groups/${id}/members`,
params
)
}
/**
* 批量清空平台用户业务用户组归属(DELETE 带 body)
* DELETE /api/admin/business-user-groups/members
*/
static clearGroupMembers(
static removeGroupMembers(
id: number,
data: SetBusinessUserGroupMembersParams
): Promise<BaseResponse<BusinessUserGroupMembersResult>> {
return this.delete<BaseResponse<BusinessUserGroupMembersResult>>(
'/api/admin/business-user-groups/members',
`/api/admin/business-user-groups/${id}/members`,
undefined,
{ data }
)

View File

@@ -20,7 +20,9 @@ import type {
EmployeeCollectionPaymentMethodListResponse,
EmployeeCollectionPaymentMethodQueryParams,
EmployeeCollectionPaymentMethodResponse,
UpdateEmployeeCollectionPaymentMethodRequest
UpdateEmployeeCollectionPaymentMethodRequest,
BaseResponse,
RecoverEmployeeCollectionApplicationResponse
} from '@/types/api'
const PAYMENT_METHODS_BASE_URL = '/api/admin/employee-collection-payment-methods'
@@ -146,4 +148,13 @@ export class EmployeeCollectionService extends BaseService {
data
)
}
/** 恢复核销申请原审批提交 */
static recoverApplicationApproval(
id: number
): Promise<BaseResponse<RecoverEmployeeCollectionApplicationResponse>> {
return this.post<BaseResponse<RecoverEmployeeCollectionApplicationResponse>>(
`${APPLICATIONS_BASE_URL}/${id}/recover-approval`
)
}
}

View File

@@ -23,8 +23,20 @@
</ElSelect>
<ElInput v-else :model-value="props.initialOrderNo" disabled style="width: 100%" />
</ElFormItem>
<ElFormItem v-if="selectedOrder" label="支付方式">
<ElInput :model-value="paymentMethodLabel" disabled style="width: 100%" />
</ElFormItem>
<ElFormItem v-if="selectedOrder" label="买家类型">
<ElInput :model-value="buyerTypeLabel" disabled style="width: 100%" />
</ElFormItem>
<ElFormItem label="退款方式" prop="method">
<ElRadioGroup v-model="formData.method">
<ElInput
v-if="walletRefundFixed"
:model-value="refundMethodLabel"
disabled
style="width: 100%"
/>
<ElRadioGroup v-else v-model="formData.method">
<ElRadio v-for="opt in RefundMethodOptions" :key="opt.value" :label="opt.value">
{{ opt.label }}
</ElRadio>
@@ -84,7 +96,7 @@
@change="formRef?.validateField('refund_voucher_key')"
/>
<div v-if="formData.method === RefundMethod.CUSTOMER_ACCOUNT" class="voucher-tip">
客户收款信息方式下须同时提供客户收款信息与至少 1 个退款凭证
线下退款方式下须同时提供收款信息与至少 1 个退款凭证
</div>
</ElFormItem>
</ElForm>
@@ -112,7 +124,6 @@
import { RefundMethod, type CreateRefundRequest, type Order } from '@/types/api'
import { RefundMethodOptions } from '@/config/constants/refund'
import { formatMoney, yuanToFen } from '@/utils/business/format'
import { getErrorMessage } from '@/utils/business'
import VoucherUpload from '@/components/business/VoucherUpload.vue'
interface Props {
@@ -222,6 +233,35 @@
return formatMoney(selectedOrder?.actual_paid_amount ?? 0, false)
})
const refundMethodLabel = computed(
() => RefundMethodOptions.find((option) => option.value === formData.method)?.label || '-'
)
const selectedOrder = computed(() =>
orderSearchOptions.value.find((order) => order.id === formData.order_id)
)
const paymentMethodLabel = computed(() => {
const labels = {
wallet: '钱包支付',
wechat: '微信支付',
alipay: '支付宝支付',
offline: '线下支付'
}
return selectedOrder.value ? labels[selectedOrder.value.payment_method] : '-'
})
const buyerTypeLabel = computed(() =>
selectedOrder.value?.buyer_type === 'agent' ? '代理商' : selectedOrder.value ? '个人客户' : '-'
)
const walletRefundFixed = computed(() => selectedOrder.value?.payment_method === 'wallet')
const resolveRefundMethod = (order: Order): RefundMethod => {
if (order.payment_method === 'wallet') {
return order.buyer_type === 'agent' ? RefundMethod.AGENT_WALLET : RefundMethod.ASSET_WALLET
}
if (order.payment_method === 'offline') return RefundMethod.CUSTOMER_ACCOUNT
return RefundMethod.ORIGINAL_ROUTE
}
const searchOrders = async (query: string) => {
orderSearchLoading.value = true
try {
@@ -238,6 +278,11 @@
orderSearchOptions.value = res.data.items || []
if (hasInitialOrder.value && props.initialOrderId) {
formData.order_id = props.initialOrderId
const initialOrder = orderSearchOptions.value.find((o) => o.id === props.initialOrderId)
if (initialOrder) {
formData.actual_received_amount = initialOrder.actual_paid_amount ?? 0
formData.method = resolveRefundMethod(initialOrder)
}
}
if (hasInitialOrder.value && props.initialOrderNo) {
const matchedOrder = orderSearchOptions.value.find(
@@ -246,6 +291,7 @@
if (matchedOrder) {
formData.order_id = matchedOrder.id
formData.actual_received_amount = matchedOrder.actual_paid_amount ?? 0
formData.method = resolveRefundMethod(matchedOrder)
}
}
}
@@ -264,6 +310,8 @@
const selectedOrder = orderSearchOptions.value.find((o) => o.id === orderId)
if (selectedOrder) {
formData.actual_received_amount = selectedOrder.actual_paid_amount ?? 0
formData.method = resolveRefundMethod(selectedOrder)
formRef.value?.validateField('method')
}
}

View File

@@ -12,6 +12,7 @@ export const AUGUST_PERMISSIONS = {
applicationDetail: 'employee_collection:application_detail',
applicationUpdate: 'employee_collection:application_update',
applicationViewVoucher: 'employee_collection:application_view_voucher',
applicationRecoverApproval: 'employee_collection:application_recover_approval',
paymentMethodPage: 'employee_collection:payment_method_view',
paymentMethodCreate: 'employee_collection:payment_method_create',
paymentMethodEdit: 'employee_collection:payment_method_edit',

View File

@@ -20,17 +20,15 @@ export const RefundStatusNameMap: Record<RefundStatus, string> = {
}
/** 退款状态标签类型映射 */
export const RefundStatusTypeMap: Record<
RefundStatus,
'warning' | 'success' | 'danger' | 'info'
> = {
[RefundStatus.PENDING]: 'warning',
[RefundStatus.APPROVED]: 'success',
[RefundStatus.REJECTED]: 'danger',
[RefundStatus.RETURNED]: 'info',
[RefundStatus.REFUNDING]: 'warning',
[RefundStatus.REFUND_FAILED]: 'danger'
}
export const RefundStatusTypeMap: Record<RefundStatus, 'warning' | 'success' | 'danger' | 'info'> =
{
[RefundStatus.PENDING]: 'warning',
[RefundStatus.APPROVED]: 'success',
[RefundStatus.REJECTED]: 'danger',
[RefundStatus.RETURNED]: 'info',
[RefundStatus.REFUNDING]: 'warning',
[RefundStatus.REFUND_FAILED]: 'danger'
}
/** 退款方式名称映射 */
export const RefundMethodNameMap: Record<RefundMethod, string> = {
@@ -62,7 +60,7 @@ export const RefundFailureReasonNameMap: Record<RefundFailureReason, string> = {
/** 退款方式选项(表单用) */
export const RefundMethodOptions = [
{ label: '原路退回', value: RefundMethod.ORIGINAL_ROUTE },
{ label: '客户收款信息', value: RefundMethod.CUSTOMER_ACCOUNT },
{ label: '线下退款', value: RefundMethod.CUSTOMER_ACCOUNT },
{ label: '资产钱包', value: RefundMethod.ASSET_WALLET },
{ label: '代理钱包', value: RefundMethod.AGENT_WALLET }
]
@@ -86,4 +84,4 @@ export const canResubmitRefund = (item: {
export const canManualApproveRefund = (item: {
status?: RefundStatus
approval_instance_id?: number | null
}): boolean => item.status === RefundStatus.PENDING && !item.approval_instance_id
}): boolean => item.status === RefundStatus.PENDING && !item.approval_instance_id

View File

@@ -19,6 +19,7 @@ export interface BusinessUserGroupItem {
remark: string // 备注
created_at: string // 创建时间
updated_at: string // 更新时间
member_count?: number
}
/** 业务用户组列表查询参数 */
@@ -67,8 +68,49 @@ export interface SetBusinessUserGroupMembersParams {
account_ids: number[] // 平台用户账号ID列表,至少一个;每个账号必须是启用平台用户
}
export interface BusinessUserGroupMembersQueryParams extends PaginationParams {
keyword?: string
account_status?: 'enabled' | 'disabled' | 'deleted'
}
export type BusinessUserGroupMemberScope = 'all' | 'ungrouped' | 'current' | 'other'
export interface BusinessUserGroupMemberCandidatesQueryParams extends PaginationParams {
keyword?: string
scope?: BusinessUserGroupMemberScope
}
export interface BusinessUserGroupMember {
account_id: number
username: string
phone?: string | null
enabled?: boolean
deleted?: boolean
status_name?: string | null
member_created_at?: string | null
current_group_id?: number | null
current_group_code?: string | null
current_group_name?: string | null
operation?: 'add' | 'already_member' | 'move' | string
operation_name?: string | null
}
export interface BusinessUserGroupMemberCandidate extends BusinessUserGroupMember {
id?: number
phone_summary?: string | null
action?: 'add' | 'already_member' | 'move' | string
can_add?: boolean
}
export interface BusinessUserGroupMembersPageResult {
items: BusinessUserGroupMember[] | null
page?: number
size?: number
total?: number
}
/** 批量设置/清空成员结果 */
export interface BusinessUserGroupMembersResult {
group_id: number // 目标业务用户组ID;清空操作为 0
account_ids: number[] | null // 本次成功维护归属的平台用户账号ID列表
}
}

View File

@@ -229,6 +229,12 @@ export interface EmployeeCollectionApplication {
decided_at?: string | null
latest_approval_instance_id?: number | null
latest_attempt_id?: number | null
approval_failure_summary?: string | null
approval_submission_name?: string | null
approval_submission_status?: number | null
last_recovery_at?: string | null
recoverable?: boolean
recovery_result?: string | null
created_at?: string | null
updated_at?: string | null
}
@@ -279,6 +285,17 @@ export interface EmployeeCollectionApplicationSubmitResult {
attempt?: EmployeeCollectionApplicationAttempt | null
}
export interface RecoverEmployeeCollectionApplicationResponse {
action?: string
application_id?: number
approval_instance_id?: number | null
last_recovery_at?: string | null
recoverable?: boolean
result?: string
submission_status?: number | null
submission_status_name?: string | null
}
// ========== 响应类型 ==========
export type EmployeeCollectionPaymentMethodListResponse = BaseResponse<

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;