Bladeren bron

fix(admin): classify members and exclude operators

彭峰 1 maand geleden
bovenliggende
commit
6fbbbe693d

File diff suppressed because it is too large
+ 10 - 3
scripts/deploy-admin-functions.mjs


+ 8 - 1
scripts/smoke-operations-functions.mjs

@@ -42,6 +42,13 @@ try {
 
   const users = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/users/list', page: 1, pageSize: 20, search: '' })).data;
   if (!Array.isArray(users.items) || !users.items.length || users.items.some((item) => 'legacyUserData' in item || 'legacyPasswordHash' in item || 'ACL' in item)) throw new Error('用户业务投影为空或泄漏技术字段');
+  if (users.items.some((item) => item.username === username) || users.items.some((item) => !item.learningStatus || !item.learningStatusLabel) || !users.refreshedAt) throw new Error('管理员过滤、学习状态或刷新时间无效');
+  const learningCounts = {};
+  for (const learningStatus of ['trial', 'studying', 'completed', 'not_started', 'not_applicable']) {
+    const result = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/users/list', page: 1, pageSize: 1, search: '', filters: { learningStatus } })).data;
+    learningCounts[learningStatus] = result.total;
+  }
+  if (Object.values(learningCounts).reduce((sum, value) => sum + Number(value || 0), 0) !== users.total) throw new Error(`学习状态分类数量与会员总数不一致:${JSON.stringify(learningCounts)}`);
   const named = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/users/list', page: 1, pageSize: 20, search: '万宇昊' })).data;
   if (!named.items?.some((item) => item.displayName === '万宇昊')) throw new Error('用户显示名称兜底未找到“万宇昊”');
 
@@ -52,7 +59,7 @@ try {
 
   const denied = await call('xiaoshu/admin/gateway', token, { operation: 'catalog' }, 403);
   if (denied.success !== false) throw new Error('普通运营管理员仍可访问技术数据目录');
-  console.log(`Operations smoke passed: users=${users.total}, metrics=${dashboard.metrics.length}.`);
+  console.log(`Operations smoke passed: users=${users.total}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);
 } finally {
   if (userId) {
     const sessions = await request(`${PARSE_URL}/classes/_Session?where=${encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }))}&limit=1000`, {}, true).catch(() => ({ results: [] }));

+ 8 - 0
src/app/admin/admin.models.ts

@@ -105,6 +105,7 @@ export interface OperationsPage<T extends Record<string, unknown> = Record<strin
   pageSize: number;
   total: number;
   items: T[];
+  refreshedAt?: string;
 }
 
 export interface OperationsDashboardMetric {
@@ -137,6 +138,13 @@ export interface OperationsUser extends Record<string, unknown> {
   periods30: number;
   periods60: number;
   trialPeriods: number;
+  activeCourseCount: number;
+  completedCourseCount: number;
+  studyRecordCount: number;
+  appointmentCount: number;
+  learningStatus: 'trial' | 'studying' | 'completed' | 'not_started' | 'not_applicable';
+  learningStatusLabel: string;
+  learningStatusReason: string;
   registeredAt: string;
   lastLoginAt: string;
   loginCount: number;

+ 1 - 1
src/app/admin/operations.service.spec.ts

@@ -15,7 +15,7 @@ describe('OperationsService', () => {
   it('uses the dedicated operations gateway for user search', async () => {
     functions.operations.and.resolveTo({ page: 1, pageSize: 20, total: 0, items: [] });
     await service.users(1, 20, '万宇昊', { status: 'active' });
-    expect(functions.operations).toHaveBeenCalledWith('ops/users/list', { page: 1, pageSize: 20, search: '万宇昊', filters: { status: 'active' } });
+    expect(functions.operations).toHaveBeenCalledWith('ops/users/list', { page: 1, pageSize: 20, search: '万宇昊', filters: { status: 'active' }, refresh: false });
   });
 
   it('maps each teaching view to a business operation instead of generic class CRUD', async () => {

+ 2 - 2
src/app/admin/operations.service.ts

@@ -12,8 +12,8 @@ export class OperationsService {
     return this.functions.operations<OperationsDashboardData>('ops/dashboard/summary');
   }
 
-  users(page: number, pageSize: number, search: string, filters: Record<string, unknown> = {}): Promise<OperationsPage<OperationsUser>> {
-    return this.functions.operations<OperationsPage<OperationsUser>>('ops/users/list', { page, pageSize, search, filters });
+  users(page: number, pageSize: number, search: string, filters: Record<string, unknown> = {}, refresh = false): Promise<OperationsPage<OperationsUser>> {
+    return this.functions.operations<OperationsPage<OperationsUser>>('ops/users/list', { page, pageSize, search, filters, refresh });
   }
 
   userDetail(objectId: string): Promise<OperationsUserDetail> {

+ 1 - 1
src/app/admin/pages/operations-user-detail.component.html

@@ -10,7 +10,7 @@
 
   <div class="detail-grid">
     <section class="panel"><header><h2>基本资料</h2><span>身份与联系信息</span></header><dl><div><dt>显示名称</dt><dd>{{ detail.displayName }}</dd></div><div><dt>手机号</dt><dd>{{ revealMobile() ? detail.mobile : detail.mobileMasked }} @if (!revealMobile() && detail.permissions.canRevealMobile) { <button class="text-button" type="button" (click)="reveal()"><lucide-icon [img]="icons.Eye" [size]="15" />查看完整号码</button> }</dd></div><div><dt>邮箱</dt><dd>{{ detail.email || '—' }}</dd></div><div><dt>注册时间</dt><dd>{{ detail.registeredAt | date:'yyyy-MM-dd HH:mm' }}</dd></div><div><dt>最后登录</dt><dd>{{ detail.lastLoginAt ? (detail.lastLoginAt | date:'yyyy-MM-dd HH:mm') : '—' }}</dd></div><div><dt>登录次数</dt><dd>{{ detail.loginCount }}</dd></div></dl></section>
-    <section class="panel"><header><h2>课程与余额</h2><span>当前可用额度</span></header><div class="balance-grid"><article><strong>{{ detail.periods30 }}</strong><span>30 分钟课时</span></article><article><strong>{{ detail.periods60 }}</strong><span>60 分钟课时</span></article><article><strong>{{ detail.trialPeriods }}</strong><span>体验/扩展课时</span></article><article><strong>{{ detail.balances.userPoint }}</strong><span>用户积分</span></article></div></section>
+    <section class="panel"><header><h2>课程与学习状态</h2><span>{{ detail.learningStatusLabel }} · {{ detail.learningStatusReason }}</span></header><div class="balance-grid"><article><strong>{{ detail.periods30 }}</strong><span>30 分钟课时</span></article><article><strong>{{ detail.periods60 }}</strong><span>60 分钟课时</span></article><article><strong>{{ detail.trialPeriods }}</strong><span>体验课时</span></article><article><strong>{{ detail.activeCourseCount }}</strong><span>有效课程绑定</span></article><article><strong>{{ detail.completedCourseCount }}</strong><span>已学完课程</span></article><article><strong>{{ detail.studyRecordCount }}</strong><span>学习记录</span></article></div></section>
     <section class="panel"><header><h2>组织关系</h2><span>用户组与所属代理</span></header><dl><div><dt>当前用户组</dt><dd>{{ detail.groupName || ('用户组 ' + detail.groupId) }}</dd></div><div><dt>所属代理</dt><dd>{{ detail.parentDisplayName || '未绑定' }}</dd></div></dl>@if (detail.permissions.canManage) { <div class="inline-form"><label>用户组 ID<input type="number" min="0" [ngModel]="groupId()" (ngModelChange)="groupId.set($event)" /></label><button type="button" (click)="changeGroup()" [disabled]="saving()">更新用户组</button><label>上级代理用户 ID<input type="number" min="0" [ngModel]="parentUserId()" (ngModelChange)="parentUserId.set($event)" /></label><button type="button" (click)="saveRelation()" [disabled]="saving()">保存代理关系</button></div> }</section>
     <section class="panel risk"><header><h2>账号与高风险操作</h2><span>所有操作都会写入审计日志</span></header>@if (detail.permissions.canManage) { <div class="risk-actions"><button type="button" (click)="changeStatus()" [disabled]="saving()"><lucide-icon [img]="icons.ShieldAlert" [size]="17" />{{ detail.status === 'disabled' ? '恢复账号' : '停用账号' }}</button></div> } @if (detail.permissions.canAdjustBalance) { <div class="balance-form"><label>调整项目<select [ngModel]="balanceType()" (ngModelChange)="balanceType.set($event)"><option [ngValue]="1">30 分钟课时</option><option [ngValue]="2">60 分钟课时</option><option [ngValue]="3">经验</option><option [ngValue]="4">积分</option></select></label><label>方向<select [ngModel]="balanceDirection()" (ngModelChange)="balanceDirection.set($event)"><option value="add">增加</option><option value="deduct">扣减</option></select></label><label>数量<input type="number" min="0.01" step="0.01" [ngModel]="balanceAmount()" (ngModelChange)="balanceAmount.set($event)" /></label><button type="button" (click)="adjustBalance()" [disabled]="saving()">确认调整</button></div> }<label class="reason">操作原因<textarea [ngModel]="reason()" (ngModelChange)="reason.set($event)" placeholder="必填:说明本次修改的业务原因"></textarea></label></section>
   </div>

+ 6 - 5
src/app/admin/pages/operations-users.component.html

@@ -1,31 +1,32 @@
 <header class="page-header">
   <div><p>USER OPERATIONS</p><h1>用户列表</h1><span>按真实姓名、昵称、手机号、用户 ID 或所属代理查找</span></div>
-  <button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button>
+  <button class="refresh-button" type="button" (click)="refresh()" [disabled]="loading()"><lucide-icon [class.spin]="loading()" [img]="icons.RefreshCw" [size]="17" />{{ loading() ? '正在刷新…' : '刷新数据' }}</button>
 </header>
 
 <section class="filters" aria-label="用户筛选">
   <label><span>搜索用户</span><div class="search-control"><lucide-icon [img]="icons.Search" [size]="18" /><input [ngModel]="query()" (ngModelChange)="query.set($event)" (keyup.enter)="load(1)" placeholder="姓名、昵称、手机号、用户 ID、代理" /></div></label>
   <label><span>账号状态</span><select [ngModel]="status()" (ngModelChange)="status.set($event)"><option value="all">全部状态</option><option value="active">正常</option><option value="disabled">已停用</option></select></label>
+  <label><span>学习状态</span><select [ngModel]="learningStatus()" (ngModelChange)="learningStatus.set($event)"><option value="all">全部学习状态</option><option value="trial">体验用户</option><option value="studying">正在学习</option><option value="completed">已经学完</option><option value="not_started">尚未开始</option><option value="not_applicable">非学员账号</option></select></label>
   <div class="filter-actions"><button class="primary" type="button" (click)="load(1)">查询</button><button type="button" (click)="reset()">重置</button></div>
 </section>
 
 <section class="table-card">
-  <header><div><lucide-icon [img]="icons.Users" [size]="19" /><strong>用户数据</strong><span>{{ page()?.total || 0 }} 人</span></div><small>手机号按当前账号权限显示</small></header>
+  <header><div><lucide-icon [img]="icons.Users" [size]="19" /><strong>会员数据</strong><span>{{ page()?.total || 0 }} 人(已排除管理员)</span></div><small>最后刷新:{{ lastRefreshedAt() | date:'HH:mm:ss' }} · 手机号按权限显示</small></header>
   @if (loading() && !page()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="26" />正在加载用户…</div> }
   @else if (error()) { <div class="state error" role="alert">{{ error() }}</div> }
   @else {
     <div class="table-scroll"><table>
-      <thead><tr><th>用户</th><th>手机号</th><th>用户组</th><th>所属代理</th><th>30 分钟</th><th>60 分钟</th><th>注册时间</th><th>状态</th><th><span class="sr-only">操作</span></th></tr></thead>
+      <thead><tr><th>用户</th><th>手机号</th><th>会员组</th><th>学习状态</th><th>所属代理</th><th>30 分钟</th><th>60 分钟</th><th>注册时间</th><th>账号状态</th><th><span class="sr-only">操作</span></th></tr></thead>
       <tbody>
         @for (row of page()?.items || []; track row.objectId) {
           <tr>
             <td><div class="user-cell"><strong>{{ row.displayName }}</strong><span>ID {{ row.userId || '—' }} · {{ row.username }}</span></div></td>
-            <td>{{ row.mobile || row.mobileMasked || '—' }}</td><td>{{ row.groupName || ('用户组 ' + row.groupId) }}</td><td>{{ row.parentDisplayName || '未绑定' }}</td>
+            <td>{{ row.mobile || row.mobileMasked || '—' }}</td><td>{{ row.groupName || ('用户组 ' + row.groupId) }}</td><td><span class="learning-status" [attr.data-status]="row.learningStatus">{{ row.learningStatusLabel }}</span><small class="status-reason">{{ row.learningStatusReason }}</small></td><td>{{ row.parentDisplayName || '未绑定' }}</td>
             <td>{{ row.periods30 | number }}</td><td>{{ row.periods60 | number }}</td><td>{{ row.registeredAt | date:'yyyy-MM-dd' }}</td>
             <td><span class="status" [class.disabled]="row.status === 'disabled'">{{ row.statusLabel }}</span></td>
             <td><a [routerLink]="['/admin/users', row.objectId]" aria-label="查看用户详情"><lucide-icon [img]="icons.Eye" [size]="17" /></a></td>
           </tr>
-        } @empty { <tr><td colspan="9"><div class="state">没有符合条件的用户</div></td></tr> }
+        } @empty { <tr><td colspan="10"><div class="state">没有符合条件的用户</div></td></tr> }
       </tbody>
     </table></div>
     <footer class="pagination"><span>第 {{ page()?.page || 1 }} / {{ pageCount() }} 页</span><div><button type="button" aria-label="上一页" [disabled]="(page()?.page || 1) <= 1" (click)="load((page()?.page || 1) - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="18" /></button><button type="button" aria-label="下一页" [disabled]="(page()?.page || 1) >= pageCount()" (click)="load((page()?.page || 1) + 1)"><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button></div></footer>

File diff suppressed because it is too large
+ 0 - 0
src/app/admin/pages/operations-users.component.scss


+ 9 - 3
src/app/admin/pages/operations-users.component.ts

@@ -18,20 +18,26 @@ export class OperationsUsersComponent implements OnInit {
   readonly page = signal<OperationsPage<OperationsUser> | null>(null);
   readonly query = signal('');
   readonly status = signal('all');
+  readonly learningStatus = signal('all');
   readonly loading = signal(true);
   readonly error = signal('');
+  readonly lastRefreshedAt = signal('');
   readonly icons = { ChevronLeft, ChevronRight, Eye, LoaderCircle, RefreshCw, Search, Users };
   private readonly operations = inject(OperationsService);
 
   ngOnInit(): void { void this.load(1); }
 
-  async load(page = this.page()?.page ?? 1): Promise<void> {
+  async load(page = this.page()?.page ?? 1, refresh = false): Promise<void> {
     this.loading.set(true); this.error.set('');
-    try { this.page.set(await this.operations.users(page, 20, this.query(), { status: this.status() })); }
+    try {
+      const result = await this.operations.users(page, 20, this.query(), { status: this.status(), learningStatus: this.learningStatus() }, refresh);
+      this.page.set(result); this.lastRefreshedAt.set(result.refreshedAt || new Date().toISOString());
+    }
     catch (error) { this.error.set(error instanceof Error ? error.message : '用户列表加载失败'); }
     finally { this.loading.set(false); }
   }
 
-  reset(): void { this.query.set(''); this.status.set('all'); void this.load(1); }
+  refresh(): void { void this.load(this.page()?.page ?? 1, true); }
+  reset(): void { this.query.set(''); this.status.set('all'); this.learningStatus.set('all'); void this.load(1); }
   pageCount(): number { const value = this.page(); return Math.max(1, Math.ceil((value?.total ?? 0) / (value?.pageSize ?? 20))); }
 }

Some files were not shown because too many files changed in this diff