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

fix(teaching): make review and training feedback optional text

彭峰 3 часов назад
Родитель
Сommit
f533fe5716

+ 17 - 0
docs/migration/optional-lesson-feedback-2026-10-12.md

@@ -0,0 +1,17 @@
+# 完课反馈两项改为选填文本
+
+2026-10-12 按用户红框要求,将“复习 / 生词本情况”和“训练进程和状态”从下拉选项改为文本框,移除星号与必填校验。每项最多 200 字,去除首尾空白;空值和自定义文字都可保存。学习内容、进度说明和作业原有要求保持不变。
+
+页面、云函数、数据库结课函数同时调整,已有历史选项文字继续兼容。结课权限、版本校验、事务扣费和幂等保护保持不变。
+
+## 验证与发布
+
+- 教师预约页面集成测试 27 项通过,含实际文本框输入、自定义文字、留空提交和进度说明仍必填。
+- 结课与多课程数据库/云函数测试 15 项通过,含反馈保存回读、重复提交只扣一次、超长与缺少进度说明拒绝。
+- 移动端生产构建通过;既有样式预算和 CommonJS 警告仍存在。
+- 11 个云函数定义验证通过,git diff 空白检查通过。
+- 发布前确认线上 app 网关与已发布 Git 基线一致,备份实际云函数与数据库例程,检查无正在执行的结课查询。
+- 仅部署 app 网关及 xs_lesson_complete 例程。线上版本为 `2026-10-12-optional-feedback-v5`,网关源码与本地生成结果一致,数据库例程回读确认可选文本校验。
+- 使用有效教师会话,对不存在的预约分别提交留空和自定义文字,均通过反馈校验并由数据库拒绝不存在的预约;缺少进度说明仍被拒绝。未实际结课、未改动客户预约或余额。
+- 临时部署函数已删除并回查。私有备份及线上验证结果位于本机受限状态目录,不提交凭据或会话。
+- 本地 4200 页面 HTTP 200,前端源代码已更新;本次未发布生产 H5 静态站点。

+ 27 - 0
projects/xiaoshu-mobile/src/app/core/lesson-workflow.integration.spec.ts

@@ -172,6 +172,33 @@ describe('appointment workflow screens',()=>{
   expect(harness.routeNativeElement?.textContent).toContain('查看学习报告');expect(harness.routeNativeElement?.textContent).toContain('待学生评价');
   expect(calls.some(c=>c.action==='content_update')).toBeFalse();
  });
