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

feat(admin): separate operations and technical consoles

彭峰 1 месяц назад
Родитель
Сommit
9f310018c8
31 измененных файлов с 797 добавлено и 192 удалено
  1. 1 0
      package.json
  2. 74 5
      scripts/deploy-admin-functions.mjs
  3. 62 0
      scripts/smoke-operations-functions.mjs
  4. 19 1
      src/app/admin/admin-session.service.ts
  5. 37 60
      src/app/admin/admin.constants.ts
  6. 16 0
      src/app/admin/admin.guard.ts
  7. 67 0
      src/app/admin/admin.models.ts
  8. 38 40
      src/app/admin/admin.routes.ts
  9. 8 0
      src/app/admin/cloud-functions.service.spec.ts
  10. 21 0
      src/app/admin/cloud-functions.service.ts
  11. 14 11
      src/app/admin/layout/admin-shell.component.html
  12. 4 1
      src/app/admin/layout/admin-shell.component.scss
  13. 13 6
      src/app/admin/layout/admin-shell.component.ts
  14. 28 0
      src/app/admin/operations.service.spec.ts
  15. 37 0
      src/app/admin/operations.service.ts
  16. 16 22
      src/app/admin/pages/admin-dashboard.component.html
  17. 0 27
      src/app/admin/pages/admin-dashboard.component.scss
  18. 10 17
      src/app/admin/pages/admin-dashboard.component.ts
  19. 24 0
      src/app/admin/pages/admin-forbidden.component.ts
  20. 1 1
      src/app/admin/pages/admin-login.component.html
  21. 3 1
      src/app/admin/pages/admin-login.component.ts
  22. 14 0
      src/app/admin/pages/operations-list.component.html
  23. 0 0
      src/app/admin/pages/operations-list.component.scss
  24. 85 0
      src/app/admin/pages/operations-list.component.ts
  25. 18 0
      src/app/admin/pages/operations-user-detail.component.html
  26. 0 0
      src/app/admin/pages/operations-user-detail.component.scss
  27. 69 0
      src/app/admin/pages/operations-user-detail.component.ts
  28. 33 0
      src/app/admin/pages/operations-users.component.html
  29. 0 0
      src/app/admin/pages/operations-users.component.scss
  30. 37 0
      src/app/admin/pages/operations-users.component.ts
  31. 48 0
      src/app/admin/pages/tech-dashboard.component.ts

+ 1 - 0
package.json

@@ -14,6 +14,7 @@
     "cloud:validate": "node scripts/deploy-admin-functions.mjs --validate",
     "cloud:deploy": "node scripts/deploy-admin-functions.mjs",
     "cloud:smoke": "node scripts/smoke-admin-functions.mjs",
+    "ops:smoke": "node scripts/smoke-operations-functions.mjs",
     "watch": "ng build --watch --configuration development",
     "test": "ng test",
     "test:ci": "ng test --no-watch --no-progress --browsers=ChromeHeadless"

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


+ 62 - 0
scripts/smoke-operations-functions.mjs

