Ver Fonte

feat(admin): add feedback and leave request dashboard

彭峰 há 6 dias atrás
pai
commit
3e2f7bcc38

+ 1 - 0
projects/xiaoshu-admin/src/app/admin.constants.ts

@@ -27,6 +27,7 @@ export const OPERATIONS_NAVIGATION: readonly AdminNavigationGroup[] = [
     { label: '工资结算', icon: 'wallet-cards', route: '/admin/payroll' },
   ] },
   { label: '记录管理', items: [
+    { label: '反馈与休假申请', icon: 'message-square-text', route: '/admin/support-requests' },
     { label: '上课记录', icon: 'clipboard-list', route: '/admin/lessons' },
     { label: '学习报表', icon: 'file-chart-column', route: '/admin/learning-reports' },
   ] },

+ 1 - 0
projects/xiaoshu-admin/src/app/admin.routes.ts

@@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
 import { adminGuard, operatorManagerGuard, superAdminGuard } from './admin.guard';
 
 const operationsChildren: Routes = [
+  { path: 'support-requests', data: { title: '意见反馈与休假申请', parentUrl: '/admin/dashboard' }, loadComponent: () => import('./pages/support-requests.component').then(m => m.SupportRequestsComponent) },
   {path:'privacy-requests',data:{title:'注销申请',parentUrl:'/admin/dashboard'},canActivate:[operatorManagerGuard],loadComponent:()=>import('./pages/privacy-requests.component').then(m=>m.PrivacyRequestsComponent)},
   { path: '', pathMatch: 'full', redirectTo: 'dashboard' },
   { path: 'dashboard', data: { title: '工作台', parentUrl: null, preloadOperationsMenu: true, reuseOperationsMenu: true }, loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent) },

+ 35 - 0
projects/xiaoshu-admin/src/app/pages/support-requests.component.html

@@ -0,0 +1,35 @@
+<header class="page-header">
+  <div><h1>意见反馈与休假申请</h1><p>集中查看学生与老师提交的意见和申请</p></div>
+  <button class="ui-button" type="button" (click)="load()" [disabled]="loading()">刷新</button>
+</header>
+<form class="toolbar request-filters" (ngSubmit)="apply()">
+  <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>开始日期<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 (data(); as d) {
+  <section class="summary-grid" aria-label="当前筛选统计">
+    <article class="summary-card"><span>当前筛选 · 共计</span><strong>{{d.total}}</strong></article>
+    <article class="summary-card"><span>意见反馈</span><strong>{{d.summary.feedback}}</strong></article>
+    <article class="summary-card"><span>休假申请</span><strong>{{d.summary.leave}}</strong></article>
+    <article class="summary-card"><span>历史未分类</span><strong>{{d.summary.legacy}}</strong></article>
+  </section>
+  <p class="history-note">历史表单未区分类型,统一保留在“历史未分类”中。身份按提交账号的当前资料显示;提交申请不代表已获准休假。</p>
+  <section class="content-card request-list" aria-label="反馈与申请列表">
+    <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>
+        <h2>{{row.title || '未填写标题'}}</h2>
+        <p class="account">提交账号:{{row.accountName}} · 用户编号 {{row.userId || '未记录'}}</p>
+        <p class="request-content">{{row.content || '未填写内容'}}</p>
+      </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>
+  </section>
+}

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

@@ -0,0 +1 @@
+: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}}

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

