feat(M08-D): 补会员与员工用户管理
This commit is contained in:
+18
-1
@@ -68,6 +68,15 @@
|
||||
<TicketCheck :size="18" />
|
||||
<span>团购平台</span>
|
||||
</button>
|
||||
<button
|
||||
class="nav-item"
|
||||
:class="{ active: activeModule === 'people' }"
|
||||
type="button"
|
||||
@click="activeModule = 'people'"
|
||||
>
|
||||
<UsersRound :size="18" />
|
||||
<span>会员员工</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -118,6 +127,11 @@
|
||||
:session="session"
|
||||
/>
|
||||
|
||||
<MembersStaffPanel
|
||||
v-else-if="activeModule === 'people'"
|
||||
:session="session"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<section class="metric-grid" aria-label="保洁概览">
|
||||
<div class="metric">
|
||||
@@ -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 });
|
||||
|
||||
+18
-1
@@ -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<MemberDetail>(session, `/members/${encodeURIComponent(memberId)}`);
|
||||
}
|
||||
|
||||
export function createStaffUser(
|
||||
session: ApiSession,
|
||||
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user