@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+
+import { randomBytes } from 'node:crypto';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || 'https://server.xiaoshu.pro/api/functions').replace(/\/$/, '');
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+async function request(url, init = {}, master = false) {
+  const response = await fetch(url, { ...init, headers: { 'X-Parse-Application-Id': APP_ID, ...(master ? { 'X-Parse-Master-Key': MASTER_KEY } : {}), 'Content-Type': 'application/json', ...(init.headers || {}) } });
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok) throw new Error(`${response.status}: ${payload.message || payload.error || JSON.stringify(payload)}`);
+  return payload;
+}
+
+async function call(path, token, params, expectedStatus = 200) {
+  const response = await fetch(`${FUNCTION_URL}/${path}`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, params }) });
+  const payload = await response.json().catch(() => ({}));
+  if (response.status !== expectedStatus) throw new Error(`${path} 期望 ${expectedStatus},实际 ${response.status}: ${payload.message || payload.error || ''}`);
+  if (expectedStatus === 200 && payload.success !== true) throw new Error(payload.message || `${path} 返回失败`);
+  return payload;
+}
+
+let userId = '';
+try {
+  const companies = await request(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
+  const companyId = companies.results?.[0]?.objectId;
+  if (!companyId) throw new Error('没有可用于运营网关测试的帐套');
+  const company = { __type: 'Pointer', className: 'Company', objectId: companyId };
+  const username = `codex_ops_smoke_${Date.now()}`;
+  const password = randomBytes(24).toString('base64url');
+  const created = await request(`${PARSE_URL}/users`, { method: 'POST', body: JSON.stringify({ username, password, isAdmin: true, roles: ['admin', 'ops-manager'], company }) }, true);
+  userId = created.objectId;
+  const login = await request(`${PARSE_URL}/login`, { method: 'POST', body: JSON.stringify({ username, password }) });
+  const token = login.sessionToken;
+  if (!token) throw new Error('临时运营管理员未返回会话');
+
+  const dashboard = (await call('xiaoshu/ops/gateway', token, { operation: 'ops/dashboard/summary' })).data;
+  if (dashboard.identity?.operationsRole !== 'ops-manager' || !Array.isArray(dashboard.metrics) || dashboard.metrics.length < 4) throw new Error('运营工作台返回结构无效');
+
+  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('用户业务投影为空或泄漏技术字段');
+  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('用户显示名称兜底未找到“万宇昊”');
+
+  for (const operation of ['ops/relations/list', 'ops/course-bindings/list', 'ops/appointments/list', 'ops/lessons/list', 'ops/learning-records/list', 'ops/finance/list', 'ops/money-logs/list']) {
+    const page = (await call('xiaoshu/ops/gateway', token, { operation, page: 1, pageSize: 5, search: '' })).data;
+    if (!Array.isArray(page.items) || typeof page.total !== 'number') throw new Error(`${operation} 未返回统一分页结构`);
+  }
+
+  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}.`);
+} 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: [] }));
+    for (const session of sessions.results || []) await request(`${PARSE_URL}/classes/_Session/${session.objectId}`, { method: 'DELETE' }, true).catch(() => undefined);
+    await request(`${PARSE_URL}/users/${userId}`, { method: 'DELETE' }, true).catch(() => undefined);
+  }
+}

+ 19 - 1
src/app/admin/admin-session.service.ts

@@ -3,7 +3,7 @@ import { computed, inject, Injectable, signal } from '@angular/core';
 import { Router } from '@angular/router';
 import { firstValueFrom } from 'rxjs';
 import { ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL, PARSE_APP_ID, PARSE_BASE_URL } from './admin.constants';
-import { AdminSession } from './admin.models';
+import { AdminIdentity, AdminSession } from './admin.models';
 
 @Injectable({ providedIn: 'root' })
 export class AdminSessionService {
@@ -29,6 +29,9 @@ export class AdminSessionService {
       username: String(result['username'] ?? username),
       sessionToken: String(result['sessionToken'] ?? ''),
       company: result['company'] as AdminSession['company'],
+      displayName: String(result['realName'] ?? result['realname'] ?? result['nickname'] ?? result['username'] ?? username),
+      roles: Array.isArray(result['roles']) ? result['roles'].map(String) : [],
+      isSuperAdmin: result['adminRoleKey'] === 'super-admin' || (Array.isArray(result['roles']) && result['roles'].includes('super-admin')),
     };
     if (!session.objectId || !session.sessionToken) throw new Error('登录响应缺少有效会话');
     sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
@@ -36,6 +39,21 @@ export class AdminSessionService {
     return session;
   }
 
+  applyIdentity(identity: AdminIdentity): void {
+    const current = this.state();
+    if (!current) return;
+    const session: AdminSession = {
+      ...current,
+      displayName: identity.displayName,
+      roles: identity.roles,
+      isSuperAdmin: identity.isSuperAdmin,
+      operationsRole: identity.operationsRole,
+      company: identity.company ?? current.company,
+    };
+    sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify(session));
+    this.state.set(session);
+  }
+
   logout(redirect = true): void {
     const sessionToken = this.state()?.sessionToken ?? '';
     sessionStorage.removeItem(ADMIN_SESSION_KEY);

+ 37 - 60
src/app/admin/admin.constants.ts

@@ -9,63 +9,40 @@ export const CLOUD_FUNCTION_BASE_URL = isLocalDevelopment
 
 export const ADMIN_SESSION_KEY = 'xiaoshu_admin_session_v1';
 
-export const ADMIN_NAVIGATION = [
-  { label: '管理概览', icon: 'layout-dashboard', route: '/admin/dashboard' },
-  { label: '修改密码', icon: 'key-round', route: '/admin/change-password' },
-  { label: '云函数接口', icon: 'braces', route: '/admin/api-catalog' },
-  { label: '图标选择器', icon: 'shapes', route: '/admin/icon-catalog' },
-  { label: 'Bootstrap 布局', icon: 'layout-template', route: '/admin/boot-layout' },
-  { label: '全部数据类', icon: 'table-properties', route: '/admin/resources' },
-  { label: '用户与组织', icon: 'users', route: '/admin/resources/_User' },
-  { label: '课程绑定', icon: 'book-open', route: '/admin/resources/CourseBinding' },
-  { label: '预约管理', icon: 'calendar-days', route: '/admin/resources/CourseAppointment' },
-  { label: '练习记录', icon: 'clipboard-check', route: '/admin/resources/PracticeRecord' },
-  { label: '上课记录', icon: 'graduation-cap', route: '/admin/resources/LessonRecord' },
-  { label: '每日学习', icon: 'chart-no-axes-combined', route: '/admin/resources/DailyStudyRecord' },
-  { label: '词库管理', icon: 'languages', route: '/admin/resources/VocabularyWord' },
-  { label: '内容管理', icon: 'files', route: '/admin/resources/CommonModel' },
-  { label: '试题分类', icon: 'folders', route: '/admin/resources/ExamClass' },
-  { label: '考点管理', icon: 'map-pin', route: '/admin/resources/ExamPoint' },
-  { label: '知识点管理', icon: 'network', route: '/admin/resources/QuestionsKnowledge' },
-  { label: '考试教师', icon: 'user-round-check', route: '/admin/resources/ExTeacher' },
-  { label: '字典分类', icon: 'library-big', route: '/admin/resources/Datadiccategory' },
-  { label: '数据字典', icon: 'list-tree', route: '/admin/resources/Datadic' },
-  { label: '多级字典分类', icon: 'layers-3', route: '/admin/resources/GradeCate' },
-  { label: '多级字典选项', icon: 'git-branch', route: '/admin/resources/Grade' },
-  { label: '货币管理', icon: 'circle-dollar-sign', route: '/admin/resources/Currency' },
-  { label: '节假日管理', icon: 'calendar-off', route: '/admin/resources/SysHoliday' },
-  { label: '后台快捷入口', icon: 'panels-top-left', route: '/admin/resources/Search' },
-  { label: '广告位管理', icon: 'gallery-horizontal-end', route: '/admin/resources/AdZone' },
-  { label: '广告内容管理', icon: 'megaphone', route: '/admin/resources/AdInfo' },
-  { label: '字体图形素材', icon: 'shapes', route: '/admin/resources/FontPicShape' },
-  { label: '字体图形分类', icon: 'tags', route: '/admin/resources/FontPicShapeType' },
-  { label: '可视化设计资源', icon: 'image', route: '/admin/resources/DesignRes' },
-  { label: '可视化场景', icon: 'panels-top-left', route: '/admin/resources/DesignScence' },
-  { label: '可视化模板', icon: 'layout-template', route: '/admin/resources/DesignTlp' },
-  { label: '系统角色', icon: 'shield-check', route: '/admin/resources/Role' },
-  { label: '问卷调查', icon: 'clipboard-list', route: '/admin/resources/DesignAsk' },
-  { label: '问卷题目', icon: 'list-todo', route: '/admin/resources/DesignQuestion' },
-  { label: '问卷答卷', icon: 'chart-bar-big', route: '/admin/resources/DesignAnswer' },
-  { label: '客服席位', icon: 'headphones', route: '/admin/resources/ServiceSeat' },
-  { label: '客服欢迎语', icon: 'message-square-text', route: '/admin/resources/Temp' },
-  { label: '店铺管理', icon: 'store', route: '/admin/resources/StoreApplication' },
-  { label: '店铺样式', icon: 'palette', route: '/admin/resources/StoreStyle' },
-  { label: '内容标签词库', icon: 'tags', route: '/admin/resources/ContentTagKey' },
-  { label: '积分等级', icon: 'badge-cent', route: '/admin/resources/UserLevel' },
-  { label: '资金积分流水', icon: 'wallet-cards', route: '/admin/resources/UserMoneyLog' },
-  { label: '用户推广关系', icon: 'network', route: '/admin/resources/UserPromotion' },
-  { label: 'CRM 客户类型', icon: 'contact-round', route: '/admin/resources/CRMSAttr' },
-  { label: '商城运费模板', icon: 'truck', route: '/admin/resources/ShopFareTlp' },
-  { label: '系统日志', icon: 'scroll-text', route: '/admin/resources/SysLog' },
-  { label: 'OA 流程类型', icon: 'workflow', route: '/admin/resources/MisType' },
-  { label: '黄页样式', icon: 'layout-template', route: '/admin/resources/PageStyle' },
-  { label: '协同办公企业', icon: 'building-2', route: '/admin/resources/PlatComp' },
-  { label: '百科审核', icon: 'book-marked', route: '/admin/resources/Baike' },
-  { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
-  { label: '考试试卷', icon: 'file-check-2', route: '/admin/resources/ExamSysPapers' },
-  { label: '试卷大题分组', icon: 'list-collapse', route: '/admin/resources/PaperQuestions' },
-  { label: '考试类型', icon: 'list-filter', route: '/admin/resources/ExamType' },
-  { label: '报刊内容', icon: 'newspaper', route: '/admin/resources/ContentPublish' },
-  { label: '报刊发布节点', icon: 'network', route: '/admin/resources/PublishNode' },
-  { label: '迁移状态', icon: 'route', route: '/admin/migration' },
-] as const;
+export interface AdminNavigationItem { label: string; icon: string; route: string; }
+export interface AdminNavigationGroup { label: string; items: readonly AdminNavigationItem[]; }
+
+export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
+  { label: '运营总览', items: [{ label: '工作台', icon: 'layout-dashboard', route: '/admin/dashboard' }] },
+  { label: '用户运营', items: [
+    { label: '用户列表', icon: 'users', route: '/admin/users' },
+    { label: '陪练与代理关系', icon: 'network', route: '/admin/relations' },
+  ] },
+  { label: '教学运营', items: [
+    { label: '用户课程绑定', icon: 'book-open', route: '/admin/course-bindings' },
+    { label: '预约排课', icon: 'calendar-days', route: '/admin/appointments' },
+    { label: '上课记录', icon: 'graduation-cap', route: '/admin/lessons' },
+    { label: '每日学习记录', icon: 'chart-no-axes-combined', route: '/admin/learning-records' },
+  ] },
+  { label: '财务与课时', items: [
+    { label: '用户课时与余额', icon: 'wallet-cards', route: '/admin/finance' },
+    { label: '资金积分流水', icon: 'receipt-text', route: '/admin/money-logs' },
+  ] },
+  { label: '个人设置', items: [{ label: '修改密码', icon: 'key-round', route: '/admin/change-password' }] },
+];
+
+export const TECH_NAVIGATION: readonly AdminNavigationGroup[] = [
+  { label: '技术总览', items: [{ label: '技术工作台', icon: 'layout-dashboard', route: '/tech-admin/dashboard' }] },
+  { label: '数据与接口', items: [
+    { label: '全部数据类', icon: 'table-properties', route: '/tech-admin/resources' },
+    { label: '云函数接口', icon: 'braces', route: '/tech-admin/api-catalog' },
+    { label: '系统日志', icon: 'scroll-text', route: '/tech-admin/resources/SysLog' },
+    { label: '系统角色', icon: 'shield-check', route: '/tech-admin/resources/Role' },
+  ] },
+  { label: '迁移与开发', items: [
+    { label: '迁移状态', icon: 'route', route: '/tech-admin/migration' },
+    { label: '图标选择器', icon: 'shapes', route: '/tech-admin/icon-catalog' },
+    { label: 'Bootstrap 布局', icon: 'layout-template', route: '/tech-admin/boot-layout' },
+  ] },
+  { label: '返回运营', items: [{ label: '运营管理后台', icon: 'briefcase-business', route: '/admin/dashboard' }] },
+];

+ 16 - 0
src/app/admin/admin.guard.ts

@@ -1,9 +1,25 @@
 import { inject } from '@angular/core';
 import { CanActivateFn, Router } from '@angular/router';
 import { AdminSessionService } from './admin-session.service';
+import { AdminIdentity } from './admin.models';
+import { CloudFunctionsService } from './cloud-functions.service';
 
 export const adminGuard: CanActivateFn = (_route, state) => {
   const sessions = inject(AdminSessionService);
   if (sessions.isAuthenticated()) return true;
   return inject(Router).createUrlTree(['/admin/login'], { queryParams: { returnUrl: state.url } });
 };
+
+export const superAdminGuard: CanActivateFn = async (_route, state) => {
+  const sessions = inject(AdminSessionService);
+  const router = inject(Router);
+  if (!sessions.isAuthenticated()) return router.createUrlTree(['/admin/login'], { queryParams: { returnUrl: state.url } });
+  if (sessions.session()?.isSuperAdmin === true) return true;
+  try {
+    const identity = (await inject(CloudFunctionsService).admin<{ identity: AdminIdentity }>('meta')).identity;
+    sessions.applyIdentity(identity);
+    return identity.isSuperAdmin ? true : router.createUrlTree(['/admin/forbidden']);
+  } catch {
+    return router.createUrlTree(['/admin/forbidden']);
+  }
+};

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

@@ -11,6 +11,7 @@ export interface AdminIdentity {
   company?: ParsePointer;
   roles: string[];
   isSuperAdmin: boolean;
+  operationsRole: OperationsRole;
 }
 
 export interface AdminSession {
@@ -18,8 +19,14 @@ export interface AdminSession {
   username: string;
   sessionToken: string;
   company?: ParsePointer;
+  displayName?: string;
+  roles?: string[];
+  isSuperAdmin?: boolean;
+  operationsRole?: OperationsRole;
 }
 
+export type OperationsRole = 'ops-manager' | 'ops-staff' | 'ops-auditor';
+
 export interface CloudEnvelope<T> {
   success: boolean;
   data: T;
@@ -92,3 +99,63 @@ export interface DashboardData {
     cloudFunctions: number;
   };
 }
+
+export interface OperationsPage<T extends Record<string, unknown> = Record<string, unknown>> {
+  page: number;
+  pageSize: number;
+  total: number;
+  items: T[];
+}
+
+export interface OperationsDashboardMetric {
+  key: string;
+  label: string;
+  value: number;
+  hint: string;
+  route: string;
+  tone: 'blue' | 'green' | 'amber' | 'rose';
+}
+
+export interface OperationsDashboardData {
+  identity: AdminIdentity;
+  date: string;
+  metrics: OperationsDashboardMetric[];
+  alerts: { key: string; label: string; value: number; route: string }[];
+}
+
+export interface OperationsUser extends Record<string, unknown> {
+  objectId: string;
+  userId: number;
+  displayName: string;
+  username: string;
+  mobile: string;
+  mobileMasked: string;
+  groupId: number;
+  groupName: string;
+  parentUserId: number;
+  parentDisplayName: string;
+  periods30: number;
+  periods60: number;
+  trialPeriods: number;
+  registeredAt: string;
+  lastLoginAt: string;
+  loginCount: number;
+  status: 'active' | 'disabled';
+  statusLabel: string;
+  canRevealMobile: boolean;
+  updatedAt: string;
+}
+
+export interface OperationsUserDetail extends OperationsUser {
+  email: string;
+  balances: {
+    purse: number;
+    silverCoin: number;
+    userExp: number;
+    userPoint: number;
+    dummyPurse: number;
+    credit: number;
+  };
+  relation: { parentUserId: number; parentDisplayName: string };
+  permissions: { canManage: boolean; canAdjustBalance: boolean; canRevealMobile: boolean };
+}

+ 38 - 40
src/app/admin/admin.routes.ts

@@ -1,49 +1,47 @@
 import { Routes } from '@angular/router';
-import { adminGuard } from './admin.guard';
+import { adminGuard, superAdminGuard } from './admin.guard';
+
+const operationsChildren: Routes = [
+  { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
+  { path: 'dashboard', loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },
+  { path: 'users', loadComponent: () => import('./pages/operations-users.component').then((module) => module.OperationsUsersComponent) },
+  { path: 'users/:objectId', loadComponent: () => import('./pages/operations-user-detail.component').then((module) => module.OperationsUserDetailComponent) },
+  { path: 'relations', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'relations' } },
+  { path: 'course-bindings', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'course-bindings' } },
+  { path: 'appointments', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'appointments' } },
+  { path: 'lessons', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'lessons' } },
+  { path: 'learning-records', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'learning-records' } },
+  { path: 'finance', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'finance' } },
+  { path: 'money-logs', loadComponent: () => import('./pages/operations-list.component').then((module) => module.OperationsListComponent), data: { kind: 'money-logs' } },
+  { path: 'change-password', loadComponent: () => import('./pages/admin-change-password.component').then((module) => module.AdminChangePasswordComponent) },
+  { path: 'forbidden', loadComponent: () => import('./pages/admin-forbidden.component').then((module) => module.AdminForbiddenComponent) },
+];
+
+const technicalChildren: Routes = [
+  { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
+  { path: 'dashboard', loadComponent: () => import('./pages/tech-dashboard.component').then((module) => module.TechDashboardComponent) },
+  { path: 'api-catalog', loadComponent: () => import('./pages/admin-api-catalog.component').then((module) => module.AdminApiCatalogComponent) },
+  { path: 'icon-catalog', loadComponent: () => import('./pages/admin-icon-catalog.component').then((module) => module.AdminIconCatalogComponent) },
+  { path: 'boot-layout', loadComponent: () => import('./pages/admin-boot-layout.component').then((module) => module.AdminBootLayoutComponent) },
+  { path: 'resources', loadComponent: () => import('./pages/admin-resource-catalog.component').then((module) => module.AdminResourceCatalogComponent) },
+  { path: 'resources/:className', loadComponent: () => import('./pages/admin-resource.component').then((module) => module.AdminResourceComponent) },
+  { path: 'migration', loadComponent: () => import('./pages/admin-migration.component').then((module) => module.AdminMigrationComponent) },
+];
 
 export const adminRoutes: Routes = [
-  {
-    path: 'admin/login',
-    loadComponent: () => import('./pages/admin-login.component').then((module) => module.AdminLoginComponent),
-  },
+  { path: 'admin/login', loadComponent: () => import('./pages/admin-login.component').then((module) => module.AdminLoginComponent) },
   {
     path: 'admin',
     canActivate: [adminGuard],
     loadComponent: () => import('./layout/admin-shell.component').then((module) => module.AdminShellComponent),
-    children: [
-      { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
-      {
-        path: 'dashboard',
-        loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent),
-      },
-      {
-        path: 'change-password',
-        loadComponent: () => import('./pages/admin-change-password.component').then((module) => module.AdminChangePasswordComponent),
-      },
-      {
-        path: 'api-catalog',
-        loadComponent: () => import('./pages/admin-api-catalog.component').then((module) => module.AdminApiCatalogComponent),
-      },
-      {
-        path: 'icon-catalog',
-        loadComponent: () => import('./pages/admin-icon-catalog.component').then((module) => module.AdminIconCatalogComponent),
-      },
-      {
-        path: 'boot-layout',
-        loadComponent: () => import('./pages/admin-boot-layout.component').then((module) => module.AdminBootLayoutComponent),
-      },
-      {
-        path: 'resources',
-        loadComponent: () => import('./pages/admin-resource-catalog.component').then((module) => module.AdminResourceCatalogComponent),
-      },
-      {
-        path: 'resources/:className',
-        loadComponent: () => import('./pages/admin-resource.component').then((module) => module.AdminResourceComponent),
-      },
-      {
-        path: 'migration',
-        loadComponent: () => import('./pages/admin-migration.component').then((module) => module.AdminMigrationComponent),
-      },
-    ],
+    data: { mode: 'operations' },
+    children: operationsChildren,
+  },
+  {
+    path: 'tech-admin',
+    canActivate: [adminGuard, superAdminGuard],
+    loadComponent: () => import('./layout/admin-shell.component').then((module) => module.AdminShellComponent),
+    data: { mode: 'technical' },
+    children: technicalChildren,
   },
 ];

+ 8 - 0
src/app/admin/cloud-functions.service.spec.ts

@@ -31,4 +31,12 @@ describe('CloudFunctionsService', () => {
     request.flush({ retcode: 0, retmsg: '', result: { implemented: ['app_update'], blocked: { cart_list: '目标 Schema 无购物车类' } } });
     await expectAsync(pending).toBeResolvedTo({ implemented: ['app_update'], blocked: { cart_list: '目标 Schema 无购物车类' } });
   });
+
+  it('calls the isolated operations gateway', async () => {
+    const pending = service.operations('ops/users/list', { page: 1, pageSize: 20 });
+    const request = http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway`);
+    expect(request.request.body).toEqual({ token: 'r:admin-session', params: { operation: 'ops/users/list', page: 1, pageSize: 20 } });
+    request.flush({ success: true, data: { page: 1, pageSize: 20, total: 0, items: [] } });
+    await expectAsync(pending).toBeResolvedTo({ page: 1, pageSize: 20, total: 0, items: [] });
+  });
 });

