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

feat: preserve vocabulary test history and compare attempts

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

+ 29 - 0
docs/qa/exam-history-2026-10-15.md

@@ -0,0 +1,29 @@
+# 词汇测试历次记录与对比
+
+## 问题与修改
+
+原页面只读取最近一份 Model 61 摘要;完成后使用 `content_update` 覆盖旧摘要。`DesignAnswer` 的逐次答卷仍存在,因此不需要伪造或重建历史答题。
+
+- 新测试始终新增 `AssessmentProfile` / `CommonModel` 摘要;旧客户端更新 Model 61 时,先验证旧记录归属,再转为新增,保留原记录。
+- 新增 `app_exam_history`:只按当前登录身份及帐套读取,忽略外部传入的用户编号。合并两组词汇答卷与旧摘要,按答卷编号去重、时间倒序分页(每页 10 条)。原答卷可恢复显示被覆盖的历史分数;只有摘要的旧记录也保留。返回汇总指标,不返回原答题内容。
+- 词汇测试页增加“历次测试记录”,展示日期、题组和得分,默认选择最近两次,可选择其他两次比较认识、不认识、选错及总题数。
+- 新一轮测试清理上一轮状态;保存失败保留答案并提供重试。答卷已成功时仅重试摘要,摘要沿用本轮请求编号,避免重复保存。
+- 历史读取失败单独显示重试,不显示为“没有测试记录”;异常旧结果标记待核对。
+
+## 验证
+
+- `node --test scripts/tests/exam-history.test.mjs`:2 项通过。实际 PostgreSQL 兼容测试覆盖历史恢复、摘要去重、23 条分页、跨账号/帐套隔离、异常数据;兼容入口覆盖归属校验、追加保存及请求编号保留。
+- 仅本任务暂存改动的隔离副本:`test:mobile:ci -- --include=projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts`,41/41 通过。混合工作区曾出现教师点读流程超时,隔离复查通过;未将同时进行的客服修改带入提交。
+- `npm run build:mobile`:通过(既有预算和 CommonJS 警告)。
+- `npm run cloud:validate`:11 个云函数定义通过。
+- `node scripts/check-exam-history-browser.mjs`:连续两次测试均新增,原 12 次记录不变;摘要失败重试不重复答卷;重新进入能看到两次结果;分页、比较选择、失败恢复及 360/390px 布局通过。
+
+## 线上发布及回滚
+
+已发布 app gateway `2026-10-15-exam-history-v15`,只变更词汇历史读取和 Model 61 追加写入兼容逻辑及版本声明。无需数据库迁移;其他网关保持原样。发布前备份实际线上代码并确认旧系统同步空闲,发布后校验代码和运行版本。
+
+使用现有学生会话只读回读,返回 6 次有效测试;与发布前对同一数据执行的新查询一致。传入其他用户编号仍仅返回本人结果。未代用户创建测试、修改历史数据或提交答题。
+
+私有发布证据位于 `~/.local/state/xiaoshu/cloud-releases/2026-10-15-exam-history/`。回滚用 `functions-at-publish.json` 恢复 app gateway,并保留已经新增的摘要和答卷;回滚旧客户端覆盖逻辑后应暂停继续测试,避免再次覆盖摘要。已存在的答卷历史仍应保留。
+
+前端仅完成本地构建和浏览器验证,尚未部署线上前端;实际微信 WebView 未验收。

+ 1 - 1
projects/xiaoshu-mobile/src/app/core/api.service.ts

@@ -81,7 +81,7 @@ export class ApiService {
 
   private isReadAction(action: string): boolean {
     return /(?:_list|_get|_detail|_overview|_tongji|_tj|_is)$/.test(action)
-      || ['reading/me','reading/query','reading/get','migration_status', 'app_update', 'node_list', 'node_get', 'content_get', 'vote_ask', 'vote_question', 'e_get_21list', 'app_live_versions', 'app_review_page', 'app_lesson_report', 'app_lesson_courses'].includes(action);
+      || ['reading/me','reading/query','reading/get','migration_status', 'app_update', 'node_list', 'node_get', 'content_get', 'vote_ask', 'vote_question', 'e_get_21list', 'app_live_versions', 'app_review_page', 'app_lesson_report', 'app_lesson_courses', 'app_exam_history'].includes(action);
   }
 
   private normalize<T>(input: unknown): ApiEnvelope<T> {

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

@@ -134,6 +134,7 @@ class LearningModulesApiService {
       });
     }
     if (action === 'node_get') return this.result<T>([{ NodeID: 3, NodeName: '专项训练' }]);
