feat(M08-B): 建立保洁管理后台
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<el-config-provider>
|
||||
<main class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">棋</div>
|
||||
<div>
|
||||
<h1>自助棋牌室</h1>
|
||||
<p>运营后台</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-list" aria-label="后台模块">
|
||||
<button class="nav-item active" type="button">
|
||||
<Sparkles :size="18" />
|
||||
<span>保洁运营</span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" disabled>
|
||||
<Store :size="18" />
|
||||
<span>门店</span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" disabled>
|
||||
<RadioTower :size="18" />
|
||||
<span>设备</span>
|
||||
</button>
|
||||
<button class="nav-item" type="button" disabled>
|
||||
<WalletCards :size="18" />
|
||||
<span>支付</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">M08-B</p>
|
||||
<h2>保洁任务与结算</h2>
|
||||
</div>
|
||||
<div class="token-box">
|
||||
<el-input
|
||||
v-model="tokenDraft"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="后台访问令牌"
|
||||
autocomplete="off"
|
||||
@keyup.enter="saveToken"
|
||||
/>
|
||||
<el-tooltip content="保存令牌" placement="bottom">
|
||||
<el-button :icon="Save" type="primary" @click="saveToken" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="metric-grid" aria-label="保洁概览">
|
||||
<div class="metric">
|
||||
<span>待验收</span>
|
||||
<strong>{{ metrics.submitted }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>已完成</span>
|
||||
<strong>{{ metrics.completed }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>待发放</span>
|
||||
<strong>{{ metrics.confirmed }}</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>本页金额</span>
|
||||
<strong>{{ money(metrics.amountCents) }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="lastError"
|
||||
class="alert-line"
|
||||
:title="lastError"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="true"
|
||||
@close="lastError = ''"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="lastMessage"
|
||||
class="alert-line"
|
||||
:title="lastMessage"
|
||||
type="success"
|
||||
show-icon
|
||||
:closable="true"
|
||||
@close="lastMessage = ''"
|
||||
/>
|
||||
|
||||
<el-tabs v-model="activeTab" class="work-tabs">
|
||||
<el-tab-pane label="任务" name="tasks">
|
||||
<CleaningTasksPanel
|
||||
:session="session"
|
||||
:loading="loading.tasks"
|
||||
:items="tasks.items"
|
||||
:total="tasks.total"
|
||||
:page="tasks.page"
|
||||
:page-size="tasks.pageSize"
|
||||
:status="taskStatus"
|
||||
@refresh="loadTasks"
|
||||
@status-change="changeTaskStatus"
|
||||
@page-change="changeTaskPage"
|
||||
@assign="handleAssign"
|
||||
@complete="handleComplete"
|
||||
@reject="handleReject"
|
||||
@reclaim="handleReclaim"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="结算" name="settlements">
|
||||
<CleaningSettlementsPanel
|
||||
:session="session"
|
||||
:loading="loading.settlements"
|
||||
:items="settlements.items"
|
||||
:total="settlements.total"
|
||||
:page="settlements.page"
|
||||
:page-size="settlements.pageSize"
|
||||
:status="settlementStatus"
|
||||
@refresh="loadSettlements"
|
||||
@status-change="changeSettlementStatus"
|
||||
@page-change="changeSettlementPage"
|
||||
@generate="handleGenerateSettlement"
|
||||
@confirm="handleConfirmSettlement"
|
||||
@transfer="handleTransfer"
|
||||
@sync-transfer="handleSyncTransfer"
|
||||
@failure="handleFailure"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</main>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
RadioTower,
|
||||
Save,
|
||||
Sparkles,
|
||||
Store,
|
||||
WalletCards
|
||||
} from '@lucide/vue';
|
||||
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
|
||||
import CleaningSettlementsPanel from './components/CleaningSettlementsPanel.vue';
|
||||
import {
|
||||
ApiError,
|
||||
assignCleaningTask,
|
||||
completeCleaningTask,
|
||||
confirmCleaningSettlement,
|
||||
executeWechatTransfer,
|
||||
generateCleaningSettlement,
|
||||
listCleaningSettlements,
|
||||
listCleaningTasks,
|
||||
reclaimCleaningTimeouts,
|
||||
recordPayoutFailure,
|
||||
rejectCleaningTask,
|
||||
syncWechatTransfer
|
||||
} from './api';
|
||||
import type {
|
||||
CleaningSettlement,
|
||||
CleaningTask,
|
||||
PageResult,
|
||||
SettlementStatus,
|
||||
TaskStatus,
|
||||
TransferMode
|
||||
} from './types';
|
||||
import { money } from './format';
|
||||
|
||||
const tokenDraft = ref(localStorage.getItem('qipai.admin.token') || '');
|
||||
const activeTab = ref('tasks');
|
||||
const lastError = ref('');
|
||||
const lastMessage = ref('');
|
||||
const taskStatus = ref<TaskStatus | ''>('SUBMITTED');
|
||||
const settlementStatus = ref<SettlementStatus | ''>('CONFIRMED');
|
||||
const session = computed(() => ({ token: tokenDraft.value }));
|
||||
const loading = reactive({ tasks: false, settlements: false });
|
||||
const tasks = reactive<PageResult<CleaningTask>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
const settlements = reactive<PageResult<CleaningSettlement>>({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
|
||||
const metrics = computed(() => ({
|
||||
submitted: tasks.items.filter((item) => item.status === 'SUBMITTED').length,
|
||||
completed: tasks.items.filter((item) => item.status === 'COMPLETED').length,
|
||||
confirmed: settlements.items.filter((item) => item.status === 'CONFIRMED').length,
|
||||
amountCents: settlements.items.reduce((sum, item) => sum + Number(item.totalRewardCents || 0), 0)
|
||||
}));
|
||||
|
||||
function saveToken() {
|
||||
localStorage.setItem('qipai.admin.token', tokenDraft.value.trim());
|
||||
ElMessage.success('已保存');
|
||||
}
|
||||
|
||||
async function runAction(work: () => Promise<void>, success: string) {
|
||||
lastError.value = '';
|
||||
lastMessage.value = '';
|
||||
try {
|
||||
await work();
|
||||
lastMessage.value = success;
|
||||
ElMessage.success(success);
|
||||
} catch (error) {
|
||||
lastError.value = error instanceof ApiError
|
||||
? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}`
|
||||
: error instanceof Error ? error.message : '操作失败';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
loading.tasks = true;
|
||||
await runAction(async () => {
|
||||
const result = await listCleaningTasks(session.value, {
|
||||
page: tasks.page,
|
||||
pageSize: tasks.pageSize,
|
||||
status: taskStatus.value || undefined
|
||||
});
|
||||
Object.assign(tasks, result);
|
||||
}, '任务已刷新');
|
||||
loading.tasks = false;
|
||||
}
|
||||
|
||||
async function loadSettlements() {
|
||||
loading.settlements = true;
|
||||
await runAction(async () => {
|
||||
const result = await listCleaningSettlements(session.value, {
|
||||
page: settlements.page,
|
||||
pageSize: settlements.pageSize,
|
||||
status: settlementStatus.value || undefined
|
||||
});
|
||||
Object.assign(settlements, result);
|
||||
}, '结算已刷新');
|
||||
loading.settlements = false;
|
||||
}
|
||||
|
||||
function changeTaskStatus(status: TaskStatus | '') {
|
||||
taskStatus.value = status;
|
||||
tasks.page = 1;
|
||||
void loadTasks();
|
||||
}
|
||||
|
||||
function changeSettlementStatus(status: SettlementStatus | '') {
|
||||
settlementStatus.value = status;
|
||||
settlements.page = 1;
|
||||
void loadSettlements();
|
||||
}
|
||||
|
||||
function changeTaskPage(page: number) {
|
||||
tasks.page = page;
|
||||
void loadTasks();
|
||||
}
|
||||
|
||||
function changeSettlementPage(page: number) {
|
||||
settlements.page = page;
|
||||
void loadSettlements();
|
||||
}
|
||||
|
||||
async function handleAssign(input: { taskId: string; cleanerUserId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await assignCleaningTask(session.value, input.taskId, input);
|
||||
await loadTasks();
|
||||
}, '已指派');
|
||||
}
|
||||
|
||||
async function handleComplete(input: { taskId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await completeCleaningTask(session.value, input.taskId, input.note);
|
||||
await loadTasks();
|
||||
}, '已验收');
|
||||
}
|
||||
|
||||
async function handleReject(input: { taskId: string; reason: string }) {
|
||||
await runAction(async () => {
|
||||
await rejectCleaningTask(session.value, input.taskId, input.reason);
|
||||
await loadTasks();
|
||||
}, '已驳回');
|
||||
}
|
||||
|
||||
async function handleReclaim(input: { olderThanMinutes: number; limit: number }) {
|
||||
await runAction(async () => {
|
||||
await reclaimCleaningTimeouts(session.value, input);
|
||||
await loadTasks();
|
||||
}, '已回收超时任务');
|
||||
}
|
||||
|
||||
async function handleGenerateSettlement(
|
||||
input: { cleanerUserId: string; storeId?: string; note?: string }
|
||||
) {
|
||||
await runAction(async () => {
|
||||
await generateCleaningSettlement(session.value, input);
|
||||
await Promise.all([loadTasks(), loadSettlements()]);
|
||||
}, '已生成结算单');
|
||||
}
|
||||
|
||||
async function handleConfirmSettlement(input: { settlementId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await confirmCleaningSettlement(session.value, input.settlementId, input.note);
|
||||
await loadSettlements();
|
||||
}, '已确认结算单');
|
||||
}
|
||||
|
||||
async function handleTransfer(input: { settlementId: string; mode: TransferMode; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await executeWechatTransfer(session.value, input.settlementId, {
|
||||
mode: input.mode,
|
||||
note: input.note
|
||||
});
|
||||
await loadSettlements();
|
||||
}, '已提交微信转账');
|
||||
}
|
||||
|
||||
async function handleSyncTransfer(input: { settlementId: string; note?: string }) {
|
||||
await runAction(async () => {
|
||||
await syncWechatTransfer(session.value, input.settlementId, input.note);
|
||||
await loadSettlements();
|
||||
}, '已同步微信状态');
|
||||
}
|
||||
|
||||
async function handleFailure(
|
||||
input: { settlementId: string; error: string; payoutReference?: string; note?: string }
|
||||
) {
|
||||
await runAction(async () => {
|
||||
await recordPayoutFailure(session.value, input.settlementId, {
|
||||
error: input.error,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: input.payoutReference,
|
||||
note: input.note
|
||||
});
|
||||
await loadSettlements();
|
||||
}, '已记录失败');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (session.value.token) {
|
||||
void Promise.all([loadTasks(), loadSettlements()]);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,170 @@
|
||||
import type {
|
||||
CleaningSettlement,
|
||||
CleaningTask,
|
||||
PageResult,
|
||||
SettlementStatus,
|
||||
TaskStatus,
|
||||
TransferMode
|
||||
} from './types';
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_ADMIN_API_BASE_URL || '/admin-api').replace(/\/$/, '');
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message = code,
|
||||
public readonly traceId = ''
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiSession {
|
||||
token: string;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
session: ApiSession,
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
if (!session.token.trim()) {
|
||||
throw new ApiError('AUTH_TOKEN_REQUIRED', '需要先填入后台访问令牌');
|
||||
}
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('authorization', `Bearer ${session.token.trim()}`);
|
||||
if (options.body && !headers.has('content-type')) {
|
||||
headers.set('content-type', 'application/json');
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload.code !== 0) {
|
||||
throw new ApiError(
|
||||
String(payload.code || `HTTP_${response.status}`),
|
||||
String(payload.message || '请求失败'),
|
||||
String(payload.traceId || '')
|
||||
);
|
||||
}
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
export function listCleaningTasks(
|
||||
session: ApiSession,
|
||||
input: { page: number; pageSize: number; status?: TaskStatus }
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(input.page),
|
||||
pageSize: String(input.pageSize)
|
||||
});
|
||||
if (input.status) params.set('status', input.status);
|
||||
return request<PageResult<CleaningTask>>(session, `/cleaning/tasks?${params}`);
|
||||
}
|
||||
|
||||
export function assignCleaningTask(
|
||||
session: ApiSession,
|
||||
taskId: string,
|
||||
input: { cleanerUserId: string; note?: string }
|
||||
) {
|
||||
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function completeCleaningTask(session: ApiSession, taskId: string, note?: string) {
|
||||
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note })
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectCleaningTask(session: ApiSession, taskId: string, reason: string) {
|
||||
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
}
|
||||
|
||||
export function reclaimCleaningTimeouts(
|
||||
session: ApiSession,
|
||||
input: { olderThanMinutes: number; limit: number }
|
||||
) {
|
||||
return request<{ reclaimed: number; taskIds: string[] }>(session, '/cleaning/reclaim-timeouts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function listCleaningSettlements(
|
||||
session: ApiSession,
|
||||
input: { page: number; pageSize: number; status?: SettlementStatus }
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(input.page),
|
||||
pageSize: String(input.pageSize)
|
||||
});
|
||||
if (input.status) params.set('status', input.status);
|
||||
return request<PageResult<CleaningSettlement>>(session, `/cleaning/settlements?${params}`);
|
||||
}
|
||||
|
||||
export function generateCleaningSettlement(
|
||||
session: ApiSession,
|
||||
input: { cleanerUserId: string; storeId?: string; note?: string }
|
||||
) {
|
||||
return request<CleaningSettlement>(session, '/cleaning/settlements', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmCleaningSettlement(session: ApiSession, settlementId: string, note?: string) {
|
||||
return request<CleaningSettlement>(
|
||||
session,
|
||||
`/cleaning/settlements/${encodeURIComponent(settlementId)}/confirm`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function executeWechatTransfer(
|
||||
session: ApiSession,
|
||||
settlementId: string,
|
||||
input: { mode: TransferMode; note?: string }
|
||||
) {
|
||||
return request<{ settlement: CleaningSettlement; transferState: string; idempotent: boolean }>(
|
||||
session,
|
||||
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function syncWechatTransfer(session: ApiSession, settlementId: string, note?: string) {
|
||||
return request<{ settlement: CleaningSettlement; transferState: string; idempotent: boolean }>(
|
||||
session,
|
||||
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer/sync`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function recordPayoutFailure(
|
||||
session: ApiSession,
|
||||
settlementId: string,
|
||||
input: { error: string; payoutChannel?: string; payoutReference?: string; note?: string }
|
||||
) {
|
||||
return request<CleaningSettlement>(
|
||||
session,
|
||||
`/cleaning/settlements/${encodeURIComponent(settlementId)}/payout-failure`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-toolbar">
|
||||
<el-segmented
|
||||
:model-value="status"
|
||||
:options="statusOptions"
|
||||
@update:model-value="$emit('status-change', $event as SettlementStatus | '')"
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<el-button :icon="FilePlus2" type="primary" @click="generateDialog.open = true">
|
||||
生成
|
||||
</el-button>
|
||||
<el-tooltip content="刷新结算">
|
||||
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" :loading="loading" class="data-table" row-key="id">
|
||||
<el-table-column prop="settlementNo" label="结算单" min-width="190" fixed>
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.settlementNo }}</strong>
|
||||
<span>{{ row.cleanerName }} · {{ compactText(row.storeName) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="115">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="settlementTag(row.status)" effect="light">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="taskCount" label="任务" width="80" />
|
||||
<el-table-column prop="totalRewardCents" label="金额" width="110">
|
||||
<template #default="{ row }">{{ money(row.totalRewardCents) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payoutState" label="转账状态" width="130">
|
||||
<template #default="{ row }">{{ compactText(row.payoutState) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payoutReference" label="流水" min-width="150">
|
||||
<template #default="{ row }">{{ compactText(row.payoutReference) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payoutError" label="失败原因" min-width="150">
|
||||
<template #default="{ row }">{{ compactText(row.payoutError) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="BadgeCheck"
|
||||
:disabled="row.status !== 'DRAFT'"
|
||||
@click="$emit('confirm', { settlementId: row.id, note: '后台确认结算' })"
|
||||
>
|
||||
确认
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:icon="Send"
|
||||
:disabled="row.status !== 'CONFIRMED'"
|
||||
@click="openTransfer(row)"
|
||||
>
|
||||
转账
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:icon="RefreshCw"
|
||||
:disabled="row.status !== 'CONFIRMED'"
|
||||
@click="$emit('sync-transfer', { settlementId: row.id, note: '后台同步微信转账状态' })"
|
||||
>
|
||||
同步
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
:icon="CircleAlert"
|
||||
:disabled="row.status !== 'CONFIRMED'"
|
||||
@click="openFailure(row)"
|
||||
>
|
||||
失败
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
layout="prev, pager, next, total"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="$emit('page-change', $event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="generateDialog.open" title="生成结算单" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="保洁员用户 ID">
|
||||
<el-input v-model="generateDialog.cleanerUserId" inputmode="numeric" />
|
||||
</el-form-item>
|
||||
<el-form-item label="门店 ID">
|
||||
<el-input v-model="generateDialog.storeId" inputmode="numeric" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="generateDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="generateDialog.open = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitGenerate">生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="transferDialog.open" title="微信转账" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="模式">
|
||||
<el-radio-group v-model="transferDialog.mode">
|
||||
<el-radio-button label="API">API</el-radio-button>
|
||||
<el-radio-button label="MOCK">MOCK</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="transferDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferDialog.open = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitTransfer">发起</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="failureDialog.open" title="记录失败" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="失败原因">
|
||||
<el-input v-model="failureDialog.error" />
|
||||
</el-form-item>
|
||||
<el-form-item label="外部流水">
|
||||
<el-input v-model="failureDialog.payoutReference" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="failureDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="failureDialog.open = false">取消</el-button>
|
||||
<el-button type="danger" @click="submitFailure">记录</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import {
|
||||
BadgeCheck,
|
||||
CircleAlert,
|
||||
FilePlus2,
|
||||
RefreshCw,
|
||||
Send
|
||||
} from '@lucide/vue';
|
||||
import { compactText, money, shortDate } from '../format';
|
||||
import type {
|
||||
CleaningSettlement,
|
||||
PageResult,
|
||||
SettlementStatus,
|
||||
TransferMode
|
||||
} from '../types';
|
||||
|
||||
defineProps<{
|
||||
session: { token: string };
|
||||
loading: boolean;
|
||||
items: PageResult<CleaningSettlement>['items'];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status: SettlementStatus | '';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
'status-change': [SettlementStatus | ''];
|
||||
'page-change': [number];
|
||||
generate: [{ cleanerUserId: string; storeId?: string; note?: string }];
|
||||
confirm: [{ settlementId: string; note?: string }];
|
||||
transfer: [{ settlementId: string; mode: TransferMode; note?: string }];
|
||||
'sync-transfer': [{ settlementId: string; note?: string }];
|
||||
failure: [{ settlementId: string; error: string; payoutReference?: string; note?: string }];
|
||||
}>();
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
{ label: '待发', value: 'CONFIRMED' },
|
||||
{ label: '已发', value: 'PAID' },
|
||||
{ label: '取消', value: 'CANCELLED' }
|
||||
];
|
||||
const generateDialog = reactive({ open: false, cleanerUserId: '', storeId: '', note: '' });
|
||||
const transferDialog = reactive({
|
||||
open: false,
|
||||
settlementId: '',
|
||||
mode: 'API' as TransferMode,
|
||||
note: ''
|
||||
});
|
||||
const failureDialog = reactive({
|
||||
open: false,
|
||||
settlementId: '',
|
||||
error: '',
|
||||
payoutReference: '',
|
||||
note: ''
|
||||
});
|
||||
|
||||
function settlementTag(status: SettlementStatus) {
|
||||
if (status === 'PAID') return 'success';
|
||||
if (status === 'CONFIRMED') return 'warning';
|
||||
if (status === 'CANCELLED') return 'info';
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
function submitGenerate() {
|
||||
emit('generate', {
|
||||
cleanerUserId: generateDialog.cleanerUserId,
|
||||
storeId: generateDialog.storeId || undefined,
|
||||
note: generateDialog.note
|
||||
});
|
||||
generateDialog.open = false;
|
||||
}
|
||||
|
||||
function openTransfer(row: CleaningSettlement) {
|
||||
transferDialog.open = true;
|
||||
transferDialog.settlementId = row.id;
|
||||
transferDialog.mode = 'API';
|
||||
transferDialog.note = '';
|
||||
}
|
||||
|
||||
function submitTransfer() {
|
||||
emit('transfer', {
|
||||
settlementId: transferDialog.settlementId,
|
||||
mode: transferDialog.mode,
|
||||
note: transferDialog.note
|
||||
});
|
||||
transferDialog.open = false;
|
||||
}
|
||||
|
||||
function openFailure(row: CleaningSettlement) {
|
||||
failureDialog.open = true;
|
||||
failureDialog.settlementId = row.id;
|
||||
failureDialog.error = row.payoutError || '';
|
||||
failureDialog.payoutReference = row.payoutReference || '';
|
||||
failureDialog.note = '';
|
||||
}
|
||||
|
||||
function submitFailure() {
|
||||
emit('failure', {
|
||||
settlementId: failureDialog.settlementId,
|
||||
error: failureDialog.error,
|
||||
payoutReference: failureDialog.payoutReference,
|
||||
note: failureDialog.note
|
||||
});
|
||||
failureDialog.open = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-toolbar">
|
||||
<el-segmented
|
||||
:model-value="status"
|
||||
:options="statusOptions"
|
||||
@update:model-value="$emit('status-change', $event as TaskStatus | '')"
|
||||
/>
|
||||
<div class="toolbar-actions">
|
||||
<el-popover trigger="click" width="280">
|
||||
<template #reference>
|
||||
<el-button :icon="RotateCcw">回收</el-button>
|
||||
</template>
|
||||
<el-form label-position="top" class="compact-form">
|
||||
<el-form-item label="分钟">
|
||||
<el-input-number v-model="reclaimForm.olderThanMinutes" :min="5" :max="1440" />
|
||||
</el-form-item>
|
||||
<el-form-item label="数量">
|
||||
<el-input-number v-model="reclaimForm.limit" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :icon="Check" @click="$emit('reclaim', reclaimForm)">
|
||||
执行
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-popover>
|
||||
<el-tooltip content="刷新任务">
|
||||
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" :loading="loading" class="data-table" row-key="id">
|
||||
<el-table-column prop="taskNo" label="任务" min-width="180" fixed>
|
||||
<template #default="{ row }">
|
||||
<div class="stack">
|
||||
<strong>{{ row.taskNo }}</strong>
|
||||
<span>{{ row.storeName }} · {{ row.roomName || row.roomNo }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="taskTag(row.status)" effect="light">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cleanerUserId" label="保洁员" width="110">
|
||||
<template #default="{ row }">{{ compactText(row.cleanerUserId) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="memberCount" label="成员" width="90" />
|
||||
<el-table-column prop="rewardCents" label="奖励" width="110">
|
||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="submittedAt" label="提交" width="130">
|
||||
<template #default="{ row }">{{ shortDate(row.submittedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="照片" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-badge :value="row.photoUrls?.length || 0" :hidden="!row.photoUrls?.length">
|
||||
<Image :size="18" />
|
||||
</el-badge>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="270" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button size="small" :icon="UserPlus" @click="openAssign(row)">指派</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="success"
|
||||
:icon="BadgeCheck"
|
||||
:disabled="row.status !== 'SUBMITTED'"
|
||||
@click="$emit('complete', { taskId: row.id, note: '后台验收通过' })"
|
||||
>
|
||||
验收
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
:icon="Undo2"
|
||||
:disabled="row.status !== 'SUBMITTED'"
|
||||
@click="openReject(row)"
|
||||
>
|
||||
驳回
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
layout="prev, pager, next, total"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
@current-change="$emit('page-change', $event)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="assignDialog.open" title="指派保洁员" width="420px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="保洁员用户 ID">
|
||||
<el-input v-model="assignDialog.cleanerUserId" inputmode="numeric" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="assignDialog.note" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialog.open = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitAssign">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="rejectDialog.open" title="驳回任务" width="420px">
|
||||
<el-input v-model="rejectDialog.reason" type="textarea" :rows="4" maxlength="512" show-word-limit />
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialog.open = false">取消</el-button>
|
||||
<el-button type="danger" @click="submitReject">驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import {
|
||||
BadgeCheck,
|
||||
Check,
|
||||
Image,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
UserPlus
|
||||
} from '@lucide/vue';
|
||||
import { compactText, money, shortDate } from '../format';
|
||||
import type { CleaningTask, PageResult, TaskStatus } from '../types';
|
||||
|
||||
defineProps<{
|
||||
session: { token: string };
|
||||
loading: boolean;
|
||||
items: PageResult<CleaningTask>['items'];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status: TaskStatus | '';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
'status-change': [TaskStatus | ''];
|
||||
'page-change': [number];
|
||||
assign: [{ taskId: string; cleanerUserId: string; note?: string }];
|
||||
complete: [{ taskId: string; note?: string }];
|
||||
reject: [{ taskId: string; reason: string }];
|
||||
reclaim: [{ olderThanMinutes: number; limit: number }];
|
||||
}>();
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待抢', value: 'WAITING' },
|
||||
{ label: '已抢', value: 'CLAIMED' },
|
||||
{ label: '进行', value: 'STARTED' },
|
||||
{ label: '待验', value: 'SUBMITTED' },
|
||||
{ label: '完成', value: 'COMPLETED' },
|
||||
{ label: '驳回', value: 'REJECTED' }
|
||||
];
|
||||
const reclaimForm = reactive({ olderThanMinutes: 60, limit: 20 });
|
||||
const assignDialog = reactive({ open: false, taskId: '', cleanerUserId: '', note: '' });
|
||||
const rejectDialog = reactive({ open: false, taskId: '', reason: '' });
|
||||
|
||||
function taskTag(status: TaskStatus) {
|
||||
if (status === 'SUBMITTED') return 'warning';
|
||||
if (status === 'COMPLETED' || status === 'SETTLED') return 'success';
|
||||
if (status === 'REJECTED') return 'danger';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function openAssign(row: CleaningTask) {
|
||||
assignDialog.open = true;
|
||||
assignDialog.taskId = row.id;
|
||||
assignDialog.cleanerUserId = row.cleanerUserId || '';
|
||||
assignDialog.note = '';
|
||||
}
|
||||
|
||||
function submitAssign() {
|
||||
emit('assign', {
|
||||
taskId: assignDialog.taskId,
|
||||
cleanerUserId: assignDialog.cleanerUserId,
|
||||
note: assignDialog.note
|
||||
});
|
||||
assignDialog.open = false;
|
||||
}
|
||||
|
||||
function openReject(row: CleaningTask) {
|
||||
rejectDialog.open = true;
|
||||
rejectDialog.taskId = row.id;
|
||||
rejectDialog.reason = row.rejectReason || '';
|
||||
}
|
||||
|
||||
function submitReject() {
|
||||
emit('reject', { taskId: rejectDialog.taskId, reason: rejectDialog.reason });
|
||||
rejectDialog.open = false;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
export function money(cents: number) {
|
||||
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function shortDate(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
export function compactText(value?: string | null) {
|
||||
return value && value.trim().length > 0 ? value : '-';
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
import './styles.css';
|
||||
import App from './App.vue';
|
||||
|
||||
createApp(App).use(ElementPlus).mount('#app');
|
||||
@@ -0,0 +1,299 @@
|
||||
:root {
|
||||
color: #18212f;
|
||||
background: #eef2f6;
|
||||
font-family:
|
||||
Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245, 247, 250, 0.96), rgba(231, 237, 243, 0.96)),
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cg fill='none' stroke='%23d5dde7' stroke-width='1'%3E%3Cpath d='M0 40h120M0 80h120M40 0v120M80 0v120'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #152033;
|
||||
color: #f8fafc;
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
border-radius: 8px;
|
||||
background: #e8f1ff;
|
||||
color: #152033;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand h1,
|
||||
.brand p,
|
||||
.workspace h2,
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand p {
|
||||
margin-top: 3px;
|
||||
color: #aab7c8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #cbd6e5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #e8f1ff;
|
||||
color: #162033;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-item:disabled {
|
||||
color: #6f7d90;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #4d6b92;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.workspace h2 {
|
||||
margin-top: 4px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.token-box {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 360px) 40px;
|
||||
gap: 8px;
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 20px 0 14px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
min-height: 82px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
display: block;
|
||||
color: #60708a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.alert-line {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.work-tabs {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid #e6ebf2;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.toolbar-actions,
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stack strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stack span {
|
||||
color: #69788c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid #e6ebf2;
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.el-tabs__nav-wrap::after {
|
||||
height: 1px;
|
||||
background: #d8e0ea;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: max-content;
|
||||
align-content: center;
|
||||
margin-top: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.token-box {
|
||||
grid-template-columns: minmax(0, 1fr) 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type TaskStatus =
|
||||
| 'WAITING'
|
||||
| 'CLAIMED'
|
||||
| 'STARTED'
|
||||
| 'SUBMITTED'
|
||||
| 'COMPLETED'
|
||||
| 'REJECTED'
|
||||
| 'EXEMPT'
|
||||
| 'SETTLED'
|
||||
| 'CANCELLED';
|
||||
|
||||
export type SettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
||||
export type TransferMode = 'API' | 'MOCK';
|
||||
|
||||
export interface PageResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CleaningTask {
|
||||
id: string;
|
||||
taskNo: string;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
roomNo: string;
|
||||
orderId: string | null;
|
||||
orderNo: string | null;
|
||||
status: TaskStatus;
|
||||
cleanerUserId: string | null;
|
||||
priority: number;
|
||||
rewardCents: number;
|
||||
requirement: string;
|
||||
photoUrls: string[];
|
||||
rejectReason: string;
|
||||
claimedAt?: string;
|
||||
startedAt?: string;
|
||||
submittedAt?: string;
|
||||
completedAt?: string;
|
||||
memberCount: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CleaningSettlement {
|
||||
id: string;
|
||||
settlementNo: string;
|
||||
cleanerUserId: string;
|
||||
cleanerName: string;
|
||||
storeId: string | null;
|
||||
storeName: string | null;
|
||||
status: SettlementStatus;
|
||||
taskCount: number;
|
||||
totalRewardCents: number;
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
paidBy: string | null;
|
||||
confirmedAt: string | null;
|
||||
paidAt: string | null;
|
||||
payoutChannel: string;
|
||||
payoutReference: string;
|
||||
payoutState: string;
|
||||
payoutPackageInfo: string;
|
||||
payoutError: string;
|
||||
note: string;
|
||||
createdAt: string;
|
||||
}
|
||||
Reference in New Issue
Block a user