@@ -0,0 +1,36 @@
+import { TestBed } from '@angular/core/testing';
+import { OperationsService } from '../operations.service';
+import { SupportRequestsComponent } from './support-requests.component';
+
+describe('反馈与休假申请看板', () => {
+  let ops: jasmine.SpyObj<OperationsService>;
+  const data = (page = 1) => ({ items: [], total: 25, page, pageSize: 20, summary: {feedback: 10, leave: 10, legacy: 5}, refreshedAt: '' });
+  const create = () => TestBed.runInInjectionContext(() => new SupportRequestsComponent());
+  beforeEach(() => {
+    ops = jasmine.createSpyObj('OperationsService', ['request']);
+    TestBed.configureTestingModule({providers: [{provide: OperationsService, useValue: ops}]});
+  });
+  it('keeps applied filters when paging even if draft fields changed', async () => {
+    const c = create(); ops.request.and.resolveTo(data());
+    c.search = ' 老师 '; c.kind = 'leave'; c.apply();
+    c.search = '未提交条件'; await c.load(2);
+    expect(ops.request.calls.mostRecent().args[1]).toEqual(jasmine.objectContaining({search: '老师', kind: 'leave', page: 2}));
+  });
+  it('clears stale results on failure and retries the failed page', async () => {
+    const c = create(); ops.request.and.resolveTo(data()); await c.load();
+    ops.request.and.rejectWith(new Error('网络中断')); await c.load(2);
+    expect(c.data()).toBeNull(); expect(c.error()).toBe('网络中断'); expect(c.loading()).toBeFalse();
+    ops.request.and.resolveTo(data(2)); await c.load();
+    expect(ops.request.calls.mostRecent().args[1]?.['page']).toBe(2); expect(c.error()).toBe('');
+  });
+  it('ignores late responses from an earlier request', async () => {
+    const c = create(); let resolve!: (v: unknown) => void;
+    ops.request.and.returnValues(new Promise(r => resolve = r), Promise.resolve(data(2)));
+    const first = c.load(1); await c.load(2); resolve(data(1)); await first;
+    expect(c.data()?.page).toBe(2);
+  });
+  it('does not submit inverted date ranges', () => {
+    const c = create(); c.dateFrom = '2026-10-20'; c.dateTo = '2026-10-15'; c.apply();
+    expect(ops.request).not.toHaveBeenCalled(); expect(c.error()).toContain('开始日期');
+  });
+});

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

@@ -0,0 +1,50 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { OperationsService } from '../operations.service';
+
+interface SupportRequest {
+  id: string; userId: string; accountName: string; title: string; content: string;
+  submittedAt: string; kind: string; role: string;
+}
+interface SupportPage {
+  items: SupportRequest[]; total: number; page: number; pageSize: number;
+  summary: { feedback: number; leave: number; legacy: number }; refreshedAt: string;
+}
+@Component({
+  selector: 'app-support-requests', standalone: true, imports: [CommonModule, FormsModule],
+  templateUrl: './support-requests.component.html',
+  styleUrls: ['./operations-professional.scss', './support-requests.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class SupportRequestsComponent implements OnInit {
+  readonly Math = Math;
+  private readonly ops = inject(OperationsService);
+  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: ''};
+  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] || '未匹配'; }
+  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};
+    void this.load(1);
+  }
+  reset(): void { this.search = this.kind = this.role = this.dateFrom = this.dateTo = ''; this.apply(); }
+  async load(page = this.requestedPage): Promise<void> {
+    this.requestedPage = page;
+    const revision = ++this.revision;
+    this.loading.set(true); this.error.set(''); this.data.set(null);
+    try {
+      const data = await this.ops.request<SupportPage>('ops/support-requests/list', {...this.applied,page,pageSize:20});
+      if (revision === this.revision) this.data.set(data);
+    } catch (error) {
+      if (revision === this.revision) this.error.set(error instanceof Error ? error.message : '读取失败,请重试');
+    } finally { if (revision === this.revision) this.loading.set(false); }
+  }
+}

+ 8 - 0
projects/xiaoshu-mobile/src/app/core/form-payload.spec.ts

