@@ -289,6 +303,7 @@ import {
Sparkles,
Store,
TicketCheck,
+ UsersRound,
WalletCards
} from '@lucide/vue';
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
@@ -301,6 +316,7 @@ import StoresRoomsPanel from './components/StoresRoomsPanel.vue';
import OrdersPanel from './components/OrdersPanel.vue';
import PaymentsPanel from './components/PaymentsPanel.vue';
import ThirdPartyPanel from './components/ThirdPartyPanel.vue';
+import MembersStaffPanel from './components/MembersStaffPanel.vue';
import {
ApiError,
assignCleaningTask,
@@ -337,7 +353,7 @@ import { money } from './format';
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
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 lastError = ref('');
const lastMessage = ref('');
@@ -352,6 +368,7 @@ const activeModuleMeta = computed(() => ({
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
payments: { stage: 'M08-D', title: '支付、退款与分账' },
thirdParty: { stage: 'M08-D', title: '团购与第三方平台运营' },
+ people: { stage: 'M08-D', title: '会员与员工用户管理' },
cleaning: { stage: 'M08-B', title: '保洁任务与结算' }
})[activeModule.value]);
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
diff --git a/admin/src/api.ts b/admin/src/api.ts
index c56f736..3f3ea6c 100644
--- a/admin/src/api.ts
+++ b/admin/src/api.ts
@@ -7,6 +7,8 @@ import type {
CleaningTaskMember,
BusinessStatistics,
ManagedRoom,
+ MemberCard,
+ MemberDetail,
ManagedOrder,
ManagedStore,
ManagedUser,
@@ -356,7 +358,8 @@ export function listStaffUsers(
) {
const params = new URLSearchParams({
page: String(input.page),
- pageSize: String(input.pageSize)
+ pageSize: String(input.pageSize),
+ userType: 'STAFF'
});
if (input.role) params.set('role', input.role);
if (input.status) params.set('status', input.status);
@@ -364,6 +367,20 @@ export function listStaffUsers(
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
(session, `/members/${encodeURIComponent(memberId)}`);
+}
+
export function createStaffUser(
session: ApiSession,
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
diff --git a/admin/src/components/MembersStaffPanel.vue b/admin/src/components/MembersStaffPanel.vue
new file mode 100644
index 0000000..0d25507
--- /dev/null
+++ b/admin/src/components/MembersStaffPanel.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+ 当前页会员{{ members.items.length }}
+ 会员余额{{ money(memberBalance) }}
+ 累计消费{{ money(memberSpend) }}
+ 员工账号{{ staff.total }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.nickname || `会员 #${row.memberId}` }}#{{ row.memberId }} · {{ row.maskedPhone || '未留手机号' }}
+ {{ row.status }}
+ {{ money(row.wallet.totalBalanceCents) }}现金 {{ money(row.wallet.cashBalanceCents) }} · 赠送 {{ money(row.wallet.giftBalanceCents) }}
+ {{ row.benefits.availableCoupons }} 张券 · {{ row.benefits.activePackages }} 个套餐{{ row.benefits.packageMinutes }} 分钟 · {{ money(row.benefits.packageAmountCents) }}
+ {{ row.orders.orderCount }} 单 · {{ money(row.orders.paidAmountCents) }}最近 {{ shortDate(row.orders.lastOrderAt) }}
+ {{ shortDate(row.registeredAt) }}登录 {{ shortDate(row.lastLoginAt) }}
+ 详情
+
+
+
+
+
+
+
+
+
+ 新增员工
+
+
+ {{ row.nickname || `员工 #${row.id}` }}#{{ row.id }} · {{ row.maskedPhone || '未留手机号' }}
+ {{ row.status }}
+ {{ roleLabel(role) }}
+ {{ storeScopeLabel(row.storeIds) }}
+ {{ row.wechatMiniappBound ? '已绑定小程序' : '未绑定小程序' }}{{ shortDate(row.lastLoginAt) }} · {{ row.maskedLastIp || '-' }}
+
+ 编辑{{ row.status === 'ACTIVE' ? '停用' : '启用' }}会话
+
+
+
+
+
+
+
+
+
+ 余额{{ money(memberDetail.wallet.totalBalanceCents) }}
+ 已支付订单{{ memberDetail.orders.paidOrderCount }}
+ 累计消费{{ money(memberDetail.orders.paidAmountCents) }}
+ 累计充值{{ money(memberDetail.recharge.creditedRechargeCents) }}
+
+ {{ memberDetail.nickname || `#${memberDetail.memberId}` }}{{ memberDetail.maskedPhone || '-' }}{{ memberDetail.benefits.availableCoupons }}{{ memberDetail.benefits.activePackages }}{{ shortDate(memberDetail.registeredAt) }}{{ shortDate(memberDetail.lastLoginAt) }}
+
最近余额流水
+ {{ signedMoney(row.cashDeltaCents) }}{{ signedMoney(row.giftDeltaCents) }}{{ money(row.cashBalanceAfterCents + row.giftBalanceAfterCents) }}{{ shortDate(row.createdAt) }}
+
+
+
+
+ {{ item.label }}
+ 取消保存
+
+
+
+
+
diff --git a/admin/src/styles.css b/admin/src/styles.css
index 462aa6f..6d33b10 100644
--- a/admin/src/styles.css
+++ b/admin/src/styles.css
@@ -1534,6 +1534,69 @@ textarea {
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 {
border-radius: 8px;
}
@@ -1639,6 +1702,8 @@ textarea {
.payment-metrics,
.third-party-metrics,
+ .people-metrics,
+ .member-detail-grid,
.payment-tools-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1749,6 +1814,8 @@ textarea {
.payment-metrics,
.third-party-metrics,
+ .people-metrics,
+ .member-detail-grid,
.payment-tools-grid {
grid-template-columns: 1fr;
}
@@ -1758,6 +1825,12 @@ textarea {
flex-direction: column;
}
+ .people-filter-row > .el-input,
+ .people-filter-row > .el-select,
+ .people-filter-row > .el-button {
+ width: 100%;
+ }
+
.toolbar-actions {
justify-content: flex-end;
}
diff --git a/admin/src/types.ts b/admin/src/types.ts
index b0a5cd9..81c1c85 100644
--- a/admin/src/types.ts
+++ b/admin/src/types.ts
@@ -307,6 +307,63 @@ export interface ManagedUser {
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 {
id: string;
taskNo: string;
diff --git a/backend/src/routes/user-management.ts b/backend/src/routes/user-management.ts
index 633258c..8a6e545 100644
--- a/backend/src/routes/user-management.ts
+++ b/backend/src/routes/user-management.ts
@@ -31,6 +31,7 @@ const updateSchema = z.object({
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
+ userType: z.enum(['STAFF']).optional(),
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
role: z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN']).optional(),
search: z.string().trim().max(128).optional()
diff --git a/backend/tests/user-management.test.mjs b/backend/tests/user-management.test.mjs
index 62715eb..291ef17 100644
--- a/backend/tests/user-management.test.mjs
+++ b/backend/tests/user-management.test.mjs
@@ -107,11 +107,12 @@ assert.deepEqual(createdInput.storeIds, ['11']);
const cleaners = await app.inject({
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}` }
});
assert.equal(cleaners.statusCode, 200);
assert.equal(listedInput.role, 'CLEANER');
+assert.equal(listedInput.userType, 'STAFF');
assert.equal(listedInput.status, 'ACTIVE');
assert.equal(listedInput.search, '保洁');
diff --git a/scripts/check-admin-m08-d.mjs b/scripts/check-admin-m08-d.mjs
index 7aedf80..c98212c 100644
--- a/scripts/check-admin-m08-d.mjs
+++ b/scripts/check-admin-m08-d.mjs
@@ -14,11 +14,13 @@ for (const pattern of [
"activeModule = 'orders'",
"activeModule = 'payments'",
"activeModule = 'thirdParty'",
+ "activeModule = 'people'",
'平台运营总览',
'StoresRoomsPanel',
'OrdersPanel',
'PaymentsPanel',
'ThirdPartyPanel',
+ 'MembersStaffPanel',
'运营总览',
'savedToken',
'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\/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');
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
@@ -173,6 +192,8 @@ for (const pattern of [
'.payment-tools-grid',
'.third-party-metrics',
'.settings-preview',
+ '.people-metrics',
+ '.member-detail-grid',
'@media (max-width: 980px)',
'@media (max-width: 560px)'
]) {