+    if (action === 'app_exam_history') return this.result<T>({items:[{GeneralID:'answer:401',df:18,totalScore:48,dontKnow:20,wrong:10,resultAvailable:true,askid:1,testedAt:'2026-10-01T08:00:00Z'}],hasMore:false});
     if (action === 'vote_ask') return this.result<T>([{ ID: 1, Title: '第一组问题' }]);
     if (action === 'vote_question') {
       return this.result<T>([{ ID: 501, AskID: 1, QType: 1, QTitle: 'apple', QContent: '苹果', QOption: JSON.stringify([{ text: '苹果', value: 1 }]) }]);

+ 29 - 1
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.html

@@ -496,7 +496,10 @@
     </div>
     <div class="sticky-actions single"><button type="button" class="primary-button" (click)="joinAntiWords()">加入生词本</button></div>
   } @else if (isExamPage) {
-    @if (examSubmitted()) {
+    @if (examSaveError()) {
+      <div class="report" role="alert"><h2>测试结果尚未保存完成</h2><p>{{examSaveError()}}</p><button class="primary-button" type="button" (click)="submitExam()" [disabled]="submitting()">重试保存</button></div>
+    } @else if (submitting()) { <div class="report" role="status">正在保存测试结果…</div>
+    } @else if (examSubmitted()) {
       <div class="report">
         <div class="report-icon"><lucide-icon [img]="icons.Flag" [size]="34" /></div>
         <h2>测试结果已保存</h2>
@@ -539,6 +542,31 @@
         <div class="sticky-actions single"><button type="button" class="secondary-button" (click)="skipExamQuestion()">不认识,下一题</button></div>
       }
     }
+    @if (!examStarted()) {
+      <section class="exam-history" aria-label="历次测试记录">
+        <header><h2>历次测试记录</h2><span>选择两次进行对比</span></header>
+        @if (comparedExams().length === 2) {
+          <div class="exam-comparison" aria-label="两次测试对比">
+            @for (row of comparedExams(); track row['GeneralID']) {
+              <article><time>{{examDateLabel(row['testedAt']) || '日期未记录'}}</time><p>第 {{row['askid'] || '—'}} 组</p><strong>{{row['df']}} / {{row['totalScore']}}</strong><p>认识 {{row['df']}} · 不认识 {{row['dontKnow']}} · 选错 {{row['wrong']}}</p></article>
+            }
+          </div>
+        }
+        <div class="exam-history-list">
+          @for (row of examHistory(); track row['GeneralID']) {
+            <button type="button" class="exam-history-row" [attr.aria-pressed]="examCompareIds().includes($any(row['GeneralID']))" [disabled]="!row['resultAvailable']" (click)="toggleExamComparison(row)">
+              <span><time>{{examDateLabel(row['testedAt']) || '日期未记录'}}</time><small>第 {{row['askid'] || '—'}} 组</small></span>
+              @if (row['resultAvailable']) { <span><strong>{{row['df']}} / {{row['totalScore']}}</strong><small>{{examCompareIds().includes($any(row['GeneralID'])) ? '已选对比' : '选择对比'}}</small></span> }
+              @else { <span>结果待核对</span> }
+            </button>
+          }
+        </div>
+        @if (examHistoryLoading()) { <p role="status">正在读取测试记录…</p> }
+        @if (examHistoryError()) { <p role="alert">测试记录暂时无法读取</p><button type="button" class="secondary-button" (click)="loadExamHistory()">重试读取记录</button> }
+        @else if (!examHistoryLoading() && !examHistory().length) { <p>还没有测试记录,完成测试后会保存在这里。</p> }
+        @if (examHistoryMore()) { <button type="button" class="secondary-button" [disabled]="examHistoryLoading()" (click)="loadExamHistory(true)">查看更多记录</button> }
+      </section>
+    }
   } @else if (isReportPage || completed()) {
     <div class="report">
       <div class="report-icon"><lucide-icon [img]="icons.Star" [size]="34" /></div>

+ 10 - 0
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.scss

@@ -1377,3 +1377,13 @@ button {
 .learning-page:has(.batch-actions) { padding-bottom: 135px; }
 
 .review-complete{display:grid;gap:16px;padding:28px 20px;text-align:center}.review-complete strong{font-size:24px;color:var(--brand-strong)}
+
+.exam-history { max-width:780px; margin:24px auto; padding:20px; background:var(--surface); border:1px solid var(--line); border-radius:16px; }
+.exam-history>header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px; }
+.exam-history h2 { margin:0; font-size:18px; }.exam-history>header span,.exam-history small { color:var(--ink-soft); font-size:12px; }
+.exam-comparison { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; margin:18px 0; }
+.exam-comparison article { padding:12px; border-radius:10px; background:var(--surface-muted); overflow-wrap:anywhere; }
+.exam-comparison time { font-size:12px; }.exam-comparison strong { color:var(--brand-strong); font-size:24px; }.exam-comparison p { font-size:12px; line-height:1.6; }
+.exam-history-row { width:100%; display:flex; align-items:center; justify-content:space-between; gap:12px; padding:16px 4px; border:0; border-bottom:1px solid var(--line); background:transparent; text-align:left; color:var(--ink); }
+.exam-history-row span { display:grid; gap:6px; }.exam-history-row span:last-child { text-align:right; }.exam-history-row[aria-pressed="true"] strong { color:var(--brand-strong); }.exam-history-row[aria-pressed="true"] { background:var(--surface-muted); }
+.exam-history-row time { font-size:13px; }.exam-history>.secondary-button { margin-top:16px; }

+ 53 - 31
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -1,3 +1,4 @@
+import { lessonDateLabel } from '../appointment/lesson-display';
 import { TeacherTodayReviewService } from '../../core/teacher-today-review.service';
 import { lessonStudySources } from '../appointment/lesson-study-sources';
 import { DropdownDirective } from '../../shared/dropdown.directive';
@@ -173,6 +174,20 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   readonly examStarted = signal(false);
   readonly examSubmitted = signal(false);
   readonly askInfo = signal<SourceItem>({});
+  readonly examHistory = signal<SourceItem[]>([]);
+  readonly examHistoryLoading = signal(false);
+  readonly examHistoryError = signal('');
+  readonly examHistoryMore = signal(false);
+  readonly examCompareIds = signal<string[]>([]);
+  readonly comparedExams = computed(() => this.examHistory().filter(r => this.examCompareIds().includes(String(r['GeneralID']))));
+  readonly examSaveError = signal('');
+  readonly examDateLabel = lessonDateLabel;
+  private examHistoryPage = 0;
+  private examHistoryRequest?: Subscription;
+  private examLoadRequest?: Subscription;
+  private examAttemptStamp = '';
+  private examLogKey = '';
+  private examAnswerId: string | number | null = null;
   readonly previousExam = signal<SourceItem>({});
   readonly questions = signal<ExamQuestion[]>([]);
   readonly answers = signal<ExamAnswer[]>([]);
@@ -321,6 +336,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     this.choiceOptionsVersion++;
     this.choiceOptionsRequest?.unsubscribe();
     this.stopExamTimer();
+    this.examHistoryRequest?.unsubscribe();this.examLoadRequest?.unsubscribe();
     this.platform.stopAudio();
   }
 