@@ -2,6 +2,14 @@ import { buildFormSubmission } from './form-payload';
 import { PAGE_BY_PATH } from './page-catalog';
 
 describe('form payload adapters', () => {
+  it('preserves the feedback save contract and explicitly distinguishes leave requests', () => {
+    const meta = PAGE_BY_PATH.get('pages/member/feedback')!;
+    for (const requestType of ['feedback', 'leave']) {
+      const submission = buildFormSubmission(meta, { name: ' 测试用户 ', content: '申请内容', requestType }, { uid: 7 });
+      expect(submission?.action).toBe('guestbook_add');
+      expect(JSON.parse(String(submission?.payload['model']))).toEqual({ UserID: 7, Title: `【${requestType === 'leave' ? '休假申请' : '意见反馈'}】测试用户`, TContent: '申请内容', Cateid: 22 });
+    }
+  });
   it('maps the address form to the legacy address API contract', () => {
     const meta = PAGE_BY_PATH.get('pages/member/receaddr_add')!;
     const submission = buildFormSubmission(meta, {

+ 1 - 1
projects/xiaoshu-mobile/src/app/core/form-payload.ts

@@ -171,7 +171,7 @@ export function buildFormSubmission(meta: PageMeta, values: FormValues, context:
       break;
     case 'pages/member/feedback':
       payload = {
-        model: JSON.stringify({ UserID: uid, Title: `${values['name']} 反馈的意见`, TContent: values['content'], Cateid: 22 }),
+        model: JSON.stringify({ UserID: uid, Title: `【${values['requestType'] === 'leave' ? '休假申请' : '意见反馈'}】${String(values['name'] ?? '').trim()}`, TContent: values['content'], Cateid: 22 }),
       };
       break;
     case 'pages/member/passwd':

+ 1 - 1
projects/xiaoshu-mobile/src/app/core/page-catalog.ts

@@ -101,7 +101,7 @@ export const PAGE_CATALOG: PageMeta[] = [
   { path: 'pages/member/myshop_sales_log', title: '结算记录', domain: 'member', kind: 'merchant', auth: true },
   { path: 'pages/member/myshop_order', title: '商户订单中心', domain: 'member', kind: 'resource', auth: true, api: { action: 'user_shop_order' }, itemRoute: '/pages/member/myshop_order_item', tabs: ['全部', '未发货', '待签收', '已完成'], tabActions: ['user_shop_order', 'user_shop_order', 'user_shop_order', 'user_shop_order'], tabParams: [{ fast: 'all' }, { fast: 'paid' }, { fast: 'issued' }, { fast: 'signed' }] },
   { path: 'pages/member/myshop_order_item', title: '订单管理', domain: 'member', kind: 'order', auth: true },
-  { path: 'pages/member/feedback', title: '意见反馈与休假申请', domain: 'member', kind: 'form', auth: true, saveAction: 'guestbook_add', fields: [text('name', '您的姓名', true), content] },
+  { path: 'pages/member/feedback', title: '意见反馈与休假申请', domain: 'member', kind: 'form', auth: true, saveAction: 'guestbook_add', description: '请先选择类型。休假申请请在内容中写明起止时间和原因,提交后由管理员查看。内容最多 800 字。', fields: [{ key: 'requestType', label: '提交类型', type: 'select', required: true, options: [{ label: '意见反馈', value: 'feedback' }, { label: '休假申请', value: 'leave' }] }, text('name', '您的姓名', true), content] },
   { path: 'pages/member/starshop', title: '生词本', domain: 'member', kind: 'learning', auth: true },
   { path: 'pages/member/passwd', title: '修改登录密码', domain: 'member', kind: 'form', auth: true, saveAction: 'user_update_pwd', fields: [text('passwd', '新密码', true, 'password'), text('passwd2', '确认密码', true, 'password'), text('vcode', '验证码', true)] },
   { path: 'pages/member/passwd2', title: '修改支付密码', domain: 'member', kind: 'form', auth: true, saveAction: 'user_update_paypwd', fields: [text('paypwd', '新支付密码', true, 'password'), text('paypwd2', '确认支付密码', true, 'password'), text('vcode', '验证码', true)] },

+ 49 - 0
scripts/check-support-requests-browser.mjs

@@ -0,0 +1,49 @@
+// Isolated browser acceptance against local builds and fixtures; no online writes.
+import {chromium} from 'playwright';
+import {createServer} from 'node:http';
+import {readFileSync,existsSync,mkdirSync,writeFileSync} from 'node:fs';
+import {resolve,extname} from 'node:path';
+import assert from 'node:assert/strict';
+const output=resolve('tmp/support-requests-qa');mkdirSync(output,{recursive:true});
+async function serve(project){
+ const root=resolve('dist/'+project+'/browser');
+ const server=createServer((req,res)=>{let file=resolve(root,'.'+new URL(req.url,'http://local').pathname);if(!file.startsWith(root+'/')||!existsSync(file)||!extname(file))file=root+'/index.html';res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.woff2':'font/woff2'})[extname(file)]||'application/octet-stream');res.end(readFileSync(file));});
+ await new Promise(r=>server.listen(0,'127.0.0.1',r));return {server,origin:'http://127.0.0.1:'+server.address().port};
+}
+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'}));
+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/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'}}});
+ });
+ 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);
+ assert.equal(await page.locator('.request-row').count(),20);assert.match(await page.locator('.summary-grid').innerText(),/25/);assert.equal(await page.evaluate(()=>window.injected),undefined);
+ await page.screenshot({path:output+'/admin-desktop.png',fullPage:true});await page.screenshot({path:output+'/admin-overview.png'});
+ await page.getByRole('button',{name:'下一页',exact:true}).click();await page.waitForFunction(()=>document.querySelectorAll('.request-row').length===5);checks.push('菜单入口、全部记录、分页及内容按纯文本显示');
+ await page.getByLabel('提交身份').selectOption('teacher');await page.getByLabel('类型',{exact:true}).selectOption('leave');await page.getByRole('button',{name:'查询',exact:true}).click();await page.waitForFunction(()=>document.querySelectorAll('.request-row').length===4);
+ assert.equal(calls.at(-1).page,1);assert.ok((await page.locator('.request-meta').allTextContents()).every(t=>t.includes('老师')&&t.includes('休假申请')));checks.push('身份与类型组合筛选,查询回到第一页');
+ await page.getByLabel('搜索',{exact:true}).fill('不存在的内容');await page.getByRole('button',{name:'查询',exact:true}).click();await page.getByText('暂无符合条件的反馈或申请').waitFor();
+ await page.getByRole('button',{name:'重置',exact:true}).click();await page.locator('.request-row').first().waitFor();
+ 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 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();
+ }
+ checks.push('学生反馈、老师休假申请:实际表单提交类型、本人编号和正文');
+ 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();}

+ 45 - 0
scripts/cloud/support-requests.js

@@ -0,0 +1,45 @@
+// Both mobile roles submit category 22 to Guestbook. Keep legacy, untyped requests visible.
+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);
+  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);
+  for (const day of [from, to]) {
+    if (day && (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(Date.parse(day)) || new Date(day).toISOString().slice(0,10) !== day)) fail(400, '日期无效');
+  }
+  if (from && to && from > to) fail(400, '开始日期不能晚于结束日期');
+  const page = Math.max(1, Math.floor(number(input.page, 1)));
+  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(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'
+        WHEN g.title LIKE '【意见反馈】%' THEN 'feedback' ELSE 'legacy' END AS kind,
+      CASE WHEN COALESCE(to_jsonb(u)->>'identityType','')='coach' OR COALESCE(u."legacyGroupId"::text,u."legacyUserData"->>'GroupID')='3' THEN 'teacher'
+        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 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)
+      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')
+  ), paged AS (SELECT * FROM filtered ORDER BY "submittedAt" DESC NULLS LAST,id DESC LIMIT $7 OFFSET $8)
+  SELECT COUNT(*)::int AS total,
+    COUNT(*) FILTER(WHERE kind='feedback')::int AS feedback,
+    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,
+    summary:{feedback:result.feedback,leave:result.leave,legacy:result.legacy},refreshedAt:new Date().toISOString()};
+}

