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

perf: 加速移动端数据页面加载

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

+ 1 - 0
package.json

@@ -24,6 +24,7 @@
     "payroll:backfill": "node scripts/backfill-payroll-lessons.mjs",
     "app:schema:ensure": "node scripts/ensure-app-member-schemas.mjs",
     "app:resources:smoke": "node scripts/smoke-app-member-resources.mjs",
+    "app:performance:smoke": "node scripts/smoke-app-performance-read.mjs",
     "app:actions:audit": "node scripts/audit-app-cloud-actions.mjs",
     "reading:prompts:sync": "node scripts/sync-reading-prompts.mjs",
     "reading:ai:configure": "node scripts/configure-reading-ai.mjs",

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

@@ -115,7 +115,7 @@ export class ApiService {
       }),
       shareReplay({ bufferSize: 1, refCount: false }),
     );
-    this.overviewCache.set(key, { expiresAt: now + 45_000, response: response as Observable<ApiEnvelope<unknown>> });
+    this.overviewCache.set(key, { expiresAt: now + 120_000, response: response as Observable<ApiEnvelope<unknown>> });
     return response;
   }
 

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

@@ -53,8 +53,8 @@ class LearningModulesApiService {
     if (action === 'node_list' && Number(params['pid']) === 801) {
       this.lastNodeParams = params;
       return this.result<T>([
-        { NodeID: 811, NodeName: 'Unit 1', yx_word: null, total: 20 },
-        { NodeID: 812, NodeName: 'Unit 2', yx_word: null, total: 18 },
+        { NodeID: 811, NodeName: 'Unit 1', yx_word: 1, total: 20 },
+        { NodeID: 812, NodeName: 'Unit 2', yx_word: 0, total: 18 },
       ]);
     }
     if (action === 'e_ck_list') {

+ 25 - 2
projects/xiaoshu-mobile/src/app/features/course-center/course-center-page.component.ts

@@ -84,6 +84,9 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   private nowTimer?: ReturnType<typeof setInterval>;
   private restoredScheduleState = false;
   private syncingQuery = false;
+  private loadedRange: { start: string; end: string } | null = null;
+  private loadedAll = false;
+  private initialScheduleLoaded = false;
 
   ngOnInit(): void {
     this.restoredScheduleState = this.restoreScheduleState();
@@ -101,17 +104,23 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     this.loading.set(true);
     this.error.set('');
     const today = this.today();
+    const range = this.isHistory || this.scheduleRange() === 'all' ? null : this.calendarWindow();
     this.api.post<CompanionOverview>('app_companion_overview', {
       uid,
       mode: this.isCoach ? 'coach' : 'student',
       history: this.isHistory,
-      limit: this.isHistory ? 100 : 500,
+      limit: this.isHistory ? 100 : range ? 200 : 500,
+      rangeStart: range?.start,
+      rangeEnd: range?.end,
       refresh,
     }).pipe(finalize(() => this.loading.set(false))).subscribe({
       next: ({ result }) => {
         this.courses.set(Array.isArray(result?.courses) ? result.courses : []);
         this.schedules.set(Array.isArray(result?.schedules) ? result.schedules : []);
-        if (!this.isHistory && !this.restoredScheduleState) this.selectDate(today);
+        this.loadedRange = range;
+        this.loadedAll = !range;
+        if (!this.initialScheduleLoaded && !this.isHistory && !this.restoredScheduleState) this.selectDate(today);
+        this.initialScheduleLoaded = true;
         if (this.restoredScheduleState) this.restoreScheduleScroll();
       },
       error: (error: ApiError) => this.error.set(error.message),
@@ -208,6 +217,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     const month = this.calendarMonth();
     this.calendarMonth.set(new Date(month.getFullYear(), month.getMonth() + amount, 1, 12));
     this.syncScheduleState();
+    this.load();
   }
 
   selectCalendarDay(day: CalendarDay): void { this.selectDate(day.date); }
@@ -225,6 +235,7 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
   showAllSchedules(): void {
     this.scheduleRange.set('all');
     this.syncScheduleState();
+    if (!this.loadedAll) this.load();
   }
 
   scheduleEmptyText(): string {
@@ -453,6 +464,18 @@ export class CourseCenterPageComponent implements OnInit, OnDestroy {
     const value = this.parseDate(date);
     this.calendarMonth.set(new Date(value.getFullYear(), value.getMonth(), 1, 12));
     this.syncScheduleState();
+    if (!this.loadedRange || date < this.loadedRange.start || date > this.loadedRange.end) this.load();
+  }
+
+  private calendarWindow(): { start: string; end: string } {
+    const month = this.calendarMonth();
+    const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
+    const offset = (first.getDay() + 6) % 7;
+    const start = new Date(first);
+    start.setDate(first.getDate() - offset);
+    const end = new Date(start);
+    end.setDate(start.getDate() + 41);
+    return { start: this.dateKey(start), end: this.dateKey(end) };
   }
 
   private ensureSelectedDate(): void {

+ 5 - 43
projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts

@@ -23,7 +23,7 @@ import {
   Volume2,
   X,
 } from 'lucide-angular';
-import { catchError, finalize, forkJoin, map, of, switchMap } from 'rxjs';
+import { catchError, finalize, forkJoin, of } from 'rxjs';
 import { GAME_AUDIO } from '../../core/app.constants';
 import { ApiError, ApiService } from '../../core/api.service';
 import {
@@ -957,60 +957,22 @@ export class LearningPageComponent implements OnInit, OnDestroy {
       params['userId'] = this.session.user()?.userId;
       params['bindingId'] = this.query('bindingId');
     }
-    this.api.get<unknown>('node_list', params).pipe(
-      switchMap(({ result }) => {
-        const nodes = this.findList(result);
-        if (!this.isUnitListPage || !nodes.length) return of(nodes);
-        const learner = this.session.student() ?? this.session.user();
-        const nodeIds = nodes.map((node) => this.nodeId(node)).filter(Boolean);
-        if (!learner?.userId || !nodeIds.length) return of(nodes);
-        return this.api.get<unknown>('e_ck_list', {
-          checkwords: 2,
-          nids: nodeIds.join(','),
-          modelId: 52,
-          cpage: 1,
-          psize: 10000,
-          uid: learner.userId,
-        }).pipe(
-          map(({ result: words }) => this.mergeUnitProgress(nodes, this.findList(words))),
-          catchError(() => of(nodes)),
-        );
-      }),
-      finalize(() => this.loading.set(false)),
-    ).subscribe({
+    this.api.get<unknown>('node_list', params).pipe(finalize(() => this.loading.set(false))).subscribe({
       next: (nodes) => {
-        if (this.isUnitListPage && !this.canStartPointReading && !nodes.length && pid) {
+        const rows = this.findList(nodes.result);
+        if (this.isUnitListPage && !this.canStartPointReading && !rows.length && pid) {
           void this.router.navigate(['/pages/stu/stu_con'], {
             queryParams: { nid: pid, readonly: 1 },
             replaceUrl: true,
           });
           return;
         }
-        this.nodes.set(nodes);
+        this.nodes.set(rows);
       },
       error: () => this.error.set(this.isCatalogPage ? '词库暂时无法加载,请稍后重试' : '学习单元暂时无法加载,请稍后重试'),
     });
   }
 
-  private mergeUnitProgress(nodes: SourceItem[], words: SourceItem[]): SourceItem[] {
-    const totals = new Map<string, number>();
-    const learned = new Map<string, number>();
-    for (const word of words) {
-      const nodeId = String(word['NodeID'] ?? word['nodeId'] ?? '');
-      if (!nodeId) continue;
-      totals.set(nodeId, (totals.get(nodeId) ?? 0) + 1);
-      if (Number(word['w_learned'] ?? 0) > 0) learned.set(nodeId, (learned.get(nodeId) ?? 0) + 1);
-    }
-    return nodes.map((node) => {
-      const nodeId = this.nodeId(node);
-      return {
-        ...node,
-        total: Math.max(Number(node['total'] ?? 0), totals.get(nodeId) ?? 0),
-        yx_word: Math.max(Number(node['yx_word'] ?? 0), learned.get(nodeId) ?? 0),
-      };
-    });
-  }
-
   private loadSelection(): void {
     if (this.meta.path === 'pages/stu/stu_abc') {
       const student = this.session.student() ?? this.session.user();

+ 14 - 0
projects/xiaoshu-mobile/src/app/features/study-records/study-records-page.component.ts

@@ -168,6 +168,7 @@ export class StudyRecordsPageComponent implements OnInit {
   shiftDatePickerMonth(amount: number): void {
     const month = this.datePickerMonth();
     this.datePickerMonth.set(new Date(month.getFullYear(), month.getMonth() + amount, 1, 12));
+    if (this.meta.path === 'pages/his/his') this.load();
   }
 
   selectDate(date: string): void {
@@ -374,10 +375,13 @@ export class StudyRecordsPageComponent implements OnInit {
       this.loading.set(false);
       return;
     }
+    const calendarRange = this.datePickerRange();
     this.api.post<LearningOverview>('app_learning_overview', {
       uid,
       studentId: targetUid,
       date: this.selectedDate,
+      calendarStart: calendarRange.start,
+      calendarEnd: calendarRange.end,
       refresh,
     }).pipe(finalize(() => this.loading.set(false))).subscribe({
       next: ({ result }) => {
@@ -394,6 +398,16 @@ export class StudyRecordsPageComponent implements OnInit {
     });
   }
 
+  private datePickerRange(): { start: string; end: string } {
+    const month = this.datePickerMonth();
+    const first = new Date(month.getFullYear(), month.getMonth(), 1, 12);
+    const start = new Date(first);
+    start.setDate(first.getDate() - first.getDay());
+    const end = new Date(start);
+    end.setDate(start.getDate() + 41);
+    return { start: this.dateKey(start), end: this.dateKey(end) };
+  }
+
   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,

+ 13 - 8
scripts/deploy-admin-functions.mjs

@@ -2564,7 +2564,10 @@ async function newWordPage(input, current) {
   const result = rows.map((source) => { const practice = { id: source.id, kcid: source.kcid, scid: source.scid, xxcs: source.xxcs, yhid: source.yhid, jrscb: source.jrscb }; const detail = normalizeWordRow(source); return { ...practice, GeneralID: number(source.__practiceGeneralId || source.id), Title: detail.Title || detail.title || '', detail: [detail] }; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
 }
 async function courseWordPage(input, current) {
-  const uid = await authorizeRequestedUser(current, input.uid); const nodes = String(input.nids || input.nid || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'); if (!nodes.length) fail(400, '缺少词库单元 ID'); const paging = { index: Math.max(1, number(input.cpage || input.page, 1)), size: Math.min(10000, Math.max(1, number(input.psize || input.pageSize, 1000))) }; const values = [DEFAULT_COMPANY_ID, String(uid), nodes]; const where = 'c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."nodeId" AS text) = ANY($3::text[])'; const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c WHERE ' + where, values); const rows = await Psql.query('SELECT c.*, row_to_json(v) AS "__addon", p."xxcs" AS "__learned", p."jrscb" AS "__newWord" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN LATERAL (SELECT pr."xxcs", pr."jrscb" FROM "PracticeRecord" pr WHERE pr."company" = $1 AND CAST(pr."yhid" AS text) = $2 AND CAST(pr."scid" AS text) = CAST(c."generalId" AS text) ORDER BY pr."updatedAt" DESC LIMIT 1) p ON TRUE WHERE ' + where + ' ORDER BY c."orderId" ASC, c."generalId" ASC LIMIT $4 OFFSET $5', values.concat([paging.size, (paging.index - 1) * paging.size])); const result = rows.map((source) => { const learned = number(source.__learned); const newWord = number(source.__newWord); delete source.__learned; delete source.__newWord; const word = normalizeWordRow(source); return { ...word, detail: word, gldy: word.GeneralID, w_learned: learned, ifnew: newWord }; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
+  const uid = await authorizeRequestedUser(current, input.uid); const nodes = String(input.nids || input.nid || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'); if (!nodes.length) fail(400, '缺少词库单元 ID'); const paging = { index: Math.max(1, number(input.cpage || input.page, 1)), size: Math.min(500, Math.max(1, number(input.psize || input.pageSize, 100))) }; const values = [DEFAULT_COMPANY_ID, String(uid), nodes]; const where = 'c."company" = $1 AND c."modelId" = 52 AND CAST(c."nodeId" AS text) = ANY($3::text[])';
+  const countPromise = Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c WHERE ' + where, values);
+  const rowPromise = Psql.query('SELECT c.*, row_to_json(v) AS "__addon", COALESCE(p."learned",0) AS "__learned", COALESCE(p."newWord",0) AS "__newWord" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND v."id" = c."itemId" LEFT JOIN (SELECT CAST(pr."scid" AS text) AS "wordId", MAX(CASE WHEN CAST(pr."xxcs" AS text) ~ \'^[0-9]+(?:\\.[0-9]+)?$\' THEN CAST(pr."xxcs" AS numeric) ELSE 0 END)::int AS "learned", MAX(CASE WHEN LOWER(CAST(pr."jrscb" AS text)) IN (\'1\',\'true\') THEN 1 ELSE 0 END)::int AS "newWord" FROM "PracticeRecord" pr WHERE pr."company" = $1 AND CAST(pr."yhid" AS text) = $2 GROUP BY CAST(pr."scid" AS text)) p ON p."wordId" = CAST(c."generalId" AS text) WHERE ' + where + ' ORDER BY c."orderId" ASC, c."generalId" ASC LIMIT $4 OFFSET $5', values.concat([paging.size, (paging.index - 1) * paging.size]));
+  const [countRow, rows] = await Promise.all([countPromise, rowPromise]); const result = rows.map((source) => { const learned = number(source.__learned); const newWord = number(source.__newWord); delete source.__learned; delete source.__newWord; const word = normalizeWordRow(source); return { ...word, detail: word, gldy: word.GeneralID, w_learned: learned, ifnew: newWord }; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
 }
 async function updateLearningWords(input, current) {
   const uid = await authorizeRequestedUser(current, input.uid); const wordIds = [...new Set(String(input.wordsId || input.wordIds || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'))]; if (!wordIds.length) fail(400, '请选择单词'); if (wordIds.length > 100) fail(400, '单次最多操作 100 个单词'); const saveLearning = number(input.save) === 1; const hasNewWordFlag = input.ifnew !== undefined && input.ifnew !== ''; const newWordFlag = hasNewWordFlag ? (number(input.ifnew) === 1 ? '1' : '0') : null; if (!saveLearning && !hasNewWordFlag) fail(400, '缺少学习或生词操作类型'); const kcid = number(input.kcid);
@@ -2666,13 +2669,15 @@ async function appLearningTarget(current,requestedId) {
   const linked=await Psql.oneOrNone('SELECT 1 AS found FROM "CourseAppointment" WHERE "company"=$1 AND "pl"=$2 AND "szyh"=$3 LIMIT 1',[DEFAULT_COMPANY_ID,String(ownId),String(targetId)]);if(!linked)fail(403,'无权查看该学员的学习记录');return targetId;
 }
 async function appLearningOverview(input,current) {
-  const targetId=await appLearningTarget(current,input.studentId||input.uid),selected=String(input.date||'').replace(/\D/g,'').slice(0,8),recordValues=[DEFAULT_COMPANY_ID,targetId],recordWhere=['d."company"=$1','d."userId"=$2'];if(selected){recordValues.push(selected);recordWhere.push('d."dqrq"=$3');}
+  const targetId=await appLearningTarget(current,input.studentId||input.uid),selected=String(input.date||'').replace(/\D/g,'').slice(0,8),calendarStart=String(input.calendarStart||'').replace(/\D/g,'').slice(0,8),calendarEnd=String(input.calendarEnd||'').replace(/\D/g,'').slice(0,8),recordValues=[DEFAULT_COMPANY_ID,targetId],recordWhere=['d."company"=$1','d."userId"=$2'];if(selected){recordValues.push(selected);recordWhere.push('d."dqrq"=$3');}
   const recordSql='SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",d."id",d."pl",d."con",d."djq",d."ygg",d."dqrq",d."dsid",d."fxrl",d."xxqs",d."szmdid",d."userId",d."learned" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=56 AND c."itemId"=d."id" WHERE '+recordWhere.join(' AND ')+' ORDER BY COALESCE(c."createTime",c."createdAt",d."updatedAt",d."createdAt") DESC LIMIT 100';
   const scheduleValues=[DEFAULT_COMPANY_ID,String(targetId)];let schedulePromise=Promise.resolve([]);if(selected)schedulePromise=Psql.query('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",a.*,n."nodeName" AS "courseTitle" FROM "CourseAppointment" a LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=54 AND c."itemId"=a."id" LEFT JOIN "Node" n ON n."company"=$1 AND n."nodeId"=CASE WHEN COALESCE(a."kcid",\'\')~\'^\\d+(?:\\.0+)?$\' THEN a."kcid"::numeric ELSE -1 END WHERE a."company"=$1 AND a."szyh"=$2 AND (REPLACE(LEFT(COALESCE(NULLIF(a."bxrq",\'\'),a."yysj"),10),\'-\',\'\')=$3 OR REPLACE(LEFT(COALESCE(NULLIF(a."yysj",\'\'),a."bxrq"),10),\'-\',\'\')=$3) ORDER BY COALESCE(NULLIF(a."yysj",\'\'),a."bxrq") ASC LIMIT 100',scheduleValues.concat([selected]));
+  const dateValues=[DEFAULT_COMPANY_ID,targetId],dateWhere=['"company"=$1','"userId"=$2'];if(calendarStart&&calendarEnd){dateValues.push(calendarStart,calendarEnd);dateWhere.push('"dqrq">=$3 AND "dqrq"<=$4');}
+  const groupValues=[DEFAULT_COMPANY_ID,String(targetId)],groupWhere=['"company"=$1','"szyh"=$2'];if(calendarStart&&calendarEnd){groupValues.push(calendarStart,calendarEnd);groupWhere.push('REPLACE(LEFT(COALESCE(NULLIF("bxrq",\'\'),"yysj"),10),\'-\',\'\')>=$3 AND REPLACE(LEFT(COALESCE(NULLIF("bxrq",\'\'),"yysj"),10),\'-\',\'\')<=$4');}
   const [records,dateRows,scheduleGroups,schedules,courses,latestReading]=await Promise.all([
     Psql.query(recordSql,recordValues),
-    Psql.query('SELECT "dqrq" AS date,COUNT(*)::int AS count FROM "DailyStudyRecord" WHERE "company"=$1 AND "userId"=$2 GROUP BY "dqrq"',[DEFAULT_COMPANY_ID,targetId]),
-    Psql.query('SELECT "bxrq","yysj","dszt",COUNT(*)::int AS count FROM "CourseAppointment" WHERE "company"=$1 AND "szyh"=$2 GROUP BY "bxrq","yysj","dszt"',[DEFAULT_COMPANY_ID,String(targetId)]),
+    Psql.query('SELECT "dqrq" AS date,COUNT(*)::int AS count FROM "DailyStudyRecord" WHERE '+dateWhere.join(' AND ')+' GROUP BY "dqrq"',dateValues),
+    Psql.query('SELECT "bxrq","yysj","dszt",COUNT(*)::int AS count FROM "CourseAppointment" WHERE '+groupWhere.join(' AND ')+' GROUP BY "bxrq","yysj","dszt"',groupValues),
     schedulePromise,
     Psql.query('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",b.*,n."nodeName" AS "courseTitle" FROM "CourseBinding" b LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=$1 AND n."nodeId"=CASE WHEN COALESCE(b."kcid",\'\')~\'^\\d+(?:\\.0+)?$\' THEN b."kcid"::numeric ELSE -1 END WHERE b."company"=$1 AND b."yhid"=$2 ORDER BY b."id" DESC LIMIT 100',[DEFAULT_COMPANY_ID,String(targetId)]),
     Psql.oneOrNone('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",d."id",d."pl",d."con",d."djq",d."ygg",d."dqrq",d."dsid",d."fxrl",d."xxqs",d."szmdid",d."userId",d."learned" FROM "DailyStudyRecord" d LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=56 AND c."itemId"=d."id" WHERE d."company"=$1 AND d."userId"=$2 AND COALESCE(NULLIF(d."xxqs",\'\'),\'[]\')<>\'[]\' ORDER BY COALESCE(c."createTime",c."createdAt",d."updatedAt",d."createdAt") DESC LIMIT 1',[DEFAULT_COMPANY_ID,targetId])
@@ -2683,8 +2688,8 @@ async function appLearningOverview(input,current) {
   return {records:normalizedRecords,latestReadingRecord:appOverviewStudyRow(latestReading),recordDateCounts,scheduleDateCounts,schedules:schedules.map(appOverviewScheduleRow),courses:courses.map(appOverviewCourseRow),refreshedAt:new Date().toISOString()};
 }
 async function appCompanionOverview(input,current) {
-  const uid=await authorizeRequestedUser(current,input.uid),coach=String(input.mode||'')==='coach'||number(current.get('legacyGroupId')||legacyData(current).GroupID)===3,history=Boolean(input.history),field=coach?'pl':'szyh',values=[DEFAULT_COMPANY_ID,String(uid)],clauses=['a."company"=$1','a."'+field+'"=$2'];if(history)clauses.push('a."dszt"~\'^\\d+(?:\\.0+)?$\' AND a."dszt"::numeric>=30');const limit=Math.min(500,Math.max(1,number(input.limit,history?100:500))),direction=coach&&!history?'ASC':'DESC';
-  const schedulePromise=Psql.query('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",a.*,n."nodeName" AS "courseTitle" FROM "CourseAppointment" a LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=54 AND c."itemId"=a."id" LEFT JOIN "Node" n ON n."company"=$1 AND n."nodeId"=CASE WHEN COALESCE(a."kcid",\'\')~\'^\\d+(?:\\.0+)?$\' THEN a."kcid"::numeric ELSE -1 END WHERE '+clauses.join(' AND ')+' ORDER BY COALESCE(NULLIF(a."yysj",\'\'),a."bxrq") '+direction+' LIMIT $3',values.concat([limit]));
+  const uid=await authorizeRequestedUser(current,input.uid),coach=String(input.mode||'')==='coach'||number(current.get('legacyGroupId')||legacyData(current).GroupID)===3,history=Boolean(input.history),field=coach?'pl':'szyh',values=[DEFAULT_COMPANY_ID,String(uid)],clauses=['a."company"=$1','a."'+field+'"=$2'];if(history)clauses.push('a."dszt"~\'^\\d+(?:\\.0+)?$\' AND a."dszt"::numeric>=30');const rangeStart=String(input.rangeStart||'').replace(/\D/g,'').slice(0,8),rangeEnd=String(input.rangeEnd||'').replace(/\D/g,'').slice(0,8);if(rangeStart&&rangeEnd){values.push(rangeStart,rangeEnd);clauses.push('REPLACE(LEFT(COALESCE(NULLIF(a."bxrq",\'\'),a."yysj"),10),\'-\',\'\')>=$'+(values.length-1)+' AND REPLACE(LEFT(COALESCE(NULLIF(a."bxrq",\'\'),a."yysj"),10),\'-\',\'\')<=$'+values.length);}const limit=Math.min(500,Math.max(1,number(input.limit,history?100:200))),direction=coach&&!history?'ASC':'DESC';
+  const schedulePromise=Psql.query('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",a.*,n."nodeName" AS "courseTitle" FROM "CourseAppointment" a LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=54 AND c."itemId"=a."id" LEFT JOIN "Node" n ON n."company"=$1 AND n."nodeId"=CASE WHEN COALESCE(a."kcid",\'\')~\'^\\d+(?:\\.0+)?$\' THEN a."kcid"::numeric ELSE -1 END WHERE '+clauses.join(' AND ')+' ORDER BY COALESCE(NULLIF(a."yysj",\'\'),a."bxrq") '+direction+' LIMIT $'+(values.length+1),values.concat([limit]));
   const coursePromise=coach?Promise.resolve([]):Psql.query('SELECT c."objectId",c."generalId",c."title",c."createTime",c."createdAt",b.*,n."nodeName" AS "courseTitle" FROM "CourseBinding" b LEFT JOIN "CommonModel" c ON c."company"=$1 AND c."modelId"=58 AND c."itemId"=b."id" LEFT JOIN "Node" n ON n."company"=$1 AND n."nodeId"=CASE WHEN COALESCE(b."kcid",\'\')~\'^\\d+(?:\\.0+)?$\' THEN b."kcid"::numeric ELSE -1 END WHERE b."company"=$1 AND b."yhid"=$2 ORDER BY b."id" DESC LIMIT 100',[DEFAULT_COMPANY_ID,String(uid)]);
   const [schedules,courses]=await Promise.all([schedulePromise,coursePromise]);return{schedules:schedules.map(appOverviewScheduleRow),courses:courses.map(appOverviewCourseRow),refreshedAt:new Date().toISOString()};
 }
@@ -2703,8 +2708,8 @@ async function appAccountOverview(input,current) {
 function learnedDate(row) { const raw = String(row.dqrq || '').trim(); return /^\d{8}$/.test(raw) ? raw.slice(0,4) + '-' + raw.slice(4,6) + '-' + raw.slice(6,8) : String(row.kywsj || '').slice(0,10); }
 function learnedAt(row) { for (const value of [row.studyCommonCreateTime,row.studyCommonCreatedAt,row.studyRecordCreatedAt,row.studyRecordUpdatedAt]) { if (!value) continue; const date = value instanceof Date ? value : new Date(value); if (!Number.isNaN(date.getTime()) && date.getUTCFullYear() > 1970) return date.toISOString(); } return learnedDate(row); }
 async function memoryPage(input, current) {
-  const uid = await authorizeRequestedUser(current, input.uid); const paging = largePageInput(input); const income = number(input.shouru) === 1; const values = [DEFAULT_COMPANY_ID, String(uid)]; const clauses = ['c."company" = $1', 'CAST(c."modelId" AS text) = \'60\'', 'CAST(m."' + (income ? 'plid' : 'yhid') + '" AS text) = $2']; if (input.status !== undefined && input.status !== '') { values.push(String(number(input.status))); clauses.push('CAST(m."fxzt" AS text) = $' + values.length); } const where = clauses.join(' AND '); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND CAST(m."id" AS text) = CAST(c."itemId" AS text) WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
-  const rows = await Psql.query('SELECT c.*, row_to_json(m) AS "__addon", d."learned", d."dqrq", dc."createTime" AS "studyCommonCreateTime", dc."createdAt" AS "studyCommonCreatedAt", d."createdAt" AS "studyRecordCreatedAt", d."updatedAt" AS "studyRecordUpdatedAt", n."nodeName" AS "kc_title" FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND CAST(m."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "CommonModel" dc ON dc."company" = $1 AND CAST(dc."modelId" AS text) = \'56\' AND CAST(dc."generalId" AS text) = CAST(m."xxjlid" AS text) LEFT JOIN "DailyStudyRecord" d ON d."company" = $1 AND CAST(d."id" AS text) = CAST(dc."itemId" AS text) LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(m."kcid" AS text) WHERE ' + where + ' ORDER BY m."kywsj" DESC, c."updatedAt" DESC LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues); const result = rows.map((source) => { const addon = source.__addon || {}; delete source.__addon; const row = legacyAliases({ ...source, ...addon }, 'CommonModel'); row.learned_date = learnedDate(row); row.learned_at = learnedAt(row); delete row.studyCommonCreateTime; delete row.studyCommonCreatedAt; delete row.studyRecordCreatedAt; delete row.studyRecordUpdatedAt; if (income) { row.money = 0; row.incomeRuleUnavailable = true; } return row; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
+  const uid = await authorizeRequestedUser(current, input.uid); const paging = largePageInput(input); const income = number(input.shouru) === 1; const values = [DEFAULT_COMPANY_ID, String(uid)]; const clauses = ['c."company" = $1', 'c."modelId" = 60', 'CAST(m."' + (income ? 'plid' : 'yhid') + '" AS text) = $2']; if (input.status !== undefined && input.status !== '') { values.push(String(number(input.status))); clauses.push('CAST(m."fxzt" AS text) = $' + values.length); } const where = clauses.join(' AND '); const countPromise = Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND m."id" = c."itemId" WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
+  const rowPromise = Psql.query('SELECT c.*, row_to_json(m) AS "__addon", d."learned", d."dqrq", dc."createTime" AS "studyCommonCreateTime", dc."createdAt" AS "studyCommonCreatedAt", d."createdAt" AS "studyRecordCreatedAt", d."updatedAt" AS "studyRecordUpdatedAt", n."nodeName" AS "kc_title" FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND m."id" = c."itemId" LEFT JOIN "CommonModel" dc ON dc."company" = $1 AND dc."modelId" = 56 AND CAST(dc."generalId" AS text) = CAST(m."xxjlid" AS text) LEFT JOIN "DailyStudyRecord" d ON d."company" = $1 AND d."id" = dc."itemId" LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(m."kcid" AS text) WHERE ' + where + ' ORDER BY m."kywsj" DESC, c."updatedAt" DESC LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues); const [countRow, rows] = await Promise.all([countPromise, rowPromise]); const result = rows.map((source) => { const addon = source.__addon || {}; delete source.__addon; const row = legacyAliases({ ...source, ...addon }, 'CommonModel'); row.learned_date = learnedDate(row); row.learned_at = learnedAt(row); delete row.studyCommonCreateTime; delete row.studyCommonCreatedAt; delete row.studyRecordCreatedAt; delete row.studyRecordUpdatedAt; if (income) { row.money = 0; row.incomeRuleUnavailable = true; } return row; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
 }
 async function memoryCompletedCount(input,current){
   const uid=await authorizeRequestedUser(current,input.uid);

+ 20 - 3
scripts/ensure-app-performance-indexes.mjs

@@ -31,17 +31,22 @@ const statements = [
   'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_item" ON "CommonModel" ("company","modelId","itemId")',
   'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_general" ON "CommonModel" ("company","modelId","generalId")',
   'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_node" ON "CommonModel" ("company","modelId","nodeId")',
+  'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_node_order" ON "CommonModel" ("company","modelId","nodeId","orderId","generalId")',
   'CREATE INDEX IF NOT EXISTS "idx_daily_company_user_date" ON "DailyStudyRecord" ("company","userId","dqrq")',
   'CREATE INDEX IF NOT EXISTS "idx_daily_company_id" ON "DailyStudyRecord" ("company","id")',
   'CREATE INDEX IF NOT EXISTS "idx_appt_company_student_time" ON "CourseAppointment" ("company","szyh","yysj")',
   'CREATE INDEX IF NOT EXISTS "idx_appt_company_coach_time" ON "CourseAppointment" ("company","pl","yysj")',
+  'CREATE INDEX IF NOT EXISTS "idx_appt_company_student_effective_date" ON "CourseAppointment" ("company","szyh",(REPLACE(LEFT(COALESCE(NULLIF("bxrq",\'\'),"yysj"),10),\'-\',\'\')))',
+  'CREATE INDEX IF NOT EXISTS "idx_appt_company_coach_effective_date" ON "CourseAppointment" ("company","pl",(REPLACE(LEFT(COALESCE(NULLIF("bxrq",\'\'),"yysj"),10),\'-\',\'\')))',
   'CREATE INDEX IF NOT EXISTS "idx_appt_company_id" ON "CourseAppointment" ("company","id")',
   'CREATE INDEX IF NOT EXISTS "idx_binding_company_user_id" ON "CourseBinding" ("company","yhid","id")',
   'CREATE INDEX IF NOT EXISTS "idx_memory_company_user_time" ON "MemoryPracticeRecord" ("company","yhid","kywsj")',
   'CREATE INDEX IF NOT EXISTS "idx_memory_company_coach_status" ON "MemoryPracticeRecord" ("company","plid","fxzt")',
   'CREATE INDEX IF NOT EXISTS "idx_memory_company_id" ON "MemoryPracticeRecord" ("company","id")',
+  'CREATE INDEX IF NOT EXISTS "idx_practice_company_user_word_updated" ON "PracticeRecord" ("company",(CAST("yhid" AS text)),(CAST("scid" AS text)),"updatedAt" DESC)',
   'CREATE INDEX IF NOT EXISTS "idx_lesson_company_coach_time" ON "LessonRecord" ("company","jsmz","lessonAt")',
   'CREATE INDEX IF NOT EXISTS "idx_node_company_node" ON "Node" ("company","nodeId")',
+  'CREATE INDEX IF NOT EXISTS "idx_vocab_company_id" ON "VocabularyWord" ("company","id")',
 ];
 const indexNames = statements.map((statement) => statement.match(/"([^"]+)"/)?.[1] || '');
 
@@ -54,9 +59,21 @@ async function handler(request, response) {
     const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
     if (current.get('adminRoleKey') !== 'super-admin' && !roles.includes('super-admin')) return response.status(403).json({ success:false, message:'仅超级管理员可执行' });
     const statements = ${JSON.stringify(statements)};
-    for (const statement of statements) await Psql.none(statement);
+    const applied = [], skipped = [];
+    for (const statement of statements) {
+      try {
+        await Psql.transaction(async (tx) => {
+          await tx.none("SET LOCAL lock_timeout = '3s'");
+          await tx.none("SET LOCAL statement_timeout = '60s'");
+          await tx.none(statement);
+        });
+        applied.push(statement.match(/"([^"]+)"/)?.[1] || statement);
+      } catch (error) {
+        skipped.push({ index: statement.match(/"([^"]+)"/)?.[1] || statement, reason: String(error.message || error) });
+      }
+    }
     const indexes = await Psql.query('SELECT indexname FROM pg_indexes WHERE schemaname=current_schema() AND indexname=ANY($1::text[]) ORDER BY indexname', [${JSON.stringify(indexNames)}]);
-    response.json({ success:true, data:{ applied:statements.length, indexes:indexes.map((row) => row.indexname) } });
+    response.json({ success:true, data:{ applied, skipped, indexes:indexes.map((row) => row.indexname) } });
   } catch (error) {
     response.status(Number(error.status)||500).json({ success:false, message:String(error.message||error) });
   }
@@ -76,7 +93,7 @@ try {
   const response = await fetch(`${FUNCTION_URL}/${path}`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, params: {} }) });
   const payload = await response.json().catch(() => ({}));
   if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `索引维护失败:${response.status}`);
-  console.log(JSON.stringify({ ensured: true, applied: payload.data?.applied || 0, indexes: payload.data?.indexes || [] }, null, 2));
+  console.log(JSON.stringify({ ensured: true, applied: payload.data?.applied || [], skipped: payload.data?.skipped || [], indexes: payload.data?.indexes || [] }, null, 2));
 } finally {
   if (functionId) await parse(`/classes/Function/${functionId}`, { method: 'DELETE' }).catch(() => undefined);
   if (userId) {

+ 82 - 0
scripts/smoke-app-performance-read.mjs

@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+
+import { randomBytes } from 'node:crypto';
+
+const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
+const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
+const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
+const FUNCTION_URL = PARSE_URL.replace(/\/parse$/, '/api/functions/xiaoshu/app/gateway');
+const SAMPLE_USER_ID = Number(process.env.XIAOSHU_SMOKE_USER_ID || 1845);
+if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
+
+const appHeaders = { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' };
+const masterHeaders = { ...appHeaders, 'X-Parse-Master-Key': MASTER_KEY };
+let userId = '';
+
+async function json(url, init = {}) {
+  const response = await fetch(url, init);
+  const payload = await response.json().catch(() => ({}));
+  if (!response.ok || payload.error || payload.retcode === -1) throw new Error(payload.error || payload.retmsg || `HTTP ${response.status}`);
+  return payload;
+}
+
+async function master(path, init = {}) {
+  return json(`${PARSE_URL}${path}`, { ...init, headers: { ...masterHeaders, ...(init.headers || {}) } });
+}
+
+async function timedCloud(token, action, params) {
+  const startedAt = performance.now();
+  const payload = await json(FUNCTION_URL, {
+    method: 'POST',
+    headers: appHeaders,
+    body: JSON.stringify({ token, params: { action, uid: SAMPLE_USER_ID, ...params } }),
+    signal: AbortSignal.timeout(30_000),
+  });
+  const result = payload.result;
+  const summary = {
+    action,
+    durationMs: Math.round(performance.now() - startedAt),
+    rows: Array.isArray(result) ? result.length : Array.isArray(result?.records) ? result.records.length : Array.isArray(result?.schedules) ? result.schedules.length : undefined,
+  };
+  console.log(JSON.stringify(summary));
+  return summary;
+}
+
+try {
+  const company = (await master('/classes/Company?limit=1&keys=objectId')).results?.[0];
+  if (!company?.objectId) throw new Error('生产 Parse 未找到 Company');
+  const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`;
+  const username = `app_read_smoke_${suffix}`;
+  const password = `${randomBytes(24).toString('base64url')}Aa9!`;
+  const created = await master('/users', {
+    method: 'POST',
+    body: JSON.stringify({
+      username,
+      password,
+      isAdmin: true,
+      role: 'admin',
+      roles: ['admin', 'super-admin'],
+      adminRoleKey: 'super-admin',
+      legacyGroupId: 1,
+      legacyUserId: 990000000 + Math.floor(Date.now() / 1000) % 9000000,
+      company: { __type: 'Pointer', className: 'Company', objectId: company.objectId },
+      testCreatedBy: 'smoke-app-performance-read',
+    }),
+  });
+  userId = created.objectId;
+  const login = await json(`${PARSE_URL}/login`, { method: 'POST', headers: appHeaders, body: JSON.stringify({ username, password }) });
+  const token = login.sessionToken;
+  const results = [];
+  results.push(await timedCloud(token, 'app_companion_overview', { mode: 'student', rangeStart: '2026-08-01', rangeEnd: '2026-09-11', limit: 200 }));
+  results.push(await timedCloud(token, 'app_learning_overview', { studentId: SAMPLE_USER_ID, calendarStart: '2026-08-01', calendarEnd: '2026-09-11' }));
+  results.push(await timedCloud(token, 'e_ck_list', { nids: '40', cpage: 1, psize: 100 }));
+  results.push(await timedCloud(token, 'app_account_overview', {}));
+  console.log(JSON.stringify({ sampleUserId: SAMPLE_USER_ID, results }, null, 2));
+} finally {
+  if (userId) {
+    const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }));
+    const sessions = await master(`/classes/_Session?where=${where}&limit=100&keys=objectId`).catch(() => ({ results: [] }));
+    for (const session of sessions.results || []) await master(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined);
+    await master(`/users/${userId}`, { method: 'DELETE' }).catch(() => undefined);
+  }
+}