feat(M08-D): 补会员与员工用户管理

This commit is contained in:
Codex
2026-08-10 12:19:24 +08:00
parent c71e130c5c
commit 93f06d2ed6
8 changed files with 313 additions and 3 deletions
+18 -1
View File
@@ -68,6 +68,15 @@
<TicketCheck :size="18" /> <TicketCheck :size="18" />
<span>团购平台</span> <span>团购平台</span>
</button> </button>
<button
class="nav-item"
:class="{ active: activeModule === 'people' }"
type="button"
@click="activeModule = 'people'"
>
<UsersRound :size="18" />
<span>会员员工</span>
</button>
</nav> </nav>
</aside> </aside>
@@ -118,6 +127,11 @@
:session="session" :session="session"
/> />
<MembersStaffPanel
v-else-if="activeModule === 'people'"
:session="session"
/>
<template v-else> <template v-else>
<section class="metric-grid" aria-label="保洁概览"> <section class="metric-grid" aria-label="保洁概览">
<div class="metric"> <div class="metric">
@@ -289,6 +303,7 @@ import {
Sparkles, Sparkles,
Store, Store,
TicketCheck, TicketCheck,
UsersRound,
WalletCards WalletCards
} from '@lucide/vue'; } from '@lucide/vue';
import CleaningTasksPanel from './components/CleaningTasksPanel.vue'; import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
@@ -301,6 +316,7 @@ import StoresRoomsPanel from './components/StoresRoomsPanel.vue';
import OrdersPanel from './components/OrdersPanel.vue'; import OrdersPanel from './components/OrdersPanel.vue';
import PaymentsPanel from './components/PaymentsPanel.vue'; import PaymentsPanel from './components/PaymentsPanel.vue';
import ThirdPartyPanel from './components/ThirdPartyPanel.vue'; import ThirdPartyPanel from './components/ThirdPartyPanel.vue';
import MembersStaffPanel from './components/MembersStaffPanel.vue';
import { import {
ApiError, ApiError,
assignCleaningTask, assignCleaningTask,
@@ -337,7 +353,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || ''); const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
const tokenDraft = ref(savedToken.value); const tokenDraft = ref(savedToken.value);
const activeModule = ref<'overview' | 'stores' | 'orders' | 'payments' | 'thirdParty' | 'cleaning'>('overview'); const activeModule = ref<'overview' | 'stores' | 'orders' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
const activeTab = ref('tasks'); const activeTab = ref('tasks');
const lastError = ref(''); const lastError = ref('');
const lastMessage = ref(''); const lastMessage = ref('');
@@ -352,6 +368,7 @@ const activeModuleMeta = computed(() => ({
orders: { stage: 'M08-D', title: '订单筛选与运营处置' }, orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
payments: { stage: 'M08-D', title: '支付、退款与分账' }, payments: { stage: 'M08-D', title: '支付、退款与分账' },
thirdParty: { stage: 'M08-D', title: '团购与第三方平台运营' }, thirdParty: { stage: 'M08-D', title: '团购与第三方平台运营' },
people: { stage: 'M08-D', title: '会员与员工用户管理' },
cleaning: { stage: 'M08-B', title: '保洁任务与结算' } cleaning: { stage: 'M08-B', title: '保洁任务与结算' }
})[activeModule.value]); })[activeModule.value]);
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false }); const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
+18 -1
View File
@@ -7,6 +7,8 @@ import type {
CleaningTaskMember, CleaningTaskMember,
BusinessStatistics, BusinessStatistics,
ManagedRoom, ManagedRoom,
MemberCard,
MemberDetail,
ManagedOrder, ManagedOrder,
ManagedStore, ManagedStore,
ManagedUser, ManagedUser,
@@ -356,7 +358,8 @@ export function listStaffUsers(
) { ) {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: String(input.page), page: String(input.page),
pageSize: String(input.pageSize) pageSize: String(input.pageSize),
userType: 'STAFF'
}); });
if (input.role) params.set('role', input.role); if (input.role) params.set('role', input.role);
if (input.status) params.set('status', input.status); if (input.status) params.set('status', input.status);
@@ -364,6 +367,20 @@ export function listStaffUsers(
return request<{ items: ManagedUser[]; total: number }>(session, `/users?${params}`); return request<{ items: ManagedUser[]; total: number }>(session, `/users?${params}`);
} }
export function listMembers(
session: ApiSession,
input: { page: number; pageSize: number; status?: UserStatus; search?: string }
) {
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
if (input.status) params.set('status', input.status);
if (input.search) params.set('search', input.search);
return request<{ items: MemberCard[]; total: number }>(session, `/members?${params}`);
}
export function getMember(session: ApiSession, memberId: string) {
return request<MemberDetail>(session, `/members/${encodeURIComponent(memberId)}`);
}
export function createStaffUser( export function createStaffUser(
session: ApiSession, session: ApiSession,
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] } input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
+123
View File
@@ -0,0 +1,123 @@
<template>
<section class="people-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="people-metrics">
<span><small>当前页会员</small><strong>{{ members.items.length }}</strong></span>
<span><small>会员余额</small><strong>{{ money(memberBalance) }}</strong></span>
<span><small>累计消费</small><strong>{{ money(memberSpend) }}</strong></span>
<span><small>员工账号</small><strong>{{ staff.total }}</strong></span>
</section>
<section class="panel">
<header class="panel-toolbar people-toolbar">
<div><p class="section-kicker">M08-D · 用户运营</p><h3>会员与员工用户管理</h3></div>
<el-button :icon="RefreshCw" :loading="loading" @click="loadActive">刷新</el-button>
</header>
<el-tabs v-model="activeTab" class="people-tabs" @tab-change="loadActive">
<el-tab-pane label="会员" name="members">
<div class="people-filter-row">
<el-input v-model="memberFilters.search" class="search-input" clearable placeholder="昵称 / 手机号 / ID" @keyup.enter="applyMemberFilters" />
<el-select v-model="memberFilters.status" class="filter-select" placeholder="全部状态" @change="applyMemberFilters"><el-option label="全部状态" value="" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select>
</div>
<el-table v-loading="loading" :data="members.items" class="data-table" row-key="memberId">
<el-table-column label="会员" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.nickname || `会员 #${row.memberId}` }}</strong><span>#{{ row.memberId }} · {{ row.maskedPhone || '未留手机号' }}</span></div></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="余额" min-width="150"><template #default="{ row }"><div class="stack"><strong>{{ money(row.wallet.totalBalanceCents) }}</strong><span>现金 {{ money(row.wallet.cashBalanceCents) }} · 赠送 {{ money(row.wallet.giftBalanceCents) }}</span></div></template></el-table-column>
<el-table-column label="权益" min-width="160"><template #default="{ row }"><div class="stack"><strong>{{ row.benefits.availableCoupons }} 张券 · {{ row.benefits.activePackages }} 个套餐</strong><span>{{ row.benefits.packageMinutes }} 分钟 · {{ money(row.benefits.packageAmountCents) }}</span></div></template></el-table-column>
<el-table-column label="订单 / 消费" min-width="160"><template #default="{ row }"><div class="stack"><strong>{{ row.orders.orderCount }} · {{ money(row.orders.paidAmountCents) }}</strong><span>最近 {{ shortDate(row.orders.lastOrderAt) }}</span></div></template></el-table-column>
<el-table-column label="注册 / 登录" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ shortDate(row.registeredAt) }}</strong><span>登录 {{ shortDate(row.lastLoginAt) }}</span></div></template></el-table-column>
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Eye" @click="openMember(row.memberId)">详情</el-button></template></el-table-column>
</el-table>
<div class="pager"><el-pagination layout="prev, pager, next, total" :current-page="members.page" :page-size="members.pageSize" :total="members.total" @current-change="changeMemberPage" /></div>
</el-tab-pane>
<el-tab-pane label="员工" name="staff">
<div class="people-filter-row">
<el-input v-model="staffFilters.search" class="search-input" clearable placeholder="姓名 / 手机号 / ID" @keyup.enter="applyStaffFilters" />
<el-select v-model="staffFilters.role" class="filter-select" placeholder="全部角色" @change="applyStaffFilters"><el-option label="全部角色" value="" /><el-option v-for="item in roleOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
<el-select v-model="staffFilters.status" class="filter-select" placeholder="全部状态" @change="applyStaffFilters"><el-option label="全部状态" value="" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select>
<el-button type="primary" :icon="UserPlus" @click="openCreate">新增员工</el-button>
</div>
<el-table v-loading="loading" :data="staff.items" class="data-table" row-key="id">
<el-table-column label="员工" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.nickname || `员工 #${row.id}` }}</strong><span>#{{ row.id }} · {{ row.maskedPhone || '未留手机号' }}</span></div></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="角色" min-width="190"><template #default="{ row }"><div class="tag-list"><el-tag v-for="role in row.roles" :key="role" effect="plain">{{ roleLabel(role) }}</el-tag></div></template></el-table-column>
<el-table-column label="门店范围" min-width="190"><template #default="{ row }">{{ storeScopeLabel(row.storeIds) }}</template></el-table-column>
<el-table-column label="微信 / 最近登录" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ row.wechatMiniappBound ? '已绑定小程序' : '未绑定小程序' }}</strong><span>{{ shortDate(row.lastLoginAt) }} · {{ row.maskedLastIp || '-' }}</span></div></template></el-table-column>
<el-table-column prop="note" label="运营备注" min-width="160" />
<el-table-column label="操作" width="250" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" :icon="Pencil" @click="openEdit(row)">编辑</el-button><el-button size="small" :type="row.status === 'ACTIVE' ? 'warning' : 'success'" @click="toggleStaff(row)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button size="small" :icon="ShieldX" @click="resetSessions(row)">会话</el-button></div></template></el-table-column>
</el-table>
<div class="pager"><el-pagination layout="prev, pager, next, total" :current-page="staff.page" :page-size="staff.pageSize" :total="staff.total" @current-change="changeStaffPage" /></div>
</el-tab-pane>
</el-tabs>
</section>
<el-drawer v-model="memberDrawer" title="会员详情" size="min(720px, 94vw)">
<div v-if="memberDetail" class="member-detail">
<section class="member-detail-grid">
<span><small>余额</small><strong>{{ money(memberDetail.wallet.totalBalanceCents) }}</strong></span>
<span><small>已支付订单</small><strong>{{ memberDetail.orders.paidOrderCount }}</strong></span>
<span><small>累计消费</small><strong>{{ money(memberDetail.orders.paidAmountCents) }}</strong></span>
<span><small>累计充值</small><strong>{{ money(memberDetail.recharge.creditedRechargeCents) }}</strong></span>
</section>
<el-descriptions :column="2" border><el-descriptions-item label="会员">{{ memberDetail.nickname || `#${memberDetail.memberId}` }}</el-descriptions-item><el-descriptions-item label="手机号">{{ memberDetail.maskedPhone || '-' }}</el-descriptions-item><el-descriptions-item label="可用券">{{ memberDetail.benefits.availableCoupons }}</el-descriptions-item><el-descriptions-item label="有效套餐">{{ memberDetail.benefits.activePackages }}</el-descriptions-item><el-descriptions-item label="注册时间">{{ shortDate(memberDetail.registeredAt) }}</el-descriptions-item><el-descriptions-item label="最近登录">{{ shortDate(memberDetail.lastLoginAt) }}</el-descriptions-item></el-descriptions>
<h4>最近余额流水</h4>
<el-table :data="memberDetail.recentLedger" class="data-table"><el-table-column prop="businessType" label="业务" min-width="120" /><el-table-column prop="entryType" label="类型" width="110" /><el-table-column label="现金变动" width="120"><template #default="{ row }">{{ signedMoney(row.cashDeltaCents) }}</template></el-table-column><el-table-column label="赠送变动" width="120"><template #default="{ row }">{{ signedMoney(row.giftDeltaCents) }}</template></el-table-column><el-table-column label="变动后余额" min-width="150"><template #default="{ row }">{{ money(row.cashBalanceAfterCents + row.giftBalanceAfterCents) }}</template></el-table-column><el-table-column label="时间" width="150"><template #default="{ row }">{{ shortDate(row.createdAt) }}</template></el-table-column></el-table>
</div>
</el-drawer>
<el-dialog v-model="staffDialog" :title="staffForm.userId ? '编辑员工' : '新增员工'" width="min(680px, 94vw)">
<el-form label-position="top" class="asset-form"><el-form-item label="姓名"><el-input v-model="staffForm.nickname" /></el-form-item><el-form-item :label="staffForm.userId ? '新手机号(留空保持原值)' : '手机号'"><el-input v-model="staffForm.phone" inputmode="tel" /></el-form-item><el-form-item label="角色" class="form-span-2"><el-checkbox-group v-model="staffForm.roles"><el-checkbox v-for="item in roleOptions" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox></el-checkbox-group></el-form-item><el-form-item label="门店授权" class="form-span-2"><el-select v-model="staffForm.storeIds" multiple filterable collapse-tags><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="运营备注" class="form-span-2"><el-input v-model="staffForm.note" type="textarea" :rows="3" /></el-form-item></el-form>
<template #footer><el-button @click="staffDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveStaff">保存</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Eye, Pencil, RefreshCw, ShieldX, UserPlus } from '@lucide/vue';
import { ApiError, createStaffUser, getMember, listManagedStores, listMembers, listStaffUsers, resetStaffSessions, updateStaffUser, type ApiSession } from '../api';
import { money, shortDate } from '../format';
import type { ManagedStore, ManagedUser, MemberCard, MemberDetail, PageResult, StaffRole, UserStatus } from '../types';
const props = defineProps<{ session: ApiSession }>();
const activeTab = ref('members');
const loading = ref(false);
const saving = ref(false);
const lastError = ref('');
const stores = ref<ManagedStore[]>([]);
const members = reactive<PageResult<MemberCard>>({ items: [], total: 0, page: 1, pageSize: 20 });
const staff = reactive<PageResult<ManagedUser>>({ items: [], total: 0, page: 1, pageSize: 20 });
const memberFilters = reactive({ search: '', status: '' as UserStatus | '' });
const staffFilters = reactive({ search: '', status: '' as UserStatus | '', role: '' as StaffRole | '' });
const memberDrawer = ref(false);
const memberDetail = ref<MemberDetail | null>(null);
const staffDialog = ref(false);
const staffForm = reactive({ userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'] as StaffRole[], storeIds: [] as string[] });
const roleOptions: Array<{ value: StaffRole; label: string }> = [{ value: 'STAFF', label: '普通员工' }, { value: 'CLEANER', label: '保洁员' }, { value: 'STORE_ADMIN', label: '门店管理员' }, { value: 'TENANT_ADMIN', label: '租户管理员' }];
const memberBalance = computed(() => members.items.reduce((sum, item) => sum + item.wallet.totalBalanceCents, 0));
const memberSpend = computed(() => members.items.reduce((sum, item) => sum + item.orders.paidAmountCents, 0));
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
async function loadWorkspace() { if (!props.session.token) return; loading.value = true; try { stores.value = await capture(() => listManagedStores(props.session)); await Promise.all([loadMembers(false), loadStaff(false)]); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadMembers(showLoading = true) { if (showLoading) loading.value = true; try { const result = await capture(() => listMembers(props.session, { page: members.page, pageSize: members.pageSize, status: memberFilters.status || undefined, search: memberFilters.search.trim() || undefined })); Object.assign(members, result); } catch { /* displayed */ } finally { if (showLoading) loading.value = false; } }
async function loadStaff(showLoading = true) { if (showLoading) loading.value = true; try { const result = await capture(() => listStaffUsers(props.session, { page: staff.page, pageSize: staff.pageSize, status: staffFilters.status || undefined, role: staffFilters.role || undefined, search: staffFilters.search.trim() || undefined })); Object.assign(staff, result); } catch { /* displayed */ } finally { if (showLoading) loading.value = false; } }
function loadActive() { return activeTab.value === 'members' ? loadMembers() : loadStaff(); }
function applyMemberFilters() { members.page = 1; void loadMembers(); }
function applyStaffFilters() { staff.page = 1; void loadStaff(); }
function changeMemberPage(page: number) { members.page = page; void loadMembers(); }
function changeStaffPage(page: number) { staff.page = page; void loadStaff(); }
async function openMember(memberId: string) { memberDrawer.value = true; memberDetail.value = null; try { memberDetail.value = await capture(() => getMember(props.session, memberId)); } catch { /* displayed */ } }
function openCreate() { Object.assign(staffForm, { userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'], storeIds: [] }); staffDialog.value = true; }
function openEdit(row: ManagedUser) { Object.assign(staffForm, { userId: row.id, nickname: row.nickname, phone: '', note: row.note, roles: [...row.roles], storeIds: [...row.storeIds] }); staffDialog.value = true; }
async function saveStaff() { if (!staffForm.nickname.trim() || (!staffForm.userId && !/^\+?[0-9]{6,20}$/.test(staffForm.phone.trim())) || staffForm.roles.length === 0) return ElMessage.warning('请填写姓名、有效手机号并至少选择一个角色'); saving.value = true; try { if (staffForm.userId) await capture(() => updateStaffUser(props.session, staffForm.userId, { nickname: staffForm.nickname.trim(), ...(staffForm.phone.trim() ? { phone: staffForm.phone.trim() } : {}), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); else await capture(() => createStaffUser(props.session, { nickname: staffForm.nickname.trim(), phone: staffForm.phone.trim(), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); staffDialog.value = false; ElMessage.success(staffForm.userId ? '员工资料已更新' : '员工账号已创建'); await loadStaff(); } catch { /* displayed */ } finally { saving.value = false; } }
async function toggleStaff(row: ManagedUser) { const status: UserStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; try { await ElMessageBox.confirm(`${status === 'DISABLED' ? '停用' : '启用'} ${row.nickname || `员工 #${row.id}`}?权限变更会撤销活动会话。`, '确认账号状态', { type: 'warning' }); await capture(() => updateStaffUser(props.session, row.id, { status })); ElMessage.success('员工状态已更新'); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function resetSessions(row: ManagedUser) { try { await ElMessageBox.confirm(`重置 ${row.nickname || `员工 #${row.id}`} 的全部活动会话?`, '确认重置会话', { type: 'warning' }); const result = await capture(() => resetStaffSessions(props.session, row.id)); ElMessage.success(`已撤销 ${result.revokedSessions} 个会话`); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
function roleLabel(role: StaffRole) { return roleOptions.find((item) => item.value === role)?.label || role; }
function storeScopeLabel(ids: string[]) { if (!ids.length) return '未配置'; return ids.map((id) => stores.value.find((item) => item.id === id)?.name || `门店 #${id}`).join('、'); }
function signedMoney(cents: number) { return `${cents > 0 ? '+' : ''}${money(cents)}`; }
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
</script>
+73
View File
@@ -1534,6 +1534,69 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.people-page {
display: grid;
gap: 14px;
}
.people-metrics,
.member-detail-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.people-metrics span,
.member-detail-grid span {
display: grid;
gap: 6px;
padding: 14px;
background: #fff;
border: 1px solid #d8e0ea;
border-radius: 8px;
}
.people-metrics small,
.member-detail-grid small {
color: #69788c;
}
.people-metrics strong,
.member-detail-grid strong {
color: #172033;
font-size: 22px;
}
.people-toolbar h3 {
margin: 0;
}
.people-tabs {
padding: 0 14px 14px;
}
.people-filter-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 8px 0 14px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.member-detail {
display: grid;
gap: 18px;
}
.member-detail h4 {
margin: 0;
}
.el-button { .el-button {
border-radius: 8px; border-radius: 8px;
} }
@@ -1639,6 +1702,8 @@ textarea {
.payment-metrics, .payment-metrics,
.third-party-metrics, .third-party-metrics,
.people-metrics,
.member-detail-grid,
.payment-tools-grid { .payment-tools-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -1749,6 +1814,8 @@ textarea {
.payment-metrics, .payment-metrics,
.third-party-metrics, .third-party-metrics,
.people-metrics,
.member-detail-grid,
.payment-tools-grid { .payment-tools-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1758,6 +1825,12 @@ textarea {
flex-direction: column; flex-direction: column;
} }
.people-filter-row > .el-input,
.people-filter-row > .el-select,
.people-filter-row > .el-button {
width: 100%;
}
.toolbar-actions { .toolbar-actions {
justify-content: flex-end; justify-content: flex-end;
} }
+57
View File
@@ -307,6 +307,63 @@ export interface ManagedUser {
lastLoginAt: string | null; lastLoginAt: string | null;
} }
export interface MemberWalletSummary {
accountCount: number;
cashBalanceCents: number;
giftBalanceCents: number;
totalBalanceCents: number;
}
export interface MemberBenefitSummary {
availableCoupons: number;
frozenCoupons: number;
activePackages: number;
frozenPackages: number;
packageMinutes: number;
packageAmountCents: number;
}
export interface MemberCard {
memberId: string;
status: UserStatus;
nickname: string;
maskedPhone: string;
registeredAt: string;
lastLoginAt: string | null;
wallet: MemberWalletSummary;
benefits: MemberBenefitSummary;
recharge: {
rechargeOrderCount: number;
creditedRechargeCount: number;
creditedRechargeCents: number;
giftedRechargeCents: number;
lastRechargeAt: string | null;
};
orders: {
orderCount: number;
paidOrderCount: number;
paidAmountCents: number;
lastOrderAt: string | null;
};
}
export interface MemberLedgerEntry {
ledgerId: string;
storeId: string | null;
businessType: string;
businessId: string;
entryType: string;
cashDeltaCents: number;
giftDeltaCents: number;
cashBalanceAfterCents: number;
giftBalanceAfterCents: number;
createdAt: string;
}
export interface MemberDetail extends MemberCard {
recentLedger: MemberLedgerEntry[];
}
export interface CleaningTask { export interface CleaningTask {
id: string; id: string;
taskNo: string; taskNo: string;
+1
View File
@@ -31,6 +31,7 @@ const updateSchema = z.object({
const listSchema = z.object({ const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1), page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20), pageSize: z.coerce.number().int().min(1).max(100).default(20),
userType: z.enum(['STAFF']).optional(),
status: z.enum(['ACTIVE', 'DISABLED']).optional(), status: z.enum(['ACTIVE', 'DISABLED']).optional(),
role: z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN']).optional(), role: z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN']).optional(),
search: z.string().trim().max(128).optional() search: z.string().trim().max(128).optional()
+2 -1
View File
@@ -107,11 +107,12 @@ assert.deepEqual(createdInput.storeIds, ['11']);
const cleaners = await app.inject({ const cleaners = await app.inject({
method: 'GET', method: 'GET',
url: '/admin-api/users?role=CLEANER&status=ACTIVE&search=%E4%BF%9D%E6%B4%81', url: '/admin-api/users?userType=STAFF&role=CLEANER&status=ACTIVE&search=%E4%BF%9D%E6%B4%81',
headers: { authorization: `Bearer ${token}` } headers: { authorization: `Bearer ${token}` }
}); });
assert.equal(cleaners.statusCode, 200); assert.equal(cleaners.statusCode, 200);
assert.equal(listedInput.role, 'CLEANER'); assert.equal(listedInput.role, 'CLEANER');
assert.equal(listedInput.userType, 'STAFF');
assert.equal(listedInput.status, 'ACTIVE'); assert.equal(listedInput.status, 'ACTIVE');
assert.equal(listedInput.search, '保洁'); assert.equal(listedInput.search, '保洁');
+21
View File
@@ -14,11 +14,13 @@ for (const pattern of [
"activeModule = 'orders'", "activeModule = 'orders'",
"activeModule = 'payments'", "activeModule = 'payments'",
"activeModule = 'thirdParty'", "activeModule = 'thirdParty'",
"activeModule = 'people'",
'平台运营总览', '平台运营总览',
'StoresRoomsPanel', 'StoresRoomsPanel',
'OrdersPanel', 'OrdersPanel',
'PaymentsPanel', 'PaymentsPanel',
'ThirdPartyPanel', 'ThirdPartyPanel',
'MembersStaffPanel',
'运营总览', '运营总览',
'savedToken', 'savedToken',
'loadCleaningWorkspace' 'loadCleaningWorkspace'
@@ -140,6 +142,23 @@ const thirdPartyRoutes = read('backend/src/routes/third-party.ts');
assert.match(thirdPartyRoutes, /'\/admin-api\/third-party\/setup'/); assert.match(thirdPartyRoutes, /'\/admin-api\/third-party\/setup'/);
assert.match(thirdPartyRoutes, /'\/admin-api\/group-vouchers\/redeem'/); assert.match(thirdPartyRoutes, /'\/admin-api\/group-vouchers\/redeem'/);
const people = read('admin/src/components/MembersStaffPanel.vue');
for (const pattern of [
'listMembers',
'getMember',
'listStaffUsers',
'createStaffUser',
'updateStaffUser',
'resetStaffSessions',
'memberDetail',
'staffForm'
]) {
assert.match(people, new RegExp(pattern));
}
const userRoutes = read('backend/src/routes/user-management.ts');
assert.match(userRoutes, /userType: z\.enum\(\['STAFF'\]\)\.optional\(\)/);
const routes = read('backend/src/routes/business-statistics.ts'); const routes = read('backend/src/routes/business-statistics.ts');
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/); assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
@@ -173,6 +192,8 @@ for (const pattern of [
'.payment-tools-grid', '.payment-tools-grid',
'.third-party-metrics', '.third-party-metrics',
'.settings-preview', '.settings-preview',
'.people-metrics',
'.member-detail-grid',
'@media (max-width: 980px)', '@media (max-width: 980px)',
'@media (max-width: 560px)' '@media (max-width: 560px)'
]) { ]) {