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

fix(mobile): align appointment detail controls and responsive cards

彭峰 1 неделя назад
Родитель
Сommit
be22d84922

+ 6 - 5
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.html

@@ -4,15 +4,15 @@
   } @else if (error()) {
     <div class="empty-state error"><div><lucide-icon [img]="icons.CircleHelp" [size]="30" /><p>{{ error() }}</p><button type="button" class="secondary-button" (click)="load()">重新加载</button></div></div>
   } @else {
+    <section class="appointment-overview" aria-label="预约信息">
     <header class="appointment-status">
       <div><span>{{ status() > 20 ? '课程陪练报告' : '预约课程' }}</span><strong>{{ statusText() }}</strong></div>
-      <lucide-icon [img]="status() >= 30 ? icons.Flag : icons.Clock3" [size]="28" />
+      <button type="button" class="refresh-detail" (click)="load()" [disabled]="submitting()"><lucide-icon [img]="icons.RefreshCw" [size]="16" aria-hidden="true" />刷新详情</button>
     </header>
     @if (detailWarning()) { <p class="detail-warning" role="alert">{{ detailWarning() }}</p> }
     @if(actionError()){<p class="detail-warning" role="alert">{{actionError()}}</p>}
     @if(order()['blockedReason']){<p class="detail-warning">{{order()['blockedReason']}}</p>}
-    @if (can('start')) { <p>预约当天(北京时间)均可上课,可提前开始,也可在计划时间之后开始。</p> }
-    <button type="button" class="secondary-button" (click)="load()" [disabled]="submitting()">刷新详情</button>
+    @if (can('start')) { <p class="schedule-hint"><lucide-icon [img]="icons.Clock3" [size]="16" aria-hidden="true" /><span>预约当天(北京时间)均可上课,可提前开始,也可在计划时间之后开始。</span></p> }
 
     <dl class="appointment-info">
       <div><dt>学生姓名</dt><dd>{{ field('student') || '-' }}</dd></div>
@@ -22,11 +22,12 @@
       <div><dt>交付方式</dt><dd>{{ field('delivery') || '-' }}</dd></div>
       @if (status() > 20) { <div><dt>陪练老师</dt><dd>{{ field('coach') || '-' }}</dd></div><div><dt>学习成果</dt><dd>{{ order()['dsxueshi'] || 0 }} 学时,累计识词 {{ order()['learned'] || 0 }}</dd></div> }
     </dl>
+    </section>
 
     @if (!order()['cancelled'] && !isStudentView && status() <= 10 && !isWriting) {
       <section class="course-section">
         <label><span>本次课程</span><strong>{{order()['courseCategoryName'] || ''}} · {{field('course')}}</strong><small>如需改课,请由管理员调整排课</small></label>
-        <div class="unit-heading"><strong>选择学习单元</strong><span>可多选,长按标记功能在 Web 中改为右侧按钮</span></div>
+        <div class="unit-heading"><strong>选择学习单元</strong><span>可多选,右侧按钮可标记已学完</span></div>
         <div class="unit-grid">
           @for (unit of units(); track $index) {
             <article [class.selected]="selectedUnits().has(unitId(unit))" [class.completed]="completedUnits().has(unitId(unit))">
@@ -103,7 +104,7 @@
 
     <div class="appointment-actions">
       @if (can('start')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="startOrContinue()">{{submitting()?'正在开始…':'开始课程'}}</button> }
-      @if (can('continue')) { <button type="button" class="secondary-button" [disabled]="submitting()" (click)="startOrContinue()">继续课程</button> }
+      @if (can('continue')) { <button type="button" [class.primary-button]="!can('complete')" [class.secondary-button]="can('complete')" [disabled]="submitting()" (click)="startOrContinue()">继续课程</button> }
       @if (can('complete')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="openCompletionFeedback()">{{status()===10?'结束课程':'补齐反馈并结课'}}</button> }
       @if (can('review')) { <button type="button" class="primary-button" [disabled]="submitting()" (click)="finishStudentLesson()">{{submitting()?'正在提交…':'提交评价'}}</button> }
       @if (status()>=30 && !order()['cancelled']) { <button type="button" class="secondary-button" (click)="openReport()">查看学习报告</button><span>{{order()['reviewStatus']==='submitted'?'学生已评价':'已结课 · 待学生评价'}}</span> }

+ 22 - 11
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.scss

@@ -1,17 +1,25 @@
-.appointment-page { min-height: 100%; padding-bottom: 78px; }
+.appointment-page { min-height: 100%; padding-bottom: calc(120px + env(safe-area-inset-bottom)); }
+.appointment-overview { margin: var(--panel-gap) var(--page-gutter); overflow: hidden; background: white; border: 1px solid var(--line); border-radius: var(--panel-radius); box-shadow: var(--shadow-sm); }
 .appointment-status { display: flex; align-items: center; justify-content: space-between; padding: 20px 17px; color: white; background: var(--brand-strong); }
 .appointment-status > div { display: flex; flex-direction: column; gap: 5px; }
 .appointment-status span { font-size: 12px; opacity: .82; }
 .appointment-status strong { font-size: 20px; }
+.refresh-detail { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; min-height: 44px; margin-left: 16px; padding: 10px 12px; gap: 8px; color: white; background: transparent; border: 1px solid rgb(255 255 255 / 55%); border-radius: var(--radius-sm); font-size: 13px; font-weight: 600; }
+.refresh-detail:disabled { opacity: .5; cursor: not-allowed; }
+.refresh-detail:active { background: rgb(255 255 255 / 15%); }
+.schedule-hint { display: flex; align-items: flex-start; margin: 0; padding: 12px 16px; gap: 8px; color: var(--brand-strong); background: var(--brand-wash); font-size: 13px; line-height: 1.6; }
+.schedule-hint lucide-icon { flex: 0 0 16px; margin-top: 3px; }
 .detail-warning { margin: 0; padding: 9px 16px; color: #8a5b12; background: #fff3d9; font-size: 12px; }
 .appointment-info { margin: 0; padding: 5px 16px; background: white; }
-.appointment-info div { display: grid; min-height: 46px; grid-template-columns: 92px minmax(0, 1fr); align-items: center; border-bottom: 1px solid var(--line); gap: 10px; }
+.appointment-info div { display: grid; min-height: 48px; padding-block: 12px; grid-template-columns: 80px minmax(0, 1fr); align-items: start; border-bottom: 1px solid var(--line); gap: 12px; line-height: 1.6; }
 .appointment-info div:last-child { border-bottom: 0; }
 .appointment-info dt { color: var(--ink-soft); font-size: 13px; }
 .appointment-info dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
 .course-section, .comment-panel, .report-panel { margin-top: 10px; padding: 15px; background: white; border-block: 1px solid var(--line); }
 .course-section > label, .comment-panel label { display: grid; gap: 7px; }
 .course-section label span, .comment-panel label span { color: var(--ink-soft); font-size: 12px; }
+.course-section > label strong { line-height: 1.6; overflow-wrap: anywhere; }
+.course-section > label small { color: var(--ink-soft); font-size: 12px; line-height: 1.6; }
 .course-section select, .comment-panel select, .comment-panel textarea { width: 100%; min-height: 45px; padding: 9px 11px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: var(--radius-sm); }
 .comment-panel label + label { margin-top: 14px; }
 .comment-panel textarea { min-height: 110px; resize: vertical; }
@@ -21,7 +29,7 @@
 .completion-feedback header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
 .completion-feedback h2 { margin: 0; font-size: 20px; }
 .completion-feedback header p { margin: 7px 0 0; color: var(--ink-soft); font-size: 13px; line-height: 1.6; }
-.feedback-close { display: grid; width: 36px; height: 36px; flex: 0 0 36px; padding: 0; color: var(--ink-soft); background: var(--surface-muted); border: 1px solid var(--line); border-radius: var(--radius-sm); place-items: center; }
+.feedback-close { display: grid; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; color: var(--ink-soft); background: var(--surface-muted); border: 1px solid var(--line); border-radius: var(--radius-sm); place-items: center; }
 .feedback-hint { margin: 18px 0; padding: 11px 12px; color: var(--ink-soft); background: var(--surface-muted); border: 1px solid var(--line); border-radius: var(--radius-sm); font-size: 12px; line-height: 1.6; }
 .feedback-fields { display: grid; min-width: 0; margin: 0; padding: 0; border: 0; gap: 18px; }
 .feedback-fields label { display: grid; min-width: 0; gap: 8px; }
@@ -38,15 +46,15 @@
 .feedback-actions { display: flex; margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--line); gap: 12px; }
 .feedback-actions button { flex: 1; min-height: 46px; }
 .unit-heading { display: flex; align-items: flex-end; justify-content: space-between; margin: 19px 0 10px; gap: 12px; }
-.unit-heading span { color: var(--ink-soft); font-size: 10px; text-align: right; }
-.unit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
-.unit-grid article { position: relative; min-height: 62px; overflow: hidden; background: white; border: 1px solid var(--line); border-radius: var(--radius-md); }
+.unit-heading span { color: var(--ink-soft); font-size: 12px; line-height: 1.5; text-align: right; }
+.unit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
+.unit-grid article { display: grid; grid-template-columns: minmax(0, 1fr) 44px; min-height: 84px; overflow: hidden; background: white; border: 1px solid var(--line); border-radius: var(--radius-md); }
 .unit-grid article.selected { border-color: var(--brand-strong); }
 .unit-grid article.completed { background: #fff7e7; border-color: #dfb85c; }
-.unit-select { display: flex; width: 100%; min-height: 62px; align-items: center; padding: 9px 38px 9px 9px; color: var(--ink); text-align: left; background: transparent; border: 0; gap: 8px; }
+.unit-select { display: flex; width: 100%; min-width: 0; min-height: 84px; align-items: center; padding: 12px; color: var(--ink); text-align: left; background: transparent; border: 0; gap: 10px; }
 .unit-select .check { display: grid; width: 19px; height: 19px; flex: 0 0 19px; color: white; border: 1px solid var(--line); border-radius: 4px; place-items: center; }
 .selected .unit-select .check { background: var(--brand-strong); border-color: var(--brand-strong); }
-.unit-select strong { font-size: 13px; overflow-wrap: anywhere; }
+.unit-select strong { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
 .unit-copy { display: grid; flex: 1; min-width: 0; gap: 6px; }
 .unit-progress-summary { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 3px 8px; font-size: 11px; }
 .unit-progress-summary b { color: var(--brand-strong); }
@@ -54,12 +62,15 @@
 .unit-progress-track { display: block; height: 4px; overflow: hidden; background: var(--line); border-radius: 4px; }
 .unit-progress-track > span { display: block; height: 100%; background: var(--brand-strong); border-radius: inherit; }
 .unit-completion-note { color: #8a5b12; font-size: 11px; }
-.mark-complete { position: absolute; top: 0; right: 0; display: grid; width: 34px; height: 100%; padding: 0; color: #8a5b12; background: #fff3d9; border: 0; place-items: center; }
+.mark-complete { display: grid; width: 44px; min-height: 44px; height: 100%; padding: 0; color: #8a5b12; background: #fff3d9; border: 0; border-left: 1px solid var(--line-soft); place-items: center; }
+.unit-select:focus-visible, .mark-complete:focus-visible { outline: 2px solid var(--brand-strong); outline-offset: -3px; }
 .report-panel h2 { display: flex; align-items: center; margin: 0 0 14px; font-size: 17px; gap: 7px; }
 .report-panel div { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; color: var(--ink-soft); font-size: 12px; }
 .report-panel p { margin: 7px 0 14px; padding: 11px; color: var(--ink); line-height: 1.6; background: #f6f8f7; border-radius: var(--radius-sm); }
-.appointment-actions { position: fixed; z-index: 20; right: 0; bottom: 0; left: 0; display: flex; padding: 10px 14px max(10px, env(safe-area-inset-bottom)); background: rgb(255 255 255 / 96%); border-top: 1px solid var(--line); gap: 9px; }
+.appointment-actions { position: fixed; z-index: 20; right: 0; bottom: 0; left: 0; display: flex; flex-wrap: wrap; align-items: center; padding: 12px var(--page-gutter) max(12px, env(safe-area-inset-bottom)); background: rgb(255 255 255 / 96%); border-top: 1px solid var(--line); gap: 10px; }
+.appointment-actions:empty { display: none; }
+.appointment-actions > span { flex-basis: 100%; color: var(--ink-soft); text-align: center; font-size: 12px; }
 .appointment-actions button { display: flex; min-height: 49px; align-items: center; justify-content: center; flex: 1; gap: 6px; }
 .empty-state.error > div { display: grid; justify-items: center; gap: 9px; }
 @media (min-width: 760px) { .appointment-page { max-width: 760px; margin: 0 auto; border-inline: 1px solid var(--line); } .appointment-actions { right: calc((100vw - 760px) / 2); left: calc((100vw - 760px) / 2); } }
-@media (max-width: 560px) { .completion-feedback { padding: 16px; } .feedback-grid { grid-template-columns: 1fr; } }
+@media (max-width: 560px) { .completion-feedback { padding: 16px; } .feedback-grid, .unit-grid { grid-template-columns: 1fr; } .unit-heading { align-items: flex-start; flex-direction: column; gap: 5px; } .unit-heading span { text-align: left; } }

+ 2 - 2
projects/xiaoshu-mobile/src/app/features/appointment/appointment-page.component.ts

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, OnInit, OnDestroy, signal, viewChild } from '@angular/core';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
-import { BookCheck, Check, ChevronRight, CircleHelp, Clock3, Flag, GraduationCap, LucideAngularModule, Play, Save, Star, X } from 'lucide-angular';
+import { BookCheck, Check, ChevronRight, CircleHelp, Clock3, Flag, GraduationCap, LucideAngularModule, Play, RefreshCw, Save, Star, X } from 'lucide-angular';
 import { finalize, Subscription } from 'rxjs';
 import { LiveDataRefreshService } from '../../core/live-data-refresh.service';
 import { ApiError, ApiService } from '../../core/api.service';
@@ -43,7 +43,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
   private stopLive?: () => void;
   private readonly liveData = inject(LiveDataRefreshService);
   readonly completionDialog = viewChild<ElementRef<HTMLDialogElement>>('completionDialog');
-  readonly icons = { BookCheck, Check, ChevronRight, CircleHelp, Clock3, Flag, GraduationCap, Play, Save, Star, X };
+  readonly icons = { BookCheck, Check, ChevronRight, CircleHelp, Clock3, Flag, GraduationCap, Play, RefreshCw, Save, Star, X };
   readonly status = computed(() => Number(this.order()['dszt'] ?? this.order()['status'] ?? 0));
 
   readonly feedback=computed(()=>this.order()['feedback'] as Record<string,unknown>|null);