+ for (const statusText of ['', '  复习到第十页,易错词下次继续  ']) {
+  it(`accepts optional feedback text ${statusText ? 'with custom wording' : 'left blank'} from the dialog`,async()=>{
+   order={...order,dszt:10,allowedActions:['continue','complete']};
+   const {harness,component}=await screen();component.openCompletionFeedback();harness.fixture.detectChanges();
+   for (const title of ['复习 / 生词本情况','训练进程和状态']) {
+    const label=Array.from(harness.routeNativeElement!.querySelectorAll('.feedback-fields label')).find(el=>el.querySelector('span')?.textContent?.trim()===title)!;
+    expect(label).toBeDefined();expect(label.querySelector('select,b')).toBeNull();
+    const input=label.querySelector('input')!;
+    expect(input.required).toBeFalse();expect(input.getAttribute('aria-required')).toBeNull();
+    input.value=statusText;input.dispatchEvent(new Event('input'));
+   }
+   component.contentSummary='Unit 1';component.trainingSummary='下次从第十页开始';component.noHomework=true;
+   harness.fixture.detectChanges();
+   (harness.routeNativeElement!.querySelector('.feedback-actions .primary-button') as HTMLButtonElement).click();
+   await harness.fixture.whenStable();harness.fixture.detectChanges();
+   const command=calls.find(c=>c.action==='app_lesson_complete');expect(command).toBeDefined();
+   const feedback=JSON.parse(command!.params['feedback'] as string);
+   expect(feedback.reviewStatus).toBe(statusText.trim());expect(feedback.trainingStatus).toBe(statusText.trim());
+   expect(component.status()).toBe(30);
+  });
+ }
+ it('still requires the progress description when the optional status fields are blank',async()=>{
+  order={...order,dszt:10,allowedActions:['continue','complete']};
+  const {component}=await screen();component.openCompletionFeedback();component.contentSummary='Unit 1';component.noHomework=true;
+  component.finishCoachLesson();expect(component.feedbackError()).toBe('请填写进度说明');
+  expect(calls.some(c=>c.action==='app_lesson_complete')).toBeFalse();
+ });
  it('submits a student review on an already completed course without changing its status',async()=>{
   user.set({userId:20,groupId:1});groupId.set(1);order={...order,dszt:30,allowedActions:['review'],reviewStatus:'pending'};
   const {harness,component}=await screen('/pages/shop/stu_get_order?id=901');component.studentScore=4;component.studentComment='有帮助';component.finishStudentLesson();harness.fixture.detectChanges();

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

@@ -73,11 +73,11 @@
           <fieldset class="feedback-fields" [disabled]="submitting()">
             <label><span>本次学习的课本或强化内容 <b>*</b></span><textarea autofocus [(ngModel)]="contentSummary" maxlength="500" aria-required="true" placeholder="例:人教版三年级 Unit 3,重点词汇与句型"></textarea></label>
             <div class="feedback-grid">
-              <label><span>复习 / 生词本情况 <b>*</b></span><select [(ngModel)]="reviewStatus" aria-required="true"><option value="">请选择</option><option>完成</option><option>部分完成</option><option>未复习</option><option>无复习任务</option></select></label>
+              <label><span>复习 / 生词本情况</span><input [(ngModel)]="reviewStatus" maxlength="200" placeholder="可填写复习或生词本情况(选填)" /></label>
               <label><span>复习说明</span><input [(ngModel)]="reviewSummary" maxlength="200" placeholder="可补充复习情况" /></label>
             </div>
             <div class="feedback-grid">
-              <label><span>训练进程和状态 <b>*</b></span><select [(ngModel)]="trainingStatus" aria-required="true"><option value="">请选择</option><option>正常进行</option><option>需要巩固</option><option>已完成</option><option>暂停</option></select></label>
+              <label><span>训练进程和状态</span><input [(ngModel)]="trainingStatus" maxlength="200" placeholder="可填写训练进程和状态(选填)" /></label>
               <label><span>进度说明 <b>*</b></span><input [(ngModel)]="trainingSummary" maxlength="200" aria-required="true" placeholder="说明当前进度与下次起点" /></label>
             </div>
             <div class="feedback-homework">

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

@@ -281,8 +281,7 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
     if (this.submitting() || !this.can('complete') || !this.completionDialog()?.nativeElement.open) return;
     this.feedbackError.set('');
     if (!this.contentSummary.trim()) { this.feedbackError.set('请填写本次学习内容'); return; }
-    if (!this.reviewStatus) { this.feedbackError.set('请选择复习或生词本情况'); return; }
-    if (!this.trainingStatus || !this.trainingSummary.trim()) { this.feedbackError.set('请选择训练状态并填写进度说明'); return; }
+    if (!this.trainingSummary.trim()) { this.feedbackError.set('请填写进度说明'); return; }
     if (!this.noHomework && !this.homework.trim()) { this.feedbackError.set('请填写作业安排,或选择“无作业”'); return; }
     this.submitting.set(true);
     this.api.post('app_lesson_complete', {
@@ -290,9 +289,9 @@ export class AppointmentPageComponent implements OnInit, OnDestroy {
       expectedUpdatedAt: this.order()['updatedAt'],
       feedback: JSON.stringify({
         contentSummary: this.contentSummary.trim(),
-        reviewStatus: this.reviewStatus,
+        reviewStatus: this.reviewStatus.trim(),
         reviewSummary: this.reviewSummary.trim(),
-        trainingStatus: this.trainingStatus,
+        trainingStatus: this.trainingStatus.trim(),
         trainingSummary: this.trainingSummary.trim(),
         homework: this.noHomework ? '无作业' : this.homework.trim(),
         noHomework: this.noHomework,

+ 6 - 5
scripts/cloud/lesson-workflow.js

@@ -10,12 +10,13 @@ function lessonFeedbackInput(input) {
     if (value.length > 1000) fail(400, '课堂反馈每项最多1000字');
     feedback[key] = value;
   }
-  feedback.reviewStatus = String(raw.reviewStatus || '');
-  feedback.trainingStatus = String(raw.trainingStatus || '');
+  for (const key of ['reviewStatus','trainingStatus']) {
+    feedback[key] = String(raw[key] || '').trim();
+    if (feedback[key].length > 200) fail(400, '复习情况和训练状态每项最多200字');
+  }
   feedback.noHomework = raw.noHomework === true;
-  if (!feedback.contentSummary || !['完成','部分完成','未复习','无复习任务'].includes(feedback.reviewStatus)
-    || !['正常进行','需要巩固','已完成','暂停'].includes(feedback.trainingStatus) || !feedback.trainingSummary
-    || !feedback.noHomework && !feedback.homework) fail(400, '请完整填写学习内容、复习、训练进度和作业');
+  if (!feedback.contentSummary || !feedback.trainingSummary
+    || !feedback.noHomework && !feedback.homework) fail(400, '请完整填写学习内容、进度说明和作业');
   return feedback;
 }
 async function appLessonComplete(input, current) {

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


+ 3 - 3
scripts/sql/lesson-workflow.sql

@@ -28,11 +28,11 @@ BEGIN
  IF request_key IS NULL OR length(request_key)<10 OR length(request_key)>160 THEN RAISE EXCEPTION '缺少有效请求编号';END IF;
  IF NOT EXISTS(SELECT 1 FROM "_User" WHERE "objectId"=actor AND company=co AND "legacyUserId"=actor_uid AND "legacyGroupId"=3 AND NOT COALESCE("isDisabled",false)) THEN RAISE EXCEPTION '用户身份已失效';END IF;
  IF NULLIF(trim(feedback->>'contentSummary'),'') IS NULL OR length(feedback->>'contentSummary')>1000
- OR COALESCE(feedback->>'reviewStatus','') NOT IN ('完成','部分完成','未复习','无复习任务')
- OR COALESCE(feedback->>'trainingStatus','') NOT IN ('正常进行','需要巩固','已完成','暂停')
+ OR length(COALESCE(feedback->>'reviewStatus',''))>200
+ OR length(COALESCE(feedback->>'trainingStatus',''))>200
  OR NULLIF(trim(feedback->>'trainingSummary'),'') IS NULL OR length(feedback->>'trainingSummary')>1000
  OR (NOT COALESCE((feedback->>'noHomework')::boolean,false) AND NULLIF(trim(feedback->>'homework'),'') IS NULL)
- THEN RAISE EXCEPTION '请完整填写学习内容、复习、训练进度和作业';END IF;
+ THEN RAISE EXCEPTION '请完整填写学习内容、进度说明和作业';END IF;
  PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
  SELECT * INTO c FROM "CommonModel" WHERE company=co AND "modelId"=54 AND "generalId"::text=gid;
  SELECT * INTO a FROM "CourseAppointment" WHERE company=co AND id=c."itemId";

+ 29 - 0
scripts/tests/lesson-workflow.test.mjs

@@ -177,3 +177,32 @@ test('同一预约多个已开通词库汇总学习词汇,按实际词库生
  assert.equal(reviews.length,2);assert.deepEqual(reviews.map(r=>Number(r.kcid)),[100,102]);
  await complete(a.gid,a.updatedAt,'multi-book-complete');assert.equal(await balance(),before-1);
 });
+
+
+test('选填复习情况和训练状态允许留空或自由文本,报告保存原文且重试不重复扣费',async()=>{
+ const {default:vm}=await import('node:vm');
+ const context={parseObject:value=>JSON.parse(value),fail:(code,message)=>{throw Error(message);}};
+ vm.createContext(context);vm.runInContext(readFileSync(new URL('../cloud/lesson-workflow.js',import.meta.url),'utf8'),context);
+ for (const value of ['', '  复习到第十页,易错词下次继续  ']) {
+  const f=context.lessonFeedbackInput({feedback:JSON.stringify({...feedback,reviewStatus:value,trainingStatus:value})});
+  assert.equal(f.reviewStatus,value.trim());assert.equal(f.trainingStatus,value.trim());
+  const a=await appointment(),before=await balance(),key='optional-feedback-'+a.gid;
+  assert.equal((await complete(a.gid,a.updatedAt,key,'teacher',30,f)).status,30);
+  assert.equal((await complete(a.gid,a.updatedAt,key,'teacher',30,f)).status,30);
+  assert.equal(await balance(),before-1);
+  const report=await one('SELECT "reviewStatus","trainingStatus" FROM "LessonCompletionReport" WHERE "appointmentGeneralId"::text=$1',[a.gid]);
+  assert.equal(report.reviewStatus,value.trim());assert.equal(report.trainingStatus,value.trim());
+ }
+ for(const field of ['reviewStatus','trainingStatus']){
+  const f={...feedback,[field]:'字'.repeat(201)};
+  assert.throws(()=>context.lessonFeedbackInput({feedback:JSON.stringify(f)}),/200字/);
+  const a=await appointment(),before=await balance();
+  await assert.rejects(complete(a.gid,a.updatedAt,'too-long-'+a.gid,'teacher',30,f),/请完整填写/);
+  assert.equal(await balance(),before);
+ }
+ const missing={...feedback,reviewStatus:'',trainingStatus:'',trainingSummary:''};
+ assert.throws(()=>context.lessonFeedbackInput({feedback:JSON.stringify(missing)}),/进度说明/);
+ const a=await appointment(),before=await balance();
+ await assert.rejects(complete(a.gid,a.updatedAt,'missing-progress-'+a.gid,'teacher',30,missing),/进度说明/);
+ assert.equal(await balance(),before);
+});

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