Explorar o código

fix: publish support request query and clarify submit action

彭峰 hai 4 días
pai
achega
22a8d8dd7b

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

@@ -39,5 +39,5 @@
     </div>
     }
   }
-  @if (!appealAudit()['ID']) { <button type="submit" class="primary-button save-button" [disabled]="loading() || !!loadError()"><lucide-icon [img]="icons.Save" [size]="19" />{{ loading() ? '保存中...' : '保存' }}</button> }
+  @if (!appealAudit()['ID']) { <button type="submit" class="primary-button save-button" [disabled]="loading() || !!loadError()"><lucide-icon [img]="meta.path === 'pages/member/feedback' ? icons.Send : icons.Save" [size]="19" />{{ meta.path === 'pages/member/feedback' ? (loading() ? '提交中...' : '提交') : (loading() ? '保存中...' : '保存') }}</button> }
 </form>

+ 43 - 0
projects/xiaoshu-mobile/src/app/features/form/form-page.component.spec.ts

@@ -0,0 +1,43 @@
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { signal } from '@angular/core';
+import { Subject } from 'rxjs';
+import { FormPageComponent } from './form-page.component';
+import { ApiService } from '../../core/api.service';
+import { SessionService } from '../../core/session.service';
+import { PlatformService } from '../../core/platform.service';
+import { PAGE_BY_PATH } from '../../core/page-catalog';
+
+describe('意见反馈提交', () => {
+  let response: Subject<unknown>;
+  let api: jasmine.SpyObj<ApiService>;
+  let platform: { loading: ReturnType<typeof signal<boolean>>; toast: jasmine.Spy };
+  beforeEach(() => {
+    response = new Subject();
+    api = jasmine.createSpyObj('ApiService', ['post']); api.post.and.returnValue(response as never);
+    platform = { loading: signal(false), toast: jasmine.createSpy('toast') };
+    TestBed.configureTestingModule({ providers: [
+      { provide: ActivatedRoute, useValue: { snapshot: { data: { meta: PAGE_BY_PATH.get('pages/member/feedback') }, queryParamMap: convertToParamMap({}) } } },
+      { provide: ApiService, useValue: api },
+      { provide: SessionService, useValue: { user: () => ({ userId: 69, groupId: 3 }) } },
+      { provide: PlatformService, useValue: platform },
+    ] });
+  });
+  it('blocks repeated submission while pending and preserves input on failure', async () => {
+    const c = TestBed.runInInjectionContext(() => new FormPageComponent());
+    c.values = {name: '测试老师', content: '请假', requestType: 'leave'};
+    await c.submit(); await c.submit();
+    expect(api.post).toHaveBeenCalledTimes(1); expect(c.loading()).toBeTrue();
+    expect(platform.loading()).toBeTrue();
+    response.error(new Error('网络错误'));
+    expect(c.loading()).toBeFalse(); expect(platform.loading()).toBeFalse();
+    expect(c.values['content']).toBe('请假');
+  });
+  it('shows 提交 and 提交中 for the feedback page', () => {
+    const fixture = TestBed.createComponent(FormPageComponent); fixture.detectChanges();
+    const button = fixture.nativeElement.querySelector('button[type=submit]');
+    expect(button.textContent.trim()).toBe('提交');
+    fixture.componentInstance.loading.set(true); fixture.detectChanges();
+    expect(button.textContent.trim()).toBe('提交中...'); expect(button.disabled).toBeTrue();
+  });
+});

+ 4 - 2
projects/xiaoshu-mobile/src/app/features/form/form-page.component.ts

@@ -228,6 +228,7 @@ export class FormPageComponent implements OnInit {
   }
 
   async submit(): Promise<void> {
+    if (this.loading()) return;
     if (this.loadError()) {
       this.platform.toast('当前页面数据未正确加载,无法保存', 'error');
       return;
@@ -269,10 +270,11 @@ export class FormPageComponent implements OnInit {
       addon: this.loadedAddon,
     });
     if (!submission) return;
+    this.loading.set(true);
     this.platform.loading.set(true);
-    this.api.post(submission.action, submission.payload).pipe(finalize(() => this.platform.loading.set(false))).subscribe({
+    this.api.post(submission.action, submission.payload).pipe(finalize(() => { this.loading.set(false); this.platform.loading.set(false); })).subscribe({
       next: () => {
-        this.platform.toast('保存成功', 'success');
+        this.platform.toast(this.meta.path === 'pages/member/feedback' ? '提交成功' : '保存成功', 'success');
         if (this.meta.path === 'pages/member/settings') {
           this.session.patchUser(this.values as Partial<import('../../core/models').AppUser>);
           return;

+ 1 - 1
scripts/check-support-requests-browser.mjs

@@ -41,7 +41,7 @@ try{
   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();
+  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('学生反馈、老师休假申请:实际表单提交类型、本人编号和正文');

+ 54 - 0
scripts/deploy-support-requests.mjs

@@ -0,0 +1,54 @@
+#!/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 {resolve} from 'node:path';
+import {pathToFileURL} from 'node:url';
+const helper=readFileSync(new URL('./cloud/support-requests.js',import.meta.url),'utf8').trim();
+const dispatch="    if(operation==='ops/support-requests/list') return response.json({success:true,data:await supportRequestsPage(context,input)});";
+const anchor="    if(operation==='ops/live/versions')";
+export function patchSupportRequests(code){
+  if(code.includes('ops/support-requests/list')||code.includes('async function supportRequestsPage(')){
+    if(code.includes(dispatch)&&code.includes(helper))return code;
+    throw Error('线上已有不同版本的反馈入口,请先人工核对');
+  }
+  if(code.split(anchor).length!==2)throw Error('线上网关分发入口与预期不一致,停止发布');
+  const next=code.replace(anchor,dispatch+'\n'+anchor)+'\n'+helper+'\n';
+  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}));
+}
+if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main().catch(e=>{console.error(e.message);process.exitCode=1;});

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

@@ -0,0 +1,16 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {patchSupportRequests} from '../deploy-support-requests.mjs';
+const before="// online-only fix must remain\nasync function handler(){\n    if(operation==='ops/live/versions') return live();\n    if(operation==='ops/existing') return existing();\n}";
+test('scoped patch preserves every byte of the baseline outside the new dispatch/helper',()=>{
+ const after=patchSupportRequests(before);
+ assert.ok(after.includes('// online-only fix must remain'));
+ assert.ok(after.includes("if(operation==='ops/existing') return existing();"));
+ assert.equal(after.replace("    if(operation==='ops/support-requests/list') return response.json({success:true,data:await supportRequestsPage(context,input)});\n",'').split('\n// Both mobile roles')[0],before);
+ assert.equal(patchSupportRequests(after),after);
+});
+test('refuses missing or ambiguous anchors and conflicting existing implementations',()=>{
+ assert.throws(()=>patchSupportRequests('async function handler(){}'),/不一致/);
+ assert.throws(()=>patchSupportRequests(before+before),/不一致/);
+ assert.throws(()=>patchSupportRequests(before+'\n// ops/support-requests/list'),/不同版本/);
+});