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

feat: process support requests and deliver replies to submitters

彭峰 5 часов назад
Родитель
Сommit
3fb447aa6c
23 измененных файлов с 514 добавлено и 58 удалено
  1. 36 0
      docs/support-workflow-delivery-2026-10-15.md
  2. 14 1
      projects/xiaoshu-admin/src/app/pages/support-requests.component.html
  3. 2 0
      projects/xiaoshu-admin/src/app/pages/support-requests.component.scss
  4. 14 0
      projects/xiaoshu-admin/src/app/pages/support-requests.component.spec.ts
  5. 23 6
      projects/xiaoshu-admin/src/app/pages/support-requests.component.ts
  6. 1 1
      projects/xiaoshu-mobile/src/app/app.routes.ts
  7. 1 0
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.html
  8. 2 1
      projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts
  9. 31 0
      projects/xiaoshu-mobile/src/app/features/support/support-center.component.html
  10. 0 0
      projects/xiaoshu-mobile/src/app/features/support/support-center.component.scss
  11. 31 0
      projects/xiaoshu-mobile/src/app/features/support/support-center.component.spec.ts
  12. 49 0
      projects/xiaoshu-mobile/src/app/features/support/support-center.component.ts
  13. 14 0
      projects/xiaoshu-mobile/src/app/features/support/support-reply-notice.component.ts
  14. 19 6
      scripts/check-support-requests-browser.mjs
  15. 26 0
      scripts/cloud/support-requests-app.js
  16. 18 4
      scripts/cloud/support-requests.js
  17. 1 0
      scripts/deploy-admin-functions.mjs
  18. 5 36
      scripts/deploy-support-requests.mjs
  19. 73 0
      scripts/deploy-support-workflow.mjs
  20. 66 0
      scripts/smoke-support-workflow.mjs
  21. 32 0
      scripts/sql/support-requests.sql
  22. 15 0
      scripts/tests/support-requests-release.test.mjs
  23. 41 3
      scripts/tests/support-requests.test.mjs

+ 36 - 0
docs/support-workflow-delivery-2026-10-15.md

@@ -0,0 +1,36 @@
+# 意见反馈与休假申请处理闭环
+
+## 使用方式
+
+管理后台 → 记录管理 → 反馈与休假申请:
+- 意见反馈:填写处理意见,标记已处理并发送回复。
+- 休假申请:选择同意或拒绝,填写处理意见后确认发送。
+- 可按待处理、已处理、已同意、已拒绝筛选;记录显示处理人、处理时间及提交人是否已读。
+
+学生和老师的个人中心会显示新回复提示,点击进入“我的记录”;也可从原来的反馈入口查看提交记录和处理意见。提示在页面显示期间每 30 秒检查一次,重新进入或切回页面时刷新。此功能是应用内回复,不包含短信或微信模板消息。
+
+历史未区分类型的提交仍归为“历史未分类”,可按反馈处理。已经处理的结果不可覆盖;重复发送同一结果返回原记录。休假审批只记录决定与处理意见,不自动改动排课。
+
+## 数据与权限
+
+原始提交仍保存在 Guestbook,处理结果保存在新增的 SupportRequestResolution。结果含公司、申请编号、原提交人编号、状态、处理意见、处理人、处理时间及已读时间。公司与申请编号唯一;数据库锁保证并发审批只能产生一个结果。处理前比较原申请内容指纹,避免对过期内容审批。
+
+管理员或运营人员可处理,审计账号仅查看。移动端只按认证账号归属查询与标记已读,不接受客户端替换用户编号。
+
+## 发布与验证
+
+2026-10-15 已完成:
+- `npm run build`:移动端与管理端构建成功,现有样式预算和 CommonJS 警告不影响构建。
+- 后端与发布脚本 12 项测试;管理端 6 项、移动端 5 项针对性测试通过。
+- 浏览器验收覆盖后台反馈处理、同意和拒绝休假、筛选/分页/错误恢复、学生和老师个人中心提示与回复查看、纯文本输出,以及 1440/768/390 宽度。
+- 本地和线上动作覆盖检查通过,新增回复查询及已读接口已纳入能力清单。
+- 新增数据库结构和两端云函数已按线上基线局部发布并回读。实际管理员列表查询成功。
+- 13:36 完成线上临时学生、老师账号的提交、处理、本人回复查询、越权拒绝、重试幂等和已读回执验证。临时记录、账号、会话、函数已清理;真实申请处理结果未改动。
+- 本地 4200/4300 页面均返回 HTTP 200。浏览器界面检查使用本地构建;线上验证为真实 API 链路,未声称已经发布独立公网静态站点或完成手机微信实机验收。
+
+线上发布命令为 `node scripts/deploy-support-workflow.mjs --commit`,依赖受信任的部署环境变量。脚本备份当前两个网关,只替换反馈模块,检查同步任务空闲与并行发布冲突。旧入口 `deploy-support-requests.mjs` 已转到完整工作流发布,避免遗漏新表及移动端接口。
+
+首次功能发布备份:`dist/support-workflow-release/2026-10-15T05-26-54-941Z/`。
+能力清单补齐后的最终发布备份:`dist/support-workflow-release/2026-10-15T05-38-25-096Z/`。
+浏览器结果:`tmp/support-requests-qa/result.json`。
+线上验收结果:`tmp/support-workflow/live-result.json`。

+ 14 - 1
projects/xiaoshu-admin/src/app/pages/support-requests.component.html

@@ -6,12 +6,14 @@
   <label class="search-field">搜索<input name="search" [(ngModel)]="search" maxlength="200" placeholder="姓名、用户编号或内容" /></label>
   <label>提交身份<select aria-label="提交身份" name="role" [(ngModel)]="role"><option value="">全部身份</option><option value="student">学生</option><option value="teacher">老师</option><option value="other">其他 / 未匹配</option></select></label>
   <label>类型<select aria-label="类型" name="kind" [(ngModel)]="kind"><option value="">全部类型</option><option value="feedback">意见反馈</option><option value="leave">休假申请</option><option value="legacy">历史未分类</option></select></label>
+  <label>处理状态<select aria-label="处理状态" name="status" [(ngModel)]="status"><option value="">全部状态</option><option value="pending">待处理</option><option value="processed">已处理</option><option value="approved">已同意</option><option value="rejected">已拒绝</option></select></label>
   <label>开始日期<input name="dateFrom" type="date" [(ngModel)]="dateFrom" /></label>
   <label>结束日期<input name="dateTo" type="date" [(ngModel)]="dateTo" /></label>
   <div class="filter-actions"><button class="ui-button primary" type="submit" [disabled]="loading()">查询</button><button class="ui-button" type="button" (click)="reset()" [disabled]="loading()">重置</button></div>
 </form>
 @if (loading()) { <p class="notice" role="status">正在读取反馈与申请…</p> }
 @if (error()) { <div class="notice error" role="alert">{{error()}} <button class="ui-button" type="button" (click)="load()" [disabled]="loading()">重新加载</button></div> }
