feat(M08-C): 补会员检索与员工概况
This commit is contained in:
@@ -72,6 +72,7 @@ export class UserManagementRepository {
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
role?: AssignableRole;
|
||||
search?: string;
|
||||
userType?: 'STAFF';
|
||||
}): Promise<{ items: ManagedUser[]; total: number }> {
|
||||
const filters = ['u.tenant_id = ?', 'u.deleted_at IS NULL'];
|
||||
const params: Array<string | number> = [input.actor.tenantId];
|
||||
@@ -79,6 +80,10 @@ export class UserManagementRepository {
|
||||
filters.push('u.status = ?');
|
||||
params.push(input.status);
|
||||
}
|
||||
if (input.userType) {
|
||||
filters.push('u.user_type = ?');
|
||||
params.push(input.userType);
|
||||
}
|
||||
if (input.role) {
|
||||
filters.push(`EXISTS (
|
||||
SELECT 1 FROM qipai_user_roles fur
|
||||
@@ -102,6 +107,8 @@ export class UserManagementRepository {
|
||||
WHERE ${filters.join(' AND ')}`,
|
||||
params
|
||||
);
|
||||
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
|
||||
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
||||
const [rows] = await this.pool.execute<UserRow[]>(
|
||||
`SELECT u.id, u.user_type AS userType, u.status, u.nickname,
|
||||
u.avatar_url AS avatarUrl, u.phone,
|
||||
@@ -119,8 +126,8 @@ export class UserManagementRepository {
|
||||
LEFT JOIN qipai_user_admin_profiles p
|
||||
ON p.tenant_id = u.tenant_id AND p.user_id = u.id
|
||||
WHERE ${filters.join(' AND ')}
|
||||
ORDER BY u.id DESC LIMIT ? OFFSET ?`,
|
||||
[...params, input.pageSize, (input.page - 1) * input.pageSize]
|
||||
ORDER BY u.id DESC LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
const items = await Promise.all(rows.map(async (row) => ({
|
||||
id: String(row.id),
|
||||
|
||||
@@ -517,7 +517,8 @@ export class CleaningTaskRepository {
|
||||
params.push(input.cleanerUserId);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
const offset = (input.page - 1) * input.pageSize;
|
||||
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
|
||||
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
||||
const [counts] = await this.pool.execute<CountRow[]>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_cleaning_settlements s WHERE ${whereSql}`,
|
||||
params
|
||||
@@ -538,14 +539,14 @@ export class CleaningTaskRepository {
|
||||
LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY s.created_at DESC, s.id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, input.pageSize, offset]
|
||||
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
return {
|
||||
items: rows.map(publicSettlement),
|
||||
total: Number(counts[0]?.total ?? 0),
|
||||
page: input.page,
|
||||
pageSize: input.pageSize
|
||||
pageSize
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1046,7 +1047,8 @@ export class CleaningTaskRepository {
|
||||
orderBy: string
|
||||
) {
|
||||
const whereSql = where.join(' AND ');
|
||||
const offset = (page - 1) * pageSize;
|
||||
const safePageSize = Math.max(1, Math.min(100, Math.trunc(pageSize)));
|
||||
const offset = Math.max(0, (Math.trunc(page) - 1) * safePageSize);
|
||||
const [counts] = await this.pool.execute<CountRow[]>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_cleaning_tasks t WHERE ${whereSql}`,
|
||||
params
|
||||
@@ -1069,10 +1071,13 @@ export class CleaningTaskRepository {
|
||||
LEFT JOIN qipai_orders o ON o.tenant_id = t.tenant_id AND o.id = t.order_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset]
|
||||
LIMIT ${safePageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
return { items: rows.map(publicTask), total: Number(counts[0]?.total ?? 0), page, pageSize };
|
||||
return {
|
||||
items: rows.map(publicTask), total: Number(counts[0]?.total ?? 0),
|
||||
page, pageSize: safePageSize
|
||||
};
|
||||
}
|
||||
|
||||
private async getMineTask(input: CleaningActor, taskId: string) {
|
||||
|
||||
@@ -58,7 +58,8 @@ export class OrderQueryRepository {
|
||||
params.push(input.storeId);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
const offset = (input.page - 1) * input.pageSize;
|
||||
const pageSize = Math.max(1, Math.min(50, Math.trunc(input.pageSize)));
|
||||
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
||||
const [counts] = await this.pool.execute<CountRow[]>(
|
||||
`SELECT COUNT(DISTINCT o.id) AS total
|
||||
FROM qipai_orders o
|
||||
@@ -89,14 +90,14 @@ export class OrderQueryRepository {
|
||||
)
|
||||
WHERE ${whereSql}
|
||||
ORDER BY o.created_at DESC, o.id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, input.pageSize, offset]
|
||||
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
return {
|
||||
items: rows.map(publicOrder),
|
||||
total: Number(counts[0]?.total ?? 0),
|
||||
page: input.page,
|
||||
pageSize: input.pageSize
|
||||
pageSize
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -54,29 +54,33 @@ export async function registerMemberRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/members', async (request, reply) => {
|
||||
const actor = await requireReader(request, reply, options);
|
||||
if (!actor) return;
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listMembers({ actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of ['/admin-api/members', '/app-api/management/members']) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireReader(request, reply, options);
|
||||
if (!actor) return;
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listMembers({ actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/admin-api/members/:id', async (request, reply) => {
|
||||
const actor = await requireReader(request, reply, options);
|
||||
if (!actor) return;
|
||||
const params = idSchema.safeParse(request.params);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.getMember({ actor, memberId: params.data.id }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of ['/admin-api/members/:id', '/app-api/management/members/:id']) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireReader(request, reply, options);
|
||||
if (!actor) return;
|
||||
const params = idSchema.safeParse(request.params);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.getMember({ actor, memberId: params.data.id }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function requireProfileReader(
|
||||
|
||||
@@ -47,14 +47,23 @@ export async function registerUserManagementRoutes(
|
||||
app: FastifyInstance,
|
||||
options: UserManagementRouteOptions
|
||||
): Promise<void> {
|
||||
app.get('/admin-api/users', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
if (!actor) return;
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
const data = await options.repository.listUsers({ actor, ...query.data });
|
||||
return { code: 0, data, traceId: request.traceId };
|
||||
});
|
||||
for (const [path, staffOnly] of [
|
||||
['/admin-api/users', false],
|
||||
['/app-api/management/staff', true]
|
||||
] as const) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
if (!actor) return;
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
const data = await options.repository.listUsers({
|
||||
actor,
|
||||
...query.data,
|
||||
...(staffOnly ? { userType: 'STAFF' as const } : {})
|
||||
});
|
||||
return { code: 0, data, traceId: request.traceId };
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/admin-api/staff', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
|
||||
@@ -138,13 +138,15 @@ export class MemberProfileService {
|
||||
WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
|
||||
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
||||
const [rows] = await this.pool.execute<MemberRow[]>(
|
||||
`SELECT u.id, u.status, u.nickname, u.phone,
|
||||
u.created_at AS createdAt, u.last_login_at AS lastLoginAt
|
||||
FROM qipai_users u
|
||||
WHERE ${where}
|
||||
ORDER BY u.id DESC LIMIT ? OFFSET ?`,
|
||||
[...params, input.pageSize, (input.page - 1) * input.pageSize]
|
||||
ORDER BY u.id DESC LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
const items = await Promise.all(rows.map((row) => this.memberCard(input.actor, row)));
|
||||
return { items, total: Number(counts[0]?.total ?? 0) };
|
||||
@@ -341,9 +343,9 @@ export class MemberProfileService {
|
||||
);
|
||||
const [orderRows] = await this.pool.execute<OrderSummaryRow[]>(
|
||||
`SELECT COUNT(*) AS orderCount,
|
||||
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED')
|
||||
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED')
|
||||
THEN 1 ELSE 0 END), 0) AS paidOrderCount,
|
||||
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED')
|
||||
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED')
|
||||
THEN o.paid_amount_cents ELSE 0 END), 0) AS paidAmountCents,
|
||||
MAX(o.created_at) AS lastOrderAt
|
||||
FROM qipai_orders o
|
||||
@@ -433,12 +435,19 @@ function memberScopeClause(actor: MemberActor, userExpression: string) {
|
||||
}
|
||||
const placeholders = actor.access.storeIds.map(() => '?').join(',');
|
||||
return {
|
||||
sql: `EXISTS (
|
||||
sql: `(EXISTS (
|
||||
SELECT 1 FROM qipai_wallet_accounts wa
|
||||
WHERE wa.tenant_id = u.tenant_id AND wa.user_id = ${userExpression}
|
||||
AND wa.store_id IN (${placeholders})
|
||||
)`,
|
||||
params: actor.access.storeIds
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM qipai_order_user_access oa
|
||||
INNER JOIN qipai_orders mo
|
||||
ON mo.tenant_id = oa.tenant_id AND mo.id = oa.order_id
|
||||
AND mo.deleted_at IS NULL
|
||||
WHERE oa.tenant_id = u.tenant_id AND oa.user_id = ${userExpression}
|
||||
AND oa.access_type = 'OWNER' AND mo.store_id IN (${placeholders})
|
||||
))`,
|
||||
params: [...actor.access.storeIds, ...actor.access.storeIds]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,15 @@ const forbiddenToken = signAccessToken({
|
||||
sub: '22', sid: '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
const managerToken = signAccessToken({
|
||||
sub: '23', sid: '7c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
|
||||
let profileInput;
|
||||
let benefitsInput;
|
||||
let memberListInput;
|
||||
let memberDetailInput;
|
||||
const app = await buildApp({
|
||||
members: {
|
||||
jwtSecret: secret,
|
||||
@@ -25,7 +31,8 @@ const app = await buildApp({
|
||||
platformAppId: '9',
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
user: {
|
||||
id: sessionId === '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e' ? '22' : '21',
|
||||
id: sessionId === '6c4d3af8-c63c-4edb-bf95-b84127bb3f6e' ? '22'
|
||||
: sessionId === '7c4d3af8-c63c-4edb-bf95-b84127bb3f6e' ? '23' : '21',
|
||||
tenantId: '7',
|
||||
userType: 'CUSTOMER',
|
||||
status: 'ACTIVE',
|
||||
@@ -39,14 +46,28 @@ const app = await buildApp({
|
||||
},
|
||||
accessControl: {
|
||||
async getAccessProfile(tenantId, userId) {
|
||||
return userId === '21'
|
||||
return userId === '23'
|
||||
? { roles: ['STORE_ADMIN'], capabilities: ['user.read'], storeIds: ['11'] }
|
||||
: userId === '21'
|
||||
? { roles: ['CUSTOMER'], capabilities: ['profile.read', 'order.self.read'], storeIds: [] }
|
||||
: { roles: ['CUSTOMER'], capabilities: ['order.self.read'], storeIds: [] };
|
||||
}
|
||||
},
|
||||
service: {
|
||||
async listMembers() { throw new Error('not called'); },
|
||||
async getMember() { throw new Error('not called'); },
|
||||
async listMembers(input) {
|
||||
memberListInput = input;
|
||||
return {
|
||||
items: [{
|
||||
memberId: '31', nickname: '门店会员', maskedPhone: '138****8000',
|
||||
wallet: { totalBalanceCents: 1200 }, orders: { orderCount: 2 }
|
||||
}],
|
||||
total: 1
|
||||
};
|
||||
},
|
||||
async getMember(input) {
|
||||
memberDetailInput = input;
|
||||
return { memberId: input.memberId, nickname: '门店会员', recentLedger: [] };
|
||||
},
|
||||
async getMyBenefits(input) {
|
||||
benefitsInput = input;
|
||||
return {
|
||||
@@ -100,6 +121,33 @@ assert.equal(benefitsInput.userId, '21');
|
||||
assert.equal(benefits.json().data.coupons[0].couponGrantId, '401');
|
||||
assert.equal(benefits.json().data.packages[0].packageHoldingId, '501');
|
||||
|
||||
const members = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/members?page=1&pageSize=20&status=ACTIVE&search=138',
|
||||
headers: { authorization: `Bearer ${managerToken}` }
|
||||
});
|
||||
assert.equal(members.statusCode, 200);
|
||||
assert.equal(memberListInput.actor.userId, '23');
|
||||
assert.deepEqual(memberListInput.actor.access.storeIds, ['11']);
|
||||
assert.equal(memberListInput.status, 'ACTIVE');
|
||||
assert.equal(memberListInput.search, '138');
|
||||
assert.equal(members.json().data.items[0].memberId, '31');
|
||||
|
||||
const memberDetail = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/members/31',
|
||||
headers: { authorization: `Bearer ${managerToken}` }
|
||||
});
|
||||
assert.equal(memberDetail.statusCode, 200);
|
||||
assert.equal(memberDetailInput.memberId, '31');
|
||||
|
||||
const memberForbidden = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/members',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(memberForbidden.statusCode, 403);
|
||||
|
||||
const unauthorized = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/profile'
|
||||
|
||||
@@ -210,7 +210,8 @@ const orders = [
|
||||
id: '301',
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
status: 'COMPLETED',
|
||||
storeId: '11',
|
||||
status: 'FINISHED',
|
||||
paidAmountCents: 12_000,
|
||||
createdAt: new Date('2026-06-23T00:00:00.000Z')
|
||||
},
|
||||
@@ -218,9 +219,19 @@ const orders = [
|
||||
id: '302',
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
storeId: '11',
|
||||
status: 'CANCELLED',
|
||||
paidAmountCents: 0,
|
||||
createdAt: new Date('2026-06-24T00:00:00.000Z')
|
||||
},
|
||||
{
|
||||
id: '303',
|
||||
tenantId: '7',
|
||||
userId: '22',
|
||||
storeId: '11',
|
||||
status: 'CANCELLED',
|
||||
paidAmountCents: 0,
|
||||
createdAt: new Date('2026-06-25T00:00:00.000Z')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -335,7 +346,7 @@ function createService() {
|
||||
const userId = String(params[0]);
|
||||
const tenantId = String(params[1]);
|
||||
const rows = orders.filter((item) => item.tenantId === tenantId && item.userId === userId);
|
||||
const paid = rows.filter((item) => ['PAID', 'IN_USE', 'COMPLETED'].includes(item.status));
|
||||
const paid = rows.filter((item) => ['PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED'].includes(item.status));
|
||||
return [[{
|
||||
orderCount: rows.length,
|
||||
paidOrderCount: paid.length,
|
||||
@@ -379,11 +390,17 @@ function queryMembers(sql, params) {
|
||||
if (user.tenantId !== tenantId || user.userType !== 'CUSTOMER' || user.deleted) return false;
|
||||
if (statusFilter && user.status !== statusFilter) return false;
|
||||
if (hasStoreScope) {
|
||||
return wallets.some((wallet) =>
|
||||
const walletMatch = wallets.some((wallet) =>
|
||||
wallet.tenantId === tenantId
|
||||
&& wallet.userId === user.id
|
||||
&& storeParams.includes(wallet.storeId)
|
||||
);
|
||||
const orderMatch = orders.some((order) =>
|
||||
order.tenantId === tenantId
|
||||
&& order.userId === user.id
|
||||
&& storeParams.includes(order.storeId)
|
||||
);
|
||||
return walletMatch || orderMatch;
|
||||
}
|
||||
return true;
|
||||
}).map((user) => ({
|
||||
@@ -458,12 +475,9 @@ const forbiddenReader = {
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
assert.equal(scoped.total, 1);
|
||||
assert.equal(scoped.items[0].memberId, '21');
|
||||
await assert.rejects(
|
||||
() => service.getMember({ actor: storeReader, memberId: '22' }),
|
||||
(error) => error instanceof MemberProfileError && error.code === 'MEMBER_NOT_FOUND'
|
||||
);
|
||||
assert.equal(scoped.total, 2);
|
||||
assert.equal(scoped.items[0].memberId, '22');
|
||||
assert.equal((await service.getMember({ actor: storeReader, memberId: '22' })).memberId, '22');
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -37,6 +37,7 @@ import { ThirdPartyClient } from '../dist/third-party/third-party-client.js';
|
||||
import { ThirdPartyService } from '../dist/third-party/third-party-service.js';
|
||||
import { DeviceRepository } from '../dist/devices/device-repository.js';
|
||||
import { IotMessageService } from '../dist/devices/iot-message-service.js';
|
||||
import { MemberProfileService } from '../dist/wallets/member-profile-service.js';
|
||||
import {
|
||||
executeMigrationPlan,
|
||||
loadMigrationPlan,
|
||||
@@ -708,6 +709,25 @@ async function assertPricingAndReservations(pool, context) {
|
||||
assert.equal(managedAudit[0].action, 'ORDER_CREATED_ON_BEHALF');
|
||||
assert.equal(String(JSON.parse(managedAudit[0].metadata).targetUserId), customerId);
|
||||
|
||||
const scopedMembers = await new MemberProfileService(pool).listMembers({
|
||||
actor: {
|
||||
tenantId: context.tenantId,
|
||||
userId: adminId,
|
||||
access: {
|
||||
roles: ['STORE_ADMIN'],
|
||||
capabilities: ['user.read'],
|
||||
storeIds: [String(targetRows[0].storeId)]
|
||||
}
|
||||
},
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
status: 'ACTIVE',
|
||||
search: customerId
|
||||
});
|
||||
assert.equal(scopedMembers.total, 1);
|
||||
assert.equal(scopedMembers.items[0].memberId, customerId);
|
||||
assert.ok(scopedMembers.items[0].orders.orderCount >= 1);
|
||||
|
||||
const [foreignUser] = await pool.query(
|
||||
`INSERT INTO qipai_users (tenant_id, user_type, status)
|
||||
VALUES (?, 'CUSTOMER', 'ACTIVE')`,
|
||||
@@ -2000,6 +2020,7 @@ try {
|
||||
'store-scoped on-behalf booking rejection',
|
||||
'manager on-behalf booking history and audit attribution',
|
||||
'cross-tenant on-behalf target user rejection',
|
||||
'store-scoped member discovery through historical owner orders',
|
||||
'share token stored as SHA-256 only',
|
||||
'default view and door permissions',
|
||||
'renew permission denied by default',
|
||||
|
||||
@@ -115,6 +115,18 @@ assert.equal(listedInput.role, 'CLEANER');
|
||||
assert.equal(listedInput.status, 'ACTIVE');
|
||||
assert.equal(listedInput.search, '保洁');
|
||||
|
||||
const miniappStaff = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/staff?page=1&pageSize=20&status=ACTIVE&search=%E5%A4%9C%E7%8F%AD',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(miniappStaff.statusCode, 200);
|
||||
assert.equal(listedInput.page, 1);
|
||||
assert.equal(listedInput.pageSize, 20);
|
||||
assert.equal(listedInput.status, 'ACTIVE');
|
||||
assert.equal(listedInput.search, '夜班');
|
||||
assert.equal(listedInput.userType, 'STAFF');
|
||||
|
||||
const updated = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/admin-api/users/22',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"pages/recharge/index",
|
||||
"pages/cleaner/tasks",
|
||||
"pages/manager/dashboard",
|
||||
"pages/manager/people",
|
||||
"pages/manager/order-create",
|
||||
"pages/logs/logs"
|
||||
],
|
||||
|
||||
@@ -37,6 +37,7 @@ Page({
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
canWrite: false,
|
||||
canReadUsers: false,
|
||||
stores: [],
|
||||
selectedStoreId: '',
|
||||
selectedStoreName: '',
|
||||
@@ -94,6 +95,9 @@ Page({
|
||||
canWrite: access.capabilities.includes('store.operation.write')
|
||||
|| access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN'),
|
||||
canReadUsers: access.capabilities.includes('user.read')
|
||||
|| access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN'),
|
||||
stores,
|
||||
selectedStoreId,
|
||||
selectedStoreName: stores.find((store) => store.id === selectedStoreId)?.name || '',
|
||||
@@ -216,6 +220,11 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
openPeople() {
|
||||
if (!this.data.canReadUsers) return
|
||||
wx.navigateTo({ url: '/pages/manager/people' })
|
||||
},
|
||||
|
||||
manageOrder(event) {
|
||||
if (!this.data.canWrite) return
|
||||
const orderId = event.currentTarget.dataset.orderId
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view wx:if="{{canReadUsers}}" class="management-tools">
|
||||
<view>
|
||||
<view class="card-title">会员与员工</view>
|
||||
<view class="card-meta">检索会员资产概况与授权范围内员工</view>
|
||||
</view>
|
||||
<button size="mini" bindtap="openPeople">进入</button>
|
||||
</view>
|
||||
|
||||
<view class="summary-grid">
|
||||
<view class="summary-card"><strong>{{summary.roomTotal}}</strong><text>房间</text></view>
|
||||
<view class="summary-card success"><strong>{{summary.available}}</strong><text>空闲</text></view>
|
||||
|
||||
@@ -53,6 +53,21 @@
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.management-tools {
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.management-tools button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #f8fafc;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
|
||||
@@ -9,7 +9,11 @@ Page({
|
||||
storeName: '',
|
||||
rooms: [],
|
||||
roomIndex: 0,
|
||||
userId: '',
|
||||
memberLoading: false,
|
||||
memberSearch: '',
|
||||
members: [],
|
||||
selectedMemberId: '',
|
||||
selectedMemberName: '',
|
||||
startDate: '',
|
||||
startTime: '',
|
||||
endDate: '',
|
||||
@@ -34,7 +38,7 @@ Page({
|
||||
endDate: this.datePart(end),
|
||||
endTime: this.timePart(end),
|
||||
})
|
||||
await this.loadRooms()
|
||||
await Promise.all([this.loadRooms(), this.loadMembers()])
|
||||
},
|
||||
|
||||
async loadRooms() {
|
||||
@@ -60,7 +64,14 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
inputUserId(event) { this.setData({ userId: String(event.detail.value || '').trim() }) },
|
||||
inputMemberSearch(event) { this.setData({ memberSearch: String(event.detail.value || '').trim() }) },
|
||||
searchMembers() { this.loadMembers() },
|
||||
selectMember(event) {
|
||||
const memberId = event.currentTarget.dataset.memberId
|
||||
const member = this.data.members.find((item) => item.memberId === memberId)
|
||||
if (!member) return
|
||||
this.setData({ selectedMemberId: member.memberId, selectedMemberName: member.displayName })
|
||||
},
|
||||
selectRoom(event) { this.setData({ roomIndex: Number(event.detail.value) }) },
|
||||
selectPricingMode(event) { this.setData({ pricingModeIndex: Number(event.detail.value) }) },
|
||||
changeStartDate(event) { this.setData({ startDate: event.detail.value }) },
|
||||
@@ -68,13 +79,39 @@ Page({
|
||||
changeEndDate(event) { this.setData({ endDate: event.detail.value }) },
|
||||
changeEndTime(event) { this.setData({ endTime: event.detail.value }) },
|
||||
|
||||
async loadMembers() {
|
||||
this.setData({ memberLoading: true, errorMessage: '' })
|
||||
try {
|
||||
await ensureLogin()
|
||||
const search = this.data.memberSearch
|
||||
? `&search=${encodeURIComponent(this.data.memberSearch)}`
|
||||
: ''
|
||||
const response = await request(`/management/members?page=1&pageSize=20&status=ACTIVE${search}`)
|
||||
const members = (response.data?.items || []).map((member) => ({
|
||||
...member,
|
||||
displayName: `${member.nickname || '未命名会员'} · ${member.maskedPhone || `ID ${member.memberId}`}`,
|
||||
balanceText: cents(member.wallet?.totalBalanceCents),
|
||||
}))
|
||||
const selectedStillVisible = members.some((member) => member.memberId === this.data.selectedMemberId)
|
||||
this.setData({
|
||||
members,
|
||||
selectedMemberId: selectedStillVisible ? this.data.selectedMemberId : '',
|
||||
selectedMemberName: selectedStillVisible ? this.data.selectedMemberName : '',
|
||||
})
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '会员加载失败' })
|
||||
} finally {
|
||||
this.setData({ memberLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
async submit() {
|
||||
if (this.data.submitting) return
|
||||
const room = this.data.rooms[this.data.roomIndex]
|
||||
const pricingMode = this.data.pricingModes[this.data.pricingModeIndex]?.value
|
||||
const userId = this.data.userId
|
||||
if (!/^[1-9]\d{0,19}$/.test(userId)) {
|
||||
this.setData({ errorMessage: '请输入有效的会员用户 ID' })
|
||||
const userId = this.data.selectedMemberId
|
||||
if (!userId) {
|
||||
this.setData({ errorMessage: '请从授权范围内选择会员' })
|
||||
return
|
||||
}
|
||||
if (!room) {
|
||||
|
||||
@@ -7,8 +7,25 @@
|
||||
<view wx:if="{{loading}}" class="loading">正在加载房间...</view>
|
||||
|
||||
<view class="field">
|
||||
<text class="label">会员用户 ID</text>
|
||||
<input type="number" value="{{userId}}" placeholder="输入会员用户 ID" bindinput="inputUserId" />
|
||||
<text class="label">会员</text>
|
||||
<view class="member-search-row">
|
||||
<input value="{{memberSearch}}" placeholder="昵称、手机号或会员 ID" bindinput="inputMemberSearch" confirm-type="search" bindconfirm="searchMembers" />
|
||||
<button size="mini" loading="{{memberLoading}}" bindtap="searchMembers">检索</button>
|
||||
</view>
|
||||
<view wx:if="{{selectedMemberId}}" class="selected-member">已选择:{{selectedMemberName}}(ID {{selectedMemberId}})</view>
|
||||
<scroll-view wx:if="{{members.length}}" class="member-results" scroll-y>
|
||||
<view
|
||||
wx:for="{{members}}"
|
||||
wx:key="memberId"
|
||||
class="member-option {{item.memberId === selectedMemberId ? 'active' : ''}}"
|
||||
data-member-id="{{item.memberId}}"
|
||||
bindtap="selectMember"
|
||||
>
|
||||
<view><strong>{{item.displayName}}</strong><text>ID {{item.memberId}}</text></view>
|
||||
<text>{{item.balanceText}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view wx:if="{{!memberLoading && !members.length}}" class="empty-members">授权范围内未找到有效会员</view>
|
||||
</view>
|
||||
<view class="field">
|
||||
<text class="label">房间</text>
|
||||
@@ -36,6 +53,6 @@
|
||||
<picker mode="time" value="{{endTime}}" bindchange="changeEndTime"><view class="picker-value">{{endTime}}</view></picker>
|
||||
</view>
|
||||
</view>
|
||||
<button type="primary" loading="{{submitting}}" disabled="{{loading || submitting || !rooms.length}}" bindtap="submit">确认代下单</button>
|
||||
<button type="primary" loading="{{submitting}}" disabled="{{loading || memberLoading || submitting || !rooms.length || !selectedMemberId}}" bindtap="submit">确认代下单</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
@@ -38,6 +38,62 @@
|
||||
padding: 18rpx;
|
||||
}
|
||||
|
||||
.member-search-row {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.member-search-row button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.selected-member {
|
||||
background: #ecfdf5;
|
||||
border-radius: 12rpx;
|
||||
color: #166534;
|
||||
font-size: 23rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 14rpx;
|
||||
}
|
||||
|
||||
.member-results {
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 14rpx;
|
||||
margin-top: 12rpx;
|
||||
max-height: 360rpx;
|
||||
}
|
||||
|
||||
.member-option {
|
||||
align-items: center;
|
||||
border-bottom: 1rpx solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.member-option.active {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.member-option strong,
|
||||
.member-option text {
|
||||
display: block;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.member-option > text,
|
||||
.member-option view text,
|
||||
.empty-members {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.empty-members {
|
||||
font-size: 23rpx;
|
||||
padding: 18rpx 0;
|
||||
}
|
||||
|
||||
.datetime-row {
|
||||
display: grid;
|
||||
gap: 14rpx;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
const { request, ensureLogin, cents } = require('../../utils/api.js')
|
||||
|
||||
const roleLabels = {
|
||||
CLEANER: '保洁员',
|
||||
STAFF: '门店员工',
|
||||
STORE_ADMIN: '门店管理员',
|
||||
TENANT_ADMIN: '租户管理员',
|
||||
PLATFORM_ADMIN: '平台管理员',
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: false,
|
||||
detailLoading: false,
|
||||
errorMessage: '',
|
||||
tab: 'members',
|
||||
query: '',
|
||||
members: [],
|
||||
staff: [],
|
||||
selectedMember: null,
|
||||
},
|
||||
|
||||
async onLoad() {
|
||||
await this.loadData()
|
||||
},
|
||||
|
||||
selectTab(event) {
|
||||
const tab = event.currentTarget.dataset.tab
|
||||
if (!tab || tab === this.data.tab) return
|
||||
this.setData({ tab, selectedMember: null, errorMessage: '' })
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
inputQuery(event) {
|
||||
this.setData({ query: String(event.detail.value || '').trim() })
|
||||
},
|
||||
|
||||
search() {
|
||||
this.loadData()
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
this.setData({ loading: true, errorMessage: '' })
|
||||
try {
|
||||
await ensureLogin()
|
||||
const search = this.data.query ? `&search=${encodeURIComponent(this.data.query)}` : ''
|
||||
if (this.data.tab === 'members') {
|
||||
const response = await request(`/management/members?page=1&pageSize=50${search}`)
|
||||
this.setData({
|
||||
members: (response.data?.items || []).map((item) => this.presentMember(item)),
|
||||
})
|
||||
} else {
|
||||
const response = await request(`/management/staff?page=1&pageSize=50${search}`)
|
||||
this.setData({
|
||||
staff: (response.data?.items || []).map((item) => this.presentStaff(item)),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '人员数据加载失败' })
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
async openMember(event) {
|
||||
const memberId = event.currentTarget.dataset.memberId
|
||||
if (!memberId || this.data.detailLoading) return
|
||||
this.setData({ detailLoading: true, errorMessage: '' })
|
||||
try {
|
||||
const response = await request(`/management/members/${encodeURIComponent(memberId)}`)
|
||||
const member = this.presentMember(response.data || {})
|
||||
member.recentLedger = (response.data?.recentLedger || []).map((item) => ({
|
||||
...item,
|
||||
deltaText: this.deltaText(item),
|
||||
createdText: this.formatTime(item.createdAt),
|
||||
}))
|
||||
this.setData({ selectedMember: member })
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: error.message || '会员详情加载失败' })
|
||||
} finally {
|
||||
this.setData({ detailLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
closeMember() {
|
||||
this.setData({ selectedMember: null })
|
||||
},
|
||||
|
||||
presentMember(item) {
|
||||
return {
|
||||
...item,
|
||||
displayName: item.nickname || '未命名会员',
|
||||
balanceText: cents(item.wallet?.totalBalanceCents),
|
||||
paidText: cents(item.orders?.paidAmountCents),
|
||||
registeredText: this.formatTime(item.registeredAt),
|
||||
}
|
||||
},
|
||||
|
||||
presentStaff(item) {
|
||||
return {
|
||||
...item,
|
||||
displayName: item.nickname || '未命名员工',
|
||||
rolesText: (item.roles || []).map((role) => roleLabels[role] || role).join(' / ') || '未分配角色',
|
||||
storesText: (item.storeIds || []).length ? `门店 ${item.storeIds.join('、')}` : '全部门店/未绑定门店',
|
||||
statusText: item.status === 'ACTIVE' ? '在职' : '已停用',
|
||||
lastLoginText: item.lastLoginAt ? this.formatTime(item.lastLoginAt) : '从未登录',
|
||||
}
|
||||
},
|
||||
|
||||
deltaText(item) {
|
||||
const cash = Number(item.cashDeltaCents || 0)
|
||||
const gift = Number(item.giftDeltaCents || 0)
|
||||
const total = cash + gift
|
||||
return `${total >= 0 ? '+' : '-'}${cents(Math.abs(total))}`
|
||||
},
|
||||
|
||||
formatTime(value) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value || '')
|
||||
const pad = (part) => String(part).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "会员与员工",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<view class="container people-page">
|
||||
<view class="title">会员与员工</view>
|
||||
<view class="subtitle">仅展示当前账号数据范围内的人员,手机号与最近 IP 保持脱敏。</view>
|
||||
|
||||
<view class="people-tabs">
|
||||
<button class="{{tab === 'members' ? 'active' : ''}}" data-tab="members" bindtap="selectTab">会员</button>
|
||||
<button class="{{tab === 'staff' ? 'active' : ''}}" data-tab="staff" bindtap="selectTab">员工</button>
|
||||
</view>
|
||||
<view class="search-row">
|
||||
<input value="{{query}}" placeholder="昵称、手机号或 ID" bindinput="inputQuery" confirm-type="search" bindconfirm="search" />
|
||||
<button size="mini" loading="{{loading}}" bindtap="search">检索</button>
|
||||
</view>
|
||||
<view wx:if="{{errorMessage}}" class="error">{{errorMessage}}</view>
|
||||
<view wx:if="{{loading}}" class="loading">正在加载...</view>
|
||||
|
||||
<view wx:if="{{selectedMember}}" class="detail-card">
|
||||
<view class="card-heading">
|
||||
<view>
|
||||
<view class="card-title">{{selectedMember.displayName}}</view>
|
||||
<view class="card-meta">{{selectedMember.maskedPhone}} · ID {{selectedMember.memberId}}</view>
|
||||
</view>
|
||||
<button size="mini" bindtap="closeMember">关闭</button>
|
||||
</view>
|
||||
<view class="metric-grid">
|
||||
<view><strong>{{selectedMember.balanceText}}</strong><text>总余额</text></view>
|
||||
<view><strong>{{selectedMember.orders.orderCount}}</strong><text>订单</text></view>
|
||||
<view><strong>{{selectedMember.benefits.availableCoupons}}</strong><text>可用券</text></view>
|
||||
<view><strong>{{selectedMember.benefits.activePackages}}</strong><text>有效套餐</text></view>
|
||||
</view>
|
||||
<view class="detail-line">累计支付:{{selectedMember.paidText}}</view>
|
||||
<view class="detail-line">注册时间:{{selectedMember.registeredText}}</view>
|
||||
<view class="subheading">最近余额流水</view>
|
||||
<view wx:if="{{!selectedMember.recentLedger.length}}" class="empty">暂无余额流水</view>
|
||||
<view wx:for="{{selectedMember.recentLedger}}" wx:key="ledgerId" class="ledger-line">
|
||||
<view><strong>{{item.businessType}}</strong><text>{{item.createdText}}</text></view>
|
||||
<strong>{{item.deltaText}}</strong>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<block wx:if="{{tab === 'members'}}">
|
||||
<view wx:if="{{!loading && !members.length}}" class="empty">授权范围内暂无会员</view>
|
||||
<view wx:for="{{members}}" wx:key="memberId" class="person-card" data-member-id="{{item.memberId}}" bindtap="openMember">
|
||||
<view class="card-heading">
|
||||
<view>
|
||||
<view class="card-title">{{item.displayName}}</view>
|
||||
<view class="card-meta">{{item.maskedPhone}} · ID {{item.memberId}}</view>
|
||||
</view>
|
||||
<view class="status-tag">{{item.status === 'ACTIVE' ? '有效' : '停用'}}</view>
|
||||
</view>
|
||||
<view class="person-summary"><text>余额 {{item.balanceText}}</text><text>订单 {{item.orders.orderCount}}</text><text>消费 {{item.paidText}}</text></view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block wx:if="{{tab === 'staff'}}">
|
||||
<view wx:if="{{!loading && !staff.length}}" class="empty">授权范围内暂无员工</view>
|
||||
<view wx:for="{{staff}}" wx:key="id" class="person-card">
|
||||
<view class="card-heading">
|
||||
<view>
|
||||
<view class="card-title">{{item.displayName}}</view>
|
||||
<view class="card-meta">{{item.maskedPhone}} · ID {{item.id}}</view>
|
||||
</view>
|
||||
<view class="status-tag {{item.status === 'ACTIVE' ? '' : 'disabled'}}">{{item.statusText}}</view>
|
||||
</view>
|
||||
<view class="detail-line">{{item.rolesText}}</view>
|
||||
<view class="detail-line">{{item.storesText}}</view>
|
||||
<view class="detail-line">最近登录:{{item.lastLoginText}} · {{item.maskedLastIp || '无 IP'}}</view>
|
||||
<view wx:if="{{item.note}}" class="staff-note">备注:{{item.note}}</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
@@ -0,0 +1,171 @@
|
||||
.people-page {
|
||||
padding-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.card-meta,
|
||||
.detail-line,
|
||||
.ledger-line text {
|
||||
color: #64748b;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
line-height: 1.6;
|
||||
margin: 8rpx 0 22rpx;
|
||||
}
|
||||
|
||||
.people-tabs,
|
||||
.search-row,
|
||||
.card-heading,
|
||||
.person-summary,
|
||||
.ledger-line {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.people-tabs {
|
||||
gap: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.people-tabs button {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.people-tabs .active {
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
grid-template-columns: 1fr auto;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.search-row input {
|
||||
background: #fff;
|
||||
border: 1rpx solid #cbd5e1;
|
||||
border-radius: 14rpx;
|
||||
padding: 18rpx;
|
||||
}
|
||||
|
||||
.search-row button,
|
||||
.card-heading button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.person-card,
|
||||
.detail-card {
|
||||
background: #fff;
|
||||
border: 1rpx solid #e2e8f0;
|
||||
border-radius: 18rpx;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 22rpx;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.card-heading,
|
||||
.ledger-line {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
background: #dcfce7;
|
||||
border-radius: 999rpx;
|
||||
color: #166534;
|
||||
font-size: 22rpx;
|
||||
padding: 8rpx 14rpx;
|
||||
}
|
||||
|
||||
.status-tag.disabled {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.person-summary {
|
||||
gap: 20rpx;
|
||||
justify-content: space-between;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.person-summary text {
|
||||
color: #334155;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
gap: 12rpx;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
margin: 20rpx 0;
|
||||
}
|
||||
|
||||
.metric-grid view {
|
||||
background: #f8fafc;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.metric-grid strong {
|
||||
color: #0f172a;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.metric-grid text {
|
||||
color: #64748b;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
|
||||
.detail-line {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
font-size: 25rpx;
|
||||
font-weight: 600;
|
||||
margin: 20rpx 0 10rpx;
|
||||
}
|
||||
|
||||
.ledger-line {
|
||||
border-top: 1rpx solid #f1f5f9;
|
||||
padding: 14rpx 0;
|
||||
}
|
||||
|
||||
.ledger-line strong,
|
||||
.ledger-line text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.staff-note {
|
||||
background: #f8fafc;
|
||||
border-radius: 10rpx;
|
||||
color: #475569;
|
||||
font-size: 23rpx;
|
||||
margin-top: 12rpx;
|
||||
padding: 12rpx;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
color: #64748b;
|
||||
padding: 28rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const read = (path) => readFileSync(join(root, path), 'utf8');
|
||||
|
||||
const appJson = JSON.parse(read('miniapp/app.json'));
|
||||
assert.ok(appJson.pages.includes('pages/manager/dashboard'));
|
||||
assert.ok(appJson.pages.includes('pages/manager/people'));
|
||||
assert.ok(appJson.pages.includes('pages/manager/order-create'));
|
||||
|
||||
const index = read('miniapp/pages/index/index.js') + read('miniapp/pages/index/index.wxml');
|
||||
@@ -36,6 +37,8 @@ for (const pattern of [
|
||||
"method: 'PATCH'",
|
||||
'store.operation.write',
|
||||
'tenant.manage',
|
||||
'canReadUsers',
|
||||
'openPeople',
|
||||
'changeRoomStatus',
|
||||
'toggleRoomConfiguration',
|
||||
'activeOrders',
|
||||
@@ -55,11 +58,13 @@ const orderCreate = read('miniapp/pages/manager/order-create.js')
|
||||
+ read('miniapp/pages/manager/order-create.wxml');
|
||||
for (const pattern of [
|
||||
'/management/stores/${encodeURIComponent(this.data.storeId)}/rooms',
|
||||
'/management/members?page=1&pageSize=20&status=ACTIVE',
|
||||
'/management/orders/reserve-on-behalf',
|
||||
'startAt.toISOString()',
|
||||
'endAt.toISOString()',
|
||||
'pricingMode',
|
||||
'会员用户 ID',
|
||||
'selectMember',
|
||||
'授权范围内选择会员',
|
||||
'价格由服务端'
|
||||
]) {
|
||||
assert.match(orderCreate, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
@@ -78,6 +83,42 @@ for (const pattern of [
|
||||
assert.match(orderRoutes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const people = read('miniapp/pages/manager/people.js')
|
||||
+ read('miniapp/pages/manager/people.wxml');
|
||||
for (const pattern of [
|
||||
'/management/members?page=1&pageSize=50',
|
||||
'/management/members/${encodeURIComponent(memberId)}',
|
||||
'/management/staff?page=1&pageSize=50',
|
||||
'selectedMember',
|
||||
'maskedLastIp',
|
||||
'会员与员工',
|
||||
'最近余额流水'
|
||||
]) {
|
||||
assert.match(people, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const peopleRoutes = read('backend/src/routes/members.ts')
|
||||
+ read('backend/src/routes/user-management.ts')
|
||||
+ read('backend/src/auth/user-management-repository.ts')
|
||||
+ read('backend/src/wallets/member-profile-service.ts');
|
||||
for (const pattern of [
|
||||
'/app-api/management/members',
|
||||
'/app-api/management/members/:id',
|
||||
'/app-api/management/staff',
|
||||
"userType: 'STAFF'",
|
||||
'qipai_order_user_access oa',
|
||||
"oa.access_type = 'OWNER'",
|
||||
'mo.store_id IN'
|
||||
]) {
|
||||
assert.match(peopleRoutes, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
||||
}
|
||||
|
||||
const mysqlPaginationRepositories = peopleRoutes
|
||||
+ read('backend/src/orders/order-query-repository.ts')
|
||||
+ read('backend/src/cleaning/cleaning-task-repository.ts');
|
||||
assert.doesNotMatch(mysqlPaginationRepositories, /LIMIT \? OFFSET \?/);
|
||||
assert.match(mysqlPaginationRepositories, /Math\.trunc/);
|
||||
|
||||
const routes = read('backend/src/routes/store-room-management.ts');
|
||||
for (const pattern of [
|
||||
'/app-api/management/stores',
|
||||
|
||||
Reference in New Issue
Block a user