feat(M08-D): 补会员与员工用户管理
This commit is contained in:
+18
-1
@@ -68,6 +68,15 @@
|
|||||||
<TicketCheck :size="18" />
|
<TicketCheck :size="18" />
|
||||||
<span>团购平台</span>
|
<span>团购平台</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: activeModule === 'people' }"
|
||||||
|
type="button"
|
||||||
|
@click="activeModule = 'people'"
|
||||||
|
>
|
||||||
|
<UsersRound :size="18" />
|
||||||
|
<span>会员员工</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -118,6 +127,11 @@
|
|||||||
:session="session"
|
:session="session"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MembersStaffPanel
|
||||||
|
v-else-if="activeModule === 'people'"
|
||||||
|
:session="session"
|
||||||
|
/>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="metric-grid" aria-label="保洁概览">
|
<section class="metric-grid" aria-label="保洁概览">
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
@@ -289,6 +303,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Store,
|
Store,
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
|
UsersRound,
|
||||||
WalletCards
|
WalletCards
|
||||||
} from '@lucide/vue';
|
} from '@lucide/vue';
|
||||||
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
|
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
|
||||||
@@ -301,6 +316,7 @@ import StoresRoomsPanel from './components/StoresRoomsPanel.vue';
|
|||||||
import OrdersPanel from './components/OrdersPanel.vue';
|
import OrdersPanel from './components/OrdersPanel.vue';
|
||||||
import PaymentsPanel from './components/PaymentsPanel.vue';
|
import PaymentsPanel from './components/PaymentsPanel.vue';
|
||||||
import ThirdPartyPanel from './components/ThirdPartyPanel.vue';
|
import ThirdPartyPanel from './components/ThirdPartyPanel.vue';
|
||||||
|
import MembersStaffPanel from './components/MembersStaffPanel.vue';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
assignCleaningTask,
|
assignCleaningTask,
|
||||||
@@ -337,7 +353,7 @@ import { money } from './format';
|
|||||||
|
|
||||||
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
const savedToken = ref(localStorage.getItem('qipai.admin.token') || '');
|
||||||
const tokenDraft = ref(savedToken.value);
|
const tokenDraft = ref(savedToken.value);
|
||||||
const activeModule = ref<'overview' | 'stores' | 'orders' | 'payments' | 'thirdParty' | 'cleaning'>('overview');
|
const activeModule = ref<'overview' | 'stores' | 'orders' | 'payments' | 'thirdParty' | 'people' | 'cleaning'>('overview');
|
||||||
const activeTab = ref('tasks');
|
const activeTab = ref('tasks');
|
||||||
const lastError = ref('');
|
const lastError = ref('');
|
||||||
const lastMessage = ref('');
|
const lastMessage = ref('');
|
||||||
@@ -352,6 +368,7 @@ const activeModuleMeta = computed(() => ({
|
|||||||
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
|
orders: { stage: 'M08-D', title: '订单筛选与运营处置' },
|
||||||
payments: { stage: 'M08-D', title: '支付、退款与分账' },
|
payments: { stage: 'M08-D', title: '支付、退款与分账' },
|
||||||
thirdParty: { stage: 'M08-D', title: '团购与第三方平台运营' },
|
thirdParty: { stage: 'M08-D', title: '团购与第三方平台运营' },
|
||||||
|
people: { stage: 'M08-D', title: '会员与员工用户管理' },
|
||||||
cleaning: { stage: 'M08-B', title: '保洁任务与结算' }
|
cleaning: { stage: 'M08-B', title: '保洁任务与结算' }
|
||||||
})[activeModule.value]);
|
})[activeModule.value]);
|
||||||
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
|
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
|
||||||
|
|||||||
+18
-1
@@ -7,6 +7,8 @@ import type {
|
|||||||
CleaningTaskMember,
|
CleaningTaskMember,
|
||||||
BusinessStatistics,
|
BusinessStatistics,
|
||||||
ManagedRoom,
|
ManagedRoom,
|
||||||
|
MemberCard,
|
||||||
|
MemberDetail,
|
||||||
ManagedOrder,
|
ManagedOrder,
|
||||||
ManagedStore,
|
ManagedStore,
|
||||||
ManagedUser,
|
ManagedUser,
|
||||||
@@ -356,7 +358,8 @@ export function listStaffUsers(
|
|||||||
) {
|
) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: String(input.page),
|
page: String(input.page),
|
||||||
pageSize: String(input.pageSize)
|
pageSize: String(input.pageSize),
|
||||||
|
userType: 'STAFF'
|
||||||
});
|
});
|
||||||
if (input.role) params.set('role', input.role);
|
if (input.role) params.set('role', input.role);
|
||||||
if (input.status) params.set('status', input.status);
|
if (input.status) params.set('status', input.status);
|
||||||
@@ -364,6 +367,20 @@ export function listStaffUsers(
|
|||||||
return request<{ items: ManagedUser[]; total: number }>(session, `/users?${params}`);
|
return request<{ items: ManagedUser[]; total: number }>(session, `/users?${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listMembers(
|
||||||
|
session: ApiSession,
|
||||||
|
input: { page: number; pageSize: number; status?: UserStatus; search?: string }
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
|
||||||
|
if (input.status) params.set('status', input.status);
|
||||||
|
if (input.search) params.set('search', input.search);
|
||||||
|
return request<{ items: MemberCard[]; total: number }>(session, `/members?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMember(session: ApiSession, memberId: string) {
|
||||||
|
return request<MemberDetail>(session, `/members/${encodeURIComponent(memberId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function createStaffUser(
|
export function createStaffUser(
|
||||||
session: ApiSession,
|
session: ApiSession,
|
||||||
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
||||||
|
|||||||
@@ -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;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.people-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people-metrics,
|
||||||
|
.member-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people-metrics span,
|
||||||
|
.member-detail-grid span {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d8e0ea;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people-metrics small,
|
||||||
|
.member-detail-grid small {
|
||||||
|
color: #69788c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people-metrics strong,
|
||||||
|
.member-detail-grid strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people-toolbar h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people-tabs {
|
||||||
|
padding: 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.people-filter-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-detail h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.el-button {
|
.el-button {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@@ -1639,6 +1702,8 @@ textarea {
|
|||||||
|
|
||||||
.payment-metrics,
|
.payment-metrics,
|
||||||
.third-party-metrics,
|
.third-party-metrics,
|
||||||
|
.people-metrics,
|
||||||
|
.member-detail-grid,
|
||||||
.payment-tools-grid {
|
.payment-tools-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -1749,6 +1814,8 @@ textarea {
|
|||||||
|
|
||||||
.payment-metrics,
|
.payment-metrics,
|
||||||
.third-party-metrics,
|
.third-party-metrics,
|
||||||
|
.people-metrics,
|
||||||
|
.member-detail-grid,
|
||||||
.payment-tools-grid {
|
.payment-tools-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1758,6 +1825,12 @@ textarea {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.people-filter-row > .el-input,
|
||||||
|
.people-filter-row > .el-select,
|
||||||
|
.people-filter-row > .el-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-actions {
|
.toolbar-actions {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,6 +307,63 @@ export interface ManagedUser {
|
|||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemberWalletSummary {
|
||||||
|
accountCount: number;
|
||||||
|
cashBalanceCents: number;
|
||||||
|
giftBalanceCents: number;
|
||||||
|
totalBalanceCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberBenefitSummary {
|
||||||
|
availableCoupons: number;
|
||||||
|
frozenCoupons: number;
|
||||||
|
activePackages: number;
|
||||||
|
frozenPackages: number;
|
||||||
|
packageMinutes: number;
|
||||||
|
packageAmountCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberCard {
|
||||||
|
memberId: string;
|
||||||
|
status: UserStatus;
|
||||||
|
nickname: string;
|
||||||
|
maskedPhone: string;
|
||||||
|
registeredAt: string;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
wallet: MemberWalletSummary;
|
||||||
|
benefits: MemberBenefitSummary;
|
||||||
|
recharge: {
|
||||||
|
rechargeOrderCount: number;
|
||||||
|
creditedRechargeCount: number;
|
||||||
|
creditedRechargeCents: number;
|
||||||
|
giftedRechargeCents: number;
|
||||||
|
lastRechargeAt: string | null;
|
||||||
|
};
|
||||||
|
orders: {
|
||||||
|
orderCount: number;
|
||||||
|
paidOrderCount: number;
|
||||||
|
paidAmountCents: number;
|
||||||
|
lastOrderAt: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberLedgerEntry {
|
||||||
|
ledgerId: string;
|
||||||
|
storeId: string | null;
|
||||||
|
businessType: string;
|
||||||
|
businessId: string;
|
||||||
|
entryType: string;
|
||||||
|
cashDeltaCents: number;
|
||||||
|
giftDeltaCents: number;
|
||||||
|
cashBalanceAfterCents: number;
|
||||||
|
giftBalanceAfterCents: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberDetail extends MemberCard {
|
||||||
|
recentLedger: MemberLedgerEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CleaningTask {
|
export interface CleaningTask {
|
||||||
id: string;
|
id: string;
|
||||||
taskNo: string;
|
taskNo: string;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const updateSchema = z.object({
|
|||||||
const listSchema = z.object({
|
const listSchema = z.object({
|
||||||
page: z.coerce.number().int().min(1).default(1),
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
userType: z.enum(['STAFF']).optional(),
|
||||||
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
|
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
|
||||||
role: z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN']).optional(),
|
role: z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN']).optional(),
|
||||||
search: z.string().trim().max(128).optional()
|
search: z.string().trim().max(128).optional()
|
||||||
|
|||||||
@@ -107,11 +107,12 @@ assert.deepEqual(createdInput.storeIds, ['11']);
|
|||||||
|
|
||||||
const cleaners = await app.inject({
|
const cleaners = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/admin-api/users?role=CLEANER&status=ACTIVE&search=%E4%BF%9D%E6%B4%81',
|
url: '/admin-api/users?userType=STAFF&role=CLEANER&status=ACTIVE&search=%E4%BF%9D%E6%B4%81',
|
||||||
headers: { authorization: `Bearer ${token}` }
|
headers: { authorization: `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
assert.equal(cleaners.statusCode, 200);
|
assert.equal(cleaners.statusCode, 200);
|
||||||
assert.equal(listedInput.role, 'CLEANER');
|
assert.equal(listedInput.role, 'CLEANER');
|
||||||
|
assert.equal(listedInput.userType, 'STAFF');
|
||||||
assert.equal(listedInput.status, 'ACTIVE');
|
assert.equal(listedInput.status, 'ACTIVE');
|
||||||
assert.equal(listedInput.search, '保洁');
|
assert.equal(listedInput.search, '保洁');
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ for (const pattern of [
|
|||||||
"activeModule = 'orders'",
|
"activeModule = 'orders'",
|
||||||
"activeModule = 'payments'",
|
"activeModule = 'payments'",
|
||||||
"activeModule = 'thirdParty'",
|
"activeModule = 'thirdParty'",
|
||||||
|
"activeModule = 'people'",
|
||||||
'平台运营总览',
|
'平台运营总览',
|
||||||
'StoresRoomsPanel',
|
'StoresRoomsPanel',
|
||||||
'OrdersPanel',
|
'OrdersPanel',
|
||||||
'PaymentsPanel',
|
'PaymentsPanel',
|
||||||
'ThirdPartyPanel',
|
'ThirdPartyPanel',
|
||||||
|
'MembersStaffPanel',
|
||||||
'运营总览',
|
'运营总览',
|
||||||
'savedToken',
|
'savedToken',
|
||||||
'loadCleaningWorkspace'
|
'loadCleaningWorkspace'
|
||||||
@@ -140,6 +142,23 @@ const thirdPartyRoutes = read('backend/src/routes/third-party.ts');
|
|||||||
assert.match(thirdPartyRoutes, /'\/admin-api\/third-party\/setup'/);
|
assert.match(thirdPartyRoutes, /'\/admin-api\/third-party\/setup'/);
|
||||||
assert.match(thirdPartyRoutes, /'\/admin-api\/group-vouchers\/redeem'/);
|
assert.match(thirdPartyRoutes, /'\/admin-api\/group-vouchers\/redeem'/);
|
||||||
|
|
||||||
|
const people = read('admin/src/components/MembersStaffPanel.vue');
|
||||||
|
for (const pattern of [
|
||||||
|
'listMembers',
|
||||||
|
'getMember',
|
||||||
|
'listStaffUsers',
|
||||||
|
'createStaffUser',
|
||||||
|
'updateStaffUser',
|
||||||
|
'resetStaffSessions',
|
||||||
|
'memberDetail',
|
||||||
|
'staffForm'
|
||||||
|
]) {
|
||||||
|
assert.match(people, new RegExp(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRoutes = read('backend/src/routes/user-management.ts');
|
||||||
|
assert.match(userRoutes, /userType: z\.enum\(\['STAFF'\]\)\.optional\(\)/);
|
||||||
|
|
||||||
const routes = read('backend/src/routes/business-statistics.ts');
|
const routes = read('backend/src/routes/business-statistics.ts');
|
||||||
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
assert.match(routes, /'\/app-api\/management\/statistics', '\/admin-api\/statistics'/);
|
||||||
|
|
||||||
@@ -173,6 +192,8 @@ for (const pattern of [
|
|||||||
'.payment-tools-grid',
|
'.payment-tools-grid',
|
||||||
'.third-party-metrics',
|
'.third-party-metrics',
|
||||||
'.settings-preview',
|
'.settings-preview',
|
||||||
|
'.people-metrics',
|
||||||
|
'.member-detail-grid',
|
||||||
'@media (max-width: 980px)',
|
'@media (max-width: 980px)',
|
||||||
'@media (max-width: 560px)'
|
'@media (max-width: 560px)'
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user