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

fix: restore teacher portal workflows

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

+ 34 - 0
projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts

@@ -91,6 +91,12 @@ class LearningModulesApiService {
   }
   }
 
 
   post<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
   post<T>(action: string, params: Record<string, unknown> = {}): Observable<ApiEnvelope<T>> {
+    if (action === 'e_order_tongji') {
+      return this.result<T>({ t30: 4, t60: 6, t_tiyan: 1, t_total: 11, t_shichang: 9, t_yongji: 320 });
+    }
+    if (action === 'e_user_list') {
+      return this.result<T>([{ szyh: 100, honeyname: '张雅茜' }]);
+    }
     if (action === 'app_learning_overview') {
     if (action === 'app_learning_overview') {
       const records = [
       const records = [
         { GeneralID: 201, CreateTime: '2026-08-01 09:30:00', dqrq: '20260801', con: '初中英语@Unit 1', xxqs: JSON.stringify([{ Title: 'apple' }]) },
         { GeneralID: 201, CreateTime: '2026-08-01 09:30:00', dqrq: '20260801', con: '初中英语@Unit 1', xxqs: JSON.stringify([{ Title: 'apple' }]) },
@@ -131,6 +137,13 @@ class LearningModulesApiService {
       });
       });
     }
     }
     if (action === 'app_account_overview') {
     if (action === 'app_account_overview') {
+      if (Number(params['uid']) === 300) {
+        return this.result<T>({
+          profile: { userId: 300, userName: 'coach', honeyName: '张老师', groupId: 3 },
+          coachPanel: { todaySchedules: 2, completedLessons: 8, reviewIncome: 36, reviewRecords: 3, totalLessons: 11, totalHours: 9, course30: 4, course60: 6, trialLessons: 1 },
+          refreshedAt: '2026-08-26T00:00:00.000Z',
+        });
+      }
       return this.result<T>({
       return this.result<T>({
         profile: { userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 },
         profile: { userId: 100, userName: 'student', honeyName: '张雅茜', groupId: 1 },
         companionSummary: { completedSessions: 1, completedMinutes: 60 },
         companionSummary: { completedSessions: 1, completedMinutes: 60 },
@@ -462,6 +475,27 @@ describe('home learning modules', () => {
     session.groupId.set(3);
     session.groupId.set(3);
     const harness = await RouterTestingHarness.create();
     const harness = await RouterTestingHarness.create();
 
 
+    await harness.navigateByUrl('/pages/home/index');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    const coachLinks = Array.from(harness.routeNativeElement?.querySelectorAll('.coach-quick-grid > a') ?? []).map((link) => link.getAttribute('href'));
+    expect(coachLinks).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/ke_fuxi']);
+    expect(harness.routeNativeElement?.querySelector('.home-header .header-action')?.getAttribute('href')).toBe('/pages/home/ucenter');
+    expect(harness.routeNativeElement?.querySelector('.content-section')).toBeNull();
+
+    await harness.navigateByUrl('/pages/his/his');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    expect((harness.routeNativeElement?.querySelector('.student-filter select') as HTMLSelectElement).selectedOptions[0]?.textContent).toContain('张雅茜');
+    expect(harness.routeNativeElement?.querySelectorAll('.learning-record-card .learning-item').length).toBe(2);
+
+    await harness.navigateByUrl('/pages/home/ucenter');
+    await harness.fixture.whenStable();
+    harness.fixture.detectChanges();
+    const teacherServices = Array.from(harness.routeNativeElement?.querySelectorAll('.service-grid > a') ?? []).map((link) => link.getAttribute('href'));
+    expect(teacherServices).toEqual(['/pages/home/shop', '/pages/member/ke_xiao', '/pages/his/his', '/pages/member/feedback']);
+    expect(harness.routeNativeElement?.querySelector('.coach-wallet-strip')?.textContent).toContain('复习收入');
+
     await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701');
     await harness.navigateByUrl('/pages/stu/stu_list?nid=801&bindingId=701');
     await harness.fixture.whenStable();
     await harness.fixture.whenStable();
     harness.fixture.detectChanges();
     harness.fixture.detectChanges();

+ 7 - 2
projects/xiaoshu-mobile/src/app/features/home/account-page.component.html

@@ -5,6 +5,7 @@
 </header>
 </header>
 
 
 @if (session.isLoggedIn()) {
 @if (session.isLoggedIn()) {
+  @if (overviewError()) { <section class="account-state error" role="alert"><lucide-icon [img]="icons.RefreshCw" [size]="22" /><span>{{ overviewError() }}</span><button type="button" (click)="retryOverview()">重新加载</button></section> }
   <section class="membership-card" aria-label="账号与学习权益">
   <section class="membership-card" aria-label="账号与学习权益">
     <header>
     <header>
       <div><span>账号与学习权益</span><strong>{{ membership().role }} · {{ membership().memberLevel }}</strong></div>
       <div><span>账号与学习权益</span><strong>{{ membership().role }} · {{ membership().memberLevel }}</strong></div>
@@ -34,7 +35,11 @@
       </div>
       </div>
     }
     }
   </section>
   </section>
-  <section class="wallet-strip"><a routerLink="/pages/member/point"><strong>{{ session.user()?.point || 0 }}</strong><span>学习令</span></a><a routerLink="/pages/member/coin"><strong>{{ session.user()?.silverCoin || 0 }}</strong><span>学习豆</span></a><a routerLink="/pages/member/purse"><strong>{{ session.user()?.purse || 0 }}</strong><span>钱包余额</span></a></section>
+  @if (session.groupId() === 3) {
+    <section class="wallet-strip coach-wallet-strip"><a routerLink="/pages/member/purse"><strong>¥{{ session.user()?.purse || 0 }}</strong><span>钱包余额</span></a><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ coachPanel().reviewIncome | number:'1.0-2' }}</strong><span>复习收入</span></a><a routerLink="/pages/member/feedback"><strong>申请</strong><span>休息时间</span></a></section>
+  } @else {
+    <section class="wallet-strip"><a routerLink="/pages/member/point"><strong>{{ session.user()?.point || 0 }}</strong><span>学习令</span></a><a routerLink="/pages/member/coin"><strong>{{ session.user()?.silverCoin || 0 }}</strong><span>学习豆</span></a><a routerLink="/pages/member/purse"><strong>{{ session.user()?.purse || 0 }}</strong><span>钱包余额</span></a></section>
+  }
   @if (session.groupId() === 3) {
   @if (session.groupId() === 3) {
     <section class="role-panel"><header><h2>陪练专用</h2><a routerLink="/pages/member/feedback">休息时间申请</a></header><div class="order-strip coach-strip"><a routerLink="/pages/home/shop"><strong>{{ coachPanel().todaySchedules }}</strong><span>今日排课</span></a><a routerLink="/pages/member/ke_xiao"><strong>{{ coachPanel().completedLessons }}</strong><span>销课记录</span></a><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ coachPanel().reviewIncome | number:'1.0-2' }}</strong><span>复习收入</span></a></div></section>
     <section class="role-panel"><header><h2>陪练专用</h2><a routerLink="/pages/member/feedback">休息时间申请</a></header><div class="order-strip coach-strip"><a routerLink="/pages/home/shop"><strong>{{ coachPanel().todaySchedules }}</strong><span>今日排课</span></a><a routerLink="/pages/member/ke_xiao"><strong>{{ coachPanel().completedLessons }}</strong><span>销课记录</span></a><a routerLink="/pages/member/ke_fuxi"><strong>¥{{ coachPanel().reviewIncome | number:'1.0-2' }}</strong><span>复习收入</span></a></div></section>
   } @else {
   } @else {
@@ -64,7 +69,7 @@
   }
   }
 }
 }
 
 
-<section class="service-section"><h2>我的服务</h2><div class="service-grid">@for (item of services; track item.path) { <a [routerLink]="item.path"><span><lucide-icon [img]="item.icon" [size]="22" /></span><strong>{{ item.label }}</strong></a> }</div></section>
+<section class="service-section"><h2>{{ session.groupId() === 3 ? '老师服务' : '我的服务' }}</h2><div class="service-grid">@for (item of services(); track item.path) { <a [routerLink]="item.path"><span><lucide-icon [img]="item.icon" [size]="22" /></span><strong>{{ item.label }}</strong></a> }</div></section>
 
 
 <section class="settings-list">
 <section class="settings-list">
   <a routerLink="/pages/home/app_update"><lucide-icon [img]="icons.CircleHelp" [size]="20" /><span>当前版本:{{ version }}</span><lucide-icon [img]="icons.ChevronRight" [size]="17" /></a>
   <a routerLink="/pages/home/app_update"><lucide-icon [img]="icons.CircleHelp" [size]="20" /><span>当前版本:{{ version }}</span><lucide-icon [img]="icons.ChevronRight" [size]="17" /></a>

+ 6 - 1
projects/xiaoshu-mobile/src/app/features/home/account-page.component.scss

@@ -7,6 +7,10 @@
 .profile-copy span { font-size: 12px; opacity: .8; }
 .profile-copy span { font-size: 12px; opacity: .8; }
 .profile-header > a { display: flex; align-items: center; color: white; text-decoration: none; gap: 2px; }
 .profile-header > a { display: flex; align-items: center; color: white; text-decoration: none; gap: 2px; }
 .login-link { font-size: 13px; }
 .login-link { font-size: 13px; }
+.account-state { display: flex; min-height: 64px; align-items: center; margin: 12px 14px 0; padding: 11px 14px; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); gap: 9px; }
+.account-state span { min-width: 0; flex: 1; color: var(--danger); font-size: 12px; }
+.account-state lucide-icon { color: var(--danger); }
+.account-state button { min-height: 34px; padding: 0 12px; color: var(--brand-strong); background: var(--brand-pale); border: 1px solid color-mix(in srgb, var(--brand) 30%, var(--line)); border-radius: 999px; font-size: 11px; font-weight: 750; }
 .membership-card { margin: 12px 14px 0; overflow: hidden; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
 .membership-card { margin: 12px 14px 0; overflow: hidden; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
 .membership-card > header { display: flex; min-height: 62px; align-items: center; justify-content: space-between; padding: 10px 16px; background: var(--brand-pale); gap: 12px; }
 .membership-card > header { display: flex; min-height: 62px; align-items: center; justify-content: space-between; padding: 10px 16px; background: var(--brand-pale); gap: 12px; }
 .membership-card > header div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
 .membership-card > header div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
@@ -33,6 +37,7 @@
 .wallet-strip a:last-child { border-right: 0; }
 .wallet-strip a:last-child { border-right: 0; }
 .wallet-strip strong { color: var(--brand-strong); font-size: 17px; }
 .wallet-strip strong { color: var(--brand-strong); font-size: 17px; }
 .wallet-strip span { color: var(--ink-soft); font-size: 11px; }
 .wallet-strip span { color: var(--ink-soft); font-size: 11px; }
+.coach-wallet-strip a:last-child strong { font-size: 14px; }
 .order-strip { display: grid; margin-top: 10px; grid-template-columns: repeat(4, minmax(0, 1fr)); background: white; }
 .order-strip { display: grid; margin-top: 10px; grid-template-columns: repeat(4, minmax(0, 1fr)); background: white; }
 .role-panel { margin: 12px 14px 0; overflow: hidden; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
 .role-panel { margin: 12px 14px 0; overflow: hidden; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
 .role-panel > header { display: flex; min-height: 46px; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid var(--line); }
 .role-panel > header { display: flex; min-height: 46px; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid var(--line); }
@@ -79,6 +84,6 @@
 footer { padding: 20px 16px 24px; color: var(--ink-soft); font-size: 11px; text-align: center; }
 footer { padding: 20px 16px 24px; color: var(--ink-soft); font-size: 11px; text-align: center; }
 
 
 @media (max-width: 380px) {
 @media (max-width: 380px) {
-  .profile-header, .membership-card, .wallet-strip, .role-panel, .service-section, .settings-list { margin-inline: 10px; }
+  .profile-header, .account-state, .membership-card, .wallet-strip, .role-panel, .service-section, .settings-list { margin-inline: 10px; }
   .memory-overview { grid-template-columns: minmax(116px, 1fr) minmax(70px, .8fr) 36px; padding-inline: 13px; gap: 8px; }
   .memory-overview { grid-template-columns: minmax(116px, 1fr) minmax(70px, .8fr) 36px; padding-inline: 13px; gap: 8px; }
 }
 }

+ 16 - 5
projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts

@@ -1,7 +1,7 @@
 import { CommonModule } from '@angular/common';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
 import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
 import { Router, RouterLink } from '@angular/router';
 import { Router, RouterLink } from '@angular/router';
-import { Award, BookMarked, ChevronRight, CircleHelp, History, LogOut, LucideAngularModule, MessageSquare, Settings, ShieldCheck, UserRound } from 'lucide-angular';
+import { Award, BookMarked, CalendarDays, CheckCircle2, ChevronRight, CircleHelp, History, LogOut, LucideAngularModule, MessageSquare, RefreshCw, Settings, ShieldCheck, UserRound, UsersRound } from 'lucide-angular';
 import { finalize } from 'rxjs';
 import { finalize } from 'rxjs';
 import { APP_VERSION } from '../../core/app.constants';
 import { APP_VERSION } from '../../core/app.constants';
 import { ApiService } from '../../core/api.service';
 import { ApiService } from '../../core/api.service';
@@ -43,6 +43,7 @@ export class AccountPageComponent implements OnInit {
   readonly version = APP_VERSION;
   readonly version = APP_VERSION;
   readonly coachPanel = signal<CoachPanelSummary>({ todaySchedules: 0, completedLessons: 0, reviewIncome: 0, reviewRecords: 0, totalLessons: 0, totalHours: 0, course30: 0, course60: 0, trialLessons: 0 });
   readonly coachPanel = signal<CoachPanelSummary>({ todaySchedules: 0, completedLessons: 0, reviewIncome: 0, reviewRecords: 0, totalLessons: 0, totalHours: 0, course30: 0, course60: 0, trialLessons: 0 });
   readonly coachPanelLoading = signal(false);
   readonly coachPanelLoading = signal(false);
+  readonly overviewError = signal('');
   readonly antiForgetLoading = signal(false);
   readonly antiForgetLoading = signal(false);
   readonly antiForgetError = signal('');
   readonly antiForgetError = signal('');
   readonly avatarFailed = signal(false);
   readonly avatarFailed = signal(false);
@@ -50,13 +51,18 @@ export class AccountPageComponent implements OnInit {
   readonly antiForgetSummary = signal<AntiForgetHistorySummary>({ total: 0, completed: 0, progress: 0, todayTotal: 0, todayCompleted: 0, duePending: 0, scheduled: 0, scheduledDays: 0 });
   readonly antiForgetSummary = signal<AntiForgetHistorySummary>({ total: 0, completed: 0, progress: 0, todayTotal: 0, todayCompleted: 0, duePending: 0, scheduled: 0, scheduledDays: 0 });
   readonly membership = computed(() => buildAccountMembership(this.session.user()));
   readonly membership = computed(() => buildAccountMembership(this.session.user()));
   readonly companionSummary = signal<CompanionLearningSummary>({ completedSessions: 0, completedMinutes: 0, durationLabel: '0 小时' });
   readonly companionSummary = signal<CompanionLearningSummary>({ completedSessions: 0, completedMinutes: 0, durationLabel: '0 小时' });
-  readonly icons = { Award, BookMarked, ChevronRight, CircleHelp, History, LogOut, MessageSquare, Settings, ShieldCheck, UserRound };
-  readonly services = [
+  readonly icons = { Award, BookMarked, CalendarDays, CheckCircle2, ChevronRight, CircleHelp, History, LogOut, MessageSquare, RefreshCw, Settings, ShieldCheck, UserRound, UsersRound };
+  readonly services = computed(() => Number(this.session.groupId()) === 3 ? [
+    { path: '/pages/home/shop', label: '陪练排课', icon: CalendarDays },
+    { path: '/pages/member/ke_xiao', label: '销课记录', icon: CheckCircle2 },
+    { path: '/pages/his/his', label: '学员记录', icon: UsersRound },
+    { path: '/pages/member/feedback', label: '意见反馈', icon: MessageSquare },
+  ] : [
     { path: '/pages/his/well', label: '专项训练', icon: Award },
     { path: '/pages/his/well', label: '专项训练', icon: Award },
     { path: '/pages/member/starshop', label: '生词本', icon: BookMarked },
     { path: '/pages/member/starshop', label: '生词本', icon: BookMarked },
     { path: '/pages/his/his', label: '学习记录', icon: History },
     { path: '/pages/his/his', label: '学习记录', icon: History },
     { path: '/pages/member/feedback', label: '意见反馈', icon: MessageSquare },
     { path: '/pages/member/feedback', label: '意见反馈', icon: MessageSquare },
-  ];
+  ]);
 
 
   private readonly api = inject(ApiService);
   private readonly api = inject(ApiService);
   private readonly router = inject(Router);
   private readonly router = inject(Router);
@@ -68,6 +74,7 @@ export class AccountPageComponent implements OnInit {
   }
   }
 
 
   loadAntiForget(): void { this.loadAccountOverview(true); }
   loadAntiForget(): void { this.loadAccountOverview(true); }
+  retryOverview(): void { this.loadAccountOverview(true); }
 
 
   private loadAccountOverview(refresh: boolean): void {
   private loadAccountOverview(refresh: boolean): void {
     const uid = this.session.user()?.userId;
     const uid = this.session.user()?.userId;
@@ -77,6 +84,7 @@ export class AccountPageComponent implements OnInit {
     this.companionLoading.set(!coach);
     this.companionLoading.set(!coach);
     this.antiForgetLoading.set(!coach);
     this.antiForgetLoading.set(!coach);
     this.antiForgetError.set('');
     this.antiForgetError.set('');
+    this.overviewError.set('');
     this.api.post<AccountOverview>('app_account_overview', { uid, refresh }).pipe(
     this.api.post<AccountOverview>('app_account_overview', { uid, refresh }).pipe(
       finalize(() => {
       finalize(() => {
         this.coachPanelLoading.set(false);
         this.coachPanelLoading.set(false);
@@ -94,7 +102,10 @@ export class AccountPageComponent implements OnInit {
         }
         }
         if (result.antiForgetSummary) this.antiForgetSummary.set(result.antiForgetSummary);
         if (result.antiForgetSummary) this.antiForgetSummary.set(result.antiForgetSummary);
       },
       },
-      error: () => this.antiForgetError.set('抗遗忘数据暂时无法加载'),
+      error: () => {
+        this.overviewError.set(coach ? '老师工作数据暂时无法加载' : '账号数据暂时无法加载');
+        if (!coach) this.antiForgetError.set('抗遗忘数据暂时无法加载');
+      },
     });
     });
   }
   }
 
 

+ 32 - 19
projects/xiaoshu-mobile/src/app/features/home/home-page.component.html

@@ -1,21 +1,25 @@
 <header class="home-header">
 <header class="home-header">
   <div class="brand"><img src="/static/images/logo-wordmark.png" alt="小树陪练" /><span>线上与线下,一对一陪练</span></div>
   <div class="brand"><img src="/static/images/logo-wordmark.png" alt="小树陪练" /><span>线上与线下,一对一陪练</span></div>
-  <a class="header-action" routerLink="/pages/home/search" aria-label="搜索" title="搜索"><lucide-icon [img]="icons.Search" [size]="23" /></a>
+  <a class="header-action" [routerLink]="isCoach() ? '/pages/home/ucenter' : '/pages/home/search'" [attr.aria-label]="isCoach() ? '个人中心' : '搜索'" [attr.title]="isCoach() ? '个人中心' : '搜索'"><lucide-icon [img]="isCoach() ? icons.UserRound : icons.Search" [size]="23" /></a>
 </header>
 </header>
 
 
 <main class="home-main">
 <main class="home-main">
   <p class="greeting">你好,{{ session.user()?.honeyName || session.user()?.userName || '同学' }}</p>
   <p class="greeting">你好,{{ session.user()?.honeyName || session.user()?.userName || '同学' }}</p>
 
 
-  @if (session.groupId() === 3) {
+  @if (isCoach()) {
     <section class="coach-hero" aria-labelledby="coach-title">
     <section class="coach-hero" aria-labelledby="coach-title">
-      <div class="hero-heading"><div><span>陪练师工作台</span><h1 id="coach-title">今日业务概览</h1></div><lucide-icon [img]="icons.GraduationCap" [size]="32" /></div>
-      <div class="coach-grid">
-        <div><strong>{{ stats()['t30'] || 0 }}</strong><span>30分钟课程</span></div>
-        <div><strong>{{ stats()['t60'] || 0 }}</strong><span>60分钟课程</span></div>
-        <div><strong>{{ stats()['t_tiyan'] || 0 }}</strong><span>体验课</span></div>
-        <div><strong>{{ stats()['t_total'] || 0 }}</strong><span>上课总次数</span></div>
-      </div>
-      <div class="coach-footer"><span>累计时长 {{ stats()['t_shichang'] || 0 }}</span><span class="commission">佣金 ¥{{ commissionHidden() ? '***' : (stats()['t_yongji'] || 0) }}<button type="button" (click)="commissionHidden.set(!commissionHidden())" [attr.aria-label]="commissionHidden() ? '显示佣金' : '隐藏佣金'"><lucide-icon [img]="commissionHidden() ? icons.EyeOff : icons.Eye" [size]="16" /></button></span></div>
+      <div class="hero-heading"><div><span>陪练师工作台</span><h1 id="coach-title">今日业务概览</h1></div><button class="coach-refresh" type="button" (click)="retryCoachStats()" [disabled]="statsLoading()" aria-label="刷新业务数据"><lucide-icon [img]="icons.RefreshCw" [size]="20" [class.spin]="statsLoading()" /></button></div>
+      @if (coachStatsError()) {
+        <div class="coach-hero-error"><lucide-icon [img]="icons.AlertCircle" [size]="22" /><span>{{ coachStatsError() }}</span><button type="button" (click)="retryCoachStats()">重新加载</button></div>
+      } @else {
+        <div class="coach-grid" [class.loading]="statsLoading()">
+          <div><strong>{{ statsLoading() ? '—' : (stats()['t30'] || 0) }}</strong><span>30分钟课程</span></div>
+          <div><strong>{{ statsLoading() ? '—' : (stats()['t60'] || 0) }}</strong><span>60分钟课程</span></div>
+          <div><strong>{{ statsLoading() ? '—' : (stats()['t_tiyan'] || 0) }}</strong><span>体验课</span></div>
+          <div><strong>{{ statsLoading() ? '—' : (stats()['t_total'] || 0) }}</strong><span>上课总次数</span></div>
+        </div>
+        <div class="coach-footer"><span>累计时长 {{ statsLoading() ? '—' : (stats()['t_shichang'] || 0) }}</span><span class="commission">佣金 ¥{{ commissionHidden() ? '***' : (stats()['t_yongji'] || 0) }}<button type="button" (click)="commissionHidden.set(!commissionHidden())" [attr.aria-label]="commissionHidden() ? '显示佣金' : '隐藏佣金'"><lucide-icon [img]="commissionHidden() ? icons.EyeOff : icons.Eye" [size]="16" /></button></span></div>
+      }
     </section>
     </section>
   } @else {
   } @else {
     <section class="memory-hero" aria-labelledby="memory-title">
     <section class="memory-hero" aria-labelledby="memory-title">
@@ -62,21 +66,30 @@
     </section>
     </section>
   }
   }
 
 
-  <section class="quick-grid" aria-label="学习入口">
-    <a routerLink="/pages/stu/stu_calendar"><span class="quick-icon learn"><lucide-icon [img]="icons.Clock3" [size]="25" /></span><span><strong>科学抗遗忘</strong><small>今日复习任务</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
-    <a routerLink="/pages/his/well" [queryParams]="{ nid: 3 }"><span class="quick-icon review"><lucide-icon [img]="icons.GraduationCap" [size]="25" /></span><span><strong>专项训练</strong><small>分类强化词汇</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
-    <a routerLink="/pages/his/his"><span class="quick-icon history"><lucide-icon [img]="icons.BookOpen" [size]="25" /></span><span><strong>学习记录</strong><small>查看历次学习</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
-    <a routerLink="/pages/his/exam"><span class="quick-icon exam"><lucide-icon [img]="icons.BarChart3" [size]="25" /></span><span><strong>词汇测试</strong><small>检查掌握程度</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
-  </section>
+  @if (isCoach()) {
+    <section class="quick-grid coach-quick-grid" aria-label="老师工作入口">
+      <a routerLink="/pages/home/shop"><span class="quick-icon learn"><lucide-icon [img]="icons.CalendarDays" [size]="25" /></span><span><strong>陪练排课</strong><small>查看今天与未来课程</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
+      <a routerLink="/pages/member/ke_xiao"><span class="quick-icon review"><lucide-icon [img]="icons.Check" [size]="25" /></span><span><strong>销课记录</strong><small>核对已完成课程</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
+      <a routerLink="/pages/his/his"><span class="quick-icon history"><lucide-icon [img]="icons.BookOpen" [size]="25" /></span><span><strong>学员记录</strong><small>按学员查看学习成果</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
+      <a routerLink="/pages/member/ke_fuxi"><span class="quick-icon exam"><lucide-icon [img]="icons.BarChart3" [size]="25" /></span><span><strong>复习收入</strong><small>查看复习任务收入</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
+    </section>
+  } @else {
+    <section class="quick-grid" aria-label="学习入口">
+      <a routerLink="/pages/stu/stu_calendar"><span class="quick-icon learn"><lucide-icon [img]="icons.Clock3" [size]="25" /></span><span><strong>科学抗遗忘</strong><small>今日复习任务</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
+      <a routerLink="/pages/his/well" [queryParams]="{ nid: 3 }"><span class="quick-icon review"><lucide-icon [img]="icons.GraduationCap" [size]="25" /></span><span><strong>专项训练</strong><small>分类强化词汇</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
+      <a routerLink="/pages/his/his"><span class="quick-icon history"><lucide-icon [img]="icons.BookOpen" [size]="25" /></span><span><strong>学习记录</strong><small>查看历次学习</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
+      <a routerLink="/pages/his/exam"><span class="quick-icon exam"><lucide-icon [img]="icons.BarChart3" [size]="25" /></span><span><strong>词汇测试</strong><small>检查掌握程度</small></span><lucide-icon class="quick-arrow" [img]="icons.ChevronRight" [size]="19" /></a>
+    </section>
+  }
 
 
-  <section class="content-section">
+  @if (!isCoach()) { <section class="content-section">
     <div class="section-heading"><div><span class="eyebrow"><lucide-icon [img]="icons.Sparkles" [size]="15" />精选</span><h2>推荐学习内容</h2></div><a routerLink="/pages/shop/shop_nodes">查看全部<lucide-icon [img]="icons.ChevronRight" [size]="17" /></a></div>
     <div class="section-heading"><div><span class="eyebrow"><lucide-icon [img]="icons.Sparkles" [size]="15" />精选</span><h2>推荐学习内容</h2></div><a routerLink="/pages/shop/shop_nodes">查看全部<lucide-icon [img]="icons.ChevronRight" [size]="17" /></a></div>
     @if (recommendations().length) {
     @if (recommendations().length) {
       <div class="product-grid">@for (item of recommendations(); track item['ID'] ?? $index) { <button type="button" class="product" (click)="openProduct(item)"><img [src]="image(item)" [alt]="name(item)" loading="lazy" /><span>{{ name(item) }}</span><strong>¥{{ price(item) }}</strong></button> }</div>
       <div class="product-grid">@for (item of recommendations(); track item['ID'] ?? $index) { <button type="button" class="product" (click)="openProduct(item)"><img [src]="image(item)" [alt]="name(item)" loading="lazy" /><span>{{ name(item) }}</span><strong>¥{{ price(item) }}</strong></button> }</div>
     } @else if (recommendationError()) {
     } @else if (recommendationError()) {
-      <div class="recommend-empty error"><lucide-icon [img]="icons.AlertCircle" [size]="30" /><span>推荐内容暂时无法加载</span><button type="button" (click)="ngOnInit()">重新加载</button></div>
+      <div class="recommend-empty error"><lucide-icon [img]="icons.AlertCircle" [size]="30" /><span>推荐内容暂时无法加载</span><button type="button" (click)="loadRecommendations()">重新加载</button></div>
     } @else {
     } @else {
       <div class="recommend-empty"><lucide-icon [img]="icons.Headphones" [size]="32" /><strong>暂无推荐内容</strong><span>完成更多学习,智能推荐更适合你的内容</span></div>
       <div class="recommend-empty"><lucide-icon [img]="icons.Headphones" [size]="32" /><strong>暂无推荐内容</strong><span>完成更多学习,智能推荐更适合你的内容</span></div>
     }
     }
-  </section>
+  </section> }
 </main>
 </main>

+ 6 - 0
projects/xiaoshu-mobile/src/app/features/home/home-page.component.scss

@@ -55,13 +55,18 @@
 .hero-heading { display: flex; align-items: flex-start; justify-content: space-between; }
 .hero-heading { display: flex; align-items: flex-start; justify-content: space-between; }
 .hero-heading span { font-size: 12px; opacity: .76; }
 .hero-heading span { font-size: 12px; opacity: .76; }
 .hero-heading h1 { margin: 4px 0 0; font-size: 22px; }
 .hero-heading h1 { margin: 4px 0 0; font-size: 22px; }
+.coach-refresh { display: grid; width: 38px; height: 38px; flex: 0 0 auto; padding: 0; color: white; background: rgb(255 255 255 / 12%); border: 1px solid rgb(255 255 255 / 22%); border-radius: 12px; place-items: center; }
+.coach-refresh:disabled { opacity: .64; }
 .coach-grid { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 20px; padding: 16px 0; border-block: 1px solid rgb(255 255 255 / 18%); }
 .coach-grid { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 20px; padding: 16px 0; border-block: 1px solid rgb(255 255 255 / 18%); }
+.coach-grid.loading { opacity: .72; }
 .coach-grid div { display: flex; align-items: center; flex-direction: column; gap: 4px; }
 .coach-grid div { display: flex; align-items: center; flex-direction: column; gap: 4px; }
 .coach-grid strong { font-size: 21px; }
 .coach-grid strong { font-size: 21px; }
 .coach-grid span { font-size: 10px; opacity: .74; }
 .coach-grid span { font-size: 10px; opacity: .74; }
 .coach-footer { display: flex; align-items: center; justify-content: space-between; padding-top: 14px; font-size: 12px; }
 .coach-footer { display: flex; align-items: center; justify-content: space-between; padding-top: 14px; font-size: 12px; }
 .commission { display: flex; align-items: center; gap: 3px; }
 .commission { display: flex; align-items: center; gap: 3px; }
 .commission button { display: grid; width: 28px; height: 28px; padding: 0; color: white; background: transparent; border: 0; place-items: center; }
 .commission button { display: grid; width: 28px; height: 28px; padding: 0; color: white; background: transparent; border: 0; place-items: center; }
+.coach-hero-error { display: flex; min-height: 142px; align-items: center; justify-content: center; padding: 18px; color: rgb(255 255 255 / 88%); text-align: center; flex-direction: column; gap: 9px; }
+.coach-hero-error button { min-height: 36px; padding: 0 15px; color: var(--brand-deep); background: white; border: 0; border-radius: 999px; font-size: 12px; font-weight: 750; }
 
 
 .quick-grid { display: grid; overflow: hidden; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 14px; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
 .quick-grid { display: grid; overflow: hidden; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 14px; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); }
 .quick-grid a { display: grid; min-width: 0; min-height: 104px; grid-template-columns: 48px minmax(0, 1fr) 20px; align-items: center; padding: 14px 12px; color: var(--ink); text-decoration: none; gap: 10px; }
 .quick-grid a { display: grid; min-width: 0; min-height: 104px; grid-template-columns: 48px minmax(0, 1fr) 20px; align-items: center; padding: 14px 12px; color: var(--ink); text-decoration: none; gap: 10px; }
@@ -93,6 +98,7 @@
 .recommend-empty.error { color: var(--danger); }
 .recommend-empty.error { color: var(--danger); }
 
 
 @keyframes spin { to { transform: rotate(360deg); } }
 @keyframes spin { to { transform: rotate(360deg); } }
+.spin { animation: spin .8s linear infinite; }
 @media (hover: hover) { .header-action:hover, .quick-grid a:hover { background: var(--surface-hover); } .continue-button:hover { background: var(--accent-strong); } }
 @media (hover: hover) { .header-action:hover, .quick-grid a:hover { background: var(--surface-hover); } .continue-button:hover { background: var(--accent-strong); } }
 @media (max-width: 430px) {
 @media (max-width: 430px) {
   .home-header { min-height: calc(70px + env(safe-area-inset-top)); }
   .home-header { min-height: calc(70px + env(safe-area-inset-top)); }

+ 31 - 4
projects/xiaoshu-mobile/src/app/features/home/home-page.component.ts

@@ -1,7 +1,7 @@
 import { CommonModule } from '@angular/common';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
 import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
 import { Router, RouterLink } from '@angular/router';
 import { Router, RouterLink } from '@angular/router';
-import { AlertCircle, BarChart3, BookOpen, CalendarDays, Check, ChevronRight, Clock3, Eye, EyeOff, GraduationCap, Headphones, Info, LucideAngularModule, Search, Sparkles, Zap } from 'lucide-angular';
+import { AlertCircle, BarChart3, BookOpen, CalendarDays, Check, ChevronRight, Clock3, Eye, EyeOff, GraduationCap, Headphones, Info, LucideAngularModule, RefreshCw, Search, Sparkles, UserRound, Zap } from 'lucide-angular';
 import { finalize } from 'rxjs';
 import { finalize } from 'rxjs';
 import { buildAntiForgetView, findAntiForgetList } from '../../core/anti-forget';
 import { buildAntiForgetView, findAntiForgetList } from '../../core/anti-forget';
 import { ApiError, ApiService } from '../../core/api.service';
 import { ApiError, ApiService } from '../../core/api.service';
@@ -21,6 +21,8 @@ type Item = Record<string, unknown>;
 export class HomePageComponent implements OnInit {
 export class HomePageComponent implements OnInit {
   readonly session = inject(SessionService);
   readonly session = inject(SessionService);
   readonly stats = signal<Record<string, number>>({});
   readonly stats = signal<Record<string, number>>({});
+  readonly statsLoading = signal(false);
+  readonly coachStatsError = signal('');
   readonly recommendations = signal<Item[]>([]);
   readonly recommendations = signal<Item[]>([]);
   readonly recommendationError = signal('');
   readonly recommendationError = signal('');
   readonly commissionHidden = signal(true);
   readonly commissionHidden = signal(true);
@@ -28,7 +30,8 @@ export class HomePageComponent implements OnInit {
   readonly antiForgetLoading = signal(false);
   readonly antiForgetLoading = signal(false);
   readonly antiForgetError = signal('');
   readonly antiForgetError = signal('');
   readonly antiForgetView = computed(() => buildAntiForgetView(this.antiForgetItems()));
   readonly antiForgetView = computed(() => buildAntiForgetView(this.antiForgetItems()));
-  readonly icons = { AlertCircle, BarChart3, BookOpen, CalendarDays, Check, ChevronRight, Clock3, Eye, EyeOff, GraduationCap, Headphones, Info, Search, Sparkles, Zap };
+  readonly isCoach = computed(() => Number(this.session.groupId()) === 3);
+  readonly icons = { AlertCircle, BarChart3, BookOpen, CalendarDays, Check, ChevronRight, Clock3, Eye, EyeOff, GraduationCap, Headphones, Info, RefreshCw, Search, Sparkles, UserRound, Zap };
 
 
   private readonly api = inject(ApiService);
   private readonly api = inject(ApiService);
   private readonly router = inject(Router);
   private readonly router = inject(Router);
@@ -37,9 +40,20 @@ export class HomePageComponent implements OnInit {
   ngOnInit(): void {
   ngOnInit(): void {
     const uid = this.session.user()?.userId;
     const uid = this.session.user()?.userId;
     if (uid) {
     if (uid) {
-      this.api.post<Record<string, number>>('e_order_tongji', { uid }).subscribe({ next: ({ result }) => this.stats.set(result || {}), error: () => undefined });
-      if (Number(this.session.groupId()) !== 3) this.loadAntiForget(uid);
+      this.loadStats(uid);
+      if (!this.isCoach()) this.loadAntiForget(uid);
     }
     }
+    if (this.isCoach()) return;
+    this.loadRecommendations();
+  }
+
+  retryCoachStats(): void {
+    const uid = this.session.user()?.userId;
+    if (uid) this.loadStats(uid);
+  }
+
+  loadRecommendations(): void {
+    this.recommendationError.set('');
     this.api.get<unknown>('product_list', { nodes: '15,16', hot: 1, cpage: 1, psize: 8 }).subscribe({
     this.api.get<unknown>('product_list', { nodes: '15,16', hot: 1, cpage: 1, psize: 8 }).subscribe({
       next: ({ result }) => this.recommendations.set(this.toList(result)),
       next: ({ result }) => this.recommendations.set(this.toList(result)),
       error: (error: ApiError) => this.recommendationError.set(error.message),
       error: (error: ApiError) => this.recommendationError.set(error.message),
@@ -60,6 +74,19 @@ export class HomePageComponent implements OnInit {
     if (uid) this.loadAntiForget(uid);
     if (uid) this.loadAntiForget(uid);
   }
   }
 
 
+  private loadStats(uid: string | number): void {
+    this.statsLoading.set(true);
+    this.coachStatsError.set('');
+    this.api.post<Record<string, number>>('e_order_tongji', { uid }).pipe(
+      finalize(() => this.statsLoading.set(false)),
+    ).subscribe({
+      next: ({ result }) => this.stats.set(result || {}),
+      error: () => {
+        if (this.isCoach()) this.coachStatsError.set('今日业务数据暂时无法加载');
+      },
+    });
+  }
+
   private loadAntiForget(uid: string | number): void {
   private loadAntiForget(uid: string | number): void {
     this.antiForgetLoading.set(true);
     this.antiForgetLoading.set(true);
     this.antiForgetError.set('');
     this.antiForgetError.set('');

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.html


+ 7 - 0
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.scss

@@ -8,6 +8,7 @@
 .filter-row :is(select, input) { width: 100%; height: 48px; padding: 0 14px; color: var(--ink); background-color: white; border: 1px solid var(--line); border-radius: var(--radius-md); font-size: 16px; font-weight: 750; }
 .filter-row :is(select, input) { width: 100%; height: 48px; padding: 0 14px; color: var(--ink); background-color: white; border: 1px solid var(--line); border-radius: var(--radius-md); font-size: 16px; font-weight: 750; }
 .student-filter { position: relative; }
 .student-filter { position: relative; }
 .student-filter select { padding-right: 44px; appearance: none; }
 .student-filter select { padding-right: 44px; appearance: none; }
+.student-filter select:disabled { cursor: wait; opacity: .68; }
 .student-filter::after { content: ""; position: absolute; right: 19px; bottom: 19px; width: 8px; height: 8px; border: solid var(--ink); border-width: 0 2px 2px 0; transform: rotate(45deg); pointer-events: none; }
 .student-filter::after { content: ""; position: absolute; right: 19px; bottom: 19px; width: 8px; height: 8px; border: solid var(--ink); border-width: 0 2px 2px 0; transform: rotate(45deg); pointer-events: none; }
 .date-filter { position: relative; }
 .date-filter { position: relative; }
 .date-picker-field { position: relative; }
 .date-picker-field { position: relative; }
@@ -102,6 +103,12 @@
 .appeal-images img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
 .appeal-images img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
 .appeal-item footer { margin-top: 12px; color: var(--ink-soft); font-size: 11px; }
 .appeal-item footer { margin-top: 12px; color: var(--ink-soft); font-size: 11px; }
 .records-state { display: flex; min-height: 260px; align-items: center; justify-content: center; color: var(--ink-soft); text-align: center; flex-direction: column; gap: 10px; }
 .records-state { display: flex; min-height: 260px; align-items: center; justify-content: center; color: var(--ink-soft); text-align: center; flex-direction: column; gap: 10px; }
+.teacher-student-state { display: flex; min-height: 250px; align-items: center; justify-content: center; margin: 14px; padding: 28px 20px; color: var(--ink-soft); text-align: center; background: white; border: 1px solid var(--line); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); flex-direction: column; gap: 8px; }
+.teacher-student-state lucide-icon { color: var(--brand-strong); }
+.teacher-student-state strong { color: var(--ink); font-size: 16px; }
+.teacher-student-state span { max-width: 310px; font-size: 12px; line-height: 1.6; }
+.teacher-student-state button { min-height: 38px; margin-top: 4px; padding: 0 15px; color: white; background: var(--brand-strong); border: 0; border-radius: 999px; font-size: 12px; font-weight: 750; }
+.teacher-student-state.error lucide-icon { color: var(--danger); }
 .chevron { color: var(--ink-soft); }
 .chevron { color: var(--ink-soft); }
 .spin { animation: spin .8s linear infinite; }
 .spin { animation: spin .8s linear infinite; }
 @keyframes spin { to { transform: rotate(360deg); } }
 @keyframes spin { to { transform: rotate(360deg); } }

+ 24 - 3
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -47,6 +47,8 @@ export class StudyRecordsPageComponent implements OnInit {
   readonly coursesExpanded = signal(false);
   readonly coursesExpanded = signal(false);
   readonly learningSchedules = signal<Item[]>([]);
   readonly learningSchedules = signal<Item[]>([]);
   readonly students = signal<Item[]>([]);
   readonly students = signal<Item[]>([]);
+  readonly studentsLoading = signal(false);
+  readonly studentsError = signal('');
   readonly loading = signal(false);
   readonly loading = signal(false);
   readonly error = signal('');
   readonly error = signal('');
   readonly totalIncome = signal(0);
   readonly totalIncome = signal(0);
@@ -111,7 +113,11 @@ export class StudyRecordsPageComponent implements OnInit {
 
 
   ngOnInit(): void {
   ngOnInit(): void {
     this.restoreHistoryState();
     this.restoreHistoryState();
-    if (this.meta.path === 'pages/his/his' && this.session.groupId() === 3) this.loadStudents();
+    if (this.meta.path === 'pages/his/his' && this.session.groupId() === 3) {
+      this.loadStudents();
+      if (this.studentId) this.load();
+      return;
+    }
     this.load();
     this.load();
   }
   }
 
 
@@ -339,6 +345,7 @@ export class StudyRecordsPageComponent implements OnInit {
     if (Number(item['cookflag'] ?? 0) === -3) return '已同意';
     if (Number(item['cookflag'] ?? 0) === -3) return '已同意';
     return '已处理';
     return '已处理';
   }
   }
+  retryStudents(): void { this.loadStudents(); }
   appealImages(item: Item): string[] {
   appealImages(item: Item): string[] {
     try { return (JSON.parse(String(item['Pics'] ?? '[]')) as Array<{ url?: string }>).map((picture) => this.api.assetUrl(picture.url)).filter(Boolean); }
     try { return (JSON.parse(String(item['Pics'] ?? '[]')) as Array<{ url?: string }>).map((picture) => this.api.assetUrl(picture.url)).filter(Boolean); }
     catch { return []; }
     catch { return []; }
@@ -409,8 +416,22 @@ export class StudyRecordsPageComponent implements OnInit {
   }
   }
 
 
   private loadStudents(): void {
   private loadStudents(): void {
-    this.api.post<unknown>('e_user_list', { uid: this.session.user()?.userId, cpage: 1, psize: 200 }).subscribe({
-      next: ({ result }) => this.students.set(this.asList(result)), error: () => undefined,
+    this.studentsLoading.set(true);
+    this.studentsError.set('');
+    this.api.post<unknown>('e_user_list', { uid: this.session.user()?.userId, cpage: 1, psize: 200 }).pipe(
+      finalize(() => this.studentsLoading.set(false)),
+    ).subscribe({
+      next: ({ result }) => {
+        const students = this.asList(result);
+        this.students.set(students);
+        if (this.studentId || !students.length) return;
+        const firstStudentId = students.map((student) => this.studentValue(student)).find((value) => value > 0) ?? 0;
+        if (!firstStudentId) return;
+        this.studentId = firstStudentId;
+        this.syncHistoryState();
+        this.load();
+      },
+      error: (error: ApiError) => this.studentsError.set(error.message || '学员列表暂时无法加载'),
     });
     });
   }
   }
 
 

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