Просмотр исходного кода

feat(admin): professionalize member operations

彭峰 1 месяц назад
Родитель
Сommit
f2080d6307

Разница между файлами не показана из-за своего большого размера
+ 3 - 1
scripts/deploy-admin-functions.mjs


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

@@ -50,8 +50,16 @@ try {
   const users = (await ops({ 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('管理员过滤、学习状态或刷新时间无效');
+  if (!users.summary || users.summary.total !== users.total || !Array.isArray(users.agents)) throw new Error('会员摘要或代理筛选项结构无效');
+  if (users.items.some((item) => item.groupId !== 1 || item.identityType !== 'member' || /陪练/.test(String(item.displayName || '')))) throw new Error('陪练或非会员身份仍混入会员列表');
+  if (users.items.some((item) => !item.registeredAt)) throw new Error('会员注册时间存在空白');
+  for (let index = 1; index < users.items.length; index += 1) {
+    const previous = new Date(users.items[index - 1].registeredAt).getTime();
+    const current = new Date(users.items[index].registeredAt).getTime();
+    if (previous < current || (previous === current && Number(users.items[index - 1].userId || 0) < Number(users.items[index].userId || 0))) throw new Error('会员列表未按注册时间和会员 ID 倒序稳定排列');
+  }
   const learningCounts = {};
-  for (const learningStatus of ['trial', 'studying', 'completed', 'not_started', 'not_applicable']) {
+  for (const learningStatus of ['trial', 'studying', 'completed', 'not_started']) {
     const result = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 1, search: '', filters: { learningStatus } })).data;
     learningCounts[learningStatus] = result.total;
   }

+ 31 - 2
src/app/admin/admin.models.ts

@@ -108,6 +108,30 @@ export interface OperationsPage<T extends Record<string, unknown> = Record<strin
   refreshedAt?: string;
 }
 
+export interface MemberSummary {
+  total: number;
+  todayNew: number;
+  sevenDayNew: number;
+  studying: number;
+  followUp: number;
+}
+
+export interface MemberAgentOption extends Record<string, unknown> {
+  objectId: string;
+  userId: number;
+  displayName: string;
+}
+
+export interface MemberPage extends OperationsPage<OperationsUser> {
+  summary: MemberSummary;
+  agents: MemberAgentOption[];
+}
+
+export interface MemberListSort {
+  field: 'registeredAt' | 'lastLoginAt' | 'lastLessonAt' | 'nextAppointmentAt' | 'remainingPeriods';
+  direction: 'asc' | 'desc';
+}
+
 export interface OperationsDashboardMetric {
   key: string;
   label: string;
@@ -174,7 +198,7 @@ export interface OperationsUser extends Record<string, unknown> {
   completedCourseCount: number;
   studyRecordCount: number;
   appointmentCount: number;
-  learningStatus: 'trial' | 'studying' | 'completed' | 'not_started' | 'not_applicable';
+  learningStatus: 'trial' | 'studying' | 'completed' | 'not_started';
   learningStatusLabel: string;
   learningStatusReason: string;
   registeredAt: string;
@@ -188,6 +212,10 @@ export interface OperationsUser extends Record<string, unknown> {
   lastLessonAt?: string;
   nextAppointmentAt?: string;
   riskFlags?: string[];
+  identityType?: 'member';
+  gender?: string;
+  grade?: string;
+  noteCount?: number;
 }
 
 export interface OperationsUserDetail extends OperationsUser {
@@ -201,7 +229,8 @@ export interface OperationsUserDetail extends OperationsUser {
     credit: number;
   };
   relation: { parentUserId: number; parentDisplayName: string };
-  permissions: { canManage: boolean; canAdjustBalance: boolean; canRevealMobile: boolean };
+  permissions: { canManage: boolean; canEdit: boolean; canAdjustBalance: boolean; canResetPassword: boolean; canRevealMobile: boolean };
+  agents: MemberAgentOption[];
 }
 
 export interface CoachSummary extends Record<string, unknown> {

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

@@ -14,8 +14,15 @@ 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' }, refresh: false });
+    await service.users(1, 20, '万宇昊', { status: 'active' }, { field: 'registeredAt', direction: 'desc' });
+    expect(functions.operations).toHaveBeenCalledWith('ops/users/list', {
+      page: 1,
+      pageSize: 20,
+      search: '万宇昊',
+      filters: { status: 'active' },
+      sort: { field: 'registeredAt', direction: 'desc' },
+      refresh: false
+    });
   });
 
   it('maps each teaching view to a business operation instead of generic class CRUD', async () => {

+ 11 - 3
src/app/admin/operations.service.ts

@@ -1,5 +1,5 @@
 import { inject, Injectable } from '@angular/core';
-import { CoachAvailability, CoachDetailData, CoachPage, OperationsDashboardData, OperationsPage, OperationsUser, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod } from './admin.models';
+import { CoachAvailability, CoachDetailData, CoachPage, MemberListSort, MemberPage, OperationsDashboardData, OperationsPage, OperationsUserDetail, PayRate, PayrollDetail, PayrollSummary, ScheduleCalendarData, ScheduleTrendData, ScheduleTrendPeriod } from './admin.models';
 import { CloudFunctionsService } from './cloud-functions.service';
 
 export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
@@ -20,14 +20,22 @@ export class OperationsService {
     return this.functions.operations<T>(operation, params);
   }
 
-  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 });
+  users(page: number, pageSize: number, search: string, filters: Record<string, unknown> = {}, sort: MemberListSort = { field: 'registeredAt', direction: 'desc' }, refresh = false): Promise<MemberPage> {
+    return this.functions.operations<MemberPage>('ops/users/list', { page, pageSize, search, filters, sort, refresh });
   }
 
   userDetail(objectId: string): Promise<OperationsUserDetail> {
     return this.functions.operations<OperationsUserDetail>('ops/users/detail', { objectId });
   }
 
+  memberRows(operation: 'ops/users/notes' | 'ops/users/audit', objectId: string): Promise<{ items: Record<string, unknown>[]; refreshedAt: string }> {
+    return this.functions.operations<{ items: Record<string, unknown>[]; refreshedAt: string }>(operation, { objectId });
+  }
+
+  exportMembers(search: string, filters: Record<string, unknown>, sort: MemberListSort): Promise<{ fileName: string; items: Record<string, unknown>[] }> {
+    return this.functions.operations<{ fileName: string; items: Record<string, unknown>[] }>('ops/users/export', { search, filters, sort });
+  }
+
   coaches(page: number, pageSize: number, search = '', filters: Record<string, unknown> = {}): Promise<CoachPage> {
     return this.functions.operations<CoachPage>('ops/coaches/list', { page, pageSize, search, filters });
   }

Разница между файлами не показана из-за своего большого размера
+ 28 - 14
src/app/admin/pages/operations-user-detail.component.html


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/app/admin/pages/operations-user-detail.component.scss


+ 44 - 50
src/app/admin/pages/operations-user-detail.component.ts

@@ -1,69 +1,63 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, RouterLink } from '@angular/router';
-import { ArrowLeft, Eye, EyeOff, LoaderCircle, LucideAngularModule, RefreshCw, ShieldAlert, UserRound } from 'lucide-angular';
-import { OperationsUserDetail } from '../admin.models';
-import { OperationsService } from '../operations.service';
+import { ArrowLeft, BookOpen, CalendarDays, ClipboardList, Clock3, Eye, FileClock, GraduationCap, LoaderCircle, LucideAngularModule, MessageSquarePlus, RefreshCw, Save, ShieldAlert, UserRound, WalletCards } from 'lucide-angular';
+import { MemberAgentOption, OperationsUserDetail, ScheduleCalendarData } from '../admin.models';
+import { OperationsListKind, OperationsService } from '../operations.service';
+
+type MemberTab = 'overview' | 'courses' | 'appointments' | 'lessons' | 'balances' | 'audit';
 
 @Component({
-  selector: 'app-operations-user-detail',
-  standalone: true,
+  selector: 'app-operations-user-detail', standalone: true,
   imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
-  templateUrl: './operations-user-detail.component.html',
-  styleUrl: './operations-user-detail.component.scss',
+  templateUrl: './operations-user-detail.component.html', styleUrl: './operations-user-detail.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class OperationsUserDetailComponent implements OnInit {
-  readonly user = signal<OperationsUserDetail | null>(null);
-  readonly loading = signal(true);
-  readonly saving = signal(false);
-  readonly error = signal('');
-  readonly message = signal('');
-  readonly reason = signal('');
-  readonly groupId = signal(0);
-  readonly parentUserId = signal(0);
-  readonly balanceType = signal(1);
-  readonly balanceDirection = signal<'add' | 'deduct'>('add');
-  readonly balanceAmount = signal(0);
-  readonly revealMobile = signal(false);
-  readonly icons = { ArrowLeft, Eye, EyeOff, LoaderCircle, RefreshCw, ShieldAlert, UserRound };
-  private readonly route = inject(ActivatedRoute);
-  private readonly operations = inject(OperationsService);
-  private readonly objectId = this.route.snapshot.paramMap.get('objectId') || '';
+  readonly user = signal<OperationsUserDetail | null>(null); readonly tab = signal<MemberTab>('overview'); readonly related = signal<Record<MemberTab, Record<string, unknown>[]>>({ overview: [], courses: [], appointments: [], lessons: [], balances: [], audit: [] });
+  readonly notes = signal<Record<string, unknown>[]>([]); readonly loading = signal(true); readonly sectionLoading = signal(false); readonly saving = signal(false); readonly error = signal(''); readonly message = signal(''); readonly reason = signal('');
+  readonly editName = signal(''); readonly editNickname = signal(''); readonly editMobile = signal(''); readonly editEmail = signal(''); readonly editGender = signal(''); readonly editGrade = signal(''); readonly parentUserId = signal(0); readonly agentSearch = signal('');
+  readonly balanceType = signal(1); readonly balanceDirection = signal<'add' | 'deduct'>('add'); readonly balanceAmount = signal(0); readonly payMethod = signal(''); readonly resetPassword = signal(''); readonly noteContent = signal(''); readonly revealMobile = signal(false);
+  readonly courseId = signal(0); readonly courseOpenedCount = signal(1); readonly courseSearch = signal(''); readonly courseOptions = signal<ScheduleCalendarData['courses']>([]);
+  readonly icons = { ArrowLeft, BookOpen, CalendarDays, ClipboardList, Clock3, Eye, FileClock, GraduationCap, LoaderCircle, MessageSquarePlus, RefreshCw, Save, ShieldAlert, UserRound, WalletCards };
+  private readonly route = inject(ActivatedRoute); private readonly operations = inject(OperationsService); private readonly objectId = this.route.snapshot.paramMap.get('objectId') || '';
+  readonly filteredAgents = computed(() => { const keyword = this.agentSearch().trim().toLowerCase(); return (this.user()?.agents || []).filter(agent => !keyword || `${agent.displayName} ${agent.userId}`.toLowerCase().includes(keyword)).slice(0, 30); });
+  readonly filteredCourses = computed(() => { const keyword = this.courseSearch().trim().toLowerCase(); return this.courseOptions().filter(course => !keyword || `${course.courseName} ${course.courseId}`.toLowerCase().includes(keyword)).slice(0, 30); });
 
   ngOnInit(): void { void this.load(); }
-
   async load(): Promise<void> {
     this.loading.set(true); this.error.set('');
-    try {
-      const user = await this.operations.userDetail(this.objectId);
-      this.user.set(user); this.groupId.set(user.groupId); this.parentUserId.set(user.parentUserId); this.revealMobile.set(Boolean(user.mobile));
-    } catch (error) { this.error.set(error instanceof Error ? error.message : '用户详情加载失败'); }
+    try { const user = await this.operations.userDetail(this.objectId); this.user.set(user); this.editName.set(user.displayName); this.editNickname.set(user.nickname || ''); this.editMobile.set(user.mobile || user.mobileMasked); this.editEmail.set(user.email || ''); this.editGender.set(user.gender || ''); this.editGrade.set(user.grade || ''); this.parentUserId.set(user.parentUserId); this.agentSearch.set(user.parentDisplayName || ''); this.revealMobile.set(Boolean(user.mobile)); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '会员详情加载失败'); }
     finally { this.loading.set(false); }
   }
-
-  async run(operation: string, payload: Record<string, unknown>, success: string): Promise<void> {
-    const user = this.user();
-    if (!user || !this.reason().trim()) { this.error.set('请输入操作原因'); return; }
-    this.saving.set(true); this.error.set(''); this.message.set('');
+  async selectTab(tab: MemberTab): Promise<void> { this.tab.set(tab); if (tab !== 'overview') await this.loadSection(tab); }
+  private kindFor(tab: MemberTab): OperationsListKind | null { return ({ courses: 'course-bindings', appointments: 'appointments', lessons: 'lessons', balances: 'money-logs' } as Partial<Record<MemberTab, OperationsListKind>>)[tab] || null; }
+  async loadSection(tab = this.tab()): Promise<void> {
+    const user = this.user(); if (!user || tab === 'overview') return; this.sectionLoading.set(true); this.error.set('');
     try {
-      await this.operations.mutate(operation, user.objectId, this.reason(), payload, user.updatedAt);
-      this.message.set(success); this.reason.set(''); await this.load();
-    } catch (error) { this.error.set(error instanceof Error ? error.message : '操作失败'); }
-    finally { this.saving.set(false); }
+      if (tab === 'audit') { const [notes, audit] = await Promise.all([this.operations.memberRows('ops/users/notes', user.objectId), this.operations.memberRows('ops/users/audit', user.objectId)]); this.notes.set(notes.items); this.related.update(value => ({ ...value, audit: audit.items })); }
+      else { const kind = this.kindFor(tab); if (kind) { const page = await this.operations.list(kind, 1, 100, String(user.userId)); this.related.update(value => ({ ...value, [tab]: page.items })); } }
+      if (tab === 'courses' && !this.courseOptions().length) { const today = new Date().toISOString().slice(0, 10); const schedule = await this.operations.schedule(today, today); this.courseOptions.set(schedule.courses); }
+    } catch (error) { this.error.set(error instanceof Error ? error.message : '业务记录加载失败'); }
+    finally { this.sectionLoading.set(false); }
   }
-
-  changeStatus(): void { const user = this.user(); if (user) void this.run('ops/users/change-status', { disabled: user.status !== 'disabled' }, user.status === 'disabled' ? '账号已恢复' : '账号已停用'); }
-  changeGroup(): void { void this.run('ops/users/change-group', { groupId: this.groupId() }, '用户组已更新'); }
-  saveRelation(): void { void this.run('ops/relations/save', { parentUserId: this.parentUserId() }, '所属代理已更新'); }
-  adjustBalance(): void { void this.run('ops/users/adjust-balance', { type: this.balanceType(), direction: this.balanceDirection(), amount: this.balanceAmount() }, '课时或余额已调整'); }
-  async reveal(): Promise<void> {
-    const user = this.user(); if (!user) return;
-    this.error.set('');
-    try {
-      const result = await this.operations.mutate<{ mobile: string }>('ops/users/reveal-mobile', user.objectId, '运营核对用户联系方式');
-      this.user.set({ ...user, mobile: result.mobile }); this.revealMobile.set(true);
-    } catch (error) { this.error.set(error instanceof Error ? error.message : '手机号查看失败'); }
+  async run(operation: string, payload: Record<string, unknown>, success: string, targetId = this.user()?.objectId || '', expectedUpdatedAt = this.user()?.updatedAt || ''): Promise<boolean> {
+    if (!this.reason().trim()) { this.error.set('请输入操作原因'); return false; } this.saving.set(true); this.error.set(''); this.message.set('');
+    try { await this.operations.mutate(operation, targetId, this.reason(), payload, expectedUpdatedAt); this.message.set(success); this.reason.set(''); return true; }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '操作失败'); return false; }
+    finally { this.saving.set(false); }
   }
+  selectAgent(agent: MemberAgentOption): void { this.parentUserId.set(agent.userId); this.agentSearch.set(agent.displayName); }
+  clearAgent(): void { this.parentUserId.set(0); this.agentSearch.set(''); }
+  selectCourse(course: ScheduleCalendarData['courses'][number]): void { this.courseId.set(course.courseId); this.courseSearch.set(course.courseName); }
+  async saveProfile(): Promise<void> { if (await this.run('ops/users/update-profile', { displayName: this.editName(), nickname: this.editNickname(), mobile: this.revealMobile() ? this.editMobile() : '', email: this.editEmail(), gender: this.editGender(), grade: this.editGrade(), parentUserId: this.parentUserId() }, '会员资料已更新')) await this.load(); }
+  async changeStatus(): Promise<void> { const user = this.user(); if (user && await this.run('ops/users/change-status', { disabled: user.status !== 'disabled' }, user.status === 'disabled' ? '账号已恢复' : '账号已停用')) await this.load(); }
+  async adjustBalance(): Promise<void> { if (!this.payMethod()) { this.error.set('请选择付款或调整方式'); return; } if (await this.run('ops/users/adjust-balance', { type: this.balanceType(), direction: this.balanceDirection(), amount: this.balanceAmount(), payMethod: this.payMethod() }, '课时已调整并写入流水')) { this.balanceAmount.set(0); await this.load(); await this.loadSection('balances'); } }
+  async resetMemberPassword(): Promise<void> { if (this.resetPassword().length < 8) { this.error.set('临时密码至少 8 位'); return; } if (await this.run('ops/users/reset-password', { password: this.resetPassword() }, '临时密码已更新,旧会话已退出')) { this.resetPassword.set(''); await this.load(); } }
+  async saveNote(): Promise<void> { if (!this.noteContent().trim()) { this.error.set('请输入跟进备注'); return; } if (await this.run('ops/users/save-note', { content: this.noteContent() }, '跟进备注已保存')) { this.noteContent.set(''); await this.loadSection('audit'); } }
+  async bindCourse(): Promise<void> { const user = this.user(); if (!user || !this.courseId()) { this.error.set('请选择课程'); return; } if (await this.run('ops/course-bindings/save', { studentId: user.userId, courseId: this.courseId(), openedCount: this.courseOpenedCount() }, '课程已绑定')) { this.courseId.set(0); this.courseSearch.set(''); await this.load(); await this.loadSection('courses'); } }
+  async toggleCourse(row: Record<string, unknown>): Promise<void> { const recycled = Number(row['status']) === -2; if (await this.run(recycled ? 'ops/course-bindings/restore' : 'ops/course-bindings/recycle', {}, recycled ? '课程绑定已恢复' : '课程绑定已移入回收站', String(row['targetId'] || ''), String(row['updatedAt'] || ''))) await this.loadSection('courses'); }
+  async reveal(): Promise<void> { const user = this.user(); if (!user) return; this.error.set(''); try { const result = await this.operations.mutate<{ mobile: string }>('ops/users/reveal-mobile', user.objectId, '运营核对会员联系方式'); this.user.set({ ...user, mobile: result.mobile }); this.editMobile.set(result.mobile); this.revealMobile.set(true); } catch (error) { this.error.set(error instanceof Error ? error.message : '手机号查看失败'); } }
 }