@@ -1115,7 +1131,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   startExam(): void {
-    if (!this.questions().length) return;
+    if (!this.questions().length || this.loading() || this.submitting()) return;
+    this.examAttemptStamp=new Date().toISOString();this.examLogKey=crypto.randomUUID();this.examAnswerId=null;this.examSaveError.set('');
+    this.answers.set(this.questions().map(q=>({qid:q.ID,qtype:String(q.QType??''),answer:'',result:2})));
     this.examStarted.set(true);
     this.examSubmitted.set(false);
     this.currentIndex.set(0);
@@ -1123,6 +1141,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   answerExam(option: ExamOption, optionIndex: number): void {
+    if(this.submitting()||this.examSaveError()||this.examSubmitted())return;
     const question = this.currentQuestion();
     if (!question) return;
     const options = question.QOption.map((item, index) => ({ ...item, checked: index === optionIndex }));
@@ -1133,6 +1152,7 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   skipExamQuestion(): void {
+    if(this.submitting()||this.examSaveError()||this.examSubmitted())return;
     const question = this.currentQuestion();
     if (!question) return;
     this.setExamAnswer({ qid: question.ID, qtype: String(question.QType ?? ''), answer: '', result: 2 });
@@ -1140,7 +1160,9 @@ export class LearningPageComponent implements OnInit, OnDestroy {
   }
 
   submitExam(): void {
-    if(this.submitting())return;
+    if(this.submitting()||this.examSubmitted())return;
+    this.examSaveError.set('');
+    if(this.examAnswerId!==null){this.submitting.set(true);this.saveExamLog(this.examAnswerId);return;}
     this.stopExamTimer();
     const ask = this.askInfo();
     const user = this.session.user();
@@ -1153,14 +1175,13 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       Zstatus: 0,
       Source: '',
       UserID: user?.userId,
-      CDate: this.dateTime(true),
+      CDate: this.examAttemptStamp,
     };
     this.submitting.set(true);
     this.api.post<string | number>('vote_add', { mu: JSON.stringify(mu) }).subscribe({
-      next: ({ result }) => this.saveExamLog(result),
+      next: ({ result }) => {this.examAnswerId=result;this.saveExamLog(result);},
       error: (error: ApiError) => {
-        this.submitting.set(false);
-        this.platform.toast(error.message, 'error');
+        this.submitting.set(false);this.examSaveError.set(error.message);
       },
     });
   }
@@ -1862,25 +1883,31 @@ export class LearningPageComponent implements OnInit, OnDestroy {
     }).subscribe({ error: () => undefined });
   }
 
