feat(M08-B): 建立保洁管理后台
This commit is contained in:
@@ -19,7 +19,7 @@
|
|||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- `backend/`:Fastify + TypeScript 后端 API;若实际后端位于仓库根目录,以 `docs/repository-map.md` 为准
|
- `backend/`:Fastify + TypeScript 后端 API;若实际后端位于仓库根目录,以 `docs/repository-map.md` 为准
|
||||||
- `admin/`:Vue3 后台管理端,必须适配桌面、平板和手机
|
- `admin/`:Vue3 后台管理端,已接入 M08-B 保洁运营工作台,必须适配桌面、平板和手机
|
||||||
- `miniapp/`:微信原生小程序
|
- `miniapp/`:微信原生小程序
|
||||||
- `database/`:迁移、种子和兼容 SQL
|
- `database/`:迁移、种子和兼容 SQL
|
||||||
- `deploy/`:Nginx、PM2、EMQX 模板与部署版本
|
- `deploy/`:Nginx、PM2、EMQX 模板与部署版本
|
||||||
@@ -121,8 +121,8 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
|
|||||||
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。
|
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。
|
||||||
|
|
||||||
- 当前执行游标:`M08-B`(PARTIAL)
|
- 当前执行游标:`M08-B`(PARTIAL)
|
||||||
- 最近工程提交:本轮 `M08-B` 微信转账回调与轮询增量,补保洁结算微信商家转账主动同步、通知回调验签解密和状态写回。
|
- 最近工程提交:`a1bace2`,上一轮 `M08-B` 微信转账回调与轮询增量;本轮正在推进保洁管理后台增量。
|
||||||
- 下一工程目标:继续 M08-B,补真实商户场景报备联调准备和管理界面。
|
- 下一工程目标:继续 M08-B,补真实商户场景报备联调准备和更完整管理界面。
|
||||||
|
|
||||||
## 版本递进
|
## 版本递进
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_ADMIN_API_BASE_URL=https://api.txyundm.cn/admin-api
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>自助棋牌室后台</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1582
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@qipai/admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lucide/vue": "^1.22.0",
|
||||||
|
"element-plus": "^2.10.4",
|
||||||
|
"vue": "^3.5.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^6.4.3",
|
||||||
|
"vue-tsc": "^2.2.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: '/admin/',
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
vue: ['vue'],
|
||||||
|
element: ['element-plus'],
|
||||||
|
icons: ['@lucide/vue']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/admin-api': {
|
||||||
|
target: 'http://127.0.0.1:3001',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
| 总纲版本 | V5.4 已成为当前权威总纲,旧版已保留为历史备份 | 根目录存在 `V5.4.md` 及旧版总纲 | 可继续按 V5.4 的执行游标开发 |
|
| 总纲版本 | V5.4 已成为当前权威总纲,旧版已保留为历史备份 | 根目录存在 `V5.4.md` 及旧版总纲 | 可继续按 V5.4 的执行游标开发 |
|
||||||
| Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main` 为 `0 0` | 本地与远端同步 |
|
| Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main` 为 `0 0` | 本地与远端同步 |
|
||||||
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、微信身份/会话,以及六角色 RBAC 和门店范围 | `backend/src/auth/**`、`database/migrations/2026061805_m02c_rbac.*.sql`、`backend/tests/**` | M01、M02-A/B/C DONE;MySQL 已验证 capability 和跨租户门店授权拒绝;下一阶段 M02-D |
|
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、微信身份/会话,以及六角色 RBAC 和门店范围 | `backend/src/auth/**`、`database/migrations/2026061805_m02c_rbac.*.sql`、`backend/tests/**` | M01、M02-A/B/C DONE;MySQL 已验证 capability 和跨租户门店授权拒绝;下一阶段 M02-D |
|
||||||
| 后台管理端 | 仅有 `admin/.gitkeep` | 当前文件扫描 | M09 未开始,不能标记 DONE |
|
| 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 工程,并接入 M08-B 保洁任务与结算管理起步界面 | `admin/src/App.vue`、`admin/src/components/CleaningTasksPanel.vue`、`admin/src/components/CleaningSettlementsPanel.vue`、`scripts/check-admin-m08-b.mjs` | M08-B 后台起点已具备;M09 完整后台仍未开始,不能标记 DONE |
|
||||||
| 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细、下单抵扣主体链路和保洁任务端照片上传/补做主体链路 | `miniapp/pages/index`、`miniapp/pages/store/detail`、`miniapp/pages/room/detail`、`miniapp/pages/orders/*`、`miniapp/pages/recharge/*`、`miniapp/pages/profile/*`、`miniapp/pages/cleaner/*`、`miniapp/utils/api.js`、`scripts/check-miniapp-m08-a.mjs` | M08 PARTIAL;M08-A 顾客端工程闭环按外部阻塞旁路,M08-B 已支持保洁任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理侧查询/指派/验收/驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、发放记录、微信转账主动同步和通知回调,仍不能标记 DONE |
|
| 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细、下单抵扣主体链路和保洁任务端照片上传/补做主体链路 | `miniapp/pages/index`、`miniapp/pages/store/detail`、`miniapp/pages/room/detail`、`miniapp/pages/orders/*`、`miniapp/pages/recharge/*`、`miniapp/pages/profile/*`、`miniapp/pages/cleaner/*`、`miniapp/utils/api.js`、`scripts/check-miniapp-m08-a.mjs` | M08 PARTIAL;M08-A 顾客端工程闭环按外部阻塞旁路,M08-B 已支持保洁任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理侧查询/指派/验收/驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、发放记录、微信转账主动同步、通知回调和后台起步管理界面,仍不能标记 DONE |
|
||||||
| 数据库迁移 | 已新增 M01-B 核心 schema up/down/verify SQL、最小脱敏 seed、旧库结构 fixture、迁移计划/执行 CLI、验证结果数量门禁和 MySQL 8 集成测试 | `database/migrations/2026061601_m01b_core_schema.*.sql`、`database/seeds/2026061601_m01b_minimal_seed.sql`、`database/fixtures/2026061801_m01b_legacy_schema.sql`、`backend/src/db/migration-runner.ts`、`backend/tests/mysql-migration-roundtrip.test.mjs` | M01-B DONE;WSL MySQL 8.4.9 已验证 8 条旧库 fixture、11 条 up、3 条 verify、10 条 down,并验证租户隔离和旧金额整数分转换;不导入真实旧数据 |
|
| 数据库迁移 | 已新增 M01-B 核心 schema up/down/verify SQL、最小脱敏 seed、旧库结构 fixture、迁移计划/执行 CLI、验证结果数量门禁和 MySQL 8 集成测试 | `database/migrations/2026061601_m01b_core_schema.*.sql`、`database/seeds/2026061601_m01b_minimal_seed.sql`、`database/fixtures/2026061801_m01b_legacy_schema.sql`、`backend/src/db/migration-runner.ts`、`backend/tests/mysql-migration-roundtrip.test.mjs` | M01-B DONE;WSL MySQL 8.4.9 已验证 8 条旧库 fixture、11 条 up、3 条 verify、10 条 down,并验证租户隔离和旧金额整数分转换;不导入真实旧数据 |
|
||||||
| M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh`、`scripts/setup/`、`deploy/` | PARTIAL,生产未执行 |
|
| M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh`、`scripts/setup/`、`deploy/` | PARTIAL,生产未执行 |
|
||||||
| 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .`、`scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 |
|
| 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .`、`scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 |
|
||||||
@@ -35,6 +35,6 @@
|
|||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
1. 继续 M08-B:在已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理侧查询/指派/验收/驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、微信转账主动同步和通知回调基础上,补真实商户场景报备联调准备和管理界面。
|
1. 继续 M08-B:在已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理侧查询/指派/验收/驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、微信转账主动同步、通知回调和后台起步管理界面基础上,补真实商户场景报备联调准备和更完整管理界面。
|
||||||
2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序。
|
2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序。
|
||||||
3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。
|
3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。
|
||||||
|
|||||||
@@ -66,6 +66,14 @@
|
|||||||
- 新增 `POST /admin-api/cleaning/settlements/:settlementId/wechat-transfer/sync`,用于后台手动同步或后续定时轮询调用。
|
- 新增 `POST /admin-api/cleaning/settlements/:settlementId/wechat-transfer/sync`,用于后台手动同步或后续定时轮询调用。
|
||||||
- 新增 `POST /app-api/cleaning/wechat-transfer/notify`,免登录接收微信转账回调,使用流式 `preParsing` 捕获原始 body,避免验签因 JSON 重序列化失效。
|
- 新增 `POST /app-api/cleaning/wechat-transfer/notify`,免登录接收微信转账回调,使用流式 `preParsing` 捕获原始 body,避免验签因 JSON 重序列化失效。
|
||||||
|
|
||||||
|
## 2026-06-29 保洁管理后台增量
|
||||||
|
|
||||||
|
- `admin/` 从空目录升级为 Vue3 + Vite + TypeScript + Element Plus 后台工程,构建基址固定为 `/admin/`,生产后台 API 固定为 `https://api.txyundm.cn/admin-api`。
|
||||||
|
- 新增保洁运营首屏,支持后台访问令牌本地保存、任务/结算双标签、待验收/完成/待发放/本页金额概览,并适配桌面、平板和手机浏览器。
|
||||||
|
- 新增保洁任务管理面板:按状态筛选任务,查看门店/房间/保洁员/成员/奖励/照片数量,支持指派、验收、驳回和超时回收。
|
||||||
|
- 新增保洁结算管理面板:按状态筛选结算单,支持生成结算单、确认结算、发起微信转账、同步微信转账状态和记录发放失败。
|
||||||
|
- 新增 `scripts/check-admin-m08-b.mjs` 并接入 Windows `test-all.ps1`,静态检查后台依赖、固定域名、构建基址、M08-B API 封装和关键控件。
|
||||||
|
|
||||||
## 验收结果
|
## 验收结果
|
||||||
|
|
||||||
- `npm run build`(`backend/`):PASS。
|
- `npm run build`(`backend/`):PASS。
|
||||||
@@ -102,14 +110,17 @@
|
|||||||
- 微信转账回调与轮询增量后 `node tests/cleaning-payout-service.test.mjs`:PASS,覆盖主动同步成功/失败/处理中和微信通知成功落库。
|
- 微信转账回调与轮询增量后 `node tests/cleaning-payout-service.test.mjs`:PASS,覆盖主动同步成功/失败/处理中和微信通知成功落库。
|
||||||
- 微信转账回调与轮询增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖后台同步路由和免登录微信通知路由。
|
- 微信转账回调与轮询增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖后台同步路由和免登录微信通知路由。
|
||||||
- 微信转账回调与轮询增量后 `npm test`(`backend/`):PASS。
|
- 微信转账回调与轮询增量后 `npm test`(`backend/`):PASS。
|
||||||
|
- 保洁管理后台增量后 `npm run build`(`admin/`):PASS。
|
||||||
|
- 保洁管理后台增量后 `npm audit --omit=dev`(`admin/`):PASS,0 vulnerabilities。
|
||||||
|
- 保洁管理后台增量后 `node scripts/check-admin-m08-b.mjs`:PASS。
|
||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
M08-B 保持 `PARTIAL`。当前已完成保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员任务查询、指派、验收、驳回、超时回收、多人协作分账、待结算候选、正式结算单生成、确认、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调和非生产 Mock 门禁;真实商户场景报备联调和后台/小程序管理界面仍待继续。M08-A 的真机合法域名、真实微信支付和实物开门验证按外部条件阻塞保留,不阻断 M08-B 工程推进。
|
M08-B 保持 `PARTIAL`。当前已完成保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员任务查询、指派、验收、驳回、超时回收、多人协作分账、待结算候选、正式结算单生成、确认、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、非生产 Mock 门禁和保洁管理后台起步界面;真实商户场景报备联调和更完整的后台/小程序管理界面仍待继续。M08-A 的真机合法域名、真实微信支付和实物开门验证按外部条件阻塞保留,不阻断 M08-B 工程推进。
|
||||||
|
|
||||||
## 提交
|
## 提交
|
||||||
|
|
||||||
- 工程提交:`M08-B微信转账回调与轮询增量`
|
- 工程提交:`M08-B保洁管理后台增量`
|
||||||
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
- 远端校验:待本轮 push 后执行 `HEAD == origin/main`。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,14 @@
|
|||||||
| OPS-003 | 整仓发布清单 | M00-E/M10 | PARTIAL | `59d92c0` | `deploy-business.sh --dry-run` 可在 Windows/WSL 验证当前 HEAD 的 release manifest 结构;`check-release-manifest.ps1` 已接入 `test-all.ps1`;正式构建待项目生成。 | 后端/后台尚未生成,生产菜单未执行。 | M01/M09 后接入真实构建结果,并在生产菜单 2 生成真实 release manifest。 |
|
| OPS-003 | 整仓发布清单 | M00-E/M10 | PARTIAL | `59d92c0` | `deploy-business.sh --dry-run` 可在 Windows/WSL 验证当前 HEAD 的 release manifest 结构;`check-release-manifest.ps1` 已接入 `test-all.ps1`;正式构建待项目生成。 | 后端/后台尚未生成,生产菜单未执行。 | M01/M09 后接入真实构建结果,并在生产菜单 2 生成真实 release manifest。 |
|
||||||
| OPS-004 | 菜单式更新与环境监测 | M00-E/M10 | PARTIAL | `292ab7f` | `setup.sh` 已接入初始化、更新、MQTT、HTTPS、状态、备份、恢复、回滚和诊断菜单;`--backup-status` 已在 WSL 验证,可检查备份工具、目录和模板。 | 未在生产 Ubuntu 执行;真实备份配置未启用。 | 生产执行后补部署记录、真实备份和恢复演练记录。 |
|
| OPS-004 | 菜单式更新与环境监测 | M00-E/M10 | PARTIAL | `292ab7f` | `setup.sh` 已接入初始化、更新、MQTT、HTTPS、状态、备份、恢复、回滚和诊断菜单;`--backup-status` 已在 WSL 验证,可检查备份工具、目录和模板。 | 未在生产 Ubuntu 执行;真实备份配置未启用。 | 生产执行后补部署记录、真实备份和恢复演练记录。 |
|
||||||
| IOT-001 | MQTT Broker 生产部署 | M00/M06 | PARTIAL | `d01f771` | 菜单 3 已支持 Ubuntu 24.04 amd64 原生安装/更新与复检;正式 Topic ACL 已固定;后端实现 MQTT.js 3.1、QoS 1、持久会话、自动重连、订阅恢复、健康检查、消息上限和真实硬件烟测运行器;Windows 全量测试通过,WSL EMQX 5.8.9 服务级检查通过。 | 缺生产 EMQX 安装、正式账号、ACL/端口/备份验收;WSL 本地账号未配置,真实硬件烟测按预期 SKIP。 | 由管理员在生产菜单 3 执行并登记结果;提供生产账号、DeviceID 和实物后执行 `node dist/devices/hardware-smoke-runner.js`。 |
|
| IOT-001 | MQTT Broker 生产部署 | M00/M06 | PARTIAL | `d01f771` | 菜单 3 已支持 Ubuntu 24.04 amd64 原生安装/更新与复检;正式 Topic ACL 已固定;后端实现 MQTT.js 3.1、QoS 1、持久会话、自动重连、订阅恢复、健康检查、消息上限和真实硬件烟测运行器;Windows 全量测试通过,WSL EMQX 5.8.9 服务级检查通过。 | 缺生产 EMQX 安装、正式账号、ACL/端口/备份验收;WSL 本地账号未配置,真实硬件烟测按预期 SKIP。 | 由管理员在生产菜单 3 执行并登记结果;提供生产账号、DeviceID 和实物后执行 `node dist/devices/hardware-smoke-runner.js`。 |
|
||||||
| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `M08-B微信转账回调与轮询增量` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、余额支付、优惠券/套餐下单抵扣参数与可视化选择器、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细,以及保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做入口、统计和管理侧验收/驳回/指派/结算/多人协作/发放 API;后端已接入微信商家转账适配、转账中间态落库、主动轮询同步、微信转账通知回调和非生产 Mock 门禁;`npm test`、相关会员/权益/充值/保洁测试通过。 | M08-A 真机联调缺微信后台/商户/硬件条件;M08-B 真实商户场景报备联调和管理界面待继续。 | 继续 M08-B,补真实商户场景报备联调准备和管理界面。 |
|
| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `M08-B保洁管理后台增量` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、余额支付、优惠券/套餐下单抵扣参数与可视化选择器、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细,以及保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做入口、统计和管理侧验收/驳回/指派/结算/多人协作/发放 API;后端已接入微信商家转账适配、转账中间态落库、主动轮询同步、微信转账通知回调和非生产 Mock 门禁;后台已建立保洁运营工作台起步界面;`npm test`、`admin npm run build`、相关会员/权益/充值/保洁测试通过。 | M08-A 真机联调缺微信后台/商户/硬件条件;M08-B 真实商户场景报备联调和更完整管理界面待继续。 | 继续 M08-B,补真实商户场景报备联调准备和管理界面。 |
|
||||||
| CFG-001 | 小程序品牌自定义 | M02-A/M03/M08 | PARTIAL | `8b8fb28` | 已建立 AppID/tenant 品牌配置表和 `/app-api/bootstrap`;同一 AppID 两租户隔离读取在 MySQL 8.4.9 实测通过。 | Logo/分享图片上传与小程序实际展示属于后续阶段。 | M03/M08 接入配置管理和小程序展示。 |
|
| CFG-001 | 小程序品牌自定义 | M02-A/M03/M08 | PARTIAL | `8b8fb28` | 已建立 AppID/tenant 品牌配置表和 `/app-api/bootstrap`;同一 AppID 两租户隔离读取在 MySQL 8.4.9 实测通过。 | Logo/分享图片上传与小程序实际展示属于后续阶段。 | M03/M08 接入配置管理和小程序展示。 |
|
||||||
| BKG-010 | 后台多小程序管理 | M02-A/M08/M10 | PARTIAL | `8b8fb28` | 已建立逻辑应用、租户绑定和租户配置模型;多租户 AppID 未指定租户会被拒绝。 | 尚无后台管理页面,多 AppID 真机配置未验证。 | M02-B/C 完成身份权限,M08 增加管理页面。 |
|
| BKG-010 | 后台多小程序管理 | M02-A/M08/M10 | PARTIAL | `8b8fb28` | 已建立逻辑应用、租户绑定和租户配置模型;多租户 AppID 未指定租户会被拒绝。 | 尚无后台管理页面,多 AppID 真机配置未验证。 | M02-B/C 完成身份权限,M08 增加管理页面。 |
|
||||||
| AUTH-001 | 多端统一小程序身份 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已实现微信 code 交换适配器、租户/AppID 隔离身份、JWT、服务端会话、当前用户和注销;禁用、角色/门店范围变化及管理员重置均使旧会话立即失效。 | 缺真实 AppSecret 真机联调和小程序动态角色界面。 | M08 完成小程序角色界面与真机登录。 |
|
| AUTH-001 | 多端统一小程序身份 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已实现微信 code 交换适配器、租户/AppID 隔离身份、JWT、服务端会话、当前用户和注销;禁用、角色/门店范围变化及管理员重置均使旧会话立即失效。 | 缺真实 AppSecret 真机联调和小程序动态角色界面。 | M08 完成小程序角色界面与真机登录。 |
|
||||||
| AUTH-002 | 多级权限 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已建立六类角色、权限目录、用户角色和门店范围;后台员工 API 同时执行 capability、租户和门店范围校验,越权分配被拒绝并记录审计。 | 后续业务资源接口和前端按钮/菜单仍需逐模块接入。 | M03 起所有管理接口复用 capability 与数据范围;M08 完成前端菜单按钮控制。 |
|
| AUTH-002 | 多级权限 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已建立六类角色、权限目录、用户角色和门店范围;后台员工 API 同时执行 capability、租户和门店范围校验,越权分配被拒绝并记录审计。 | 后续业务资源接口和前端按钮/菜单仍需逐模块接入。 | M03 起所有管理接口复用 capability 与数据范围;M08 完成前端菜单按钮控制。 |
|
||||||
| CLN-001 | 保洁人员管理 | M08-B/M09 | PARTIAL | `2a4dcd0` | `CLEANER` 角色已接入 `cleaning.task.read/write` 和 `cleaning.statistics.read`,保洁接口按登录态、capability 和门店范围裁剪任务大厅。 | 员工授权管理页面和保洁员资料管理仍待管理员端/后台接入。 | 继续 M08-B/M09,补管理员指派和保洁员管理界面。 |
|
| CLN-001 | 保洁人员管理 | M08-B/M09 | PARTIAL | `2a4dcd0` | `CLEANER` 角色已接入 `cleaning.task.read/write` 和 `cleaning.statistics.read`,保洁接口按登录态、capability 和门店范围裁剪任务大厅。 | 员工授权管理页面和保洁员资料管理仍待管理员端/后台接入。 | 继续 M08-B/M09,补管理员指派和保洁员管理界面。 |
|
||||||
| CLN-002 | 保洁任务大厅 | M08-B/M09 | PARTIAL | `M08-B微信转账回调与轮询增量` | 新增 `qipai_cleaning_tasks`、`qipai_cleaning_task_events`、`GET /app-api/cleaning/tasks/hall`、`GET /app-api/cleaning/tasks/mine`、接单、开始、照片上传、提交验收、驳回补做接口;订单 `FINISHED` 时同事务幂等创建 `WAITING` 保洁任务;管理侧已支持任务查询、指派、验收、驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、发放成功标记、发放失败记录、微信转账发放、主动同步和微信通知回调;小程序 `pages/cleaner/tasks` 已调用真实 app-api 并支持相机/相册上传。 | 真实商户场景报备联调和管理界面仍待继续。 | 继续 M08-B,补真实商户场景报备联调准备和管理界面。 |
|
| CLN-002 | 保洁任务大厅 | M08-B/M09 | PARTIAL | `M08-B保洁管理后台增量` | 新增 `qipai_cleaning_tasks`、`qipai_cleaning_task_events`、`GET /app-api/cleaning/tasks/hall`、`GET /app-api/cleaning/tasks/mine`、接单、开始、照片上传、提交验收、驳回补做接口;订单 `FINISHED` 时同事务幂等创建 `WAITING` 保洁任务;管理侧已支持任务查询、指派、验收、驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、发放成功标记、发放失败记录、微信转账发放、主动同步和微信通知回调;小程序 `pages/cleaner/tasks` 已调用真实 app-api 并支持相机/相册上传;后台 `admin/` 已提供任务筛选、指派、验收、驳回和超时回收界面。 | 真实商户场景报备联调和更完整管理界面仍待继续。 | 继续 M08-B,补真实商户场景报备联调准备和管理界面。 |
|
||||||
| CLN-003 | 保洁统计 | M08-B/M09 | PARTIAL | `M08-B微信转账回调与轮询增量` | 新增 `GET /app-api/cleaning/stats`,返回当前保洁员负责人/协作者任务状态计数和按成员分账计算的待结算金额,小程序展示已抢、进行、待验、完成和待结算金额;管理侧新增结算单、结算明细、任务成员底表和发放字段,生成结算时按成员固化奖励金额,发放结果记录渠道、流水、失败原因、微信转账状态、package 信息、主动同步结果和微信回调结果。 | 统计明细、周期筛选、驳回数、结算单反查详情、真实商户场景报备联调和管理界面待继续。 | M08-B/M09-C 补统计明细、结算口径校验、真实商户联调准备和管理界面。 |
|
| CLN-003 | 保洁统计 | M08-B/M09 | PARTIAL | `M08-B保洁管理后台增量` | 新增 `GET /app-api/cleaning/stats`,返回当前保洁员负责人/协作者任务状态计数和按成员分账计算的待结算金额,小程序展示已抢、进行、待验、完成和待结算金额;管理侧新增结算单、结算明细、任务成员底表和发放字段,生成结算时按成员固化奖励金额,发放结果记录渠道、流水、失败原因、微信转账状态、package 信息、主动同步结果和微信回调结果;后台首屏汇总展示待验收、已完成、待发放和本页金额。 | 统计明细、周期筛选、驳回数、结算单反查详情、真实商户场景报备联调和更完整管理界面待继续。 | M08-B/M09-C 补统计明细、结算口径校验、真实商户联调准备和管理界面。 |
|
||||||
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;WSL MySQL 实测权限变化撤销活动会话。 | 后台与管理员小程序页面尚未实现。 | M08 接入管理界面。 |
|
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;WSL MySQL 实测权限变化撤销活动会话。 | 后台与管理员小程序页面尚未实现。 | M08 接入管理界面。 |
|
||||||
| BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现分页筛选 API、注册/登录时间、状态、角色、门店范围以及手机号/IP 脱敏。 | Vue3 后台页面尚未实现。 | M08 实现响应式用户管理页面。 |
|
| BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现分页筛选 API、注册/登录时间、状态、角色、门店范围以及手机号/IP 脱敏。 | Vue3 后台页面尚未实现。 | M08 实现响应式用户管理页面。 |
|
||||||
| TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 |
|
| TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 |
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ execution_cursor:
|
|||||||
stage_status: PARTIAL
|
stage_status: PARTIAL
|
||||||
last_completed_stage: M07-D
|
last_completed_stage: M07-D
|
||||||
next_stage: M08-B
|
next_stage: M08-B
|
||||||
last_engineering_commit: `M08-B微信转账回调与轮询增量`
|
last_engineering_commit: `M08-B保洁管理后台增量`
|
||||||
last_push_verified: true
|
last_push_verified: true
|
||||||
base_branch: main
|
base_branch: main
|
||||||
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 已完成照片上传、驳回补做、订单结束自动建保洁任务、管理员查询/指派/验收/驳回、超时回收、待结算候选、正式结算单生成/确认、多保洁员协作分账策略、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调和非生产 Mock 门禁,仍需继续补真实商户场景报备联调和管理界面。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
|
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 已完成照片上传、驳回补做、订单结束自动建保洁任务、管理员查询/指派/验收/驳回、超时回收、待结算候选、正式结算单生成/确认、多保洁员协作分账策略、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、非生产 Mock 门禁和保洁管理后台起步界面,仍需继续补真实商户场景报备联调和更完整的管理界面。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
|
||||||
updated_at: 2026-06-29T00:00:00+08:00
|
updated_at: 2026-06-29T00:00:00+08:00
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ execution_cursor:
|
|||||||
| M05 会员、余额、套餐、优惠券 | PARTIAL | `1680d73` | docs/devlogs/2026-06-22-M05-D-分账与收款配置.md | M05-A/B/C/D 工程闭环已完成:统一支付、微信支付退款、团购直订、门店收款账户、分账接收方、比例策略、授权门禁、幂等分账和对账查询均通过 Windows 与 WSL MySQL 回归;真实微信/团购/分账权限未提供,模块保持 PARTIAL/BLOCKED_EXTERNAL。 |
|
| M05 会员、余额、套餐、优惠券 | PARTIAL | `1680d73` | docs/devlogs/2026-06-22-M05-D-分账与收款配置.md | M05-A/B/C/D 工程闭环已完成:统一支付、微信支付退款、团购直订、门店收款账户、分账接收方、比例策略、授权门禁、幂等分账和对账查询均通过 Windows 与 WSL MySQL 回归;真实微信/团购/分账权限未提供,模块保持 PARTIAL/BLOCKED_EXTERNAL。 |
|
||||||
| M06 设备、MQTT 与真实硬件联动 | PARTIAL | `d01f771` | docs/devlogs/2026-06-24-M06-G-真实硬件联调.md | M06-A 至 F 已完成 MQTT、拓扑、协议幂等、控制箱、Sub-1G 门锁、智慧插座和订单自动联动任务;M06-G 已补真实硬件烟测运行器,缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 |
|
| M06 设备、MQTT 与真实硬件联动 | PARTIAL | `d01f771` | docs/devlogs/2026-06-24-M06-G-真实硬件联调.md | M06-A 至 F 已完成 MQTT、拓扑、协议幂等、控制箱、Sub-1G 门锁、智慧插座和订单自动联动任务;M06-G 已补真实硬件烟测运行器,缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 |
|
||||||
| M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
|
| M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
|
||||||
| M08 微信原生小程序完整业务 | PARTIAL | `M08-B微信转账回调与轮询增量` | docs/devlogs/2026-06-25-M08-B-保洁端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细、优惠券/套餐/余额下单抵扣工程闭环和权益可视化选择器;真机合法域名、真实微信支付和实物开门验证按 BLOCKED_EXTERNAL 旁路。M08-B 已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员查询、指派、验收、驳回、超时回收、待结算候选、正式结算单生成/确认、多保洁员协作分账策略、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调和非生产 Mock 门禁;真实商户场景报备联调和管理界面待继续。 |
|
| M08 微信原生小程序完整业务 | PARTIAL | `M08-B保洁管理后台增量` | docs/devlogs/2026-06-25-M08-B-保洁端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细、优惠券/套餐/余额下单抵扣工程闭环和权益可视化选择器;真机合法域名、真实微信支付和实物开门验证按 BLOCKED_EXTERNAL 旁路。M08-B 已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员查询、指派、验收、驳回、超时回收、待结算候选、正式结算单生成/确认、多保洁员协作分账策略、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、非生产 Mock 门禁和保洁管理后台起步界面;真实商户场景报备联调和更完整的管理界面待继续。 |
|
||||||
| M09 后台管理端完整业务 | TODO | - | - | - |
|
| M09 后台管理端完整业务 | TODO | - | - | - |
|
||||||
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
|
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const root = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1');
|
||||||
|
const read = (path) => readFileSync(join(root, path), 'utf8');
|
||||||
|
|
||||||
|
const packageJson = JSON.parse(read('admin/package.json'));
|
||||||
|
assert.equal(packageJson.dependencies.vue.startsWith('^3.'), true);
|
||||||
|
assert.ok(packageJson.dependencies['element-plus']);
|
||||||
|
assert.ok(packageJson.dependencies['@lucide/vue']);
|
||||||
|
|
||||||
|
const envExample = read('admin/.env.example');
|
||||||
|
assert.match(envExample, /https:\/\/api\.txyundm\.cn\/admin-api/);
|
||||||
|
assert.doesNotMatch(envExample, /localhost|127\.0\.0\.1|http:\/\//);
|
||||||
|
|
||||||
|
const vite = read('admin/vite.config.ts');
|
||||||
|
assert.match(vite, /base: '\/admin\/'/);
|
||||||
|
assert.match(vite, /'\/admin-api'/);
|
||||||
|
|
||||||
|
const app = read('admin/src/App.vue');
|
||||||
|
for (const pattern of [
|
||||||
|
'CleaningTasksPanel',
|
||||||
|
'CleaningSettlementsPanel',
|
||||||
|
'qipai.admin.token',
|
||||||
|
'loadTasks',
|
||||||
|
'loadSettlements',
|
||||||
|
'handleSyncTransfer'
|
||||||
|
]) {
|
||||||
|
assert.match(app, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = read('admin/src/api.ts');
|
||||||
|
for (const route of [
|
||||||
|
'/cleaning/tasks?',
|
||||||
|
'/assign',
|
||||||
|
'/complete',
|
||||||
|
'/reject',
|
||||||
|
'/cleaning/reclaim-timeouts',
|
||||||
|
'/cleaning/settlements?',
|
||||||
|
'/cleaning/settlements',
|
||||||
|
'/wechat-transfer',
|
||||||
|
'/wechat-transfer/sync',
|
||||||
|
'/payout-failure'
|
||||||
|
]) {
|
||||||
|
assert.match(api, new RegExp(route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = read('admin/src/components/CleaningTasksPanel.vue');
|
||||||
|
for (const pattern of ['指派', '验收', '驳回', '回收', 'photoUrls']) {
|
||||||
|
assert.match(tasks, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const settlements = read('admin/src/components/CleaningSettlementsPanel.vue');
|
||||||
|
for (const pattern of ['生成', '确认', '转账', '同步', '失败', 'MOCK']) {
|
||||||
|
assert.match(settlements, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = read('admin/src/styles.css');
|
||||||
|
assert.match(styles, /@media \(max-width: 980px\)/);
|
||||||
|
assert.match(styles, /@media \(max-width: 560px\)/);
|
||||||
|
|
||||||
|
console.log('PASS: M08-B admin cleaning workspace uses fixed admin API and covers task, settlement and transfer controls.');
|
||||||
@@ -9,6 +9,13 @@ $ErrorActionPreference = "Stop"
|
|||||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-status-docs.ps1
|
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-status-docs.ps1
|
||||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-readme-config.ps1
|
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-readme-config.ps1
|
||||||
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-backend.ps1
|
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-backend.ps1
|
||||||
|
node scripts/check-admin-m08-b.mjs
|
||||||
|
|
||||||
|
if (Test-Path "admin/package.json") {
|
||||||
|
Push-Location admin
|
||||||
|
npm run build
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
|
||||||
if (Test-Path "package.json") {
|
if (Test-Path "package.json") {
|
||||||
npm run lint --if-present
|
npm run lint --if-present
|
||||||
|
|||||||
Reference in New Issue
Block a user