Разница между файлами не показана из-за своего большого размера
+ 31 - 25
src/app/admin/pages/operations-users.component.html


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
src/app/admin/pages/operations-users.component.scss


+ 43 - 23
src/app/admin/pages/operations-users.component.ts

@@ -1,43 +1,63 @@
 import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { RouterLink } from '@angular/router';
-import { ChevronLeft, ChevronRight, Eye, LoaderCircle, LucideAngularModule, RefreshCw, Search, Users } from 'lucide-angular';
-import { OperationsPage, OperationsUser } from '../admin.models';
+import { ChevronLeft, ChevronRight, Download, Eye, LoaderCircle, LucideAngularModule, Plus, RefreshCw, Search, UserPlus, Users, X } from 'lucide-angular';
+import { MemberAgentOption, MemberListSort, MemberPage } from '../admin.models';
+import { AdminSessionService } from '../admin-session.service';
 import { OperationsService } from '../operations.service';
 
 @Component({
-  selector: 'app-operations-users',
-  standalone: true,
+  selector: 'app-operations-users', standalone: true,
   imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
-  templateUrl: './operations-users.component.html',
-  styleUrl: './operations-users.component.scss',
+  templateUrl: './operations-users.component.html', styleUrl: './operations-users.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 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);
+  readonly page = signal<MemberPage | null>(null);
+  readonly query = signal(''); readonly status = signal('all'); readonly learningStatus = signal('all'); readonly risk = signal('all');
+  readonly parentUserId = signal(0); readonly registeredFrom = signal(''); readonly registeredTo = signal(''); readonly sortValue = signal('registeredAt:desc');
+  readonly loading = signal(true); readonly saving = signal(false); readonly exporting = signal(false); readonly error = signal(''); readonly message = signal(''); readonly lastRefreshedAt = signal('');
+  readonly createOpen = signal(false); readonly createName = signal(''); readonly createNickname = signal(''); readonly createMobile = signal(''); readonly createGender = signal(''); readonly createGrade = signal(''); readonly createPassword = signal(''); readonly createParentUserId = signal(0); readonly createAgentSearch = signal(''); readonly createReason = signal('新增会员');
+  readonly icons = { ChevronLeft, ChevronRight, Download, Eye, LoaderCircle, Plus, RefreshCw, Search, UserPlus, Users, X };
+  private readonly operations = inject(OperationsService); private readonly sessions = inject(AdminSessionService);
+
+  readonly canCreate = computed(() => this.sessions.session()?.operationsRole !== 'ops-auditor');
+  readonly filteredCreateAgents = computed(() => { const keyword = this.createAgentSearch().trim().toLowerCase(); return (this.page()?.agents || []).filter(agent => !keyword || `${agent.displayName} ${agent.userId}`.toLowerCase().includes(keyword)).slice(0, 30); });
 
   ngOnInit(): void { void this.load(1); }
+  private filters(): Record<string, unknown> { return { status: this.status(), learningStatus: this.learningStatus(), risk: this.risk(), parentUserId: this.parentUserId(), registeredFrom: this.registeredFrom(), registeredTo: this.registeredTo() }; }
+  private sort(): MemberListSort { const [field, direction] = this.sortValue().split(':') as [MemberListSort['field'], MemberListSort['direction']]; return { field, direction }; }
 
   async load(page = this.page()?.page ?? 1, refresh = false): Promise<void> {
     this.loading.set(true); this.error.set('');
-    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 : '用户列表加载失败'); }
+    try { const result = await this.operations.users(page, 20, this.query(), this.filters(), this.sort(), 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); }
   }