+  loadExamHistory(more = false): void {
+    if(more && this.examHistoryLoading())return;
+    this.examHistoryRequest?.unsubscribe();this.examHistoryLoading.set(true);this.examHistoryError.set('');
+    const page=more?this.examHistoryPage+1:1;
+    this.examHistoryRequest=this.api.get<{items:SourceItem[];hasMore:boolean}>('app_exam_history',{page}).pipe(finalize(()=>this.examHistoryLoading.set(false))).subscribe({
+      next:({result})=>{
+        if(!result || !Array.isArray(result.items)){this.examHistoryError.set('测试记录返回异常,请重试');return;}
+        this.examHistoryPage=page;this.examHistoryMore.set(result.hasMore);
+        this.examHistory.set(more?[...this.examHistory(),...result.items]:result.items);
+        if(!more){this.previousExam.set(result.items.find(r=>r['resultAvailable'])||{});this.examCompareIds.set(result.items.filter(r=>r['resultAvailable']).slice(0,2).map(r=>String(r['GeneralID'])));}
+      },error:(e:ApiError)=>this.examHistoryError.set(e.message),
+    });
+  }
+  toggleExamComparison(item:SourceItem):void {
+    const id=String(item['GeneralID']);if(!item['resultAvailable'])return;
+    this.examCompareIds.update(ids=>ids.includes(id)?ids.filter(x=>x!==id):[...ids.slice(-1),id]);
+  }
   private loadExam(): void {
-    this.turnAskId();
+    this.stopExamTimer();this.examLoadRequest?.unsubscribe();this.examStarted.set(false);this.examSubmitted.set(false);this.examSaveError.set('');
+    this.loading.set(true);this.error.set('');this.questions.set([]);this.turnAskId();
     const askId = Number(this.storageGet('askId') ?? 1);
-    const user = this.session.user();
-    this.api.get<unknown>('vote_ask', { id: askId }).subscribe({
-      next: ({ result }) => this.askInfo.set(this.findList(result)[0] ?? {}),
-      error: () => undefined,
-    });
-    this.api.get<unknown>('content_list', { nid: 389, modelId: 61, cpage: 1, psize: 2, myfield2: `UserID=${user?.userId}`, orders: 'CreateTime=desc' }).subscribe({
-      next: ({ result }) => this.previousExam.set(this.findList(result)[0] ?? {}),
-      error: () => undefined,
-    });
-    this.api.get<unknown>('vote_question', { askid: askId }).pipe(finalize(() => this.loading.set(false))).subscribe({
-      next: ({ result }) => {
-        const questions = this.shuffle(this.findList(result).map((item) => this.toQuestion(item)));
-        this.questions.set(questions);
-        this.answers.set(questions.map((question) => ({ qid: question.ID, qtype: String(question.QType ?? ''), answer: '', result: 2 })));
-      },
-      error: (error: ApiError) => this.error.set(error.message),
+    this.loadExamHistory();
+    this.examLoadRequest=forkJoin({ask:this.api.get<unknown>('vote_ask',{id:askId}),questions:this.api.get<unknown>('vote_question',{askid:askId})}).pipe(finalize(()=>this.loading.set(false))).subscribe({
+      next:({ask,questions})=>{this.askInfo.set(this.findList(ask.result)[0]??{});const items=this.shuffle(this.findList(questions.result).map(item=>this.toQuestion(item)));this.questions.set(items);this.answers.set(items.map(q=>({qid:q.ID,qtype:String(q.QType??''),answer:'',result:2})));},
+      error:(e:ApiError)=>this.error.set(e.message),
     });
   }
 
@@ -1964,18 +1991,13 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       df: score,
       UserID: user?.userId,
     };
-    let action = 'content_add';
-    if (previous['GeneralID']) {
-      content['GeneralID'] = previous['GeneralID'];
-      action = 'content_update';
-    }
-    this.api.post(action, { content: JSON.stringify(content), addon: JSON.stringify(addon) }).pipe(finalize(() => this.submitting.set(false))).subscribe({
+    this.api.post('content_add', { requestKey:this.examLogKey,content: JSON.stringify(content), addon: JSON.stringify(addon) }).pipe(finalize(() => this.submitting.set(false))).subscribe({
       next: () => {
         this.examStarted.set(false);
         this.examSubmitted.set(true);
-        this.platform.toast('答题结果已保存', 'success');
+        this.platform.toast('答题结果已保存', 'success');this.loadExamHistory();
       },
-      error: (error: ApiError) => this.platform.toast(error.message, 'error'),
+      error: (error: ApiError) => this.examSaveError.set(error.message),
     });
   }
 

+ 46 - 0
scripts/check-exam-history-browser.mjs

@@ -0,0 +1,46 @@
+#!/usr/bin/env node
+// Isolated local fixture browser acceptance. Does not read or write online accounts.
+import {chromium} from 'playwright';
+import {createServer} from 'node:http';
+import {readFileSync,existsSync,mkdirSync,writeFileSync} from 'node:fs';
+import {resolve,extname} from 'node:path';
+import assert from 'node:assert/strict';
+const root=resolve('dist/xiaoshu-mobile/browser'),out=resolve('tmp/exam-history-qa');mkdirSync(out,{recursive:true});
+const server=createServer((req,res)=>{let file=resolve(root,'.'+decodeURIComponent(new URL(req.url,'http://local').pathname));if(!file.startsWith(root+'/'))file=root+'/index.html';if(!existsSync(file)||!extname(file))file=root+'/index.html';try{res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.png':'image/png','.svg':'image/svg+xml','.woff2':'font/woff2'})[extname(file)]||'application/octet-stream');res.end(readFileSync(file));}catch{res.statusCode=404;res.end();}});
+await new Promise(r=>server.listen(0,'127.0.0.1',r));const origin='http://127.0.0.1:'+server.address().port;
+const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
+const calls=[],errors=[],records=Array.from({length:12},(_,i)=>({GeneralID:'old-'+i,testedAt:new Date(Date.UTC(2026,9,14-i,8)).toISOString(),askid:i%2+1,resultAvailable:true,df:8+i,totalScore:48,dontKnow:40-i,wrong:0}));
+const baseline=JSON.stringify(records);let failSummary=true,failHistory=false,answerId=0;
+try{
+ const context=await browser.newContext({viewport:{width:390,height:844}});
+ await context.addInitScript(()=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:exam-fixture-only',user:{userId:100,groupId:1,honeyName:'测试学生'}})));
+ await context.route('**/*',async route=>{
+  const u=new URL(route.request().url());if(u.origin!==origin)return route.abort();if(!u.pathname.startsWith('/cloud-functions/'))return route.continue();
+  const p=route.request().postDataJSON().params;calls.push(p);let result=[];
+  if(p.action==='app_exam_history'){if(failHistory)return route.fulfill({status:500,json:{retcode:-1,retmsg:'模拟历史读取失败'}});const start=(p.page-1)*10;result={items:records.slice(start,start+10),hasMore:records.length>start+10};}
+  if(p.action==='vote_ask')result=[{ID:Number(p.id),Title:'第'+p.id+'组问题'}];
+  if(p.action==='vote_question')result=[1,2].map(i=>({ID:i,AskID:Number(p.askid),QTitle:'apple',QContent:'苹果',QType:1,QOption:[{text:'苹果',value:'A'},{text:'香蕉',value:'B'}]}));
+  if(p.action==='vote_add')result=++answerId;
+  if(p.action==='content_update')throw Error('Must never overwrite a previous result');
+  if(p.action==='content_add'){
+   assert.equal(JSON.parse(p.content).ModelID,61);assert.equal(JSON.parse(p.content).GeneralID,undefined);
+   if(failSummary)return route.fulfill({status:409,json:{retcode:-1,retmsg:'模拟摘要保存失败'}});
+   const row=JSON.parse(p.addon);records.unshift({...row,GeneralID:'new-'+answerId,testedAt:new Date().toISOString(),resultAvailable:true});result='new-'+answerId;
+  }
+  await route.fulfill({json:{retcode:1,result}});
+ });
+ const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
+ await page.goto(origin+'/pages/his/exam');await page.locator('.exam-history-row').first().waitFor();assert.equal(await page.locator('.exam-history-row').count(),10);assert.equal(await page.locator('.exam-comparison article').count(),2);
+ await page.getByRole('button',{name:'查看更多记录',exact:true}).click();await page.waitForFunction(()=>document.querySelectorAll('.exam-history-row').length===12);
+ await page.locator('.exam-history-row').nth(11).click();await page.waitForFunction(()=>document.querySelector('.exam-comparison')?.textContent.includes('19 / 48'));assert.match(await page.locator('.exam-comparison').innerText(),/19 \/ 48/);
+ await page.getByRole('button',{name:'开始测试',exact:true}).click();await page.getByRole('button',{name:'苹果',exact:true}).click();await page.getByRole('button',{name:'不认识,下一题',exact:true}).click();
+ await page.getByRole('button',{name:'重试保存',exact:true}).waitFor();assert.equal(calls.filter(c=>c.action==='vote_add').length,1);
+ failSummary=false;await page.getByRole('button',{name:'重试保存',exact:true}).click();await page.getByText('测试结果已保存',{exact:true}).waitFor();assert.equal(calls.filter(c=>c.action==='vote_add').length,1);assert.equal(records.length,13);assert.equal(JSON.stringify(records.slice(1)),baseline);
+ const saves=calls.filter(c=>c.action==='content_add');assert.equal(saves[0].requestKey,saves[1].requestKey);
+ await page.getByRole('button',{name:'换一套再测',exact:true}).click();await page.getByRole('button',{name:'开始测试',exact:true}).waitFor();await page.getByRole('button',{name:'开始测试',exact:true}).click();await page.getByRole('button',{name:'苹果',exact:true}).click();await page.getByRole('button',{name:'苹果',exact:true}).click();await page.getByText('测试结果已保存',{exact:true}).waitFor();assert.equal(records.length,14);assert.equal(JSON.stringify(records.slice(2)),baseline);
+ await page.reload();await page.locator('.exam-history-row').first().waitFor();await page.waitForFunction(()=>document.querySelector('.exam-comparison')?.textContent.includes('2 / 2'));assert.match(await page.locator('.exam-comparison').innerText(),/1 \/ 2/);
+ for(const width of [390,360]){await page.setViewportSize({width,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));await page.screenshot({path:out+'/history-'+width+'.png',fullPage:true});}
+ failHistory=true;await page.reload();await page.getByText('测试记录暂时无法读取',{exact:true}).waitFor();assert.equal(await page.getByText('还没有测试记录,完成测试后会保存在这里。',{exact:true}).count(),0);
+ failHistory=false;await page.getByRole('button',{name:'重试读取记录',exact:true}).click();await page.locator('.exam-history-row').first().waitFor();assert.deepEqual(errors,[]);
+ writeFileSync(out+'/result.json',JSON.stringify({passed:true,checks:['连续两次测试均新增并保留旧记录','保存失败重试不重复答卷且摘要复用请求编号','重新进入可对比两次成绩','分页与选择历史记录','读取失败不报无记录','360/390布局'],environment:'local production build and fixture API'},null,2));console.log('Exam history browser checks passed');
+}finally{await browser.close();await new Promise(r=>server.close(r));}

+ 28 - 0
scripts/cloud/exam-history.js

@@ -0,0 +1,28 @@
+// Read each vocabulary attempt; old summaries may have overwritten earlier scores,
+// but DesignAnswer retains their original answer sheets. Never backfill on reads.
+function examHistoryItem(row) {
+  const data=row.data||{};
+  let answers;try { answers=typeof data.answer==='string'?JSON.parse(data.answer):data.answer; } catch { answers=null; }
+  const valid=Array.isArray(answers)&&answers.length>0&&answers.every(a=>a && a.result != null && [0,1,2,'0','1','2'].includes(a.result));
+  const count=key=>data[key]==null?NaN:Number(data[key]);
+  const total=valid?answers.length:count('totalScore'),score=valid?answers.filter(a=>Number(a.result)===1).length:count('df');
+  const wrong=valid?answers.filter(a=>Number(a.result)===0).length:count('wrong'),unknown=valid?answers.filter(a=>Number(a.result)===2).length:count('dontKnow');
+  const available=[total,score,wrong,unknown].every(Number.isFinite)&&total>0&&Math.min(score,wrong,unknown)>=0&&score+wrong+unknown===total;
+  return {GeneralID:row.key,testedAt:row.tested_at,askid:row.ask_id,resultAvailable:available,df:available?score:null,totalScore:available?total:null,wrong:available?wrong:null,dontKnow:available?unknown:null};
+}
+async function appExamHistory(input,current) {
+  assertLessonTenant(current);
+  const uid=ownLegacyId(current);if(!uid)fail(403,'无法确认学员身份');
+  const page=Math.max(1,Math.floor(number(input.page,1))),pageSize=10;
+  const rows=await Psql.query(`WITH attempts AS (
+    SELECT 'answer:'||a.id::text AS key,a."objectId" AS object_id,a."askId"::text AS ask_id,COALESCE(a.cdate,a."createdAt") AS tested_at,to_jsonb(a) AS data,0 AS priority
+    FROM "DesignAnswer" a WHERE a.company::text=ANY($1::text[]) AND a."userId"::text=$2 AND a."askId"::text IN ('1','2')
+    UNION ALL
+    SELECT CASE WHEN COALESCE(p.answerid::text,'') NOT IN ('','0') THEN 'answer:'||p.answerid::text ELSE 'profile:'||p."objectId" END,p."objectId",p.askid::text,COALESCE(p."updatedAt",p."createdAt"),to_jsonb(p),1
+    FROM "AssessmentProfile" p WHERE p.company::text=ANY($1::text[]) AND p."userId"::text=$2
+  ), unique_attempts AS (
+    SELECT DISTINCT ON (key) * FROM attempts ORDER BY key,priority,tested_at DESC NULLS LAST,object_id
+  ) SELECT * FROM unique_attempts ORDER BY tested_at DESC NULLS LAST,key DESC LIMIT $3 OFFSET $4`,
+  [[DEFAULT_COMPANY_ID,'Company$'+DEFAULT_COMPANY_ID],String(uid),pageSize+1,(page-1)*pageSize]);
+  return {items:rows.slice(0,pageSize).map(examHistoryItem),page,hasMore:rows.length>pageSize};
+}

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


+ 28 - 0
scripts/tests/exam-history.test.mjs

@@ -0,0 +1,28 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+const code=readFileSync(new URL('../cloud/exam-history.js',import.meta.url),'utf8');
+test('考试历史恢复原答卷、摘要去重、分页并按当前账号及帐套隔离',async()=>{
+ const db=new PGlite();try{
+ await db.exec(`CREATE TABLE "DesignAnswer" ("objectId" text,id numeric,"askId" numeric,"userId" numeric,company text,cdate timestamptz,"createdAt" timestamptz,answer text);
+ CREATE TABLE "AssessmentProfile" ("objectId" text,answerid numeric,askid numeric,"userId" numeric,company text,"createdAt" timestamptz,"updatedAt" timestamptz,df numeric,"totalScore" numeric,wrong numeric,"dontKnow" numeric);`);
+ const answers=JSON.stringify([{result:1},{result:0},{result:2}]);
+ for(let i=1;i<=22;i++)await db.query('INSERT INTO "DesignAnswer" VALUES($1,$2,1,100,$3,$4,$4,$5)',['a'+i,i,i%2?'co':'Company$co',new Date(Date.UTC(2026,9,i)).toISOString(),answers]);
+ await db.query(`INSERT INTO "DesignAnswer" VALUES('other',100,1,200,'co',now(),now(),$1),('foreign',101,1,100,'foreign',now(),now(),$1),('unrelated',102,3,100,'co',now(),now(),$1)`,[answers]);
+ await db.exec(`INSERT INTO "AssessmentProfile" VALUES('old-summary',22,1,100,'co',now(),now(),1,3,1,1),('summary-only',999,2,100,'co','2026-01-01','2026-01-01',2,3,0,1)`);
+ const ctx={DEFAULT_COMPANY_ID:'co',assertLessonTenant:()=>{},ownLegacyId:()=>100,number:(n,d=0)=>Number.isFinite(Number(n))?Number(n):d,fail:(_c,m)=>{throw Error(m)},Psql:{query:async(s,p)=>(await db.query(s,p)).rows}};vm.createContext(ctx);vm.runInContext(code,ctx);
+ const first=await ctx.appExamHistory({page:1,uid:200},{}),second=await ctx.appExamHistory({page:2},{}),last=await ctx.appExamHistory({page:3},{});
+ assert.equal(first.items.length,10);assert.equal(first.items[0].GeneralID,'answer:22');assert.equal(first.hasMore,true);assert.equal(second.items.length,10);assert.equal(last.items.length,3);assert.equal(last.hasMore,false);
+ const all=[...first.items,...second.items,...last.items];assert.equal(new Set(all.map(r=>r.GeneralID)).size,23);assert.ok(all.every(r=>r.resultAvailable));assert.equal(first.items[0].df,1);assert.equal(last.items[2].df,2);
+ assert.equal(first.items[0].wrong,1);assert.equal(first.items[0].dontKnow,1);assert.ok(all.every(r=>!Object.hasOwn(r,'answer')));
+ assert.equal(ctx.examHistoryItem({key:'bad',data:{answer:'broken'}}).resultAvailable,false);
+ }finally{await db.close();}
+});
+test('旧客户端更新考试成绩改为新增,保留原归属校验和请求幂等编号',async()=>{
+ const src=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');const fragment=src.slice(src.indexOf('async function updateContentPair('),src.indexOf('async function createContentPair('));let appended,authorized=false;
+ const ctx={DEFAULT_COMPANY_ID:'co',parseObject:s=>JSON.parse(s),number:Number,resolveContent:async()=>({object:{get:k=>({modelId:61,itemId:10}[k])}}),CONTENT_ADDONS:{61:{className:'AssessmentProfile',fields:['df','askid','userId','answerid','wrong','dontKnow','totalScore']}},CONTENT_WRITE_AUTH:{61:{subjects:['userId'],actors:[]}},Psql:{query:async()=>[{objectId:'old'}]},Parse:{Query:class{async get(){return {id:'old'};}}},safe:()=>({userId:100,df:8,askid:1,answerid:5}),authorizeBusinessRow:async()=>{if(!authorized)throw Error('Forbidden')},createContentPair:async input=>{appended=input;return 'new-record'},fail:(_c,m)=>{throw Error(m)}};
+ vm.createContext(ctx);vm.runInContext(fragment,ctx);const input={requestKey:'same-attempt-key',content:JSON.stringify({GeneralID:1}),addon:JSON.stringify({UserID:100,df:12,answerid:6})};
+ await assert.rejects(ctx.updateContentPair(input,{}),/Forbidden/);assert.equal(appended,undefined);authorized=true;assert.equal(await ctx.updateContentPair(input,{}),'new-record');assert.equal(appended.requestKey,input.requestKey);assert.equal(JSON.parse(appended.content).ModelID,61);assert.equal(JSON.parse(appended.addon).df,12);assert.equal(JSON.parse(appended.addon).userId,100);
+});

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