+ 4 - 2
scripts/deploy-admin-functions.mjs

@@ -7,6 +7,7 @@ const legacyAppointmentCode = readFileSync(new URL('./cloud/legacy-appointment.j
 const creditLedgerCode = readFileSync(new URL('./cloud/member-credit-ledger.js', import.meta.url), 'utf8') + readFileSync(new URL('./cloud/legacy-finance.js', import.meta.url), 'utf8');
 const enrollmentOpsCode = readFileSync(new URL('./cloud/member-enrollment.js', import.meta.url), 'utf8');
 const lessonRecordsCode = readFileSync(new URL('./cloud/lesson-records.js', import.meta.url), 'utf8');
+const supportRequestsCode = readFileSync(new URL('./cloud/support-requests.js', import.meta.url), 'utf8');
 const lessonWorkflowOpsCode = readFileSync(new URL('./cloud/lesson-workflow-ops.js', import.meta.url), 'utf8');
 const accountDeletionCode = readFileSync(new URL('./cloud/account-deletion.js', import.meta.url), 'utf8');
 const accountDeletionOpsCode = readFileSync(new URL('./cloud/account-deletion-ops.js', import.meta.url), 'utf8');
@@ -3791,6 +3792,7 @@ async function handler(request,response) {
     context=await auth(request);requireRole(context,['ops-manager','ops-staff','ops-auditor']);
     if(context.current.get('passwordResetRequired')===true)fail(403,'请先修改临时密码');
     if(operation==='ops/live/versions') return response.json({success:true,data:await operationsLiveVersions(context,input)});
+    if(operation==='ops/support-requests/list') return response.json({success:true,data:await supportRequestsPage(context,input)});
     if(operation==='ops/sync/status') return response.json({success:true,data:await syncStatus(context,input)});
     if(operation==='ops/sync/run') return response.json({success:true,data:await catchUpSync(context,input)});
     if(operation==='ops/sync/retry') return response.json({success:true,data:await retrySync(context,input)});
@@ -3922,8 +3924,8 @@ async function handler(request,response) {
 `;
 
 const definitions = [
-  { name: 'xiaoshu.ops.gateway', desc: '小树陪练运营管理后台专用网关(业务投影、分级隐私、三级权限与审计)', path: 'xiaoshu/ops/gateway', code: operationsGatewayCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode, params: [{ name: 'operation', type: 'String', required: true }] },
-  { name: 'xiaoshu.ops.gateway.v3', desc: '小树陪练运营管理后台专用网关 V3(含历史排课投影、上海业务日期与陪练排课汇总)', path: 'xiaoshu/ops/gateway-v3', code: operationsGatewayCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode, params: [{ name: 'operation', type: 'String', required: true }] },
+  { name: 'xiaoshu.ops.gateway', desc: '小树陪练运营管理后台专用网关(业务投影、分级隐私、三级权限与审计)', path: 'xiaoshu/ops/gateway', code: operationsGatewayCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode + supportRequestsCode, params: [{ name: 'operation', type: 'String', required: true }] },
+  { name: 'xiaoshu.ops.gateway.v3', desc: '小树陪练运营管理后台专用网关 V3(含历史排课投影、上海业务日期与陪练排课汇总)', path: 'xiaoshu/ops/gateway-v3', code: operationsGatewayCode + accountDeletionOpsCode + adminRolePolicyCode + reviewRewardCode + enrollmentOpsCode + creditLedgerCode + payrollIntegrityCode + lessonWorkflowOpsCode + lessonRecordsCode + supportRequestsCode, params: [{ name: 'operation', type: 'String', required: true }] },
   { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode + adminRolePolicyCode + operatorAccountsCode, params: [{ name: 'operation', type: 'String', required: true }] },
   { name: 'cms.content-normalizer', desc: '替代 ZL_StripeHtmlTag/ZL_StripeTrimstr,保守输出纯文本', path: 'xiaoshu/cms/content-normalizer', code: cmsReadCode('content-normalizer'), params: [{ name: 'content', type: 'String', required: false }] },
   { name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] },

+ 71 - 0
scripts/tests/support-requests.test.mjs

@@ -0,0 +1,71 @@
+import {test,before,after} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+
+const 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,
+  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'};
+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');`);
+  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],
+    ['old','a',1,'学生甲 反馈的意见','旧表单的休假申请内容','2026-10-14T15:59:59Z',0,99,22],
+    ['missing','a',9,'原账号反馈','<script>alert(1)</script>','2026-10-16T00:00:00Z',0,0,22],
+    ['other','b',1,'别的公司','不可见','2026-10-15T00:00:00Z',0,0,22],
+    ['reply','a',1,'回复','不应算新申请','2026-10-15T00:00:00Z',123,99,22],
+    ['deleted','a',1,'已删除','不可见','2026-10-15T00:00:00Z',0,-2,22],
+    ['community','a',1,'社区留言','其他栏目','2026-10-15T00:00:00Z',0,99,10],
+  ];
+  for(const r of rows)await query(`INSERT INTO "Guestbook" VALUES($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,'legacy')`,r);
+});
+after(()=>db.close());
+test('includes pending and historical requests; excludes replies, recycle bin, other categories and tenants',async()=>{
+  const d=await ctx.supportRequestsPage(admin,{});
+  assert.equal(d.total,4);assert.equal(d.summary.feedback,1);assert.equal(d.summary.leave,1);assert.equal(d.summary.legacy,2);
+  assert.equal(d.items.find(r=>r.id==='old').kind,'legacy');
+  assert.equal(d.items.find(r=>r.id==='new').role,'student');
+  assert.equal(d.items.find(r=>r.id==='leave').role,'teacher');
+  assert.equal(d.items.find(r=>r.id==='missing').role,'other');
+  assert.equal(d.items.find(r=>r.id==='missing').content,'<script>alert(1)</script>');
+});
+test('role, type, account, content and literal search filters',async()=>{
+  assert.equal((await ctx.supportRequestsPage(admin,{role:'student'})).total,2);
+  assert.equal((await ctx.supportRequestsPage(admin,{role:'teacher',kind:'leave',search:'老师乙'})).total,1);
+  assert.equal((await ctx.supportRequestsPage(admin,{search:'旧表单'})).total,1);
+  assert.equal((await ctx.supportRequestsPage(admin,{search:"%' OR 1=1--"})).total,0);
+  assert.equal((await ctx.supportRequestsPage(admin,{search:'%'})).total,0);
+});
+test('Shanghai inclusive dates and stable pagination; counts survive empty pages',async()=>{
+  const p=await ctx.supportRequestsPage(admin,{dateFrom:'2026-10-15',dateTo:'2026-10-15',pageSize:1});
+  assert.equal(p.total,2);assert.equal(p.items[0].id,'leave');
+  assert.equal((await ctx.supportRequestsPage(admin,{dateFrom:'2026-10-15',dateTo:'2026-10-15',pageSize:1,page:2})).items[0].id,'new');
+  const empty=await ctx.supportRequestsPage(admin,{page:99});assert.equal(empty.total,4);assert.equal(empty.items.length,0);
+});
+test('rejects missing tenant, unauthorized roles and invalid filters',async()=>{
+  await assert.rejects(ctx.supportRequestsPage({...admin,company:null},{}),/帐套/);
+  await assert.rejects(ctx.supportRequestsPage({...admin,role:'student'},{}),/forbidden/);
+  for(const input of [{kind:'approved'},{role:'admin'},{dateFrom:'2026-02-30'},{dateFrom:'2026-10-20',dateTo:'2026-10-15'}]) await assert.rejects(ctx.supportRequestsPage(admin,input));
+  assert.equal((await ctx.supportRequestsPage({...admin,role:'ops-auditor'},{})).total,4);
+});
+test('same user ID in another tenant cannot change submitter identity',async()=>{
+  assert.equal((await ctx.supportRequestsPage({...admin,company:'b'},{})).items[0].accountName,'其他帐套');
+  assert.equal((await ctx.supportRequestsPage(admin,{search:'其他帐套'})).total,0);
+});
+test('existing mobile save function persists typed and legacy submissions for this board',async()=>{
+  const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
+  let id=0;
+  const saveCtx={...ctx,parseObject:v=>JSON.parse(v),ownLegacyId:()=>2,firstValue:(...vs)=>vs.find(v=>v!==undefined&&v!==null&&v!==''),cleanText:(v,n)=>String(v??'').trim().slice(0,n),safe:v=>v.fields,isAdminUser:()=>false,
+    Parse:{Object:class {fields={};set(k,v){this.fields[k]=v;}async save(){const f=this.fields;this.fields.objectId='saved'+ ++id;await query(`INSERT INTO "Guestbook" VALUES($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,$10)`,[f.objectId,f.company,f.userid,f.title,f.tcontent,f.gdate.toISOString(),f.parentid,f.status,f.cateid,f.sourceKey]);}}}};
+  vm.createContext(saveCtx);vm.runInContext(source.slice(source.indexOf('async function createGuestbook('),source.indexOf('async function authorizeRequestedUser(')),saveCtx);
+  for(const title of ['【休假申请】老师乙','老师乙 反馈的意见'])await saveCtx.createGuestbook({model:JSON.stringify({UserID:2,Title:title,TContent:'保存链路验收',Cateid:22})},{get:()=> 'a'});
+  const d=await ctx.supportRequestsPage(admin,{search:'保存链路验收'});assert.equal(d.total,2);
+  assert.deepEqual(d.items.map(r=>r.kind).sort(),['leave','legacy']);
+});