feat(M08-B): 补后台协作与结算详情
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
CleaningSettlement,
|
||||
CleaningSettlementDetail,
|
||||
CleaningTask,
|
||||
CleaningTaskMember,
|
||||
PageResult,
|
||||
SettlementStatus,
|
||||
TaskStatus,
|
||||
@@ -86,6 +88,44 @@ export function rejectCleaningTask(session: ApiSession, taskId: string, reason:
|
||||
});
|
||||
}
|
||||
|
||||
export function listCleaningTaskMembers(session: ApiSession, taskId: string) {
|
||||
return request<CleaningTaskMember[]>(
|
||||
session,
|
||||
`/cleaning/tasks/${encodeURIComponent(taskId)}/members`
|
||||
);
|
||||
}
|
||||
|
||||
export function addCleaningTaskMember(
|
||||
session: ApiSession,
|
||||
taskId: string,
|
||||
input: { cleanerUserId: string; rewardCents: number; note?: string }
|
||||
) {
|
||||
return request<CleaningTaskMember[]>(
|
||||
session,
|
||||
`/cleaning/tasks/${encodeURIComponent(taskId)}/members`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function removeCleaningTaskMember(
|
||||
session: ApiSession,
|
||||
taskId: string,
|
||||
cleanerUserId: string,
|
||||
note?: string
|
||||
) {
|
||||
return request<CleaningTaskMember[]>(
|
||||
session,
|
||||
`/cleaning/tasks/${encodeURIComponent(taskId)}/members/${encodeURIComponent(cleanerUserId)}/remove`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function reclaimCleaningTimeouts(
|
||||
session: ApiSession,
|
||||
input: { olderThanMinutes: number; limit: number }
|
||||
@@ -118,6 +158,13 @@ export function generateCleaningSettlement(
|
||||
});
|
||||
}
|
||||
|
||||
export function getCleaningSettlementDetail(session: ApiSession, settlementId: string) {
|
||||
return request<CleaningSettlementDetail>(
|
||||
session,
|
||||
`/cleaning/settlements/${encodeURIComponent(settlementId)}`
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmCleaningSettlement(session: ApiSession, settlementId: string, note?: string) {
|
||||
return request<CleaningSettlement>(
|
||||
session,
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button size="small" :icon="ListChecks" @click="openDetail(row)">详情</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="BadgeCheck"
|
||||
@@ -188,6 +189,56 @@
|
||||
<el-button type="danger" @click="submitFailure">记录</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailDialog.open" title="结算单详情" width="720px">
|
||||
<div v-if="detailDialog.detail" class="dialog-stack">
|
||||
<div class="detail-grid">
|
||||
<span>单号<strong>{{ detailDialog.detail.settlement.settlementNo }}</strong></span>
|
||||
<span>保洁员<strong>{{ detailDialog.detail.settlement.cleanerName }}</strong></span>
|
||||
<span>金额<strong>{{ money(detailDialog.detail.settlement.totalRewardCents) }}</strong></span>
|
||||
<span>状态<strong>{{ detailDialog.detail.settlement.status }}</strong></span>
|
||||
</div>
|
||||
<el-table :data="detailDialog.detail.items" size="small" v-loading="detailDialog.loading">
|
||||
<el-table-column prop="taskNo" label="任务" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.taskNo }}</strong>
|
||||
<span>{{ compactText(row.orderNo) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="房间" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.storeName }} · {{ row.roomName || row.roomNo }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cleanerName" label="成员" width="130" />
|
||||
<el-table-column prop="rewardCents" label="金额" width="110">
|
||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completedAt" label="完成" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-descriptions :column="2" size="small" border>
|
||||
<el-descriptions-item label="转账状态">
|
||||
{{ compactText(detailDialog.detail.settlement.payoutState) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="外部流水">
|
||||
{{ compactText(detailDialog.detail.settlement.payoutReference) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="失败原因" :span="2">
|
||||
{{ compactText(detailDialog.detail.settlement.payoutError) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ compactText(detailDialog.detail.settlement.note) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="detailDialog.open = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -197,14 +248,16 @@ import {
|
||||
CircleAlert,
|
||||
ClipboardCheck,
|
||||
FilePlus2,
|
||||
ListChecks,
|
||||
RefreshCw,
|
||||
Send
|
||||
} from '@lucide/vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { preflightWechatTransfer } from '../api';
|
||||
import { getCleaningSettlementDetail, preflightWechatTransfer } from '../api';
|
||||
import { compactText, money, shortDate } from '../format';
|
||||
import type {
|
||||
CleaningSettlement,
|
||||
CleaningSettlementDetail,
|
||||
PageResult,
|
||||
SettlementStatus,
|
||||
TransferMode,
|
||||
@@ -260,6 +313,15 @@ const failureDialog = reactive({
|
||||
payoutReference: '',
|
||||
note: ''
|
||||
});
|
||||
const detailDialog = reactive<{
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
detail: CleaningSettlementDetail | null;
|
||||
}>({
|
||||
open: false,
|
||||
loading: false,
|
||||
detail: null
|
||||
});
|
||||
|
||||
function settlementTag(status: SettlementStatus) {
|
||||
if (status === 'PAID') return 'success';
|
||||
@@ -325,4 +387,16 @@ function submitFailure() {
|
||||
});
|
||||
failureDialog.open = false;
|
||||
}
|
||||
|
||||
async function openDetail(row: CleaningSettlement) {
|
||||
detailDialog.open = true;
|
||||
detailDialog.loading = true;
|
||||
try {
|
||||
detailDialog.detail = await getCleaningSettlementDetail(props.session, row.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '结算详情加载失败');
|
||||
} finally {
|
||||
detailDialog.loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button size="small" :icon="UserPlus" @click="openAssign(row)">指派</el-button>
|
||||
<el-button size="small" :icon="Users" @click="openMembers(row)">成员</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="success"
|
||||
@@ -120,6 +121,66 @@
|
||||
<el-button type="danger" @click="submitReject">驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="memberDialog.open" title="协作者管理" width="640px">
|
||||
<div class="dialog-stack">
|
||||
<div class="detail-header">
|
||||
<strong>{{ memberDialog.taskNo }}</strong>
|
||||
<span>任务奖励 {{ money(memberDialog.taskRewardCents) }}</span>
|
||||
</div>
|
||||
<el-table :data="memberDialog.members" size="small" v-loading="memberDialog.loading">
|
||||
<el-table-column prop="nickname" label="成员" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.nickname || compactText(row.userId) }}</strong>
|
||||
<span>ID {{ row.userId }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="memberRole" label="角色" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.memberRole === 'LEAD' ? 'success' : 'info'" effect="light">
|
||||
{{ row.memberRole }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="rewardCents" label="分账" width="110">
|
||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="settledAt" label="结算" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.settledAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
:icon="UserMinus"
|
||||
:disabled="row.memberRole === 'LEAD' || !!row.settledAt"
|
||||
@click="removeMember(row.userId)"
|
||||
>
|
||||
移除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-form label-position="top" class="member-form">
|
||||
<el-form-item label="协作者用户 ID">
|
||||
<el-input v-model="memberDialog.cleanerUserId" inputmode="numeric" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分账金额">
|
||||
<el-input-number v-model="memberDialog.rewardCents" :min="0" :max="1000000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="memberDialog.note" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :icon="UserPlus" @click="submitMember">添加/更新</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="memberDialog.open = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -131,12 +192,20 @@ import {
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
UserPlus
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
Users
|
||||
} from '@lucide/vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
addCleaningTaskMember,
|
||||
listCleaningTaskMembers,
|
||||
removeCleaningTaskMember
|
||||
} from '../api';
|
||||
import { compactText, money, shortDate } from '../format';
|
||||
import type { CleaningTask, PageResult, TaskStatus } from '../types';
|
||||
import type { CleaningTask, CleaningTaskMember, PageResult, TaskStatus } from '../types';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
session: { token: string };
|
||||
loading: boolean;
|
||||
items: PageResult<CleaningTask>['items'];
|
||||
@@ -168,6 +237,17 @@ const statusOptions = [
|
||||
const reclaimForm = reactive({ olderThanMinutes: 60, limit: 20 });
|
||||
const assignDialog = reactive({ open: false, taskId: '', cleanerUserId: '', note: '' });
|
||||
const rejectDialog = reactive({ open: false, taskId: '', reason: '' });
|
||||
const memberDialog = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
taskId: '',
|
||||
taskNo: '',
|
||||
taskRewardCents: 0,
|
||||
cleanerUserId: '',
|
||||
rewardCents: 0,
|
||||
note: '',
|
||||
members: [] as CleaningTaskMember[]
|
||||
});
|
||||
|
||||
function taskTag(status: TaskStatus) {
|
||||
if (status === 'SUBMITTED') return 'warning';
|
||||
@@ -202,4 +282,58 @@ function submitReject() {
|
||||
emit('reject', { taskId: rejectDialog.taskId, reason: rejectDialog.reason });
|
||||
rejectDialog.open = false;
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
memberDialog.loading = true;
|
||||
try {
|
||||
memberDialog.members = await listCleaningTaskMembers(props.session, memberDialog.taskId);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '成员加载失败');
|
||||
} finally {
|
||||
memberDialog.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openMembers(row: CleaningTask) {
|
||||
memberDialog.open = true;
|
||||
memberDialog.taskId = row.id;
|
||||
memberDialog.taskNo = row.taskNo;
|
||||
memberDialog.taskRewardCents = row.rewardCents;
|
||||
memberDialog.cleanerUserId = '';
|
||||
memberDialog.rewardCents = 0;
|
||||
memberDialog.note = '';
|
||||
await loadMembers();
|
||||
}
|
||||
|
||||
async function submitMember() {
|
||||
try {
|
||||
memberDialog.members = await addCleaningTaskMember(props.session, memberDialog.taskId, {
|
||||
cleanerUserId: memberDialog.cleanerUserId,
|
||||
rewardCents: memberDialog.rewardCents,
|
||||
note: memberDialog.note
|
||||
});
|
||||
memberDialog.cleanerUserId = '';
|
||||
memberDialog.rewardCents = 0;
|
||||
memberDialog.note = '';
|
||||
ElMessage.success('协作者已更新');
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '协作者更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(cleanerUserId: string) {
|
||||
try {
|
||||
memberDialog.members = await removeCleaningTaskMember(
|
||||
props.session,
|
||||
memberDialog.taskId,
|
||||
cleanerUserId,
|
||||
'后台移除协作者'
|
||||
);
|
||||
ElMessage.success('协作者已移除');
|
||||
emit('refresh');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '协作者移除失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -255,6 +255,65 @@ textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f5f8fb;
|
||||
}
|
||||
|
||||
.detail-header span {
|
||||
color: #60708a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.member-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 140px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.member-form .el-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-grid span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 9px 10px;
|
||||
color: #60708a;
|
||||
font-size: 12px;
|
||||
border: 1px solid #e1e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f5f8fb;
|
||||
}
|
||||
|
||||
.detail-grid strong {
|
||||
overflow: hidden;
|
||||
color: #18212f;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -327,4 +386,9 @@ textarea {
|
||||
.preflight-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.member-form,
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,18 @@ export interface CleaningTask {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CleaningTaskMember {
|
||||
id: string;
|
||||
taskId: string;
|
||||
userId: string;
|
||||
nickname: string;
|
||||
memberRole: 'LEAD' | 'ASSIST';
|
||||
rewardCents: number;
|
||||
joinedAt?: string;
|
||||
removedAt?: string | null;
|
||||
settledAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CleaningSettlement {
|
||||
id: string;
|
||||
settlementNo: string;
|
||||
@@ -69,6 +81,27 @@ export interface CleaningSettlement {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CleaningSettlementItem {
|
||||
id: string;
|
||||
settlementId: string;
|
||||
taskId: string;
|
||||
taskNo: string;
|
||||
orderNo: string | null;
|
||||
storeName: string;
|
||||
roomName: string;
|
||||
roomNo: string;
|
||||
cleanerUserId: string;
|
||||
cleanerName: string;
|
||||
rewardCents: number;
|
||||
completedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CleaningSettlementDetail {
|
||||
settlement: CleaningSettlement;
|
||||
items: CleaningSettlementItem[];
|
||||
}
|
||||
|
||||
export type WechatTransferPreflightStatus = 'PASS' | 'WARN' | 'FAIL';
|
||||
|
||||
export interface WechatTransferPreflightCheck {
|
||||
|
||||
Reference in New Issue
Block a user