+@if (message()) { <p class="notice" role="status">{{message()}}</p> }
 @if (data(); as d) {
   <section class="summary-grid" aria-label="当前筛选统计">
     <article class="summary-card"><span>当前筛选 · 共计</span><strong>{{d.total}}</strong></article>
@@ -24,10 +26,21 @@
     <header><strong>提交记录</strong><small>更新于 {{d.refreshedAt | date:'MM-dd HH:mm':'+0800'}} · 北京时间</small></header>
     @for (row of d.items; track row.id) {
       <article class="request-row">
-        <div class="request-meta"><span class="request-kind" [class.leave]="row.kind==='leave'">{{kindLabel(row.kind)}}</span><span>{{roleLabel(row.role)}}</span><time>{{row.submittedAt | date:'yyyy-MM-dd HH:mm':'+0800'}}</time></div>
+        <div class="request-meta"><span class="request-kind" [class.leave]="row.kind==='leave'">{{kindLabel(row.kind)}}</span><span>{{roleLabel(row.role)}}</span><strong class="request-status">{{statusLabel(row.status)}}</strong><time>{{row.submittedAt | date:'yyyy-MM-dd HH:mm':'+0800'}}</time></div>
         <h2>{{row.title || '未填写标题'}}</h2>
         <p class="account">提交账号:{{row.accountName}} · 用户编号 {{row.userId || '未记录'}}</p>
         <p class="request-content">{{row.content || '未填写内容'}}</p>
+        @if (row.status && row.status !== 'pending') {
+          <section class="resolution"><strong>处理意见 · {{statusLabel(row.status)}}</strong><p>{{row.response}}</p><small>{{row.actorName || '管理员'}} · {{row.handledAt | date:'yyyy-MM-dd HH:mm':'+0800'}} · {{row.readAt ? '提交人已读' : '提交人未读'}}</small></section>
+        } @else if (d.canProcess) {
+          @if (selected()?.id === row.id) {
+            <form class="process-form" (ngSubmit)="process()">
+              @if (row.kind === 'leave') { <label>处理结果<select name="decision" aria-label="处理结果" [(ngModel)]="decision" [disabled]="saving()" required><option value="">请选择</option><option value="approved">同意休假</option><option value="rejected">拒绝休假</option></select></label> }
+              <label>处理意见(提交人可见)<textarea name="comment" [(ngModel)]="comment" rows="3" maxlength="1000" required [disabled]="saving()" placeholder="请填写处理意见"></textarea></label>
+              <div><button class="ui-button primary" type="submit" [disabled]="saving() || !decision || !comment.trim()">{{saving() ? '正在发送…' : decision === 'approved' ? '确认同意并发送回复' : decision === 'rejected' ? '确认拒绝并发送回复' : '标记已处理并发送回复'}}</button><button class="ui-button" type="button" (click)="selected.set(null)" [disabled]="saving()">取消</button></div>
+            </form>
+          } @else { <button class="ui-button handle-button" type="button" (click)="open(row)" [disabled]="saving()">{{row.kind === 'leave' ? '处理申请' : '处理反馈'}}</button> }
+        }
       </article>
     } @empty { <div class="empty-state"><strong>暂无符合条件的反馈或申请</strong><p>可以调整筛选条件,或稍后刷新查看新提交。</p></div> }
     <footer class="request-pagination"><span>共 {{d.total}} 条 · 第 {{d.page}} / {{Math.ceil(d.total / d.pageSize) || 1}} 页</span><div><button class="ui-button" type="button" (click)="load(d.page-1)" [disabled]="loading() || d.page<=1">上一页</button><button class="ui-button" type="button" (click)="load(d.page+1)" [disabled]="loading() || d.page*d.pageSize>=d.total">下一页</button></div></footer>

+ 2 - 0
projects/xiaoshu-admin/src/app/pages/support-requests.component.scss

@@ -1 +1,3 @@
 :host{display:block}.page-header p{letter-spacing:normal;line-height:1.6;color:#62716c}.request-filters{flex-wrap:wrap}.request-filters label{flex:1 1 140px;min-width:0}.request-filters .search-field{flex:2 1 230px}.request-filters input,.request-filters select{width:100%;box-sizing:border-box;min-width:0}.filter-actions{display:flex;gap:8px}.history-note{font-size:13px;color:#62716c;line-height:1.8;margin:0 0 16px}.request-list>header{gap:12px;flex-wrap:wrap}.request-list small{color:#62716c}.request-row{padding:22px 24px;border-bottom:1px solid #e2e9e6}.request-meta{display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-size:13px;color:#62716c}.request-meta time{margin-left:auto;font-variant-numeric:tabular-nums}.request-kind{padding:4px 10px;border-radius:6px;background:#edf3f0;color:#315e50;font-weight:700}.request-kind.leave{background:#fff2dd;color:#88500f}.request-row h2{font-size:17px;margin:14px 0 6px;overflow-wrap:anywhere}.account{font-size:13px;color:#62716c;margin:0 0 14px;overflow-wrap:anywhere}.request-content{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.85;font-size:15px;margin:0;max-height:28rem;overflow:auto}.request-pagination{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px;flex-wrap:wrap;font-size:13px}.request-pagination div{display:flex;gap:8px}.empty-state{padding:48px 24px;text-align:center;color:#62716c}.empty-state strong{color:#25342f}button{cursor:pointer}.ui-button{min-height:44px}input:focus-visible,select:focus-visible,button:focus-visible{outline:2px solid #176c55;outline-offset:3px}@media(max-width:700px){.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.request-row{padding:18px 16px}.request-meta time{margin-left:0;flex-basis:100%}.request-filters label{flex-basis:100%}.page-header{flex-wrap:wrap}.request-pagination{align-items:flex-start;flex-direction:column}}
+
+.request-status{color:#315e50}.resolution{background:#f1f7f4;border-left:3px solid #176c55;border-radius:8px;margin-top:18px;padding:16px}.resolution p{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.8}.resolution small{color:#62716c}.handle-button{margin-top:16px}.process-form{display:grid;gap:12px;margin-top:18px;padding:16px;background:#f7faf9;border:1px solid #d7e1dd;border-radius:12px}.process-form label{display:grid;gap:8px;font-size:14px}.process-form textarea,.process-form select{padding:12px;border:1px solid #ccd9d3;border-radius:8px;font:inherit;color:inherit;background:white;max-width:100%;box-sizing:border-box}.process-form>div{display:flex;gap:8px;flex-wrap:wrap}.process-form textarea{resize:vertical}

+ 14 - 0
projects/xiaoshu-admin/src/app/pages/support-requests.component.spec.ts

@@ -33,4 +33,18 @@ describe('反馈与休假申请看板', () => {
     const c = create(); c.dateFrom = '2026-10-20'; c.dateTo = '2026-10-15'; c.apply();
     expect(ops.request).not.toHaveBeenCalled(); expect(c.error()).toContain('开始日期');
   });
+  it('requires a leave decision and reply, preserves draft on failure, then refreshes after success',async()=>{
+    const c=create();const row={id:'leave',kind:'leave',version:'v1'} as any;c.open(row);
+    await c.process();expect(ops.request).not.toHaveBeenCalled();
+    c.decision='approved';c.comment='  同意,请完成交接  ';ops.request.and.rejectWith(Error('保存失败'));await c.process();
+    expect(c.selected()?.id).toBe('leave');expect(c.comment).toContain('完成交接');expect(c.error()).toBe('保存失败');
+    ops.request.and.resolveTo(data());await c.process();expect(c.selected()).toBeNull();expect(c.message()).toContain('处理结果已发送');
+    expect(ops.request).toHaveBeenCalledWith('ops/support-requests/process',{targetId:'leave',status:'approved',comment:'同意,请完成交接',version:'v1'});
+  });
+  it('deduplicates a pending processing request',async()=>{
+    const c=create();c.open({id:'feedback',kind:'feedback',version:'v2'} as any);c.comment='已处理';let done!:(v:unknown)=>void;
+    ops.request.and.returnValue(new Promise(r=>done=r));const first=c.process();await c.process();expect(ops.request).toHaveBeenCalledTimes(1);
+    ops.request.and.resolveTo(data());done({});await first;expect(c.saving()).toBeFalse();
+  });
+
 });

+ 23 - 6
projects/xiaoshu-admin/src/app/pages/support-requests.component.ts

@@ -5,10 +5,10 @@ import { OperationsService } from '../operations.service';
 
 interface SupportRequest {
   id: string; userId: string; accountName: string; title: string; content: string;
-  submittedAt: string; kind: string; role: string;
+  submittedAt: string; kind: string; role: string; status: string; response: string; handledAt: string; actorName: string; readAt: string; version: string;
 }
 interface SupportPage {
-  items: SupportRequest[]; total: number; page: number; pageSize: number;
+  canProcess: boolean; items: SupportRequest[]; total: number; page: number; pageSize: number;
   summary: { feedback: number; leave: number; legacy: number }; refreshedAt: string;
 }
 @Component({
@@ -23,19 +23,36 @@ export class SupportRequestsComponent implements OnInit {
   readonly data = signal<SupportPage | null>(null);
   readonly loading = signal(false);
   readonly error = signal('');
-  search = ''; kind = ''; role = ''; dateFrom = ''; dateTo = '';
-  private applied = {search: '', kind: '', role: '', dateFrom: '', dateTo: ''};
+  readonly selected = signal<SupportRequest | null>(null);
+  readonly saving = signal(false);
+  readonly message = signal('');
+  decision = ''; comment = '';
+  search = ''; kind = ''; role = ''; status = ''; dateFrom = ''; dateTo = '';
+  private applied = {search: '', kind: '', role: '', status: '', dateFrom: '', dateTo: ''};
   private revision = 0;
   private requestedPage = 1;
   ngOnInit(): void { void this.load(); }
   kindLabel(kind: string): string { return ({feedback: '意见反馈', leave: '休假申请', legacy: '历史未分类'} as Record<string,string>)[kind] || '未分类'; }
   roleLabel(role: string): string { return ({student: '学生', teacher: '老师', other: '其他 / 未匹配'} as Record<string,string>)[role] || '未匹配'; }
+  statusLabel(status: string): string { return ({pending:'待处理',processed:'已处理',approved:'已同意',rejected:'已拒绝'} as Record<string,string>)[status] || '待处理'; }
+  open(row: SupportRequest): void { this.selected.set(row); this.decision = row.kind === 'leave' ? '' : 'processed'; this.comment = ''; this.error.set(''); this.message.set(''); }
+  async process(): Promise<void> {
+    const row = this.selected();
+    if (!row || this.saving() || !this.decision || !this.comment.trim()) return;
+    this.saving.set(true); this.error.set('');
+    try {
+      await this.ops.request('ops/support-requests/process', {targetId: row.id, status: this.decision, comment: this.comment.trim(), version: row.version});
+      this.selected.set(null); this.message.set('处理结果已发送,提交人可在“我的记录”中查看回复。');
+      await this.load();
+    } catch (error) { this.error.set(error instanceof Error ? error.message : '处理失败,请核对后重试'); }
+    finally { this.saving.set(false); }
+  }
   apply(): void {
     if (this.dateFrom && this.dateTo && this.dateFrom > this.dateTo) { this.error.set('开始日期不能晚于结束日期'); return; }
-    this.applied = {search: this.search.trim(), kind: this.kind, role: this.role, dateFrom: this.dateFrom, dateTo: this.dateTo};
+    this.applied = {search: this.search.trim(), kind: this.kind, role: this.role, status: this.status, dateFrom: this.dateFrom, dateTo: this.dateTo};
     void this.load(1);
   }
-  reset(): void { this.search = this.kind = this.role = this.dateFrom = this.dateTo = ''; this.apply(); }
+  reset(): void { this.search = this.kind = this.role = this.status = this.dateFrom = this.dateTo = ''; this.apply(); }
   async load(page = this.requestedPage): Promise<void> {
     this.requestedPage = page;
     const revision = ++this.revision;

+ 1 - 1
projects/xiaoshu-mobile/src/app/app.routes.ts

@@ -40,7 +40,7 @@ const components: Record<PageKind, ComponentLoader> = {
 
 const migratedRoutes: Routes = PAGE_CATALOG.map((meta): Route => ({
   path: meta.path,
-  loadComponent: meta.path === 'pages/member/acc_destory' ? () => import('./features/auth/deletion-page.component').then(m=>m.DeletionPageComponent) : meta.path === 'pages/member/passwd' ? () => import('./features/auth/password-page.component').then(m=>m.PasswordPageComponent) : unavailableReason(meta) ? () => import('./features/resource/unavailable-page.component').then(m=>m.UnavailablePageComponent) : meta.path === 'pages/shop/baobiao' ? () => import('./features/appointment/lesson-report.component').then(m=>m.LessonReportComponent) : components[meta.kind],
+  loadComponent: meta.path === 'pages/member/feedback' ? () => import('./features/support/support-center.component').then(m=>m.SupportCenterComponent) : meta.path === 'pages/member/acc_destory' ? () => import('./features/auth/deletion-page.component').then(m=>m.DeletionPageComponent) : meta.path === 'pages/member/passwd' ? () => import('./features/auth/password-page.component').then(m=>m.PasswordPageComponent) : unavailableReason(meta) ? () => import('./features/resource/unavailable-page.component').then(m=>m.UnavailablePageComponent) : meta.path === 'pages/shop/baobiao' ? () => import('./features/appointment/lesson-report.component').then(m=>m.LessonReportComponent) : components[meta.kind],
   canDeactivate: meta.path === 'pages/stu/record' ? [canLeaveReadingExercise] : undefined,
   canActivate: meta.auth ? [authGuard] : undefined,
   data: { meta },

+ 1 - 0
projects/xiaoshu-mobile/src/app/features/home/account-page.component.html

@@ -3,6 +3,7 @@
   <div class="profile-copy"><strong>{{ session.user()?.honeyName || session.user()?.userName || '匿名用户' }}</strong><span>{{ session.isLoggedIn() ? '个人信息设置' : '登录后同步学习进度' }}</span></div>
   @if (session.isLoggedIn()) { <a routerLink="/pages/member/settings" aria-label="个人设置" title="个人设置"><lucide-icon [img]="icons.Settings" [size]="21" /></a> } @else { <a class="login-link" routerLink="/pages/member/wxauth">前去登录<lucide-icon [img]="icons.ChevronRight" [size]="17" /></a> }
 </header>
+@if(session.isLoggedIn()){<app-support-reply-notice />}
 
 @if (session.isLoggedIn()) {
   @if (overviewError()) { <section class="account-state error" role="alert"><lucide-icon [img]="icons.RefreshCw" [size]="22" /><span>{{ overviewError() }}</span><button type="button" (click)="retryOverview()">重新加载</button></section> }

+ 2 - 1
projects/xiaoshu-mobile/src/app/features/home/account-page.component.ts

@@ -1,3 +1,4 @@
+import { SupportReplyNoticeComponent } from '../support/support-reply-notice.component';
 import { CommonModule } from '@angular/common';
 import { ChangeDetectionStrategy, Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
 import { Router, RouterLink } from '@angular/router';
@@ -40,7 +41,7 @@ interface AccountOverview {
 @Component({
   selector: 'app-account-page',
   standalone: true,
-  imports: [CommonModule, RouterLink, LucideAngularModule],
+  imports: [SupportReplyNoticeComponent, CommonModule, RouterLink, LucideAngularModule],
   templateUrl: './account-page.component.html',
   styleUrl: './account-page.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush,

+ 31 - 0
projects/xiaoshu-mobile/src/app/features/support/support-center.component.html

@@ -0,0 +1,31 @@
+<nav class="support-tabs" aria-label="反馈与申请"><button type="button" [class.active]="!history()" (click)="history.set(false)">提交反馈 / 申请</button><button type="button" [class.active]="history()" (click)="showHistory()">我的记录 @if(inbox()?.unread){<span class="unread">{{inbox()!.unread}} 条新回复</span>}</button></nav>
+@if(message()){<p class="notice" role="status">{{message()}}</p>}
+@if(error()){<div class="error" role="alert">{{error()}} <button type="button" (click)="load()" [disabled]="loading() || !!reading()">刷新回复</button></div>}
+@if(!history()){
+<form class="request-form" (ngSubmit)="submit()">
+  @if(submitError()){<p class="error" role="alert">{{submitError()}}</p>}
+  <p>管理员处理后,可在“我的记录”查看结果和处理意见。休假申请请写明起止时间和原因。</p>
+  <label for="requestType">提交类型</label><select id="requestType" name="requestType" [(ngModel)]="requestType" [disabled]="sending()"><option value="feedback">意见反馈</option><option value="leave">休假申请</option></select>
+  <label for="name">您的姓名 <span>*</span></label><input id="name" name="name" [(ngModel)]="name" maxlength="80" required [disabled]="sending()" />
+  <label for="content">内容 <span>*</span></label><textarea id="content" name="content" [(ngModel)]="content" rows="5" maxlength="800" required [disabled]="sending()" placeholder="请输入内容"></textarea><small>{{content.length}} / 800</small>
+  <button class="primary" type="submit" [disabled]="sending()">{{sending()?'提交中…':'提交'}}</button>
+</form>
+} @else {
+<section class="history-panel" aria-label="我的反馈记录">
+  <header><h2>我的提交与回复</h2><button type="button" (click)="load()" [disabled]="loading() || !!reading()">刷新</button></header>
+  @if(loading()){<p role="status">正在读取…</p>}
+  @if(inbox(); as d){
+    @for(row of d.items;track row.id){
+      <article class="reply-card"><header><strong [class.pending]="row.status==='pending'">{{statusLabel(row.status)}}</strong>@if(row.unread){<span class="unread">新回复</span>}</header>
+        <h3>{{row.title}}</h3><time>{{row.submittedAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</time><p class="original">{{row.content}}</p>
+        @if(row.status==='pending'){<p class="waiting">已提交,等待管理员处理。</p>}
+        @else{
+          <button type="button" (click)="viewReply(row)" [disabled]="!!reading()">{{expanded()===row.id&&row.unread?'重试标记已读':'查看处理意见'}}</button>
+          @if(expanded()===row.id){<section class="response"><strong>管理员回复 · {{statusLabel(row.status)}}</strong><p>{{row.response}}</p><time>{{row.handledAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</time>@if(readError()){<p class="error" role="alert">{{readError()}}</p>}</section>}
+        }
+      </article>
+    } @empty {@if(!loading()&&!error()){<p class="empty">还没有提交记录。您可以提交意见反馈或休假申请,处理结果会保存在这里。</p>}}
+    @if(d.total>pageSize){<footer><button type="button" (click)="changePage(page-1)" [disabled]="!!reading()||loading()||page<=1">上一页</button><span>{{page}} / {{Math.ceil(d.total/pageSize)}}</span><button type="button" (click)="changePage(page+1)" [disabled]="!!reading()||loading()||page*pageSize>=d.total">下一页</button></footer>}
+  }
+</section>
+}

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


+ 31 - 0
projects/xiaoshu-mobile/src/app/features/support/support-center.component.spec.ts

@@ -0,0 +1,31 @@
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { of, Subject, throwError } from 'rxjs';
+import { ApiService } from '../../core/api.service';
+import { SessionService } from '../../core/session.service';
+import { SupportCenterComponent } from './support-center.component';
+import { SupportReplyNoticeComponent } from './support-reply-notice.component';
+describe('本人反馈与处理回复',()=>{
+ let api:jasmine.SpyObj<ApiService>;
+ let token:string;
+ const inbox={items:[{id:'own',title:'意见反馈',content:'建议',kind:'feedback',submittedAt:'2026-10-15',status:'processed',response:'已处理建议',handledAt:'2026-10-15',unread:true,readAt:''}],total:1,unread:1};
+ const create=()=>TestBed.runInInjectionContext(()=>new SupportCenterComponent());
+ beforeEach(()=>{token='r:student';api=jasmine.createSpyObj('ApiService',['post']);TestBed.configureTestingModule({providers:[{provide:ApiService,useValue:api},{provide:SessionService,useValue:{token:()=>token,user:()=>({userId:7,honeyName:'学生'})}},{provide:ActivatedRoute,useValue:{snapshot:{queryParamMap:convertToParamMap({view:'history'})}}}]});});
+ it('keeps original draft and submission error even when background history refresh succeeds',async()=>{
+  const c=create();c.name='学生';c.content='保留我的反馈';api.post.and.returnValue(throwError(()=>Error('网络中断')));await c.submit();expect(c.content).toBe('保留我的反馈');expect(c.submitError()).toBe('网络中断');
+  api.post.and.returnValue(of({retcode:1,result:inbox}));await c.load(false);expect(c.submitError()).toBe('网络中断');
+ });
+ it('does not mark read on failure; permits retry and decrements unread only once',async()=>{
+  const c=create();c.inbox.set(structuredClone(inbox));api.post.and.returnValue(throwError(()=>Error('保存已读失败')));await c.viewReply(c.inbox()!.items[0]);expect(c.inbox()?.unread).toBe(1);expect(c.expanded()).toBe('own');expect(c.readError()).toContain('失败');
+  api.post.and.returnValue(of({retcode:1,result:{read:true}}));await c.viewReply(c.inbox()!.items[0]);await c.viewReply(c.inbox()!.items[0]);expect(c.inbox()?.unread).toBe(0);expect(api.post).toHaveBeenCalledTimes(2);
+ });
+ it('ignores history responses from a previous account and keeps other user IDs out of read requests',async()=>{
+  const c=create(),late=new Subject<any>();api.post.and.returnValue(late);const pending=c.load();token='r:teacher';late.next({retcode:1,result:inbox});late.complete();await pending;expect(c.inbox()).toBeNull();expect(api.post.calls.mostRecent().args[1]).toEqual({page:1,pageSize:10});
+ });
+ it('does not let an older history response restore a cleared unread badge',async()=>{
+  const c=create();c.inbox.set(structuredClone(inbox));const late=new Subject<any>();api.post.and.returnValue(late);const pending=c.load(false);api.post.and.returnValue(of({retcode:1,result:{read:true}}));await c.viewReply(c.inbox()!.items[0]);late.next({retcode:1,result:inbox});late.complete();await pending;expect(c.inbox()?.unread).toBe(0);
+ });
+ it('account notice updates its count and clears it after returning to read replies',async()=>{
+  const c=TestBed.runInInjectionContext(()=>new SupportReplyNoticeComponent());api.post.and.returnValue(of({retcode:1,result:{unread:2}}));await c.load();expect(c.unread()).toBe(2);api.post.and.returnValue(of({retcode:1,result:{unread:0}}));await c.load();expect(c.unread()).toBe(0);expect(c.error()).toBeFalse();
+ });
+});

+ 49 - 0
projects/xiaoshu-mobile/src/app/features/support/support-center.component.ts

@@ -0,0 +1,49 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { firstValueFrom } from 'rxjs';
+import { ApiService } from '../../core/api.service';
+import { SessionService } from '../../core/session.service';
+import { buildFormSubmission } from '../../core/form-payload';
+import { PAGE_BY_PATH } from '../../core/page-catalog';
+import { DropdownDirective } from '../../shared/dropdown.directive';
+interface Reply {id:string;title:string;content:string;kind:string;submittedAt:string;status:string;response:string;handledAt:string;unread:boolean;readAt:string;}
+interface Inbox {items:Reply[];total:number;unread:number;}
+@Component({selector:'app-support-center',standalone:true,imports:[CommonModule,FormsModule,DropdownDirective],templateUrl:'./support-center.component.html',styleUrl:'./support-center.component.scss',changeDetection:ChangeDetectionStrategy.OnPush})
+export class SupportCenterComponent implements OnInit,OnDestroy {
+  private readonly api=inject(ApiService);
+  private readonly session=inject(SessionService);
+  readonly history=signal(inject(ActivatedRoute).snapshot.queryParamMap.get('view')==='history');
+  readonly inbox=signal<Inbox|null>(null);
+  readonly loading=signal(false);readonly sending=signal(false);readonly error=signal('');readonly submitError=signal('');readonly message=signal('');readonly readError=signal('');readonly reading=signal('');readonly expanded=signal('');
+  name='';content='';requestType='feedback';page=1;readonly pageSize=10;readonly Math=Math;
+  private revision=0;private timer?: ReturnType<typeof setInterval>;
+  private readonly refresh=()=>{if(document.visibilityState==='visible'&&!this.reading()&&!this.sending())void this.load(false);};
+  ngOnInit():void {this.name=this.session.user()?.honeyName||this.session.user()?.userName||'';void this.load();this.timer=setInterval(this.refresh,30000);window.addEventListener('focus',this.refresh);document.addEventListener('visibilitychange',this.refresh);}
+  ngOnDestroy():void {this.revision++;clearInterval(this.timer);window.removeEventListener('focus',this.refresh);document.removeEventListener('visibilitychange',this.refresh);}
+  statusLabel(status:string):string{return ({pending:'待处理',processed:'已处理',approved:'已同意休假',rejected:'未同意休假'} as Record<string,string>)[status]||'待处理';}
+  async load(foreground=true):Promise<void>{
+    const revision=++this.revision,token=this.session.token();
+    if(foreground)this.loading.set(true);
+    try{const {result}=await firstValueFrom(this.api.post<Inbox>('app_support_requests_list',{page:this.page,pageSize:this.pageSize}));if(revision!==this.revision||token!==this.session.token())return;this.inbox.set(result);this.error.set('');}
+    catch(e){if(revision===this.revision&&token===this.session.token())this.error.set(e instanceof Error?e.message:'回复暂时无法加载,请重试');}
+    finally{if(revision===this.revision)this.loading.set(false);}
+  }
+  showHistory():void{this.history.set(true);void this.load();}
+  changePage(page:number):void{this.page=page;this.expanded.set('');void this.load();}
+  async submit():Promise<void>{
+    if(this.sending())return;
+    if(!this.name.trim()||!this.content.trim()){this.submitError.set('请填写姓名和内容');return;}
+    const payload=buildFormSubmission(PAGE_BY_PATH.get('pages/member/feedback')!,{name:this.name,content:this.content.trim(),requestType:this.requestType},{uid:this.session.user()?.userId})!;
+    this.sending.set(true);this.submitError.set('');this.message.set('');
+    try{await firstValueFrom(this.api.post(payload.action,payload.payload));this.content='';this.message.set('提交成功,管理员处理后会在这里回复。');this.history.set(true);this.page=1;await this.load();}
+    catch(e){this.submitError.set(e instanceof Error?e.message:'提交失败,请重试');}finally{this.sending.set(false);}
+  }
+  async viewReply(row:Reply):Promise<void>{
+    this.expanded.set(row.id);this.readError.set('');if(!row.unread||this.reading())return;
+    this.revision++;this.loading.set(false);this.reading.set(row.id);const token=this.session.token();
+    try{await firstValueFrom(this.api.post('app_support_request_read',{id:row.id}));if(token!==this.session.token())return;this.inbox.update(d=>d?{...d,unread:Math.max(0,d.unread-(d.items.some(r=>r.id===row.id&&r.unread)?1:0)),items:d.items.map(r=>r.id===row.id?{...r,unread:false}:r)}:d);}
+    catch(e){this.readError.set(e instanceof Error?e.message:'已读状态未保存,可重试');}finally{this.reading.set('');}
+  }
+}

+ 14 - 0
projects/xiaoshu-mobile/src/app/features/support/support-reply-notice.component.ts

@@ -0,0 +1,14 @@
+import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit, signal } from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { firstValueFrom } from 'rxjs';
+import { ApiService } from '../../core/api.service';
+import { SessionService } from '../../core/session.service';
+@Component({selector:'app-support-reply-notice',standalone:true,imports:[RouterLink],changeDetection:ChangeDetectionStrategy.OnPush,template:`@if(unread()>0){<a class="reply-notice" routerLink="/pages/member/feedback" [queryParams]="{view:'history'}"><strong>您有 {{unread()}} 条新的处理回复</strong><span>意见反馈 / 休假申请 · 点击查看</span></a>}@if(error()){<a class="reply-error" routerLink="/pages/member/feedback" [queryParams]="{view:'history'}">反馈回复暂未更新,点击查看</a>}`,styles:[`.reply-notice{display:grid;gap:6px;margin:14px 0;padding:16px;border:1px solid #b9d9cc;border-radius:12px;color:#195e4c;background:#eaf7f0;text-decoration:none;line-height:1.6}.reply-notice strong{font-size:15px}.reply-notice span,.reply-error{font-size:13px}.reply-error{display:block;padding:12px;color:#8b5130}.reply-notice:focus-visible{outline:2px solid #207965;outline-offset:3px}`]})
+export class SupportReplyNoticeComponent implements OnInit,OnDestroy {
+  private readonly api=inject(ApiService);private readonly session=inject(SessionService);
+  readonly unread=signal(0);readonly error=signal(false);private timer?:ReturnType<typeof setInterval>;private revision=0;
+  private readonly refresh=()=>{if(document.visibilityState==='visible')void this.load();};
+  ngOnInit():void{void this.load();this.timer=setInterval(this.refresh,30000);window.addEventListener('focus',this.refresh);document.addEventListener('visibilitychange',this.refresh);}
+  ngOnDestroy():void{this.revision++;clearInterval(this.timer);window.removeEventListener('focus',this.refresh);document.removeEventListener('visibilitychange',this.refresh);}
+  async load():Promise<void>{const token=this.session.token(),revision=++this.revision;if(!token){this.unread.set(0);return;}try{const {result}=await firstValueFrom(this.api.post<{unread:number}>('app_support_requests_list',{page:1,pageSize:1}));if(revision===this.revision&&token===this.session.token()){this.unread.set(result.unread);this.error.set(false);}}catch{if(revision===this.revision){this.error.set(true);}}}
+}

+ 19 - 6
scripts/check-support-requests-browser.mjs

@@ -13,17 +13,18 @@ async function serve(project){
 const admin=await serve('xiaoshu-admin'),mobile=await serve('xiaoshu-mobile');
 const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
 const errors=[],checks=[],calls=[];let fail=false;
-const records=Array.from({length:25},(_,i)=>({id:'request'+i,userId:String(i%2+1),accountName:i%2?'张老师':'李同学',title:(i%3===0?'【休假申请】':i%3===1?'【意见反馈】':'历史提交 ')+(i%2?'张老师':'李同学'),content:i===0?'10月16日至17日因事申请休假。\n请管理员查看并协调安排。':i===1?'<script>window.injected=true</script>\n希望优化课程提醒。':'希望调整上课时间,感谢老师的帮助。',submittedAt:'2026-10-15T04:30:00Z',kind:['leave','feedback','legacy'][i%3],role:i%2?'teacher':'student'}));
+const records=Array.from({length:25},(_,i)=>({id:'request'+i,userId:String(i%2+1),accountName:i%2?'张老师':'李同学',title:(i%3===0?'【休假申请】':i%3===1?'【意见反馈】':'历史提交 ')+(i%2?'张老师':'李同学'),content:i===0?'10月16日至17日因事申请休假。\n请管理员查看并协调安排。':i===1?'<script>window.injected=true</script>\n希望优化课程提醒。':'希望调整上课时间,感谢老师的帮助。',submittedAt:'2026-10-15T04:30:00Z',kind:['leave','feedback','legacy'][i%3],role:i%2?'teacher':'student',status:'pending',response:'',version:'fixture-version',unread:false}));
 try{
  const context=await browser.newContext({viewport:{width:1440,height:1000}});
  await context.addInitScript(()=>{const auth={objectId:'fixture-admin',sessionToken:'fixture-only',username:'验收管理员',operationsRole:'ops-manager'};localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth));});
  await context.route('**/*',async route=>{
   const u=new URL(route.request().url());if(u.origin!==admin.origin)return route.abort();if(!u.pathname.startsWith('/cloud-functions/'))return route.continue();
   const {params:p}=route.request().postDataJSON();calls.push(p);
+  if(p.operation==='ops/support-requests/process'){const row=records.find(r=>r.id===p.targetId);assert.ok(p.comment);assert.equal(p.version,'fixture-version');Object.assign(row,{status:p.status,response:p.comment,handledAt:new Date().toISOString(),actorName:'验收管理员',unread:true});return route.fulfill({json:{success:true,data:row}});}
   if(p.operation!=='ops/support-requests/list')return route.fulfill({json:{success:true,data:{}}});
   if(fail)return route.fulfill({status:500,json:{success:false,message:'模拟读取失败'}});
   const filtered=records.filter(r=>(!p.role||r.role===p.role)&&(!p.kind||r.kind===p.kind)&&(!p.search||(r.title+r.accountName+r.content).includes(p.search)));
-  await route.fulfill({json:{success:true,data:{items:filtered.slice((p.page-1)*20,p.page*20),total:filtered.length,page:p.page,pageSize:20,summary:{feedback:filtered.filter(r=>r.kind==='feedback').length,leave:filtered.filter(r=>r.kind==='leave').length,legacy:filtered.filter(r=>r.kind==='legacy').length},refreshedAt:'2026-10-15T04:45:00Z'}}});
+  await route.fulfill({json:{success:true,data:{canProcess:true,items:filtered.slice((p.page-1)*20,p.page*20),total:filtered.length,page:p.page,pageSize:20,summary:{feedback:filtered.filter(r=>r.kind==='feedback').length,leave:filtered.filter(r=>r.kind==='leave').length,legacy:filtered.filter(r=>r.kind==='legacy').length},refreshedAt:'2026-10-15T04:45:00Z'}}});
  });
  const page=await context.newPage();page.setDefaultTimeout(12000);page.on('pageerror',e=>errors.push(e.message));await page.goto(admin.origin+'/admin/support-requests');await page.locator('.request-row').first().waitFor();
  assert.equal(await page.getByRole('link',{name:'反馈与休假申请',exact:true}).count(),1);
@@ -37,13 +38,25 @@ try{
  fail=true;await page.getByRole('button',{name:'刷新',exact:true}).click();await page.getByRole('alert').filter({hasText:'模拟读取失败'}).waitFor();assert.equal(await page.locator('.request-row').count(),0);
  fail=false;await page.getByRole('button',{name:'重新加载',exact:true}).click();await page.locator('.request-row').first().waitFor();checks.push('空状态、失败不伪装为空记录、重试恢复');
  for(const width of [768,390]){await page.setViewportSize({width,height:900});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));await page.screenshot({path:output+'/admin-'+width+'.png',fullPage:true});}checks.push('1440/768/390宽度布局');
+ for(const [index,decision,comment] of [[0,'approved','已同意休假,请提前完成交接。'],[3,'rejected','当天已有排课,请另行选择时间。'],[1,'processed','提醒设置已调整,感谢反馈。']]){
+  const row=page.locator('.request-row').nth(index);await row.getByRole('button',{name:/处理申请|处理反馈/}).click();
+  if(decision!=='processed')await row.getByLabel('处理结果').selectOption(decision);
+  await row.getByLabel('处理意见(提交人可见)').fill(comment);
+  await row.getByRole('button',{name:/并发送回复/}).click();await page.locator('.request-row').nth(index).locator('.resolution').waitFor();
+  assert.ok((await page.locator('.request-row').nth(index).innerText()).includes(comment));
+ }
+ checks.push('后台处理意见反馈、同意及拒绝休假,提交处理意见并显示未读状态');
+ await page.screenshot({path:output+'/admin-processed.png',fullPage:true});
  for(const role of [{id:1,group:1,name:'李同学',type:'feedback'},{id:2,group:3,name:'张老师',type:'leave'}]){
   const c=await browser.newContext({viewport:{width:390,height:844}});await c.addInitScript(role=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:fixture-only',user:{userId:role.id,groupId:role.group,honeyName:role.name}})),role);
   let submitted;
-  await c.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin!==mobile.origin)return route.abort();if(!u.pathname.startsWith('/cloud-functions/'))return route.continue();const {params:p}=route.request().postDataJSON();if(p.action==='guestbook_add'){submitted=JSON.parse(p.model);return route.fulfill({json:{retcode:1,result:{GID:123}}});}return route.fulfill({json:{retcode:1,result:{valid:true}}});});
-  const p=await c.newPage();p.setDefaultTimeout(12000);p.on('pageerror',e=>errors.push(e.message));await p.goto(mobile.origin+'/pages/member/feedback');await p.locator('#name').fill(role.name);await p.getByRole('combobox',{name:/提交类型/}).click();await p.getByRole('option',{name:role.type==='leave'?'休假申请':'意见反馈',exact:true}).click();await p.locator('#content').fill('浏览器提交验收');await p.screenshot({path:output+'/mobile-'+role.type+'.png',fullPage:true});await p.getByRole('button',{name:'提交',exact:true}).click();await p.getByText('提交成功',{exact:true}).waitFor();
-  assert.equal(submitted.UserID,role.id);assert.equal(submitted.Cateid,22);assert.equal(submitted.Title,`【${role.type==='leave'?'休假申请':'意见反馈'}】${role.name}`);assert.equal(submitted.TContent,'浏览器提交验收');await c.close();
+  await c.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin!==mobile.origin)return route.abort();if(!u.pathname.startsWith('/cloud-functions/'))return route.continue();const {params:p}=route.request().postDataJSON();if(p.action==='app_account_overview')return route.fulfill({json:{retcode:1,result:{profile:{userId:role.id,groupId:role.group,honeyName:role.name},refreshedAt:new Date().toISOString()}}});if(p.action==='app_support_requests_list'){const own=records.filter(r=>Number(r.userId)===role.id);return route.fulfill({json:{retcode:1,result:{items:own.slice(0,10),total:own.length,unread:own.filter(r=>r.unread).length}}});}if(p.action==='app_support_request_read'){const row=records.find(r=>r.id===p.id);assert.equal(Number(row.userId),role.id);row.unread=false;return route.fulfill({json:{retcode:1,result:{read:true}}});}if(p.action==='guestbook_add'){submitted=JSON.parse(p.model);records.unshift({id:'submitted-'+role.id,userId:String(role.id),title:submitted.Title,content:submitted.TContent,kind:role.type,status:'pending',unread:false,submittedAt:new Date().toISOString()});return route.fulfill({json:{retcode:1,result:{GID:123}}});}return route.fulfill({json:{retcode:1,result:{valid:true}}});});
+  const p=await c.newPage();p.setDefaultTimeout(12000);p.on('pageerror',e=>errors.push(e.message));await p.goto(mobile.origin+'/pages/home/ucenter');await p.getByRole('link',{name:/条新的处理回复/}).click();await p.getByRole('heading',{name:'我的提交与回复'}).waitFor();await p.getByRole('button',{name:'提交反馈 / 申请',exact:true}).click();await p.locator('#name').fill(role.name);await p.getByRole('combobox',{name:/提交类型/}).click();await p.getByRole('option',{name:role.type==='leave'?'休假申请':'意见反馈',exact:true}).click();await p.locator('#content').fill('浏览器提交验收');await p.screenshot({path:output+'/mobile-'+role.type+'.png',fullPage:true});await p.getByRole('button',{name:'提交',exact:true}).click();await p.getByText('提交成功,管理员处理后会在这里回复。',{exact:true}).waitFor();
+  assert.equal(submitted.UserID,role.id);assert.equal(submitted.Cateid,22);assert.equal(submitted.Title,`【${role.type==='leave'?'休假申请':'意见反馈'}】${role.name}`);assert.equal(submitted.TContent,'浏览器提交验收');
+  const reply=p.locator('.reply-card').filter({has:p.getByText('新回复',{exact:true})}).first();await reply.waitFor();
+  const ownRecord=records.find(r=>Number(r.userId)===role.id&&r.unread);await reply.getByRole('button',{name:'查看处理意见'}).click();await p.locator('.response').waitFor();assert.ok((await p.locator('.response').innerText()).includes(ownRecord.response));
+  assert.equal(ownRecord.unread,false);assert.equal(await p.evaluate(()=>window.injected),undefined);assert.ok(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));await p.screenshot({path:output+'/mobile-reply-'+role.type+'.png',fullPage:true});await c.close();
  }
- checks.push('学生反馈、老师休假申请:实际表单提交类型、本人编号和正文');
+ checks.push('学生和老师个人中心新回复提示、跳转我的记录、提交、查看本人处理意见及已读,390宽度无溢出');
  assert.deepEqual(errors,[]);writeFileSync(output+'/result.json',JSON.stringify({passed:true,checks,errors},null,2));console.log(JSON.stringify({passed:true,checks,output}));
 }finally{await browser.close();admin.server.close();mobile.server.close();}

+ 26 - 0
scripts/cloud/support-requests-app.js

@@ -0,0 +1,26 @@
+// Ownership always comes from the authenticated account, never a selected student/input uid.
+async function appSupportRequests(input,current) {
+  const company=current.get('company')?.id || current.get('company')?.objectId;
+  const uid=ownLegacyId(current);
+  if(!company || !uid)fail(403,'账号缺少反馈记录归属');
+  if(input.action==='app_support_request_read') {
+    const rows=await Psql.query(`UPDATE "SupportRequestResolution" r SET "readAt"=COALESCE(r."readAt",now())
+      FROM "Guestbook" g WHERE r.company=$1 AND r."requestId"=$2 AND r."ownerId"=$3
+      AND g.company=r.company AND g."objectId"=r."requestId" AND g.userid=$3 AND COALESCE(g.status,0)<>-2 RETURNING r."requestId"`,[company,String(input.id||''),uid]);
+    if(!rows.length)fail(404,'回复不存在或不属于当前账号');
+    return {read:true};
+  }
+  const page=Math.max(1,Math.floor(number(input.page,1))),size=Math.min(50,Math.max(1,Math.floor(number(input.pageSize,10))));
+  return await Psql.one(`WITH owned AS (
+    SELECT g."objectId" AS id,g.title,COALESCE(g.tcontent,'') AS content,COALESCE(g.gdate,g."createdAt") AS "submittedAt",
+      CASE WHEN g.title LIKE '【休假申请】%' THEN 'leave' WHEN g.title LIKE '【意见反馈】%' THEN 'feedback' ELSE 'legacy' END AS kind,
+      COALESCE(r.status,'pending') AS status,COALESCE(r.comment,'') AS response,r."handledAt",r."readAt",
+      (r."requestId" IS NOT NULL AND r."readAt" IS NULL) AS unread
+    FROM "Guestbook" g LEFT JOIN "SupportRequestResolution" r ON r.company=g.company AND r."requestId"=g."objectId" AND r."ownerId"=$2
+    WHERE g.company=$1 AND g.userid=$2 AND COALESCE(g.parentid,0)=0 AND COALESCE(g.status,0)<>-2
+      AND (g.cateid=22 OR g."sourceKey" LIKE 'cloud:guestbook_add:%')
+  ), paged AS (SELECT * FROM owned ORDER BY unread DESC,"submittedAt" DESC NULLS LAST,id DESC LIMIT $3 OFFSET $4)
+  SELECT COUNT(*)::int AS total,COUNT(*) FILTER(WHERE unread)::int AS unread,
+    COALESCE((SELECT jsonb_agg(p ORDER BY p.unread DESC,p."submittedAt" DESC NULLS LAST,p.id DESC) FROM paged p),'[]'::jsonb) AS items
+  FROM owned`,[company,uid,size,(page-1)*size]);
+}

+ 18 - 4
scripts/cloud/support-requests.js

@@ -3,7 +3,8 @@ async function supportRequestsPage(context, input) {
   requireRole(context, ['ops-manager', 'ops-staff', 'ops-auditor']);
   const company = companyIdOf(context, input);
   if (!company) fail(400, '请先选择帐套');
-  const kind = text(input.kind), role = text(input.role), search = text(input.search);
+  const kind = text(input.kind), role = text(input.role), search = text(input.search), status = text(input.status);
+  if (status && !['pending','processed','approved','rejected'].includes(status)) fail(400, '处理状态无效');
   if (kind && !['feedback', 'leave', 'legacy'].includes(kind)) fail(400, '申请类型无效');
   if (role && !['student', 'teacher', 'other'].includes(role)) fail(400, '提交身份无效');
   const from = text(input.dateFrom), to = text(input.dateTo);
@@ -15,6 +16,8 @@ async function supportRequestsPage(context, input) {
   const pageSize = Math.min(100, Math.max(1, Math.floor(number(input.pageSize, 20))));
   const result = await Psql.one(`WITH requests AS (
     SELECT g."objectId" AS id, g.userid::text AS "userId", COALESCE(g.title,'') AS title,
+      COALESCE(r.status,'pending') AS status, COALESCE(r.comment,'') AS response, r."handledAt", r."actorName", r."readAt",
+      md5(COALESCE(g.title,'')||chr(10)||COALESCE(g.tcontent,'')||chr(10)||COALESCE(g.userid::text,'')) AS version,
       COALESCE(g.tcontent,'') AS content, COALESCE(g.gdate,g."createdAt") AS "submittedAt",
       COALESCE(NULLIF(u."realName",''),NULLIF(u.nickname,''),NULLIF(u.username,''),'账号未匹配') AS "accountName",
       CASE WHEN g.title LIKE '【休假申请】%' THEN 'leave'
@@ -23,13 +26,14 @@ async function supportRequestsPage(context, input) {
         WHEN COALESCE(to_jsonb(u)->>'identityType','')='member' OR COALESCE(u."legacyGroupId"::text,u."legacyUserData"->>'GroupID')='1' THEN 'student'
         ELSE 'other' END AS role
     FROM "Guestbook" g
+    LEFT JOIN "SupportRequestResolution" r ON r.company=g.company AND r."requestId"=g."objectId"
     LEFT JOIN LATERAL (SELECT u.* FROM "_User" u WHERE u.company=g.company
       AND COALESCE(u."legacyUserId"::text,u."legacyUserData"->>'UserID')=g.userid::text
       ORDER BY u."createdAt" DESC,u."objectId" LIMIT 1) u ON TRUE
     WHERE g.company=$1 AND COALESCE(g.parentid,0)=0 AND COALESCE(g.status,0)<>-2
       AND (g.cateid=22 OR g."sourceKey" LIKE 'cloud:guestbook_add:%')
   ), filtered AS (
-    SELECT * FROM requests WHERE ($2='' OR kind=$2) AND ($3='' OR role=$3)
+    SELECT * FROM requests WHERE ($9='' OR status=$9) AND ($2='' OR kind=$2) AND ($3='' OR role=$3)
       AND ($4='' OR strpos(lower(title||' '||content||' '||"accountName"||' '||COALESCE("userId",'')),lower($4))>0)
       AND ($5='' OR "submittedAt">=NULLIF($5,'')::date::timestamp AT TIME ZONE 'Asia/Shanghai')
       AND ($6='' OR "submittedAt"<(NULLIF($6,'')::date+1)::timestamp AT TIME ZONE 'Asia/Shanghai')
@@ -39,7 +43,17 @@ async function supportRequestsPage(context, input) {
     COUNT(*) FILTER(WHERE kind='leave')::int AS leave,
     COUNT(*) FILTER(WHERE kind='legacy')::int AS legacy,
     COALESCE((SELECT jsonb_agg(p ORDER BY p."submittedAt" DESC NULLS LAST,p.id DESC) FROM paged p),'[]'::jsonb) AS items
-  FROM filtered`, [company,kind,role,search.slice(0,200),from,to,pageSize,(page-1)*pageSize]);
-  return {items:result.items, total:result.total, page, pageSize,
+  FROM filtered`, [company,kind,role,search.slice(0,200),from,to,pageSize,(page-1)*pageSize,status]);
+  return {canProcess:['ops-manager','ops-staff'].includes(roleOf(context)),items:result.items, total:result.total, page, pageSize,
     summary:{feedback:result.feedback,leave:result.leave,legacy:result.legacy},refreshedAt:new Date().toISOString()};
 }
+
+async function supportRequestProcess(context,input) {
+  requireRole(context,['ops-manager','ops-staff']);
+  const company=companyIdOf(context,input), id=text(input.targetId), status=text(input.status), note=text(input.comment);
+  if(!company || !id) fail(400,'缺少帐套或申请编号');
+  if(!['processed','approved','rejected'].includes(status)||!note||note.length>1000)fail(400,'请填写处理结果和 1 至 1000 字的处理意见');
+  const actorName=text(context.current.get('realName')||context.current.get('nickname')||context.current.get('username')||'管理员');
+  try {return (await Psql.one('SELECT xs_support_request_process($1,$2,$3,$4,$5,$6,$7) AS result',[company,id,context.current.id,actorName,status,note,text(input.version)])).result;}
+  catch(error){if(error.code==='P0001')fail(409,error.message);throw error;}
+}

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


+ 5 - 36
scripts/deploy-support-requests.mjs

@@ -1,7 +1,6 @@
 #!/usr/bin/env node
 // Patch the actual online baseline, never deploy unrelated local gateway edits.
-import {readFileSync,writeFileSync,mkdirSync} from 'node:fs';
-import {createHash} from 'node:crypto';
+import {readFileSync} from 'node:fs';
 import {resolve} from 'node:path';
 import {pathToFileURL} from 'node:url';
 const helper=readFileSync(new URL('./cloud/support-requests.js',import.meta.url),'utf8').trim();
@@ -17,38 +16,8 @@ export function patchSupportRequests(code){
   if(new Function(next+'\nreturn typeof handler;')()!=='function')throw Error('网关语法校验失败');
   return next;
 }
-async function main(){
- const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN;
- if(!master)throw Error('请通过受信任的部署配置设置 XIAOSHU_MASTER_KEY;不要将密钥写入脚本或提交到 Git');
- if(!token)throw Error('缺少用于发布后只读验证的 XIAOSHU_SYNC_OPERATOR_TOKEN');
- const commit=process.argv.includes('--commit'),base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
- const hash=s=>createHash('sha256').update(s).digest('hex');
- const root=resolve(process.env.XIAOSHU_RELEASE_DIR||'dist/support-requests-release',new Date().toISOString().replace(/[:.]/g,'-'));
- const headers={'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master};
- async function parse(path,method='GET',body){const r=await fetch(base+path,{method,headers,body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(20000)});const p=await r.json();if(!r.ok||p.error)throw Error('发布接口失败:'+path.split('?')[0]+' HTTP '+r.status+' '+JSON.stringify(p.error||p.code));return p;}
- const rows=(await parse('/classes/Function?'+new URLSearchParams({where:JSON.stringify({path:'xiaoshu/ops/gateway-v3'}),limit:'2'}))).results;
- if(rows?.length!==1)throw Error('未找到唯一的运营 V3 网关');
- const before=rows[0],code=patchSupportRequests(before.code);
- mkdirSync(root,{recursive:true,mode:0o700});
- writeFileSync(root+'/before.json',JSON.stringify(before,null,2),{mode:0o600});
- writeFileSync(root+'/candidate.js',code,{mode:0o600});
- const proof={objectId:before.objectId,beforeHash:hash(before.code),afterHash:hash(code),backup:root+'/before.json'};
- if(!commit){console.log(JSON.stringify({...proof,dryRun:true}));return;}
- const functionBase=(process.env.XIAOSHU_FUNCTION_URL||base.replace(/\/parse$/,'/api/functions')).replace(/\/$/,'');
- const syncResponse=await fetch(functionBase+'/xiaoshu/sync/direct-v1',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{action:'status'}}),signal:AbortSignal.timeout(20000)});
- const sync=await syncResponse.json();
- if(!syncResponse.ok||!sync.success||!Array.isArray(sync.data?.runs))throw Error('无法确认同步任务状态,停止发布');
- if(sync.data.runs.some(j=>j.status==='running'))throw Error('同步任务正在运行,请空闲后重试');
- const latest=await parse('/classes/Function/'+before.objectId);
- if(latest.code!==before.code||latest.updatedAt!==before.updatedAt)throw Error('线上网关已被其他发布修改,停止覆盖');
- if(code!==before.code)await parse('/classes/Function/'+before.objectId,'PUT',{code});
- const after=await parse('/classes/Function/'+before.objectId);
- if(after.code!==code)throw Error('发布后代码回读不一致,请检查备份和线上状态');
- const url=(process.env.XIAOSHU_FUNCTION_URL||base.replace(/\/parse$/,'/api/functions'))+'/xiaoshu/ops/gateway-v3';
- const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{operation:'ops/support-requests/list',page:1,pageSize:20}}),signal:AbortSignal.timeout(20000)}),result=await r.json();
- const passed=r.ok&&result.success===true&&Array.isArray(result.data?.items);
- writeFileSync(root+'/proof.json',JSON.stringify({...proof,deployed:true,readbackVerified:true,liveQueryVerified:passed,total:result.data?.total,httpStatus:r.status},null,2),{mode:0o600});
- if(!passed)throw Error('代码发布成功,但真实查询未通过:'+String(result.message||result.error||r.status));
- console.log(JSON.stringify({...proof,deployed:true,readbackVerified:true,liveQueryVerified:true,total:result.data.total}));
+// The read-only board now depends on the reply ledger: deploy the complete scoped workflow.
+if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href) {
+  const {deployWorkflow}=await import('./deploy-support-workflow.mjs');
+  await deployWorkflow(process.argv.includes('--commit')).catch(e=>{console.error(e.message);process.exitCode=1;});
 }
-if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main().catch(e=>{console.error(e.message);process.exitCode=1;});

+ 73 - 0
scripts/deploy-support-workflow.mjs

@@ -0,0 +1,73 @@
+#!/usr/bin/env node
+// Scope both patches to the actual online baseline and migrate only the reply ledger.
+import {readFileSync,writeFileSync,mkdirSync} from 'node:fs';
+import {createHash,randomBytes} from 'node:crypto';
+import {resolve} from 'node:path';
+import {pathToFileURL} from 'node:url';
+const hash=s=>createHash('sha256').update(s).digest('hex');
+const adminHelper=readFileSync(new URL('./cloud/support-requests.js',import.meta.url),'utf8').trim();
+const appHelper=readFileSync(new URL('./cloud/support-requests-app.js',import.meta.url),'utf8').trim();
+const processDispatch="    if(operation==='ops/support-requests/process') return response.json({success:true,data:await supportRequestProcess(context,input)});";
+const listDispatch="    if(operation==='ops/support-requests/list') return response.json({success:true,data:await supportRequestsPage(context,input)});";
+const appDispatch="    if (['app_support_requests_list','app_support_request_read'].includes(action)) return response.json(envelope(await appSupportRequests(input,current)));";
+function replaceOnce(code,anchor,value){if(code.split(anchor).length!==2)throw Error('发布锚点缺失或不唯一');return code.replace(anchor,value);}
+function supportCapabilities(code){if(!code.includes("action === 'migration_status'"))return code;const prefix="implemented: ['app_support_requests_list','app_support_request_read',";return code.includes(prefix)?code:replaceOnce(code,'implemented: [',prefix);}
+export function patchWorkflow(code,kind){
+ let next=code;
+ if(kind==='admin'){
+  if(code.includes(adminHelper)&&code.includes(processDispatch))return code;
+  if(code.includes('supportRequestProcess('))throw Error('线上已有不同版本处理逻辑');
+  const start=code.indexOf('// Both mobile roles submit category 22');
+  if(start<0)throw Error('未找到已发布的反馈看板');
+  const end=code.indexOf('\n}',start)+2,old=code.slice(start,end);
+  if(hash(old+'\n')!=='6414de94dd69ee55cecb634ccf9ba08a837397d316c0d6ba0d482047f03db7e0')throw Error('线上反馈模块已变化,停止覆盖');
+  next=replaceOnce(next,old,adminHelper);
+  next=replaceOnce(next,listDispatch,processDispatch+'\n'+listDispatch);
+ }else if(kind==='app'){
+  if(code.includes(appHelper)&&code.includes(appDispatch))return supportCapabilities(code);
+  if(code.includes('appSupportRequests('))throw Error('线上已有不同版本回复逻辑');
+  const anchor="    if (action === 'guestbook_add')";
+  next=replaceOnce(next,anchor,appDispatch+'\n'+anchor)+'\n'+appHelper+'\n';
+ }else throw Error('无效网关类型');
+ if(kind==='app')next=supportCapabilities(next);
+ if(new Function(next+'\nreturn typeof handler;')()!=='function')throw Error('网关语法校验失败');
+ return next;
+}
+export async function deployWorkflow(commit=false){
+ const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN;
+ if(!master||!token)throw Error('缺少受信任部署配置');
+ const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',functionBase=(process.env.XIAOSHU_FUNCTION_URL||base.replace(/\/parse$/,'/api/functions')).replace(/\/$/,'');
+ const headers={'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master};
+ const parse=async(path,method='GET',body)=>{const r=await fetch(base+path,{method,headers,body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000)});const d=await r.json();if(!r.ok||d.error)throw Error('发布接口失败 '+path.split('?')[0]+' HTTP '+r.status);return d;};
+ const call=async(path,params={})=>{const r=await fetch(functionBase+'/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(60000)});const d=await r.json();if(!r.ok||d.success===false)throw Error(d.message||'网关验证失败 HTTP '+r.status);return d;};
+ const meResponse=await fetch(base+'/users/me',{headers:{'X-Parse-Application-Id':app,'X-Parse-Session-Token':token}}),me=await meResponse.json();
+ if(!meResponse.ok||!me.objectId||!me.company?.objectId)throw Error('无法确认发布账号归属');
+ const idle=await call('xiaoshu/sync/direct-v1',{action:'status'});
+ if(!Array.isArray(idle.data?.runs)||idle.data.runs.some(r=>r.status==='running'))throw Error('同步任务状态不可用或正在运行');
+ const root=resolve('dist/support-workflow-release',new Date().toISOString().replace(/[:.]/g,'-'));mkdirSync(root,{recursive:true,mode:0o700});
+ const candidates=[];
+ for(const [kind,path] of [['app','xiaoshu/app/gateway'],['admin','xiaoshu/ops/gateway-v3']]){
+  const rows=(await parse('/classes/Function?'+new URLSearchParams({where:JSON.stringify({path}),limit:'2'}))).results;
+  if(rows?.length!==1)throw Error('未找到唯一网关 '+path);
+  const before=rows[0],code=patchWorkflow(before.code,kind);
+  writeFileSync(root+'/'+kind+'-before.json',JSON.stringify(before,null,2),{mode:0o600});writeFileSync(root+'/'+kind+'-candidate.js',code,{mode:0o600});candidates.push({kind,before,code});
+ }
+ if(!commit){console.log(JSON.stringify({dryRun:true,backup:root}));return;}
+ let helper;
+ try{
+  const sql=readFileSync(new URL('./sql/support-requests.sql',import.meta.url),'utf8'),path='xiaoshu/system/support-migration-'+randomBytes(12).toString('hex');
+  const code=`async function handler(request,response){try{const u=request.user;if(!u||u.id!==${JSON.stringify(me.objectId)}||Date.now()>${Date.now()+600000})return response.status(403).json({success:false});await u.fetch({useMasterKey:true});if(u.get('company')?.id!==${JSON.stringify(me.company.objectId)})return response.status(403).json({success:false});await Psql.tx(async t=>{await t.none(${JSON.stringify(sql)});});return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;
+  helper=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});
+  await call(path);
+  for(const {kind,before,code} of candidates){
+   const latest=await parse('/classes/Function/'+before.objectId);
+   if(latest.code!==before.code||latest.updatedAt!==before.updatedAt)throw Error('网关已被并行发布修改,停止覆盖 '+kind);
+   if(code!==before.code)await parse('/classes/Function/'+before.objectId,'PUT',{code});
+   if((await parse('/classes/Function/'+before.objectId)).code!==code)throw Error('发布回读失败 '+kind);
+  }
+  const live=await call('xiaoshu/ops/gateway-v3',{operation:'ops/support-requests/list',page:1,pageSize:1});
+  if(!Array.isArray(live.data?.items)||typeof live.data.canProcess!=='boolean')throw Error('真实看板查询失败');
+  const proof={deployed:true,backup:root,total:live.data.total,gateways:candidates.map(x=>({kind:x.kind,id:x.before.objectId,before:hash(x.before.code),after:hash(x.code)}))};writeFileSync(root+'/proof.json',JSON.stringify(proof,null,2),{mode:0o600});console.log(JSON.stringify(proof));
+ }finally{if(helper)await parse('/classes/Function/'+helper.objectId,'DELETE');}
+}
+if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)deployWorkflow(process.argv.includes('--commit')).catch(e=>{console.error(e.message);process.exitCode=1;});

+ 66 - 0
scripts/smoke-support-workflow.mjs

@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+// Real isolated end-to-end fixtures. No real student's/teacher's decision is changed.
+import {randomBytes} from 'node:crypto';
+import {mkdirSync,writeFileSync} from 'node:fs';
+import assert from 'node:assert/strict';
+const master=process.env.XIAOSHU_MASTER_KEY,operator=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN;
+if(!master||!operator)throw Error('缺少部署验收配置');
+const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',functions=(process.env.XIAOSHU_FUNCTION_URL||base.replace(/\/parse$/,'/api/functions')).replace(/\/$/,'');
+const users=[],suffix=Date.now()+'_'+randomBytes(4).toString('hex'),checks=[],requests=[];let helper;
+const parse=async(path,method='GET',body)=>{const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000)});const d=await r.json();if(!r.ok||d.error)throw Error('Fixture request failed '+path.split('?')[0]+' HTTP '+r.status+' '+String(d.error||d.message||''));return d;};
+const call=async(path,token,params,allow=false)=>{const r=await fetch(functions+'/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(30000)});const d=await r.json();if(!allow&&(!r.ok||d.success===false||d.retcode<0))throw Error('Live workflow '+(params.action||params.operation)+': '+(d.message||d.retmsg||r.status));return {status:r.status,data:d.data??d.result};};
+const ops=(operation,params={},token=operator,allow=false)=>call('xiaoshu/ops/gateway-v3',token,{operation,...params},allow);
+const mobile=(user,action,params={},allow=false)=>call('xiaoshu/app/gateway',user.sessionToken,{action,...params},allow);
+const meR=await fetch(base+'/users/me',{headers:{'X-Parse-Application-Id':app,'X-Parse-Session-Token':operator}}),me=await meR.json();if(!meR.ok||!me.company?.objectId)throw Error('Unknown operator');const co=me.company.objectId;
+const baseline=(await ops('ops/support-requests/list',{pageSize:100})).data;
+try{
+ const idle=await call('xiaoshu/sync/direct-v1',operator,{action:'status'});assert.ok(Array.isArray(idle.data.runs)&&idle.data.runs.every(r=>r.status!=='running'));
+ for(const [index,kind] of ['student','teacher'].entries()){
+  const uid=999000000+(Date.now()%100000)*10+index;
+  const collision=await parse('/classes/_User?'+new URLSearchParams({where:JSON.stringify({company:me.company,legacyUserId:uid}),limit:'1',keys:'objectId'}));
+  if(collision.results?.length)throw Error('隔离编号已存在,停止创建测试记录');
+  const user=await parse('/users','POST',{username:'support_'+String(Date.now()).slice(-8)+'_'+kind,password:randomBytes(24).toString('hex'),company:me.company});
+  users.push({...user,uid,kind});
+  const fields={legacyUserId:uid,legacyUserData:{UserID:uid,GroupID:kind==='teacher'?3:1,ParentUserID:0,Purse:0,SilverCoin:0,UserExp:0,UserPoint:0},legacyGroupId:kind==='teacher'?3:1,identityType:kind==='teacher'?'coach':'member',realName:'隔离验收'+kind,testCreatedBy:'support-workflow-smoke'};
+  for(const [key,value] of Object.entries(fields)){try{await parse('/users/'+user.objectId,'PUT',{[key]:value});}catch{throw Error('Fixture field update failed: '+kind+' '+key);}}
+ }
+ const [student,teacher]=users,ids=users.map(u=>u.uid),path='xiaoshu/system/support-cleanup-'+suffix;
+ const deleteGuestbook="DELETE FROM \"Guestbook\" WHERE company=$1 AND userid=ANY($2::numeric[]) AND \"sourceKey\" LIKE 'cloud:guestbook_add:%'";
+ const code=`async function handler(request,response){try{if(request.user?.id!==${JSON.stringify(me.objectId)}||Date.now()>${Date.now()+900000})return response.status(403).json({success:false});await Psql.tx(async t=>{await t.none('DELETE FROM "SupportRequestResolution" WHERE company=$1 AND "ownerId"=ANY($2::numeric[])',[${JSON.stringify(co)},${JSON.stringify(ids)}]);await t.none(${JSON.stringify(deleteGuestbook)},[${JSON.stringify(co)},${JSON.stringify(ids)}]);});return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;
+ new Function(code);
+ helper={...(await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})),path};
+ for(const [user,kind,status,comment] of [[student,'feedback','processed','感谢反馈,已调整提醒设置。'],[teacher,'leave','approved','同意休假,请提前完成课程交接。'],[student,'leave','rejected','本次时间无法安排,请调整后重新提交。']]){
+  const title=`【${kind==='leave'?'休假申请':'意见反馈'}】隔离验收 ${suffix}`;
+  const submitted=(await mobile(user,'guestbook_add',{model:JSON.stringify({UserID:user.uid,Title:title,TContent:'处理与回信隔离验收,仅用于功能检查',Cateid:22})})).data;
+  assert.ok(submitted.objectId);requests.push({id:submitted.objectId,user,kind,status,comment});
+ }
+ const board=(await ops('ops/support-requests/list',{search:suffix,pageSize:100})).data;assert.equal(board.total,3);
+ assert.equal((await ops('ops/support-requests/list',{},student.sessionToken,true)).status,403);
+ for(const req of requests){
+  const row=board.items.find(r=>r.id===req.id),input={targetId:req.id,status:req.status,comment:req.comment,version:row.version};
+  assert.equal(row.status,'pending');assert.equal((await ops('ops/support-requests/process',input,student.sessionToken,true)).status,403);
+  assert.equal((await ops('ops/support-requests/process',{...input,version:'outdated'},operator,true)).status,409);
+  const saved=(await ops('ops/support-requests/process',input)).data;assert.equal(saved.status,req.status);
+  const retried=(await ops('ops/support-requests/process',input)).data;assert.equal(retried.handledAt,saved.handledAt);
+  assert.equal((await ops('ops/support-requests/process',{...input,comment:'不得覆盖原决定'},operator,true)).status,409);
+ }
+ checks.push('真实学生反馈、教师休假同意、学生休假拒绝,意见与处理时间持久化;学生账号无后台处理权限;重复处理不可覆盖');
+ const studentInbox=(await mobile(student,'app_support_requests_list',{uid:teacher.uid})).data,teacherInbox=(await mobile(teacher,'app_support_requests_list',{})).data;
+ assert.equal(studentInbox.total,2);assert.equal(studentInbox.unread,2);assert.equal(teacherInbox.total,1);assert.equal(teacherInbox.unread,1);
+ assert.equal(teacherInbox.items[0].response,requests[1].comment);assert.ok(studentInbox.items.every(r=>r.id!==requests[1].id));
+ assert.equal((await mobile(student,'app_support_request_read',{id:requests[1].id},true)).status,404);
+ for(const req of requests){await mobile(req.user,'app_support_request_read',{id:req.id});await mobile(req.user,'app_support_request_read',{id:req.id});}
+ assert.equal((await mobile(student,'app_support_requests_list')).data.unread,0);assert.equal((await mobile(teacher,'app_support_requests_list')).data.unread,0);
+ assert.ok((await ops('ops/support-requests/list',{search:suffix})).data.items.every(r=>r.readAt));
+ checks.push('学生与老师只收本人回复,伪造用户编号无效;已读确认可重试,后台同步显示已读');
+}finally{
+ const failures=[];
+ if(helper){try{await call(helper.path,operator,{});}catch(e){failures.push('fixture records: '+e.message);}try{await parse('/classes/Function/'+helper.objectId,'DELETE');}catch{failures.push('function '+helper.objectId);}}
+ for(const u of users){try{const sessions=(await parse('/classes/_Session?'+new URLSearchParams({where:JSON.stringify({user:{__type:'Pointer',className:'_User',objectId:u.objectId}})}))).results;for(const s of sessions)await parse('/classes/_Session/'+s.objectId,'DELETE');await parse('/users/'+u.objectId,'DELETE');}catch{failures.push('user '+u.objectId);}}
+ if(failures.length)throw Error('验收清理未完成 '+failures.join('; '));
+}
+const after=(await ops('ops/support-requests/list',{pageSize:100})).data;
+for(const row of baseline.items){const current=after.items.find(r=>r.id===row.id);assert.ok(current);assert.equal(current.status,row.status);assert.equal(current.response,row.response);}
+assert.equal((await ops('ops/support-requests/list',{search:suffix})).data.total,0);
+checks.push('测试账号、会话、记录、临时函数已清理;原有真实申请结果未改动');
+mkdirSync('tmp/support-workflow',{recursive:true});writeFileSync('tmp/support-workflow/live-result.json',JSON.stringify({passed:true,checkedAt:new Date().toISOString(),checks},null,2));console.log(JSON.stringify({passed:true,checks}));

+ 32 - 0
scripts/sql/support-requests.sql

@@ -0,0 +1,32 @@
+CREATE TABLE IF NOT EXISTS "SupportRequestResolution" (
+  company text NOT NULL,
+  "requestId" text NOT NULL,
+  "ownerId" numeric NOT NULL,
+  status text NOT NULL CHECK(status IN ('processed','approved','rejected')),
+  comment text NOT NULL CHECK(length(btrim(comment)) BETWEEN 1 AND 1000),
+  actor text NOT NULL,
+  "actorName" text NOT NULL,
+  "handledAt" timestamptz NOT NULL DEFAULT now(),
+  "readAt" timestamptz,
+  PRIMARY KEY(company,"requestId")
+);
+CREATE INDEX IF NOT EXISTS support_reply_owner ON "SupportRequestResolution"(company,"ownerId","handledAt" DESC);
+CREATE OR REPLACE FUNCTION xs_support_request_process(co text,rid text,actor_id text,actor_name text,next_status text,note text,expected text)
+RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE g "Guestbook"; r "SupportRequestResolution"; kind text; fingerprint text;
+BEGIN
+ IF NULLIF(btrim(co),'') IS NULL OR NULLIF(btrim(actor_id),'') IS NULL OR note IS NULL OR next_status IS NULL OR length(btrim(note)) NOT BETWEEN 1 AND 1000 OR next_status NOT IN ('processed','approved','rejected') THEN RAISE EXCEPTION '请填写处理结果和 1 至 1000 字的处理意见'; END IF;
+ SELECT * INTO g FROM "Guestbook" WHERE company=co AND "objectId"=rid AND COALESCE(parentid,0)=0 AND COALESCE(status,0)<>-2 AND (cateid=22 OR "sourceKey" LIKE 'cloud:guestbook_add:%') FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION '申请不存在或已删除'; END IF;
+ SELECT * INTO r FROM "SupportRequestResolution" WHERE company=co AND "requestId"=rid;
+ IF FOUND THEN
+   IF r.actor=actor_id AND r.status=next_status AND r.comment=btrim(note) THEN RETURN to_jsonb(r); END IF;
+   RAISE EXCEPTION '该申请已处理,请刷新查看结果';
+ END IF;
+ fingerprint=md5(COALESCE(g.title,'')||chr(10)||COALESCE(g.tcontent,'')||chr(10)||COALESCE(g.userid::text,''));
+ IF expected IS DISTINCT FROM fingerprint THEN RAISE EXCEPTION '申请内容已变化,请刷新后重新处理'; END IF;
+ kind=CASE WHEN g.title LIKE '【休假申请】%' THEN 'leave' ELSE 'feedback' END;
+ IF (kind='leave' AND next_status NOT IN ('approved','rejected')) OR (kind='feedback' AND next_status<>'processed') THEN RAISE EXCEPTION '处理结果与申请类型不符'; END IF;
+ INSERT INTO "SupportRequestResolution"(company,"requestId","ownerId",status,comment,actor,"actorName") VALUES(co,rid,g.userid,next_status,btrim(note),actor_id,actor_name) RETURNING * INTO r;
+ RETURN to_jsonb(r);
+END $$;

+ 15 - 0
scripts/tests/support-requests-release.test.mjs

@@ -14,3 +14,18 @@ test('refuses missing or ambiguous anchors and conflicting existing implementati
  assert.throws(()=>patchSupportRequests(before+before),/不一致/);
  assert.throws(()=>patchSupportRequests(before+'\n// ops/support-requests/list'),/不同版本/);
 });
+
+import {patchWorkflow} from '../deploy-support-workflow.mjs';
+import {readFileSync} from 'node:fs';
+test('app patch preserves online changes, is idempotent and refuses competing implementation',()=>{
+ const before="// production fix\nasync function handler(){\n    if (action === 'migration_status') return {implemented: ['existing']};\n    if (action === 'guestbook_add') return save();\n}";
+ const after=patchWorkflow(before,'app');assert.ok(after.includes('// production fix'));assert.ok(after.includes("implemented: ['app_support_requests_list','app_support_request_read','existing']"));assert.ok(after.includes("if (action === 'guestbook_add') return save();"));assert.equal(patchWorkflow(after,'app'),after);
+ assert.throws(()=>patchWorkflow(before+before,'app'),/不唯一/);
+ assert.throws(()=>patchWorkflow(before+'async function appSupportRequests(){}','app'),/不同版本/);
+});
+test('admin patch fails closed for unknown baseline, complete candidate is idempotent',()=>{
+ assert.throws(()=>patchWorkflow(before,'admin'),/未找到/);
+ assert.throws(()=>patchWorkflow(before+'\n// Both mobile roles submit category 22\nasync function supportRequestsPage(){\n}', 'admin'),/已变化/);
+ const candidate=before+"\n    if(operation==='ops/support-requests/process') return response.json({success:true,data:await supportRequestProcess(context,input)});\n"+readFileSync(new URL('../cloud/support-requests.js',import.meta.url),'utf8');
+ assert.equal(patchWorkflow(candidate,'admin'),candidate);
+});

+ 41 - 3
scripts/tests/support-requests.test.mjs

@@ -5,15 +5,18 @@ import vm from 'node:vm';
 import {PGlite} from '@electric-sql/pglite';
 
 const db=new PGlite(), query=async(sql,params=[])=>(await db.query(sql,params)).rows;
-const ctx={Psql:{one:async(...args)=>(await query(...args))[0]},text:v=>String(v??'').trim(),
-  number:(v,d=0)=>Number.isFinite(Number(v))?Number(v):d,companyIdOf:c=>c.company,
+const ctx={Psql:{query,one:async(...args)=>(await query(...args))[0]},text:v=>String(v??'').trim(),
+  number:(v,d=0)=>Number.isFinite(Number(v))?Number(v):d,companyIdOf:c=>c.company,roleOf:c=>c.role,ownLegacyId:c=>c.uid,
   requireRole:(c,roles)=>{if(!roles.includes(c.role))throw Error('forbidden');},fail:(_,m)=>{throw Error(m);}};
 vm.createContext(ctx);vm.runInContext(readFileSync(new URL('../cloud/support-requests.js',import.meta.url),'utf8'),ctx);
-const admin={role:'ops-staff',company:'a'};
+vm.runInContext(readFileSync(new URL('../cloud/support-requests-app.js',import.meta.url),'utf8'),ctx);
+const admin={role:'ops-staff',company:'a',current:{id:'admin',get:()=> '管理员'}};
+const member=(uid,company='a')=>({uid,get:()=>({id:company})});
 before(async()=>{
   await db.exec(`CREATE TABLE "Guestbook"("objectId" text,company text,userid numeric,title text,tcontent text,gdate timestamptz,"createdAt" timestamptz,parentid numeric,status numeric,cateid numeric,"sourceKey" text);
     CREATE TABLE "_User"("objectId" text,company text,"legacyUserId" numeric,"legacyGroupId" numeric,"identityType" text,"legacyUserData" jsonb,"realName" text,nickname text,username text,"createdAt" timestamptz);
     INSERT INTO "_User" VALUES('s','a',1,1,'member','{}','学生甲','','','2026-10-01'),('t','a',2,3,'coach','{}','老师乙','','','2026-10-01'),('other','b',1,3,'coach','{}','其他帐套','','','2026-10-01');`);
+  await db.exec(readFileSync(new URL('../sql/support-requests.sql',import.meta.url),'utf8'));
   const rows=[
     ['new','a',1,'【意见反馈】学生甲','希望调整课程','2026-10-14T16:00:00Z',0,0,22],
     ['leave','a',2,'【休假申请】老师乙','10月16日休假一天','2026-10-15T15:59:59Z',0,0,22],
@@ -69,3 +72,38 @@ test('existing mobile save function persists typed and legacy submissions for th
   const d=await ctx.supportRequestsPage(admin,{search:'保存链路验收'});assert.equal(d.total,2);
   assert.deepEqual(d.items.map(r=>r.kind).sort(),['leave','legacy']);
 });
+
+test('staff resolves feedback once; owner receives unread reply; cross-owner and cross-tenant cannot acknowledge',async()=>{
+ const row=(await ctx.supportRequestsPage(admin,{})).items.find(r=>r.id==='new');
+ const input={targetId:row.id,version:row.version,status:'processed',comment:'已调整提醒设置'};
+ await assert.rejects(ctx.supportRequestProcess({...admin,role:'ops-auditor'},input),/forbidden/);
+ await assert.rejects(ctx.supportRequestProcess({...admin,company:'b'},input),/不存在/);
+ await assert.rejects(ctx.supportRequestProcess(admin,{...input,version:'stale'}),/变化/);
+ await assert.rejects(ctx.supportRequestProcess(admin,{...input,status:'approved'}),/类型/);
+ await assert.rejects(ctx.supportRequestProcess(admin,{...input,comment:' '}),/处理意见/);
+ const done=await ctx.supportRequestProcess(admin,input);
+ assert.equal(done.status,'processed');assert.equal(done.actor,'admin');
+ const again=await ctx.supportRequestProcess(admin,input);assert.equal(again.handledAt,done.handledAt);
+ await assert.rejects(ctx.supportRequestProcess(admin,{...input,comment:'覆盖原意见'}),/已处理/);
+ let own=await ctx.appSupportRequests({uid:2},member(1));assert.equal(own.unread,1);assert.equal(own.items[0].response,input.comment);
+ assert.ok(own.items.every(r=>r.id!=='leave'));assert.equal((await ctx.appSupportRequests({},member(2))).unread,0);
+ await assert.rejects(ctx.appSupportRequests({action:'app_support_request_read',id:'new'},member(2)),/不属于/);
+ await assert.rejects(ctx.appSupportRequests({action:'app_support_request_read',id:'new'},member(1,'b')),/不属于/);
+ await ctx.appSupportRequests({action:'app_support_request_read',id:'new'},member(1));
+ await ctx.appSupportRequests({action:'app_support_request_read',id:'new'},member(1));
+ assert.equal((await ctx.appSupportRequests({},member(1))).unread,0);
+ assert.ok((await ctx.supportRequestsPage(admin,{status:'processed'})).items[0].readAt);
+ assert.equal((await ctx.supportRequestsPage({...admin,role:'ops-auditor'},{})).canProcess,false);
+});
+test('leave approval or refusal is atomic and cannot be marked as ordinary feedback',async()=>{
+ const row=(await ctx.supportRequestsPage(admin,{})).items.find(r=>r.id==='leave');
+ const input={targetId:row.id,version:row.version,status:'approved',comment:'同意,已安排代课'};
+ await assert.rejects(ctx.supportRequestProcess(admin,{...input,status:'processed'}),/类型/);
+ const results=await Promise.allSettled([ctx.supportRequestProcess(admin,input),ctx.supportRequestProcess(admin,{...input,status:'rejected',comment:'冲突操作'})]);
+ assert.equal(results.filter(r=>r.status==='fulfilled').length,1);
+ assert.equal((await ctx.appSupportRequests({},member(2))).items.find(r=>r.id==='leave').status,'approved');
+ const other=(await ctx.supportRequestsPage(admin,{search:'保存链路验收'})).items.find(r=>r.kind==='leave');
+ await ctx.supportRequestProcess(admin,{targetId:other.id,version:other.version,status:'rejected',comment:'请补充休假时间后重新申请'});
+ assert.equal((await ctx.supportRequestsPage(admin,{status:'rejected'})).total,1);
+ assert.equal((await ctx.appSupportRequests({},member(2))).unread,2);
+});

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