-
   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); }
+  reset(): void { this.query.set(''); this.status.set('all'); this.learningStatus.set('all'); this.risk.set('all'); this.parentUserId.set(0); this.registeredFrom.set(''); this.registeredTo.set(''); this.sortValue.set('registeredAt:desc'); void this.load(1); }
   pageCount(): number { const value = this.page(); return Math.max(1, Math.ceil((value?.total ?? 0) / (value?.pageSize ?? 20))); }
+  selectCreateAgent(agent: MemberAgentOption): void { this.createParentUserId.set(agent.userId); this.createAgentSearch.set(agent.displayName); }
+  clearCreateAgent(): void { this.createParentUserId.set(0); this.createAgentSearch.set(''); }
+  openCreate(): void { this.createName.set(''); this.createNickname.set(''); this.createMobile.set(''); this.createGender.set(''); this.createGrade.set(''); this.createPassword.set(''); this.clearCreateAgent(); this.createReason.set('新增会员'); this.error.set(''); this.createOpen.set(true); }
+
+  async createMember(): Promise<void> {
+    if (!this.createName().trim() || !/^1\d{10}$/.test(this.createMobile()) || this.createPassword().length < 8 || this.createReason().trim().length < 2) { this.error.set('请填写姓名、有效手机号、至少 8 位临时密码和操作原因'); return; }
+    this.saving.set(true); this.error.set('');
+    try { await this.operations.mutate('ops/users/create', '', this.createReason(), { displayName: this.createName(), nickname: this.createNickname() || this.createName(), mobile: this.createMobile(), username: this.createMobile(), gender: this.createGender(), grade: this.createGrade(), parentUserId: this.createParentUserId(), password: this.createPassword() }); this.createOpen.set(false); this.message.set('会员已新增,首次登录需要修改临时密码'); await this.load(1, true); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '新增会员失败'); }
+    finally { this.saving.set(false); }
+  }
+
+  async exportCsv(): Promise<void> {
+    this.exporting.set(true); this.error.set('');
+    try {
+      const result = await this.operations.exportMembers(this.query(), this.filters(), this.sort());
+      const columns: Array<[string, string]> = [['userId', '会员ID'], ['displayName', '姓名'], ['nickname', '昵称'], ['mobile', '手机号'], ['learningStatus', '学习状态'], ['registeredAt', '注册时间'], ['parentDisplayName', '所属门店/代理'], ['periods30', '30分钟课时'], ['periods60', '60分钟课时'], ['trialPeriods', '体验课时'], ['lastLessonAt', '最近上课'], ['nextAppointmentAt', '下次预约'], ['riskFlags', '风险提示'], ['status', '账号状态']];
+      const quote = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`; const csv = '\ufeff' + [columns.map(([, label]) => label), ...result.items.map(row => columns.map(([key]) => row[key]))].map(row => row.map(quote).join(',')).join('\n');
+      const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); const anchor = document.createElement('a'); anchor.href = url; anchor.download = result.fileName; anchor.click(); URL.revokeObjectURL(url);
+    } catch (error) { this.error.set(error instanceof Error ? error.message : '会员数据导出失败'); }
+    finally { this.exporting.set(false); }
+  }
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов