Archived
E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排
活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2): - 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进 S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视 - 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo, 计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标) - 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源, 正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填 - 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键; 标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段 - 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数 - 阶段刻度对齐文档 7 步;起单样例预填满 基座侧: - 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY 广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻 - 多行 write 整批先验后写(dryRun),被拒 = 一行都没落 - 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors) - 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行) - 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端 - 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸 - 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组; JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖 - 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01 契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo, objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。 1700 测 / typecheck / lint / depcruise / api:check 绿。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 会去请求服务端的那种按钮 —— 三面共用(2026-08-24 用户:「点击了都不知道点没点上」)。
|
||||
*
|
||||
* 一枚按钮按下去之后,人要在**三个时刻**都看得见东西:
|
||||
* · **按下那一刻** —— 键帽下沉(`:active`,纯 CSS,见 `styles/press.css`),证明这一下点着了;
|
||||
* · **在办事的这一段** —— `aria-busy` 亮着,按钮脚下走一道不定长进度条,鼠标也进不来
|
||||
* (`pointer-events: none`)。这一段从前是**完全静默**的:屏上什么都没变,人只好再点一次;
|
||||
* · **办完那一刻** —— 条收掉,按钮恢复可按。
|
||||
*
|
||||
* 为什么用 `aria-disabled` + `pointer-events: none`,不用原生 `disabled`:
|
||||
* `disabled` 会把按钮画成灰的 —— 那是「这件事现在做不了」,而它此刻正在**做**。
|
||||
* 界面不撒谎:在办事就画成在办事,别画成不可用。真正做不了的照旧给 `disabled`。
|
||||
*
|
||||
* 在办的判断有两条路,谁先来算谁:
|
||||
* · `busy`(常用)—— 把 react-query 的 `isPending` 递进来;
|
||||
* · `onClick` 返回了 Promise —— 自己盯着它落地。
|
||||
* 两条路之外还有唯一通道上的去重闸(`typed-fetch`),那是**最后一道**:
|
||||
* 就算这里漏了,同一笔请求也不会真的发两遍。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type ButtonHTMLAttributes, type MouseEvent, type ReactNode } from 'react';
|
||||
|
||||
type BtnProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> & {
|
||||
/** 在办事(通常是 `mutation.isPending`) */
|
||||
busy?: boolean | undefined;
|
||||
/** 返回 Promise 的话,不必再给 `busy` —— 落地之前一直算在办 */
|
||||
onClick?: ((e: MouseEvent<HTMLButtonElement>) => unknown) | undefined;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
function isThenable(v: unknown): v is Promise<unknown> {
|
||||
return typeof v === 'object' && v !== null && typeof (v as { then?: unknown }).then === 'function';
|
||||
}
|
||||
|
||||
export function Btn({ busy, onClick, disabled, type = 'button', ...rest }: BtnProps) {
|
||||
const [awaiting, setAwaiting] = useState(false);
|
||||
const alive = useRef(true);
|
||||
// 上来先把旗子插回去 —— StrictMode 开发下 effect 走「挂载 → 拆卸 → 再挂载」,
|
||||
// 只在拆卸时落旗的话第二次挂载后它永远是 false,靠 Promise 收尾的按钮会一直亮着「在办事」。
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => {
|
||||
alive.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pending = busy === true || awaiting;
|
||||
|
||||
const fire = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
// 在办事的时候再点一下:什么也不做(不是拒绝,是这一下本来就是余震)
|
||||
if (pending || disabled === true || onClick === undefined) return;
|
||||
const r = onClick(e);
|
||||
if (!isThenable(r)) return;
|
||||
setAwaiting(true);
|
||||
const settle = (): void => {
|
||||
if (alive.current) setAwaiting(false);
|
||||
};
|
||||
r.then(settle, settle);
|
||||
},
|
||||
[pending, disabled, onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
type={type}
|
||||
disabled={disabled === true}
|
||||
{...(pending ? { 'aria-busy': true, 'aria-disabled': true } : {})}
|
||||
onClick={fire}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 唯一通道上的那道闸 —— 「同一笔请求不发两遍」(2026-08-24)。
|
||||
*
|
||||
* 这道闸不是性能优化,是**双击不该变成两笔**:点「新建纪要」手抖点两下,
|
||||
* 从前真的建两份;点「装入」两下,真的装两遍。
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { markCommReadRoute, sessionRoute } from '@contracts';
|
||||
import { ApiCallError, DOUBLE_CLICK_MS, call, callKey, resetCallGuards } from '../typed-fetch.ts';
|
||||
|
||||
const write = markCommReadRoute;
|
||||
const read = sessionRoute;
|
||||
|
||||
/** 手动控制何时落地的假网关 —— 在途那一段要摆得住才测得了 */
|
||||
function gateway() {
|
||||
const calls: string[] = [];
|
||||
const pending: Array<(v: unknown) => void> = [];
|
||||
const fetchMock = vi.fn((url: string, init?: { method?: string }) => {
|
||||
calls.push(`${init?.method ?? 'GET'} ${url}`);
|
||||
return new Promise((resolve) => {
|
||||
pending.push(() => resolve({ ok: true, status: 200, json: () => Promise.resolve({ n: calls.length }) }));
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return { calls, settleAll: () => pending.splice(0).forEach((f) => f(undefined)) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetCallGuards();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('callKey —— 差一个字就是另一笔', () => {
|
||||
it('方法 + 落地地址 + 报文,三样合起来才是身份', () => {
|
||||
expect(callKey(write, { params: { commId: 'a' } })).toBe('POST /api/desk/comms/a/read ');
|
||||
expect(callKey(write, { params: { commId: 'a' }, body: { read: true } })).toBe(
|
||||
'POST /api/desk/comms/a/read {"read":true}',
|
||||
);
|
||||
});
|
||||
|
||||
it('**改了一格再存不算重复**:报文不同,身份就不同', () => {
|
||||
expect(callKey(write, { body: { read: true } })).not.toBe(callKey(write, { body: { read: false } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('在途去重', () => {
|
||||
it('**同一笔还在路上,第二次点击不再发第二个请求**', async () => {
|
||||
const g = gateway();
|
||||
const a = call(write, { params: { commId: 'a' } });
|
||||
const b = call(write, { params: { commId: 'a' } });
|
||||
expect(g.calls).toHaveLength(1);
|
||||
g.settleAll();
|
||||
expect(await a).toEqual(await b);
|
||||
});
|
||||
|
||||
it('不同的那一笔照发不误', async () => {
|
||||
const g = gateway();
|
||||
void call(write, { params: { commId: 'a' } });
|
||||
void call(write, { params: { commId: 'b' } });
|
||||
expect(g.calls).toEqual(['POST /api/desk/comms/a/read', 'POST /api/desk/comms/b/read']);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
it('读请求同样只发一个(点开同一份资料连点两下)', async () => {
|
||||
const g = gateway();
|
||||
void call(read);
|
||||
void call(read);
|
||||
expect(g.calls).toHaveLength(1);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
});
|
||||
|
||||
describe('落地后的双击窗 —— 只管写请求', () => {
|
||||
it('**刚成功那一笔在窗内原样重来,交回上一次的结果、不再发**', async () => {
|
||||
const g = gateway();
|
||||
const first = call(write, { params: { commId: 'a' } });
|
||||
g.settleAll();
|
||||
await first;
|
||||
|
||||
const again = await call(write, { params: { commId: 'a' } });
|
||||
expect(g.calls).toHaveLength(1);
|
||||
expect(again).toEqual(await first);
|
||||
});
|
||||
|
||||
it('过了窗口就是新的一次点击,照发', async () => {
|
||||
const g = gateway();
|
||||
const first = call(write, { params: { commId: 'a' } });
|
||||
g.settleAll();
|
||||
await first;
|
||||
|
||||
vi.advanceTimersByTime(DOUBLE_CLICK_MS + 1);
|
||||
void call(write, { params: { commId: 'a' } });
|
||||
expect(g.calls).toHaveLength(2);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
it('**读请求不设这个窗** —— 写完立刻重取的那一下不能被当成余震压掉', async () => {
|
||||
const g = gateway();
|
||||
const first = call(read);
|
||||
g.settleAll();
|
||||
await first;
|
||||
|
||||
void call(read);
|
||||
expect(g.calls).toHaveLength(2);
|
||||
g.settleAll();
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
it('**失败的不进窗**:那时候再点是重试,重试必须真的重试', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve({ ok: false, status: 500, statusText: 'x', json: () => Promise.resolve({ code: 'internal', message: '炸了' }) })),
|
||||
);
|
||||
await expect(call(write, { params: { commId: 'a' } })).rejects.toBeInstanceOf(ApiCallError);
|
||||
await expect(call(write, { params: { commId: 'a' } })).rejects.toBeInstanceOf(ApiCallError);
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FlowEdge, FlowGraph, FlowNode, FlowNodeKind } from "./flow-layout.ts";
|
||||
import { createFlowEditor, syncElementLabel, type FlowEditorHandles } from "./flow-editor-core.ts";
|
||||
import "diagram-js/assets/diagram-js.css";
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
/**
|
||||
* 手工绘图编辑器(React 壳):内核是 flow-editor-core 的 diagram-js modeling 全栈
|
||||
@@ -208,10 +209,10 @@ export function FlowEditor({ initial, drilledIds, onSave, onCancel }: FlowEditor
|
||||
>
|
||||
<Ico name="x" s={12} /> 取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<Btn
|
||||
className="be-btn solid"
|
||||
disabled={!canSave}
|
||||
busy={busy}
|
||||
onClick={() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
@@ -225,7 +226,7 @@ export function FlowEditor({ initial, drilledIds, onSave, onCancel }: FlowEditor
|
||||
}}
|
||||
>
|
||||
<Ico name="check" s={12} /> {busy ? "保存中…" : "保存绘图"}
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
{error && <p className="fe-error">{error}</p>}
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@ import type {
|
||||
GovWorkGroupsOutput,
|
||||
GovWorkflowOutput,
|
||||
ProcessPackage,
|
||||
GovLedgerByActivityOutput,
|
||||
} from '@contracts';
|
||||
import type { ApprovalTemplate } from '@contracts';
|
||||
import {
|
||||
browseLedgerRoute,
|
||||
ledgerByActivityRoute,
|
||||
govMetricsRoute,
|
||||
govTaskLogRoute,
|
||||
govWorkflowRoute,
|
||||
@@ -57,6 +59,7 @@ export type GovActivities = GovActivitiesOutput;
|
||||
export type GovTools = GovToolsOutput;
|
||||
export type GovObjects = GovObjectsOutput;
|
||||
export type GovLedgerPage = GovLedgerPageOutput;
|
||||
export type GovLedgerByActivity = GovLedgerByActivityOutput;
|
||||
export type GovMemory = GovMemoryOutput;
|
||||
export type GovMetrics = GovMetricsOutput;
|
||||
export type GovChannels = GovChannelsOutput;
|
||||
@@ -139,6 +142,17 @@ export const useGovObjects = (opts?: { enabled?: boolean }): UseQueryResult<GovO
|
||||
enabled: opts?.enabled ?? true,
|
||||
});
|
||||
|
||||
/**
|
||||
* **按活动看数据域**(2026-08-24)—— 「我这个活动写了哪几张表,现在各有几行」。
|
||||
* 键挂在 `['gov','data','by-activity']` 下:`kind:'system'` 的推送按 `gov` 前缀失效,
|
||||
* 一单跑完这一屏跟着变(数据域不以任务为轴,靠的就是那条系统级广播)。
|
||||
*/
|
||||
export const useGovDataByActivity = (): UseQueryResult<GovLedgerByActivity> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'data', 'by-activity'],
|
||||
queryFn: () => call<GovLedgerByActivity>(ledgerByActivityRoute),
|
||||
});
|
||||
|
||||
export const useGovLedger = (domain: string | undefined, object: string | undefined): UseQueryResult<GovLedgerPage> =>
|
||||
useQuery({
|
||||
queryKey: ['gov', 'ledger', domain, object],
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { useState, type CSSProperties, type Dispatch, type ReactNode, type SetStateAction } from 'react';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { Btn } from '@web-shared/Btn.tsx';
|
||||
|
||||
export function Chip({ tone = 'mute', ico, children }: { tone?: string; ico?: string; children: ReactNode }) {
|
||||
return (
|
||||
@@ -359,16 +360,16 @@ export function ListEdit({
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(items.map((x, j) => (j === i ? e.target.value : x)))}
|
||||
/>
|
||||
<button className="g-le-x" title="删除该项" onClick={() => onChange(items.filter((_, j) => j !== i))}>
|
||||
<Btn className="g-le-x" title="删除该项" onClick={() => onChange(items.filter((_, j) => j !== i))}>
|
||||
<Ico name="trash-2" s={13} />
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <span className="g-le-e">未声明</span>}
|
||||
<button className="g-le-add" onClick={() => onChange([...items, ''])}>
|
||||
<Btn className="g-le-add" onClick={() => onChange([...items, ''])}>
|
||||
<Ico name="plus" s={12} />
|
||||
添加
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -390,6 +391,8 @@ export type ActSpec = {
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
/** 这一钮的请求还在路上 —— 递 `mutation.isPending`,按钮自己会说「在办事」 */
|
||||
busy?: boolean;
|
||||
ask?: AskSpec | undefined;
|
||||
onClick?: () => void;
|
||||
};
|
||||
@@ -417,10 +420,10 @@ export function AskBand({ spec, danger, onCancel }: { spec: AskSpec; danger?: bo
|
||||
</div>
|
||||
</div>
|
||||
<div className="g-ask-a">
|
||||
<button className="g-btn" onClick={onCancel}>
|
||||
<Btn className="g-btn" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
</Btn>
|
||||
<Btn
|
||||
className={'g-btn ' + (danger ? 'danger' : 'primary')}
|
||||
onClick={() => {
|
||||
spec.onOk();
|
||||
@@ -428,7 +431,7 @@ export function AskBand({ spec, danger, onCancel }: { spec: AskSpec; danger?: bo
|
||||
}}
|
||||
>
|
||||
{spec.okLabel}
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -441,9 +444,10 @@ export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; al
|
||||
<div className="g-acts">
|
||||
<div className="g-acts-r" style={{ justifyContent: align === 'end' ? 'flex-end' : 'flex-start' }}>
|
||||
{items.map((it, i) => (
|
||||
<button
|
||||
<Btn
|
||||
key={i}
|
||||
disabled={it.disabled === true}
|
||||
busy={it.busy === true}
|
||||
className={
|
||||
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
|
||||
}
|
||||
@@ -451,7 +455,7 @@ export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; al
|
||||
>
|
||||
{it.ico && <Ico name={it.ico} s={13} />}
|
||||
{it.label}
|
||||
</button>
|
||||
</Btn>
|
||||
))}
|
||||
</div>
|
||||
{a?.ask && <AskBand spec={a.ask} {...(a.danger === undefined ? {} : { danger: a.danger })} onCancel={() => setArmed(null)} />}
|
||||
@@ -495,8 +499,10 @@ export function PageHead({
|
||||
{acts && (
|
||||
<div className="g-dh-a">
|
||||
{acts.map((it, i) => (
|
||||
<button
|
||||
<Btn
|
||||
key={i}
|
||||
disabled={it.disabled === true}
|
||||
busy={it.busy === true}
|
||||
className={
|
||||
'g-btn' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (armed === i ? ' armed' : '')
|
||||
}
|
||||
@@ -504,7 +510,7 @@ export function PageHead({
|
||||
>
|
||||
{it.ico && <Ico name={it.ico} s={13} />}
|
||||
{it.label}
|
||||
</button>
|
||||
</Btn>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
/**
|
||||
* 服务端交互的唯一通道(编码规范 §9.2):组件内**禁止裸 fetch**。
|
||||
* 路由契约来自 `@contracts/api`;**禁止 Hono RPC 客户端** —— 那要求前端 import 后端类型(§5.1)。
|
||||
*
|
||||
* **同一笔请求不发两遍**(2026-08-24):唯一通道正是安放这道闸的地方 ——
|
||||
* 按在这里,所有点击路径(按钮 / 下拉 / 勾选 / 组件里临时写的 mutate)一次盖全,
|
||||
* 不必指望每个调用点自己记得防抖。两段:
|
||||
* · **在途去重** —— 同一笔还在路上,第二次点击拿到的是同一个 Promise,不再发第二个请求;
|
||||
* · **落地后的双击窗**(仅写请求)—— 刚成功那一笔在 `DOUBLE_CLICK_MS` 内原样重来,
|
||||
* 当作同一次点击的余震,交回上一次的结果。失败的不进这个窗:那时候再点是**重试**,
|
||||
* 重试必须真的重试。
|
||||
* 读请求(GET)只做在途去重,不设落地窗 —— 写完立刻重取的那一下不能被当成余震压掉,
|
||||
* 否则屏上摆的是改之前的数(界面不撒谎)。
|
||||
*/
|
||||
import type { RouteContract } from '@contracts';
|
||||
|
||||
@@ -36,7 +46,36 @@ function queryString(query: Readonly<Record<string, string | undefined>> = {}):
|
||||
return s === '' ? '' : `?${s}`;
|
||||
}
|
||||
|
||||
export async function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> {
|
||||
/** 双击阈值 —— 一次成功落地之后这么久之内的同一笔,算同一次点击的余震 */
|
||||
export const DOUBLE_CLICK_MS = 450;
|
||||
|
||||
/**
|
||||
* 一笔请求的身份 = 方法 + 落地后的完整地址 + 报文。
|
||||
* 差一个字就是另一笔(改了一格再存不会被当成重复),一模一样才谈得上「点了两下」。
|
||||
*/
|
||||
export function callKey(route: RouteContract, options: CallOptions = {}): string {
|
||||
const url = resolvePath(route.path, options.params) + queryString(options.query);
|
||||
return `${route.method} ${url} ${options.body === undefined ? '' : JSON.stringify(options.body)}`;
|
||||
}
|
||||
|
||||
/** 在路上的:键 → 那一个 Promise */
|
||||
const inFlight = new Map<string, Promise<unknown>>();
|
||||
/** 刚落地的写请求:键 → { 时刻, 结果 } */
|
||||
const justDone = new Map<string, { at: number; value: unknown }>();
|
||||
|
||||
/** 过了窗口的旧条目扫掉 —— 这张表只服务「刚刚那一下」,不该随会话一直长 */
|
||||
function sweep(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of justDone) if (now - v.at >= DOUBLE_CLICK_MS) justDone.delete(k);
|
||||
}
|
||||
|
||||
/** 测试用:把两张表清空(浏览器里没有调用方) */
|
||||
export function resetCallGuards(): void {
|
||||
inFlight.clear();
|
||||
justDone.clear();
|
||||
}
|
||||
|
||||
async function send<T>(route: RouteContract, options: CallOptions): Promise<T> {
|
||||
const response = await fetch(resolvePath(route.path, options.params) + queryString(options.query), {
|
||||
method: route.method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -56,3 +95,35 @@ export async function call<T>(route: RouteContract, options: CallOptions = {}):
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> {
|
||||
const key = callKey(route, options);
|
||||
const write = route.method !== 'GET';
|
||||
|
||||
const flying = inFlight.get(key);
|
||||
if (flying !== undefined) return flying as Promise<T>;
|
||||
|
||||
if (write) {
|
||||
const done = justDone.get(key);
|
||||
if (done !== undefined && Date.now() - done.at < DOUBLE_CLICK_MS) return Promise.resolve(done.value as T);
|
||||
if (done !== undefined) justDone.delete(key);
|
||||
}
|
||||
|
||||
const promise = send<T>(route, options).then(
|
||||
(value) => {
|
||||
inFlight.delete(key);
|
||||
// 只记成功的:失败之后再点是重试,重试得真的发出去
|
||||
if (write) {
|
||||
sweep();
|
||||
justDone.set(key, { at: Date.now(), value });
|
||||
}
|
||||
return value;
|
||||
},
|
||||
(error: unknown) => {
|
||||
inFlight.delete(key);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user