feat(matchmaker): ZC-MM-002 A1 服务对象分配建模(DEC-MATCHMAKER-002 LOCKED 落地)
数据库(V053/V054):
- 新表 matchmaker_assignments:独立分配关系(assigned_by/reason/scope/
expires_at/ended_at/end_reason),状态机 active/ended/revoked/transferred/
expired(CHECK 白名单),部分唯一索引防同用户多 ACTIVE 主责分配,
存量进行中服务单幂等回填
- 新角色 matchmaker_leader(014) + 新权限 matchmaker.team.assign(10000000-…-023);
组长授 view/service/team.assign(不给平台级 matchmaker.assign)
后端:
- ServiceAssignmentPolicy 有效分配判定器(读前惰性过期,失效分配即时拒绝)
- 分配/转分配/撤销/结束四操作:状态机非法转换拒绝、理由必填、
同用户主责冲突拒绝;组织范围校验(team.assign 同组织,NULL=NULL 视为
平台直属,技术默认安全值待治理登记);transfer 同时校验原持有人与新目标范围
- 全操作审计 MATCHMAKER_ASSIGNMENT_CREATE/TRANSFER/REVOKE/END,
operatorId=当前操作者,afterData 仅记摘要(无敏感内容)
- 删除认领端点 POST /services/{id}/claim(A1 禁止普通红娘认领,MM-AUTH-004=404)
- GET /pending 收敛为分配权持有者(assign or team.assign);
dashboard today/completed 改本人口径,inProgress 口径修正 'processing'→'in_service'
- recommend/followup/complete/detail 归属判定接入判定器,拒绝走
DataScopeGuard(自动 DATA_SCOPE_DENIED 审计),不再裸 IllegalStateException;
assignService 与创建分配同事务联动;删除死查询 findAssignedTo
前端:
- admin-web 分配弹窗去硬编码假数据(operator_wang/chen)→ assignable-matchmakers
真实接口(仅 id/name/organizationId,无联系方式)+ 理由必填;
队列 tab 按 assign/team.assign 显隐;新增「服务对象分配」tab(转分配/撤销/结束)
- 小程序红娘工作台:删除接单入口,数据源切 GET /operator/matchmaker/assignments/my,
枚举码不上屏(中文映射),空 serviceId 不跳转
测试:
- MatchmakerAssignmentA1Test 14 用例(MM-AUTH-001~004、MM-AUDIT-001、
MM-LIFECYCLE-001、组长跨组织拒绝、NULL=NULL 平台直属边界、transfer 原持有人
范围负向、/pending 403+审计),负向注入自证有效
- MatchmakerAssignmentPermissionTest 3 用例(端点权限契约 + claim 残留扫描)
- MatchmakerServiceTest 补 assignment 状态/scope 白名单守卫;迁移基线 LATEST=54
- 验证:定向 38/38 全绿;UI 契约 24/24;小程序 wcc/wcsc 真编译 0 失败
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>公益红娘管理</span></div></template>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="服务请求队列" name="services">
|
||||
<!-- ZC-MM-002:服务请求队列仅分配权持有者可见(matchmaker.assign 或 matchmaker.team.assign) -->
|
||||
<el-tab-pane v-if="canAssign" label="服务请求队列" name="services">
|
||||
<el-table :data="services" stripe v-loading="loading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="serviceType" label="服务类型" width="160">
|
||||
@@ -19,7 +20,7 @@
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
|
||||
<el-button v-if="row.status==='assigned' || row.status==='processing'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
|
||||
<el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -38,16 +39,47 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) -->
|
||||
<el-tab-pane label="服务对象分配" name="assignments">
|
||||
<el-table :data="assignments" stripe v-loading="assignmentsLoading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="assignmentScope" label="范围" width="90">
|
||||
<template #default="{ row }">{{ assignmentScopeLabel(row.assignmentScope) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignmentReason" label="分配理由" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }"><el-tag :type="assignmentStatusTag(row.status)">{{ assignmentStatusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignedAt" label="分配时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.assignedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expiresAt" label="有效期至" width="170">
|
||||
<template #default="{ row }">{{ row.expiresAt ? formatDateTime(row.expiresAt) : '长期' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" @click="openTransfer(row)">转分配</el-button>
|
||||
<el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button>
|
||||
<el-button size="small" @click="openTerminate(row, 'end')">结束</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- 分配弹窗 -->
|
||||
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="红娘"><el-select v-model="assignForm.operatorId" placeholder="选择红娘">
|
||||
<el-option label="王红娘" value="operator_wang" />
|
||||
<el-option label="陈红娘" value="operator_chen" />
|
||||
</el-select></el-form-item>
|
||||
<!-- 分配弹窗(ZC-MM-002:去硬编码红娘名单,改为加载可分配红娘接口;理由必填) -->
|
||||
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="红娘">
|
||||
<el-select v-model="assignForm.matchmakerOperatorId" placeholder="选择红娘" style="width: 100%">
|
||||
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分配理由" required>
|
||||
<el-input v-model="assignForm.reason" type="textarea" :rows="2" placeholder="说明分配依据(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialogVisible=false">取消</el-button>
|
||||
@@ -55,6 +87,37 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 转分配弹窗(ZC-MM-002:服务对象分配) -->
|
||||
<el-dialog v-model="transferDialogVisible" title="转分配服务对象" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="新红娘">
|
||||
<el-select v-model="transferForm.newMatchmakerOperatorId" placeholder="选择接手红娘" style="width: 100%">
|
||||
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="转分配理由" required>
|
||||
<el-input v-model="transferForm.reason" type="textarea" :rows="2" placeholder="说明转分配依据(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doTransfer">确认转分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 撤销/结束弹窗(ZC-MM-002:服务对象分配) -->
|
||||
<el-dialog v-model="terminateDialogVisible" :title="terminateAction==='revoke' ? '撤销服务对象分配' : '结束服务对象分配'" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item :label="terminateAction==='revoke' ? '撤销理由' : '结束理由'" required>
|
||||
<el-input v-model="terminateForm.reason" type="textarea" :rows="2" placeholder="说明理由(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="terminateDialogVisible=false">取消</el-button>
|
||||
<el-button type="danger" @click="doTerminate">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建推荐弹窗 -->
|
||||
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
|
||||
<el-form label-width="100px">
|
||||
@@ -71,18 +134,35 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const activeTab = ref('services')
|
||||
const authStore = useAuthStore()
|
||||
// ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长)
|
||||
const canAssign = computed(() => {
|
||||
const perms: string[] = authStore.permissions || []
|
||||
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
|
||||
})
|
||||
|
||||
const activeTab = ref(canAssign.value ? 'services' : 'assignments')
|
||||
const loading = ref(false)
|
||||
const services = ref<any[]>([])
|
||||
const recommendations = ref<any[]>([])
|
||||
const assignments = ref<any[]>([])
|
||||
const assignmentsLoading = ref(false)
|
||||
const assignableMatchmakers = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const recDialogVisible = ref(false)
|
||||
const transferDialogVisible = ref(false)
|
||||
const terminateDialogVisible = ref(false)
|
||||
const currentServiceId = ref('')
|
||||
const assignForm = ref({ operatorId: '' })
|
||||
const currentUserId = ref('')
|
||||
const currentAssignmentId = ref('')
|
||||
const terminateAction = ref<'revoke' | 'end'>('revoke')
|
||||
const assignForm = ref({ matchmakerOperatorId: '', reason: '' })
|
||||
const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' })
|
||||
const terminateForm = ref({ reason: '' })
|
||||
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
@@ -90,10 +170,12 @@ const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
referral_activity: '转介活动', referral_station: '转介服务站'
|
||||
}
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
submitted: '待分配', assigned: '已分配', processing: '处理中', completed: '已完成', cancelled: '已取消'
|
||||
submitted: '待分配', assigned: '已分配', in_service: '服务中', waiting_user: '等待用户',
|
||||
processing: '处理中', completed: '已完成', cancelled: '已取消'
|
||||
}
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
submitted: 'warning', assigned: 'primary', processing: '', completed: 'success', cancelled: 'danger'
|
||||
submitted: 'warning', assigned: 'primary', in_service: 'primary', waiting_user: 'warning',
|
||||
processing: '', completed: 'success', cancelled: 'danger'
|
||||
}
|
||||
const REC_STATUS_LABEL: Record<string, string> = {
|
||||
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
|
||||
@@ -103,18 +185,27 @@ const REC_STATUS_TAG: Record<string, string> = {
|
||||
pending_view: 'warning', viewed: 'info', liked: 'primary',
|
||||
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info'
|
||||
}
|
||||
const ASSIGNMENT_STATUS_LABEL: Record<string, string> = {
|
||||
active: '生效中', ended: '已结束', revoked: '已撤销', transferred: '已转分配', expired: '已过期'
|
||||
}
|
||||
const ASSIGNMENT_STATUS_TAG: Record<string, string> = {
|
||||
active: 'success', ended: 'info', revoked: 'danger', transferred: 'info', expired: 'info'
|
||||
}
|
||||
|
||||
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
|
||||
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
|
||||
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
|
||||
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
|
||||
function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' }
|
||||
function assignmentStatusLabel(s: string) { return ASSIGNMENT_STATUS_LABEL[s] || s }
|
||||
function assignmentStatusTag(s: string) { return ASSIGNMENT_STATUS_TAG[s] || 'info' }
|
||||
function assignmentScopeLabel(s: string) { return s === 'primary' ? '主责' : '协同' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
async function loadServices() {
|
||||
loading.value = true
|
||||
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
|
||||
// 后端实际为 GET /operator/matchmaker/pending(待分配队列)。
|
||||
// 后端实际为 GET /operator/matchmaker/pending(待分配队列,ZC-MM-002 后仅分配权持有者可调)。
|
||||
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function loadRecommendations() {
|
||||
@@ -123,19 +214,80 @@ async function loadRecommendations() {
|
||||
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
|
||||
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function loadAssignments() {
|
||||
assignmentsLoading.value = true
|
||||
// ZC-MM-002:本人名下生效中的服务对象分配
|
||||
try { const res: any = await get('/operator/matchmaker/assignments/my'); assignments.value = res?.data || [] } finally { assignmentsLoading.value = false }
|
||||
}
|
||||
async function loadAssignableMatchmakers() {
|
||||
// ZC-MM-002:去硬编码红娘名单,改为后端可分配红娘接口(组长按组织过滤、平台授权人员全部)
|
||||
try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] }
|
||||
}
|
||||
async function refresh() {
|
||||
if (activeTab.value === 'services') await loadServices(); else await loadRecommendations()
|
||||
if (activeTab.value === 'services') await loadServices()
|
||||
else if (activeTab.value === 'assignments') await loadAssignments()
|
||||
else await loadRecommendations()
|
||||
}
|
||||
|
||||
async function openAssign(row: any) { currentServiceId.value = row.id; assignForm.value.operatorId = ''; assignDialogVisible.value = true }
|
||||
async function openAssign(row: any) {
|
||||
// /pending 返回字段为 serviceId(Phase 13 契约),兜底 row.id
|
||||
currentServiceId.value = row.serviceId || row.id
|
||||
currentUserId.value = row.userId
|
||||
assignForm.value = { matchmakerOperatorId: '', reason: '' }
|
||||
await loadAssignableMatchmakers()
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
async function doAssign() {
|
||||
// Phase 13 契约修复:原 PUT /matchmaker/services/{id}/assign → 后端为 POST /operator/matchmaker/services/{id}/assign
|
||||
if (!assignForm.value.matchmakerOperatorId) { ElMessage.warning('请选择红娘'); return }
|
||||
if (!assignForm.value.reason || !assignForm.value.reason.trim()) { ElMessage.warning('分配理由必填'); return }
|
||||
// ZC-MM-002:分配走统一分配接口(创建 assignment + 服务单联动置 assigned,同事务)
|
||||
try {
|
||||
await post(`/operator/matchmaker/services/${currentServiceId.value}/assign`, { operatorId: assignForm.value.operatorId })
|
||||
await post('/operator/matchmaker/assignments', {
|
||||
userId: currentUserId.value,
|
||||
matchmakerOperatorId: assignForm.value.matchmakerOperatorId,
|
||||
serviceId: currentServiceId.value,
|
||||
reason: assignForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
async function openTransfer(row: any) {
|
||||
currentAssignmentId.value = row.id
|
||||
transferForm.value = { newMatchmakerOperatorId: '', reason: '' }
|
||||
await loadAssignableMatchmakers()
|
||||
transferDialogVisible.value = true
|
||||
}
|
||||
async function doTransfer() {
|
||||
if (!transferForm.value.newMatchmakerOperatorId) { ElMessage.warning('请选择接手红娘'); return }
|
||||
if (!transferForm.value.reason || !transferForm.value.reason.trim()) { ElMessage.warning('转分配理由必填'); return }
|
||||
try {
|
||||
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/transfer`, {
|
||||
newMatchmakerOperatorId: transferForm.value.newMatchmakerOperatorId,
|
||||
reason: transferForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('转分配成功'); transferDialogVisible.value = false; loadAssignments()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
function openTerminate(row: any, action: 'revoke' | 'end') {
|
||||
currentAssignmentId.value = row.id
|
||||
terminateAction.value = action
|
||||
terminateForm.value = { reason: '' }
|
||||
terminateDialogVisible.value = true
|
||||
}
|
||||
async function doTerminate() {
|
||||
if (!terminateForm.value.reason || !terminateForm.value.reason.trim()) {
|
||||
ElMessage.warning(terminateAction.value === 'revoke' ? '撤销理由必填' : '结束理由必填'); return
|
||||
}
|
||||
try {
|
||||
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/${terminateAction.value}`, {
|
||||
reason: terminateForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('操作成功'); terminateDialogVisible.value = false; loadAssignments()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
function openRecommendation(row: any) {
|
||||
currentServiceId.value = row.id
|
||||
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
|
||||
|
||||
@@ -31,6 +31,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value && !!operator.value)
|
||||
const permissions = computed(() => operator.value?.permissions || [])
|
||||
// ZC-MM-002:最小只读 helper —— 组件按权限码做 UI 显隐(与后端 @PreAuthorize 同码)
|
||||
const hasPermission = (code: string) => permissions.value.includes(code)
|
||||
|
||||
function applyAuthData(data: any) {
|
||||
token.value = data.accessToken || ''
|
||||
@@ -87,5 +89,5 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
clearAccessToken()
|
||||
}
|
||||
|
||||
return { token, operator, isLoggedIn, permissions, login, logout, initializeSession }
|
||||
return { token, operator, isLoggedIn, permissions, hasPermission, login, logout, initializeSession }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user