feat(M08-C): 补会员检索与员工概况

This commit is contained in:
Codex
2026-08-10 10:35:44 +08:00
parent 8b46863cd8
commit 1b0839e881
22 changed files with 758 additions and 74 deletions
+1
View File
@@ -10,6 +10,7 @@
"pages/recharge/index",
"pages/cleaner/tasks",
"pages/manager/dashboard",
"pages/manager/people",
"pages/manager/order-create",
"pages/logs/logs"
],
+9
View File
@@ -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
+8
View File
@@ -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>
+15
View File
@@ -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;
+43 -6
View File
@@ -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) {
+20 -3
View File
@@ -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>
+56
View File
@@ -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;
+123
View File
@@ -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())}`
},
})
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "会员与员工",
"enablePullDownRefresh": false
}
+72
View File
@@ -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>
+171
View File
@@ -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;
}