+ 21 - 0
src/app/admin/cloud-functions.service.ts

@@ -37,6 +37,27 @@ export class CloudFunctionsService {
     }
   }
 
+  async operations<T>(operation: string, params: Record<string, unknown> = {}): Promise<T> {
+    const token = this.sessions.session()?.sessionToken;
+    if (!token) throw new CloudFunctionError('管理员会话已失效', 401);
+    try {
+      const envelope = await firstValueFrom(this.http.post<CloudEnvelope<T>>(
+        `${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway`,
+        { token, params: { operation, ...params } },
+      ));
+      if (!envelope.success) throw new CloudFunctionError(envelope.message || envelope.error || '运营云函数执行失败');
+      return envelope.data;
+    } catch (error) {
+      if (error instanceof CloudFunctionError) throw error;
+      if (error instanceof HttpErrorResponse) {
+        const payload = error.error as { error?: string; message?: string } | null;
+        if (error.status === 401) this.sessions.logout();
+        throw new CloudFunctionError(payload?.message || payload?.error || error.message, error.status, payload);
+      }
+      throw new CloudFunctionError(error instanceof Error ? error.message : '未知运营云函数错误');
+    }
+  }
+
   async app<T>(action: string, params: Record<string, unknown> = {}): Promise<T> {
     const token = this.sessions.session()?.sessionToken;
     if (!token) throw new CloudFunctionError('管理员会话已失效', 401);

+ 14 - 11
src/app/admin/layout/admin-shell.component.html

@@ -1,16 +1,19 @@
 <div class="admin-frame" [class.sidebar-collapsed]="collapsed()">
   <aside class="admin-sidebar" [class.mobile-open]="mobileOpen()">
-    <a class="brand" routerLink="/admin/dashboard" aria-label="小树陪练管理后台">
+    <a class="brand" [routerLink]="mode === 'technical' ? '/tech-admin/dashboard' : '/admin/dashboard'" aria-label="小树陪练管理后台">
       <img src="/static/images/logo.png" alt="小树陪练" />
-      <span>小树陪练<small>管理后台</small></span>
+      <span>小树陪练<small>{{ mode === 'technical' ? '技术后台' : '运营管理后台' }}</small></span>
     </a>
 
     <nav aria-label="后台主导航">
-      @for (item of navigation; track item.route) {
-        <a [routerLink]="item.route" routerLinkActive="active" (click)="mobileOpen.set(false)">
-          <lucide-icon [img]="iconFor(item.icon)" [size]="19" />
-          <span>{{ item.label }}</span>
-        </a>
+      @for (group of navigation; track group.label) {
+        <p class="nav-group-label">{{ group.label }}</p>
+        @for (item of group.items; track item.route) {
+          <a [routerLink]="item.route" routerLinkActive="active" (click)="mobileOpen.set(false)">
+            <lucide-icon [img]="iconFor(item.icon)" [size]="19" />
+            <span>{{ item.label }}</span>
+          </a>
+        }
       }
     </nav>
 
@@ -32,16 +35,16 @@
         <button class="collapse-button" type="button" aria-label="切换侧栏" (click)="collapsed.set(!collapsed())">
           <lucide-icon [img]="collapsed() ? icons.ChevronRight : icons.ChevronLeft" [size]="18" />
         </button>
-        <div class="global-search">
+        <a class="global-search" [routerLink]="mode === 'technical' ? '/tech-admin/resources' : '/admin/users'" aria-label="进入后台搜索">
           <lucide-icon [img]="icons.Search" [size]="18" />
-          <span>搜索用户、课程或业务数据</span>
+          <span>{{ mode === 'technical' ? '搜索数据类或接口' : '搜索用户、手机号或代理' }}</span>
           <kbd>⌘ K</kbd>
-        </div>
+        </a>
       </div>
       <div class="topbar-account">
         <button class="notification" type="button" aria-label="通知"><lucide-icon [img]="icons.Bell" [size]="19" /></button>
         <div class="avatar">{{ (sessions.session()?.username || '管').slice(0, 1).toUpperCase() }}</div>
-        <span>{{ sessions.session()?.username || '管理员' }}</span>
+        <span>{{ sessions.session()?.displayName || sessions.session()?.username || '管理员' }}</span>
       </div>
     </header>
 

+ 4 - 1
src/app/admin/layout/admin-shell.component.scss

@@ -7,18 +7,21 @@
 .brand span { display: grid; min-width: 135px; font-size: 16px; font-weight: 700; line-height: 1.15; }
 .brand small { margin-top: 5px; color: #8ca4c8; font-size: 11px; font-weight: 500; letter-spacing: .18em; }
 nav { display: grid; gap: 5px; padding: 18px 12px; overflow: auto; }
+.nav-group-label { margin: 15px 14px 5px; color: #7188aa; font-size: 10px; font-weight: 800; letter-spacing: .13em; white-space: nowrap; }
+.nav-group-label:first-child { margin-top: 0; }
 nav a, .sidebar-foot button { display: flex; align-items: center; gap: 13px; min-height: 44px; padding: 0 14px; overflow: hidden; border: 0; border-radius: 10px; color: #aebdd3; background: transparent; font: inherit; white-space: nowrap; text-decoration: none; cursor: pointer; transition: .18s ease; }
 nav a:hover, .sidebar-foot button:hover { color: white; background: rgba(255,255,255,.07); }
 nav a.active { color: white; background: linear-gradient(135deg, #2f80ed, #236bd1); box-shadow: 0 8px 20px rgba(47,128,237,.28); }
 .sidebar-foot { margin-top: auto; padding: 14px 12px 20px; border-top: 1px solid rgba(255,255,255,.08); }
 .sidebar-foot button { width: 100%; }
-.sidebar-collapsed .brand span, .sidebar-collapsed nav span, .sidebar-collapsed .sidebar-foot span { opacity: 0; pointer-events: none; }
+.sidebar-collapsed .brand span, .sidebar-collapsed nav span, .sidebar-collapsed .sidebar-foot span, .sidebar-collapsed .nav-group-label { opacity: 0; pointer-events: none; }
 .admin-workspace { min-width: 0; }
 .admin-topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; min-height: 70px; padding: 0 28px; border-bottom: 1px solid #e5eaf1; background: rgba(255,255,255,.94); backdrop-filter: blur(12px); }
 .topbar-leading, .topbar-account { display: flex; align-items: center; gap: 12px; }
 .collapse-button, .mobile-menu, .notification { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid #e1e7ef; border-radius: 9px; color: #55647a; background: white; cursor: pointer; }
 .mobile-menu { display: none; }
 .global-search { display: flex; align-items: center; gap: 9px; width: min(360px, 34vw); height: 39px; padding: 0 12px; border: 1px solid #e1e7ef; border-radius: 10px; color: #8a96a8; background: #f8fafc; font-size: 13px; }
+.global-search { text-decoration: none; }
 .global-search kbd { margin-left: auto; padding: 2px 6px; border: 1px solid #dfe5ed; border-radius: 5px; background: white; font: inherit; }
 .topbar-account { font-size: 13px; font-weight: 600; }
 .avatar { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; color: white; background: linear-gradient(135deg, #43b37d, #208d5c); }

+ 13 - 6
src/app/admin/layout/admin-shell.component.ts

@@ -1,9 +1,10 @@
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
-import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
+import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
 import {
   BadgeCent,
   Bell,
+  BriefcaseBusiness,
   BookMarked,
   BookOpen,
   Braces,
@@ -30,9 +31,11 @@ import {
   Menu,
   Megaphone,
   MessageSquareText,
+  Network,
   Palette,
   PanelsTopLeft,
   Route,
+  ReceiptText,
   Search,
   ScrollText,
   ShieldCheck,
@@ -44,7 +47,7 @@ import {
   WalletCards,
   Workflow,
 } from 'lucide-angular';
-import { ADMIN_NAVIGATION } from '../admin.constants';
+import { OPERATIONS_NAVIGATION, TECH_NAVIGATION } from '../admin.constants';
 import { AdminSessionService } from '../admin-session.service';
 
 @Component({
@@ -56,16 +59,20 @@ import { AdminSessionService } from '../admin-session.service';
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class AdminShellComponent {
-  readonly navigation = ADMIN_NAVIGATION;
+  readonly mode: 'operations' | 'technical';
+  readonly navigation;
   readonly collapsed = signal(false);
   readonly mobileOpen = signal(false);
   readonly icons = {
-    BadgeCent, Bell, BookMarked, BookOpen, Braces, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
+    BadgeCent, Bell, BookMarked, BookOpen, Braces, BriefcaseBusiness, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
     ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Headphones, Image, KeyRound, Languages, LayoutDashboard, ListChecks, ListTodo, ScrollText,
-    LayoutTemplate, LogOut, Megaphone, Menu, MessageSquareText, Palette, PanelsTopLeft, Route, Search, ShieldCheck, Shapes, Store, TableProperties, Tags, Users, WalletCards, Workflow,
+    LayoutTemplate, LogOut, Megaphone, Menu, MessageSquareText, Network, Palette, PanelsTopLeft, ReceiptText, Route, Search, ShieldCheck, Shapes, Store, TableProperties, Tags, Users, WalletCards, Workflow,
   };
 
-  constructor(readonly sessions: AdminSessionService) {}
+  constructor(readonly sessions: AdminSessionService, route: ActivatedRoute) {
+    this.mode = route.snapshot.data['mode'] === 'technical' ? 'technical' : 'operations';
+    this.navigation = this.mode === 'technical' ? TECH_NAVIGATION : OPERATIONS_NAVIGATION;
+  }
 
   iconFor(name: string) {
     const key = Object.keys(this.icons).find((value) => value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`).replace(/^-/, '') === name);

+ 28 - 0
src/app/admin/operations.service.spec.ts

@@ -0,0 +1,28 @@
+import { TestBed } from '@angular/core/testing';
+import { CloudFunctionsService } from './cloud-functions.service';
+import { OperationsService } from './operations.service';
+
+describe('OperationsService', () => {
+  let service: OperationsService;
+  let functions: jasmine.SpyObj<CloudFunctionsService>;
+
+  beforeEach(() => {
+    functions = jasmine.createSpyObj<CloudFunctionsService>('CloudFunctionsService', ['operations']);
+    TestBed.configureTestingModule({ providers: [OperationsService, { provide: CloudFunctionsService, useValue: functions }] });
+    service = TestBed.inject(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' } });
+  });
+
+  it('maps each teaching view to a business operation instead of generic class CRUD', async () => {
+    functions.operations.and.resolveTo({ page: 1, pageSize: 20, total: 0, items: [] });
+    await service.list('course-bindings', 1, 20, '课程');
+    await service.list('appointments', 1, 20, '学员');
+    expect(functions.operations.calls.argsFor(0)[0]).toBe('ops/course-bindings/list');
+    expect(functions.operations.calls.argsFor(1)[0]).toBe('ops/appointments/list');
+  });
+});

+ 37 - 0
src/app/admin/operations.service.ts

@@ -0,0 +1,37 @@
+import { inject, Injectable } from '@angular/core';
+import { OperationsDashboardData, OperationsPage, OperationsUser, OperationsUserDetail } from './admin.models';
+import { CloudFunctionsService } from './cloud-functions.service';
+
+export type OperationsListKind = 'relations' | 'course-bindings' | 'appointments' | 'lessons' | 'learning-records' | 'finance' | 'money-logs';
+
+@Injectable({ providedIn: 'root' })
+export class OperationsService {
+  private readonly functions = inject(CloudFunctionsService);
+
+  dashboard(): Promise<OperationsDashboardData> {
+    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 });
+  }
+
+  userDetail(objectId: string): Promise<OperationsUserDetail> {
+    return this.functions.operations<OperationsUserDetail>('ops/users/detail', { objectId });
+  }
+
+  list(kind: OperationsListKind, page: number, pageSize: number, search: string, filters: Record<string, unknown> = {}): Promise<OperationsPage> {
+    const operation = kind === 'relations' ? 'ops/relations/list'
+      : kind === 'course-bindings' ? 'ops/course-bindings/list'
+      : kind === 'appointments' ? 'ops/appointments/list'
+      : kind === 'lessons' ? 'ops/lessons/list'
+      : kind === 'learning-records' ? 'ops/learning-records/list'
+      : kind === 'finance' ? 'ops/finance/list'
+      : 'ops/money-logs/list';
+    return this.functions.operations<OperationsPage>(operation, { page, pageSize, search, filters });
+  }
+
+  mutate<T>(operation: string, targetId: string, reason: string, payload: Record<string, unknown> = {}, expectedUpdatedAt = ''): Promise<T> {
+    return this.functions.operations<T>(operation, { targetId, reason, payload, expectedUpdatedAt });
+  }
+}

+ 16 - 22
src/app/admin/pages/admin-dashboard.component.html

@@ -1,40 +1,34 @@
 <header class="page-header">
-  <div><p>ADMIN OVERVIEW</p><h1>管理概览</h1><span>查看核心业务数据与迁移运行状态</span></div>
+  <div><p>OPERATIONS OVERVIEW</p><h1>运营工作台</h1><span>聚焦今天需要处理的用户、预约和教学任务</span></div>
   <button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()" />刷新数据</button>
 </header>
 
 @if (loading() && !data()) {
-  <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在通过云函数汇总数据…</div>
+  <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在汇总运营数据…</div>
 } @else if (error()) {
-  <div class="state error"><strong>暂时无法载入概览</strong><span>{{ error() }}</span><button type="button" (click)="load()">重新加载</button></div>
+  <div class="state error" role="alert"><strong>暂时无法载入运营工作台</strong><span>{{ error() }}</span><button type="button" (click)="load()">重新加载</button></div>
 } @else {
   @if (data(); as dashboard) {
   <section class="welcome">
-    <div><span>欢迎回来</span><h2>{{ dashboard.identity.displayName || dashboard.identity.username }}</h2><p>当前数据范围:{{ dashboard.identity.company ? '所属帐套' : (dashboard.identity.isSuperAdmin ? '全部帐套' : '未分配帐套') }}</p></div>
-    <div class="migration-progress"><span>规范化迁移登记</span><strong>{{ dashboard.migration.registeredRecords | number }}</strong><small>SQL Server 来源记录</small></div>
+    <div><span>{{ dashboard.date }}</span><h2>{{ dashboard.identity.displayName || dashboard.identity.username }},欢迎回来</h2><p>当前角色:{{ dashboard.identity.operationsRole === 'ops-manager' ? '运营管理者' : dashboard.identity.operationsRole === 'ops-staff' ? '普通运营' : '只读审计' }}</p></div>
+    <a routerLink="/admin/appointments">查看今日排课<lucide-icon [img]="icons.ArrowRight" [size]="18" /></a>
   </section>
 
-  <section class="metric-grid">
-    @for (metric of dashboard.metrics; track metric.className) {
-      <a [routerLink]="metric.route" class="metric-card">
-        <span>{{ metric.label }}</span><strong>{{ metric.count | number }}</strong><small>{{ metric.className }}</small><lucide-icon [img]="icons.ArrowRight" [size]="18" />
+  <section class="metric-grid" aria-label="核心运营指标">
+    @for (metric of dashboard.metrics; track metric.key) {
+      <a [routerLink]="metric.route" class="metric-card" [class]="'metric-card ' + metric.tone">
+        <span>{{ metric.label }}</span><strong>{{ metric.value | number }}</strong><small>{{ metric.hint }}</small><lucide-icon [img]="icons.ArrowRight" [size]="18" />
       </a>
     }
   </section>
 
-  <section class="status-grid">
-    <article>
-      <div class="status-icon blue"><lucide-icon [img]="icons.Database" [size]="22" /></div>
-      <div><span>来源对象</span><strong>{{ dashboard.migration.sourceObjects }}</strong><small>表、视图与函数审计登记</small></div>
-    </article>
-    <article>
-      <div class="status-icon green"><lucide-icon [img]="icons.TableProperties" [size]="22" /></div>
-      <div><span>已迁移业务类</span><strong>{{ dashboard.migration.migratedClasses }}</strong><small>Parse 规范化类</small></div>
-    </article>
-    <article>
-      <div class="status-icon amber"><lucide-icon [img]="icons.FunctionSquare" [size]="22" /></div>
-      <div><span>已部署云函数</span><strong>{{ dashboard.migration.cloudFunctions }}</strong><small>Function 表有效记录</small></div>
-    </article>
+  <section class="attention">
+    <header><div><lucide-icon [img]="icons.CircleAlert" [size]="20" /><h2>需要关注</h2></div><span>点击指标进入对应处理页面</span></header>
+    <div>
+      @for (alert of dashboard.alerts; track alert.key) {
+        <a [routerLink]="alert.route"><span>{{ alert.label }}</span><strong>{{ alert.value | number }}</strong><lucide-icon [img]="icons.ArrowRight" [size]="17" /></a>
+      } @empty { <p>当前没有需要特别关注的运营异常。</p> }
+    </div>
   </section>
   }
 }

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


+ 10 - 17
src/app/admin/pages/admin-dashboard.component.ts

@@ -1,9 +1,9 @@
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
 import { RouterLink } from '@angular/router';
-import { ArrowRight, Database, FunctionSquare, LoaderCircle, LucideAngularModule, RefreshCw, TableProperties } from 'lucide-angular';
-import { DashboardData } from '../admin.models';
-import { CloudFunctionsService } from '../cloud-functions.service';
+import { ArrowRight, CircleAlert, LoaderCircle, LucideAngularModule, RefreshCw } from 'lucide-angular';
+import { OperationsDashboardData } from '../admin.models';
+import { OperationsService } from '../operations.service';
 
 @Component({
   selector: 'app-admin-dashboard',
@@ -14,23 +14,16 @@ import { CloudFunctionsService } from '../cloud-functions.service';
   changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class AdminDashboardComponent implements OnInit {
-  readonly data = signal<DashboardData | null>(null);
+  readonly data = signal<OperationsDashboardData | null>(null);
   readonly loading = signal(true);
   readonly error = signal('');
-  readonly icons = { ArrowRight, Database, FunctionSquare, LoaderCircle, RefreshCw, TableProperties };
-  private readonly functions = inject(CloudFunctionsService);
-
+  readonly icons = { ArrowRight, CircleAlert, LoaderCircle, RefreshCw };
+  private readonly operations = inject(OperationsService);
   ngOnInit(): void { void this.load(); }
-
   async load(): Promise<void> {
-    this.loading.set(true);
-    this.error.set('');
-    try {
-      this.data.set(await this.functions.admin<DashboardData>('dashboard'));
-    } catch (error) {
-      this.error.set(error instanceof Error ? error.message : '概览加载失败');
-    } finally {
-      this.loading.set(false);
-    }
+    this.loading.set(true); this.error.set('');
+    try { this.data.set(await this.operations.dashboard()); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '运营工作台加载失败'); }
+    finally { this.loading.set(false); }
   }
 }

+ 24 - 0
src/app/admin/pages/admin-forbidden.component.ts

@@ -0,0 +1,24 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { LockKeyhole, LucideAngularModule } from 'lucide-angular';
+
+@Component({
+  selector: 'app-admin-forbidden',
+  standalone: true,
+  imports: [RouterLink, LucideAngularModule],
+  template: `
+    <section class="forbidden" role="alert">
+      <lucide-icon [img]="icon" [size]="34" />
+      <h1>没有访问权限</h1>
+      <p>当前账号不能进入该页面。如需技术后台或高风险操作权限,请联系超级管理员。</p>
+      <a routerLink="/admin/dashboard">返回运营工作台</a>
+    </section>
+  `,
+  styles: [`
+    .forbidden { display:grid; place-items:center; gap:12px; min-height:60vh; padding:40px; text-align:center; color:#607086; }
+    h1 { margin:4px 0 0; color:#172033; } p { max-width:520px; margin:0; line-height:1.7; }
+    a { margin-top:8px; padding:10px 16px; border-radius:9px; color:white; background:#217b62; text-decoration:none; font-weight:700; }
+  `],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminForbiddenComponent { readonly icon = LockKeyhole; }

+ 1 - 1
src/app/admin/pages/admin-login.component.html

@@ -4,7 +4,7 @@
       <div class="story-brand"><img src="/static/images/logo.png" alt="" /><span>小树陪练</span></div>
       <p class="eyebrow">XIAOSHU PRACTICE</p>
       <h1>让每一次管理,<br />都服务于真实成长。</h1>
-      <p class="story-copy">统一管理用户、课程、预约、练习、内容与学习数据。所有业务操作均通过云函数执行,并遵循帐套隔离。</p>
+      <p class="story-copy">统一管理用户、陪练与代理关系、课程绑定、预约排课、上课记录和学习数据。所有业务操作均通过云函数执行,并遵循帐套隔离。</p>
       <div class="security-note"><lucide-icon [img]="icons.ShieldCheck" [size]="21" /><span>Parse 会话认证 · 云函数权限校验 · 多租户数据隔离</span></div>
     </div>
   </section>

+ 3 - 1
src/app/admin/pages/admin-login.component.ts

@@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
 import { Eye, EyeOff, LoaderCircle, LockKeyhole, LucideAngularModule, ShieldCheck, UserRound } from 'lucide-angular';
 import { AdminSessionService } from '../admin-session.service';
 import { CloudFunctionsService } from '../cloud-functions.service';
+import { AdminIdentity } from '../admin.models';
 
 @Component({
   selector: 'app-admin-login',
@@ -39,7 +40,8 @@ export class AdminLoginComponent {
     this.error.set('');
     try {
       await this.sessions.login(this.username, this.password);
-      await this.functions.admin('meta');
+      const meta = await this.functions.admin<{ identity: AdminIdentity }>('meta');
+      this.sessions.applyIdentity(meta.identity);
       const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') || '/admin/dashboard';
       await this.router.navigateByUrl(returnUrl);
     } catch (error) {

+ 14 - 0
src/app/admin/pages/operations-list.component.html

@@ -0,0 +1,14 @@
+<header class="page-header"><div><p>{{ config.eyebrow }}</p><h1>{{ config.title }}</h1><span>{{ config.description }}</span></div><div class="header-actions">@if (kind === 'course-bindings' && canMutate()) { <button class="primary" type="button" (click)="createOpen.set(!createOpen())"><lucide-icon [img]="icons.Plus" [size]="17" />新增绑定</button> }<button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button></div></header>
+
+@if (createOpen()) { <section class="create-panel"><h2>新增用户课程绑定</h2><div><label>学员用户 ID<input type="number" min="1" [ngModel]="bindingStudentId()" (ngModelChange)="bindingStudentId.set($event)" /></label><label>课程 ID<input type="number" min="1" [ngModel]="bindingCourseId()" (ngModelChange)="bindingCourseId.set($event)" /></label><label>开课数量<input type="number" min="0" [ngModel]="bindingOpenedCount()" (ngModelChange)="bindingOpenedCount.set($event)" /></label><label>学习进度<input [ngModel]="bindingProgress()" (ngModelChange)="bindingProgress.set($event)" /></label><label class="wide">操作原因<input [ngModel]="actionReason()" (ngModelChange)="actionReason.set($event)" placeholder="必填" /></label><button type="button" (click)="createBinding()" [disabled]="saving()">确认绑定</button></div></section> }
+
+<section class="filters"><label><span>搜索</span><div><lucide-icon [img]="icons.Search" [size]="18" /><input [ngModel]="query()" (ngModelChange)="query.set($event)" (keyup.enter)="load(1)" placeholder="按姓名、用户 ID、课程或状态搜索" /></div></label><button class="primary" type="button" (click)="load(1)">查询</button><button type="button" (click)="reset()">重置</button></section>
+@if (message()) { <p class="notice" role="status">{{ message() }}</p> } @if (error()) { <p class="notice error" role="alert">{{ error() }}</p> }
+
+<section class="table-card"><header><strong>{{ config.operationLabel }}</strong><span>{{ page()?.total || 0 }} 条</span></header>
+  @if (loading() && !page()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="26" />正在加载…</div> } @else {
+    <div class="table-scroll"><table><thead><tr>@for (column of config.columns; track column.key) { <th>{{ column.label }}</th> }<th><span class="sr-only">操作</span></th></tr></thead><tbody>
+      @for (row of page()?.items || []; track rowId(row)) { <tr>@for (column of config.columns; track column.key) { <td>@if (column.format === 'date') { {{ date(value(row, column)) }} } @else if (column.format === 'number') { {{ numericValue(row, column) | number }} } @else if (column.format === 'status') { <span class="status" [class.disabled]="value(row, column) === '已取消' || value(row, column) === '已停用' || value(row, column) === '异常'">{{ value(row, column) }}</span> } @else { {{ value(row, column) }} }</td> }<td><div class="row-actions">@if (row['userObjectId'] || row['studentObjectId']) { <a [routerLink]="['/admin/users', row['userObjectId'] || row['studentObjectId']]" aria-label="查看用户详情"><lucide-icon [img]="icons.Eye" [size]="16" /></a> } @if (kind === 'relations' && canMutate()) { <button type="button" aria-label="修改代理关系" (click)="rowAction(row)"><lucide-icon [img]="icons.Link2" [size]="16" /></button> } @if (kind === 'course-bindings' && isManager()) { <button type="button" [attr.aria-label]="isRecycled(row) ? '恢复课程绑定' : '取消课程绑定'" (click)="rowAction(row)"><lucide-icon [img]="isRecycled(row) ? icons.RotateCcw : icons.Trash2" [size]="16" /></button> } @if (kind === 'appointments' && canMutate() && nextAppointmentStatus(appointmentStatus(row)) !== null) { <button class="text-action" type="button" (click)="rowAction(row)">{{ nextAppointmentLabel(appointmentStatus(row)) }}</button> }</div></td></tr> } @empty { <tr><td [attr.colspan]="config.columns.length + 1"><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>
+  }
+</section>

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


+ 85 - 0
src/app/admin/pages/operations-list.component.ts

@@ -0,0 +1,85 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, RouterLink } from '@angular/router';
+import { ChevronLeft, ChevronRight, Eye, Link2, LoaderCircle, LucideAngularModule, Plus, RefreshCw, RotateCcw, Search, Trash2 } from 'lucide-angular';
+import { AdminSessionService } from '../admin-session.service';
+import { OperationsPage } from '../admin.models';
+import { OperationsListKind, OperationsService } from '../operations.service';
+
+interface ListColumn { key: string; label: string; format?: 'date' | 'status' | 'number'; }
+interface ListConfig { title: string; eyebrow: string; description: string; operationLabel: string; rowKey: string; columns: ListColumn[]; }
+
+const CONFIGS: Record<OperationsListKind, ListConfig> = {
+  relations: { title: '陪练与代理关系', eyebrow: 'USER RELATIONS', description: '核对用户、所属代理及直属关系', operationLabel: '关系记录', rowKey: 'userObjectId', columns: [{ key: 'displayName', label: '用户' }, { key: 'userId', label: '用户 ID' }, { key: 'groupName', label: '用户组' }, { key: 'parentDisplayName', label: '所属代理' }, { key: 'parentUserId', label: '代理 ID' }, { key: 'registeredAt', label: '注册时间', format: 'date' }] },
+  'course-bindings': { title: '用户课程绑定', eyebrow: 'COURSE BINDINGS', description: '查看学员课程、学习进度和绑定状态', operationLabel: '绑定记录', rowKey: 'targetId', columns: [{ key: 'studentName', label: '学员' }, { key: 'studentId', label: '学员 ID' }, { key: 'courseName', label: '课程' }, { key: 'learnedCount', label: '已学', format: 'number' }, { key: 'openedCount', label: '课程内容量', format: 'number' }, { key: 'progressStage', label: '进度' }, { key: 'statusLabel', label: '状态', format: 'status' }, { key: 'createdAt', label: '绑定时间', format: 'date' }] },
+  appointments: { title: '预约排课', eyebrow: 'APPOINTMENTS', description: '按学员、陪练、课程和状态核对预约', operationLabel: '预约记录', rowKey: 'targetId', columns: [{ key: 'studentName', label: '学员' }, { key: 'coachName', label: '陪练' }, { key: 'courseName', label: '课程' }, { key: 'appointmentAt', label: '预约时间', format: 'date' }, { key: 'classTypeLabel', label: '课程类型' }, { key: 'billingTypeLabel', label: '计费方式' }, { key: 'statusLabel', label: '状态', format: 'status' }] },
+  lessons: { title: '上课记录', eyebrow: 'LESSON RECORDS', description: '查看学员、陪练、课程、评价和上课时间', operationLabel: '上课记录', rowKey: 'targetId', columns: [{ key: 'studentName', label: '学员' }, { key: 'coachName', label: '陪练' }, { key: 'courseName', label: '课程' }, { key: 'classTypeLabel', label: '课程类型' }, { key: 'score', label: '评分', format: 'number' }, { key: 'comment', label: '评价' }, { key: 'lessonAt', label: '上课时间', format: 'date' }] },
+  'learning-records': { title: '每日学习记录', eyebrow: 'LEARNING RECORDS', description: '跟踪学员学习、掌握和待复习情况', operationLabel: '学习记录', rowKey: 'targetId', columns: [{ key: 'studentName', label: '学员' }, { key: 'courseName', label: '课程' }, { key: 'studyDate', label: '学习日期', format: 'date' }, { key: 'learned', label: '学习数量', format: 'number' }, { key: 'mastered', label: '已掌握', format: 'number' }, { key: 'reviewDue', label: '待复习', format: 'number' }, { key: 'statusLabel', label: '状态', format: 'status' }] },
+  finance: { title: '用户课时与余额', eyebrow: 'PERIODS & BALANCES', description: '核对用户 30/60 分钟课时、经验和积分', operationLabel: '用户余额', rowKey: 'userObjectId', columns: [{ key: 'displayName', label: '用户' }, { key: 'userId', label: '用户 ID' }, { key: 'periods30', label: '30 分钟', format: 'number' }, { key: 'periods60', label: '60 分钟', format: 'number' }, { key: 'trialPeriods', label: '体验/经验', format: 'number' }, { key: 'userPoint', label: '积分', format: 'number' }, { key: 'statusLabel', label: '账号状态', format: 'status' }] },
+  'money-logs': { title: '资金积分流水', eyebrow: 'FINANCE LEDGER', description: '查看课时、余额、经验和积分变动记录', operationLabel: '流水记录', rowKey: 'targetId', columns: [{ key: 'userName', label: '用户' }, { key: 'userId', label: '用户 ID' }, { key: 'moneyTypeLabel', label: '项目' }, { key: 'score', label: '变动', format: 'number' }, { key: 'scoreBefore', label: '变动前', format: 'number' }, { key: 'detail', label: '说明' }, { key: 'occurredAt', label: '时间', format: 'date' }] },
+};
+
+@Component({
+  selector: 'app-operations-list', standalone: true,
+  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  templateUrl: './operations-list.component.html', styleUrl: './operations-list.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OperationsListComponent implements OnInit {
+  readonly kind: OperationsListKind;
+  readonly config: ListConfig;
+  readonly page = signal<OperationsPage | null>(null);
+  readonly query = signal(''); readonly loading = signal(true); readonly saving = signal(false); readonly error = signal(''); readonly message = signal('');
+  readonly createOpen = signal(false); readonly bindingStudentId = signal(0); readonly bindingCourseId = signal(0); readonly bindingOpenedCount = signal(0); readonly bindingProgress = signal(''); readonly actionReason = signal('');
+  readonly icons = { ChevronLeft, ChevronRight, Eye, Link2, LoaderCircle, Plus, RefreshCw, RotateCcw, Search, Trash2 };
+  private readonly operations = inject(OperationsService); private readonly sessions = inject(AdminSessionService);
+
+  constructor(route: ActivatedRoute) { this.kind = route.snapshot.data['kind'] as OperationsListKind; this.config = CONFIGS[this.kind]; }
+  ngOnInit(): void { void this.load(1); }
+  canMutate(): boolean { return this.sessions.session()?.operationsRole !== 'ops-auditor'; }
+  isManager(): boolean { return this.sessions.session()?.operationsRole === 'ops-manager' || this.sessions.session()?.isSuperAdmin === true; }
+  pageCount(): number { const value = this.page(); return Math.max(1, Math.ceil((value?.total ?? 0) / (value?.pageSize ?? 20))); }
+  rowId(row: Record<string, unknown>): string { return String(row[this.config.rowKey] ?? ''); }
+  value(row: Record<string, unknown>, column: ListColumn): unknown { return row[column.key] ?? '—'; }
+  numericValue(row: Record<string, unknown>, column: ListColumn): number { return Number(row[column.key] ?? 0); }
+  isRecycled(row: Record<string, unknown>): boolean { return Number(row['status']) === -2; }
+  appointmentStatus(row: Record<string, unknown>): number { return Number(row['statusCode'] ?? 0); }
+  date(value: unknown): string { if (!value) return '—'; const date = new Date(String(value)); return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short' }).format(date); }
+
+  async load(page = this.page()?.page ?? 1): Promise<void> { this.loading.set(true); this.error.set(''); try { this.page.set(await this.operations.list(this.kind, page, 20, this.query())); } catch (error) { this.error.set(error instanceof Error ? error.message : '运营数据加载失败'); } finally { this.loading.set(false); } }
+  reset(): void { this.query.set(''); void this.load(1); }
+
+  async createBinding(): Promise<void> {
+    if (!this.bindingStudentId() || !this.bindingCourseId() || !this.actionReason().trim()) { this.error.set('请输入学员 ID、课程 ID 和操作原因'); return; }
+    await this.mutate('ops/course-bindings/save', '', { studentId: this.bindingStudentId(), courseId: this.bindingCourseId(), openedCount: this.bindingOpenedCount(), progressStage: this.bindingProgress() }, '课程绑定已创建');
+    this.createOpen.set(false);
+  }
+
+  async rowAction(row: Record<string, unknown>): Promise<void> {
+    if (this.kind === 'relations') {
+      const parent = globalThis.prompt('请输入新的上级代理用户 ID;输入 0 表示解除关系', String(row['parentUserId'] ?? 0)); if (parent === null) return;
+      const reason = globalThis.prompt('请输入修改原因'); if (!reason?.trim()) return;
+      this.actionReason.set(reason); await this.mutate('ops/relations/save', this.rowId(row), { parentUserId: Number(parent) }, '代理关系已更新'); return;
+    }
+    if (this.kind === 'course-bindings') {
+      const recycled = Number(row['status']) === -2; const reason = globalThis.prompt(recycled ? '请输入恢复绑定原因' : '请输入取消绑定原因'); if (!reason?.trim()) return;
+      this.actionReason.set(reason); await this.mutate(recycled ? 'ops/course-bindings/restore' : 'ops/course-bindings/recycle', this.rowId(row), {}, recycled ? '课程绑定已恢复' : '课程绑定已移入回收站', String(row['updatedAt'] ?? '')); return;
+    }
+    if (this.kind === 'appointments') {
+      const next = this.nextAppointmentStatus(Number(row['statusCode'])); if (next === null) return;
+      const reason = globalThis.prompt('请输入预约状态变更原因'); if (!reason?.trim()) return;
+      this.actionReason.set(reason); await this.mutate('ops/appointments/transition', this.rowId(row), { status: next }, '预约状态已更新', String(row['updatedAt'] ?? ''));
+    }
+  }
+
+  nextAppointmentStatus(status: number): number | null { return status === 0 ? 10 : status === 10 ? 11 : status === 11 ? 20 : status === 20 ? 30 : null; }
+  nextAppointmentLabel(status: number): string { return status === 0 ? '开始上课' : status === 10 ? '完成课程' : status === 11 ? '确认完成' : status === 20 ? '归档' : ''; }
+
+  private async mutate(operation: string, targetId: string, payload: Record<string, unknown>, success: string, expectedUpdatedAt = ''): Promise<void> {
+    this.saving.set(true); this.error.set(''); this.message.set('');
+    try { await this.operations.mutate(operation, targetId, this.actionReason(), payload, expectedUpdatedAt); this.message.set(success); this.actionReason.set(''); await this.load(1); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '操作失败'); }
+    finally { this.saving.set(false); }
+  }
+}

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

@@ -0,0 +1,18 @@
+<header class="page-header"><div><a routerLink="/admin/users"><lucide-icon [img]="icons.ArrowLeft" [size]="17" />返回用户列表</a><p>USER PROFILE</p><h1>{{ user()?.displayName || '用户详情' }}</h1><span>只展示运营需要的资料、关系、课时和账号状态</span></div><button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button></header>
+
+@if (loading() && !user()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在加载用户资料…</div> }
+@else if (error() && !user()) { <div class="state error" role="alert">{{ error() }}</div> }
+@else {
+ @if (user(); as detail) {
+  <section class="profile-summary"><div class="avatar"><lucide-icon [img]="icons.UserRound" [size]="28" /></div><div><h2>{{ detail.displayName }}</h2><p>用户 ID {{ detail.userId || '—' }} · {{ detail.username }}</p></div><span class="status" [class.disabled]="detail.status === 'disabled'">{{ detail.statusLabel }}</span></section>
+
+  @if (message()) { <p class="notice" role="status">{{ message() }}</p> } @if (error()) { <p class="notice error" role="alert">{{ error() }}</p> }
+
+  <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>用户组与所属代理</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>
+ }
+}

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


+ 69 - 0
src/app/admin/pages/operations-user-detail.component.ts

@@ -0,0 +1,69 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, 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';
+
+@Component({
+  selector: 'app-operations-user-detail',
+  standalone: true,
+  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  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') || '';
+
+  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 : '用户详情加载失败'); }
+    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('');
+    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); }
+  }
+
+  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 : '手机号查看失败'); }
+  }
+}

+ 33 - 0
src/app/admin/pages/operations-users.component.html

@@ -0,0 +1,33 @@
+<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>
+</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>
+  <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>
+  @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>
+      <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.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> }
+      </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>
+  }
+</section>

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


+ 37 - 0
src/app/admin/pages/operations-users.component.ts

@@ -0,0 +1,37 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, 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 { OperationsService } from '../operations.service';
+
+@Component({
+  selector: 'app-operations-users',
+  standalone: true,
+  imports: [CommonModule, FormsModule, RouterLink, LucideAngularModule],
+  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 loading = signal(true);
+  readonly error = 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> {
+    this.loading.set(true); this.error.set('');
+    try { this.page.set(await this.operations.users(page, 20, this.query(), { status: this.status() })); }
+    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); }
+  pageCount(): number { const value = this.page(); return Math.max(1, Math.ceil((value?.total ?? 0) / (value?.pageSize ?? 20))); }
+}

+ 48 - 0
src/app/admin/pages/tech-dashboard.component.ts

@@ -0,0 +1,48 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { Braces, Database, LoaderCircle, LucideAngularModule, RefreshCw, Route, TableProperties } from 'lucide-angular';
+import { DashboardData } from '../admin.models';
+import { CloudFunctionsService } from '../cloud-functions.service';
+
+@Component({
+  selector: 'app-tech-dashboard',
+  standalone: true,
+  imports: [CommonModule, RouterLink, LucideAngularModule],
+  template: `
+    <header class="page-header">
+      <div><p>TECHNICAL CONSOLE</p><h1>技术工作台</h1><span>仅超级管理员可访问的数据、云函数与迁移工具</span></div>
+      <button type="button" (click)="load()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button>
+    </header>
+    @if (loading() && !data()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="28" />正在汇总技术状态…</div> }
+    @else if (error()) { <div class="state error" role="alert">{{ error() }}</div> }
+    @else {
+      @if (data(); as dashboard) {
+      <section class="tech-grid">
+        <a routerLink="/tech-admin/resources"><lucide-icon [img]="icons.TableProperties" [size]="24" /><span>数据类</span><strong>{{ dashboard.migration.migratedClasses }}</strong><small>规范化 Parse 类</small></a>
+        <a routerLink="/tech-admin/api-catalog"><lucide-icon [img]="icons.Braces" [size]="24" /><span>云函数</span><strong>{{ dashboard.migration.cloudFunctions }}</strong><small>当前启用接口</small></a>
+        <a routerLink="/tech-admin/migration"><lucide-icon [img]="icons.Database" [size]="24" /><span>来源记录</span><strong>{{ dashboard.migration.registeredRecords | number }}</strong><small>SQL Server 登记记录</small></a>
+        <a routerLink="/tech-admin/migration"><lucide-icon [img]="icons.Route" [size]="24" /><span>来源对象</span><strong>{{ dashboard.migration.sourceObjects }}</strong><small>迁移审计对象</small></a>
+      </section>
+      }
+    }
+  `,
+  styles: [`
+    .page-header{display:flex;align-items:end;justify-content:space-between;gap:20px;margin-bottom:24px}.page-header p{margin:0 0 7px;color:#3979b9;font-size:11px;font-weight:800;letter-spacing:.18em}.page-header h1{margin:0;font-size:28px}.page-header span{display:block;margin-top:8px;color:#7c899b;font-size:13px}.page-header button{display:flex;align-items:center;gap:8px;min-height:38px;padding:0 14px;border:1px solid #dfe5ed;border-radius:9px;background:#fff}.tech-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.tech-grid a{display:grid;gap:10px;min-height:180px;padding:24px;border:1px solid #e2e8f0;border-radius:15px;color:#172033;background:#fff;text-decoration:none}.tech-grid lucide-icon{color:#3979b9}.tech-grid span{color:#66758a;font-size:13px}.tech-grid strong{font-size:30px}.tech-grid small{color:#98a4b4}.state{display:grid;place-items:center;gap:10px;min-height:320px;color:#758297}.error{color:#b43d47}.spin{animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}@media(max-width:1000px){.tech-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:620px){.tech-grid{grid-template-columns:1fr}}
+  `],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TechDashboardComponent implements OnInit {
+  readonly data = signal<DashboardData | null>(null);
+  readonly loading = signal(true);
+  readonly error = signal('');
+  readonly icons = { Braces, Database, LoaderCircle, RefreshCw, Route, TableProperties };
+  private readonly functions = inject(CloudFunctionsService);
+  ngOnInit(): void { void this.load(); }
+  async load(): Promise<void> {
+    this.loading.set(true); this.error.set('');
+    try { this.data.set(await this.functions.admin<DashboardData>('techDashboard')); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '技术工作台加载失败'); }
+    finally { this.loading.set(false); }
+  }
+}

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