增加多用户配置报价

Co-authored-by: Cursor <cursoragent@cursor.com>
master
你的GitHub用户名 4 weeks ago
parent 360c2c2d71
commit 4ef21f41db

@ -1,14 +1,24 @@
# 宿主机 MySQL 端口docker-compose 映射,避开本机已占用的 3306 # 查价中台环境变量模板(本地开发)
# 生产部署请使用cp deploy/.env.production.example .env && bash deploy/install.sh
# 详见 deploy/README.md
MYSQL_HOST_PORT=3307 MYSQL_HOST_PORT=3307
MYSQL_ROOT_PASSWORD=changeme MYSQL_ROOT_PASSWORD=changeme
JWT_SECRET=your-256-bit-secret-change-in-production JWT_SECRET=your-256-bit-secret-change-in-production
# 宿主 Service Token 映射JSONtoken → { customerId, permissions[] } # 宿主 Service Token 映射JSONtoken → { customerId, permissions[] }
HOST_SERVICE_TOKENS={"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}} HOST_SERVICE_TOKENS={"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}
# 本地 development 默认跳过宿主 Token 鉴权npm run dev生产环境无效
# 若要在本地也校验 Token设为 false
# HOST_SERVICE_AUTH_DISABLED=false
# 管理端可见客户列表(逗号分隔,与 HOST_SERVICE_TOKENS 合并) # 管理端可见客户列表(逗号分隔,与 HOST_SERVICE_TOKENS 合并)
CUSTOMER_REGISTRY=CUST_001,CUST_002,CUST_003 CUSTOMER_REGISTRY=CUST_001,CUST_002,CUST_003
# 宿主两步公开接口美美与共等true=免 Token后期改 false 并走 Service Token
HOST_PUBLIC_API_ENABLED=false
HOST_PUBLIC_DEFAULT_CUSTOMER_ID=CUST_001
# ── RPAPRD v0.6 GD-15headed 录制后填入)──────────────────────── # ── RPAPRD v0.6 GD-15headed 录制后填入)────────────────────────
# 逗号分隔、按序探测;禁止 dashboard.mothership.com / www.mothership.com/quote # 逗号分隔、按序探测;禁止 dashboard.mothership.com / www.mothership.com/quote
# 英文 locale 避免中文精简 widget无 cargo editor首页 + en-US 为推荐组合 # 英文 locale 避免中文精简 widget无 cargo editor首页 + en-US 为推荐组合
@ -16,6 +26,8 @@ MOTHERSHIP_QUOTE_URLS=https://www.mothership.com/
# 留空则 Playwright 默认en-US headless 仅显示 zip 精简条,与地址 RPA 不兼容 # 留空则 Playwright 默认en-US headless 仅显示 zip 精简条,与地址 RPA 不兼容
# RPA_BROWSER_LOCALE=en-US # RPA_BROWSER_LOCALE=en-US
RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json
# 缺会话文件时 headless 自动访问 MOTHERSHIP_QUOTE_URLS 获取 cookies默认 true设 false 则须手动 record:mothership
# RPA_AUTO_BOOTSTRAP_STORAGE=true
RPA_MOCK_MODE=false RPA_MOCK_MODE=false
# 地址 commit 隔离real=真实 MotherShipmock=仅替代 commitprove-rpa-chain 下游验证用) # 地址 commit 隔离real=真实 MotherShipmock=仅替代 commitprove-rpa-chain 下游验证用)
RPA_ADDRESS_MODE=real RPA_ADDRESS_MODE=real
@ -75,6 +87,13 @@ RPA_QUOTE_NETWORK_VISIBILITY=false
RPA_QUOTE_DEBUG_RAW=false RPA_QUOTE_DEBUG_RAW=false
# 设为 true 时日志输出 JSON 候选 URL校准 HAR pinned 用) # 设为 true 时日志输出 JSON 候选 URL校准 HAR pinned 用)
RPA_QUOTE_NETWORK_SNIFF=false RPA_QUOTE_NETWORK_SNIFF=false
# Priority1 worker 填表提速(生产建议)
# RPA_DWELL_MS=0
# RPA_SLOW_MO_MS=0
# RPA_QUOTE_POLL_MS=600
# RPA_QUOTE_WAIT_MS=90000
# RPA_PRIORITY1_WORKER_FAST=true
RPA_SELECTOR_TRANSIT=text=business days RPA_SELECTOR_TRANSIT=text=business days
# 货物字段已改为 fillCargoInDrawer 动态 placeholder 定位,下列 env 仅兼容旧脚本runtime 不再读取 # 货物字段已改为 fillCargoInDrawer 动态 placeholder 定位,下列 env 仅兼容旧脚本runtime 不再读取
# RPA_SELECTOR_PALLET_QTY=... # RPA_SELECTOR_PALLET_QTY=...

1
.gitattributes vendored

@ -0,0 +1 @@
*.sh text eol=lf

4
.gitignore vendored

@ -35,3 +35,7 @@ next-env.d.ts
# test artifacts # test artifacts
playwright-report/ playwright-report/
test-results/ test-results/
# local upload bundles
*.zip
上传到服务器/

@ -9,6 +9,10 @@ FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ARG NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN=chajia-neibu-2026
ARG NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID=CUST_001
ENV NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN=$NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN
ENV NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID=$NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID
RUN npx prisma generate RUN npx prisma generate
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN npm run build

@ -1,4 +1,5 @@
FROM node:20-alpine # Playwright 官方镜像(含 Chromium 与系统依赖RPA Worker 生产环境必需
FROM mcr.microsoft.com/playwright:v1.61.0-jammy
WORKDIR /app WORKDIR /app
@ -9,5 +10,7 @@ COPY . .
RUN npx prisma generate RUN npx prisma generate
ENV NODE_ENV=production ENV NODE_ENV=production
# 镜像已预装浏览器,禁止重复下载
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
CMD ["npm", "run", "worker:rpa"] CMD ["npm", "run", "worker:rpa"]

@ -9,6 +9,10 @@ vi.mock("@/lib/prisma", () => ({
findMany: vi.fn(), findMany: vi.fn(),
upsert: vi.fn(), upsert: vi.fn(),
}, },
hostCustomer: {
findMany: vi.fn().mockResolvedValue([]),
findFirst: vi.fn().mockResolvedValue(null),
},
auditLog: { auditLog: {
create: vi.fn(), create: vi.fn(),
}, },

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { axelSearchRowToCandidate } from "@/lib/axel/candidates"; import {
axelSearchRowToCandidate,
resolveAxelMothershipCandidates,
} from "@/lib/axel/candidates";
import { AxelHttpClient } from "@/lib/axel/client";
describe("axelSearchRowToCandidate", () => { describe("axelSearchRowToCandidate", () => {
it("display_label 与 axel description 一致option_id 保留原始 placeId", () => { it("display_label 与 axel description 一致option_id 保留原始 placeId", () => {
@ -22,3 +26,102 @@ describe("axelSearchRowToCandidate", () => {
expect(candidate?.street).toContain("123"); expect(candidate?.street).toContain("123");
}); });
}); });
describe("resolveAxelMothershipCandidates", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.stubEnv("AXEL_CANDIDATE_VERIFY_TOP_K", "3");
});
function makeSearchRow(suffix: string) {
return {
placeId: `ChIJ-${suffix}`,
description: `${suffix} Main Street, Dallas, TX, USA`,
mainText: `${suffix} Main Street`,
secondaryText: "Dallas, TX, USA",
};
}
it("仅对前 3 条候选 fetchPlace 校验,其余默认可选", async () => {
const fetchPlace = vi.fn().mockResolvedValue({ placeId: "ok" });
const searchLocation = vi
.fn()
.mockResolvedValueOnce([
makeSearchRow("1"),
makeSearchRow("2"),
makeSearchRow("3"),
makeSearchRow("4"),
makeSearchRow("5"),
])
.mockResolvedValueOnce([makeSearchRow("d1")]);
vi.spyOn(AxelHttpClient, "create").mockResolvedValue({
searchLocation,
fetchPlace,
} as unknown as AxelHttpClient);
const result = await resolveAxelMothershipCandidates({
pickup: {
street: "1 Main St",
city: "Dallas",
state: "TX",
zip: "75201",
},
delivery: {
street: "2 Oak Ave",
city: "Los Angeles",
state: "CA",
zip: "90001",
},
});
expect(result.pickup_candidates).toHaveLength(5);
expect(result.delivery_candidates).toHaveLength(1);
expect(fetchPlace).toHaveBeenCalledTimes(4);
expect(result.pickup_candidates[0]?.selectable).toBe(true);
expect(result.pickup_candidates[3]?.selectable).toBe(true);
expect(result.pickup_candidates[4]?.selectable).toBe(true);
});
it("前 K 条均失败时,未校验候选仍可使 assert 通过", async () => {
const fetchPlace = vi
.fn()
.mockRejectedValueOnce(new Error("place 404"))
.mockRejectedValueOnce(new Error("place 404"))
.mockRejectedValueOnce(new Error("place 404"))
.mockResolvedValueOnce({ placeId: "ChIJ-d1" });
const searchLocation = vi
.fn()
.mockResolvedValueOnce([
makeSearchRow("bad1"),
makeSearchRow("bad2"),
makeSearchRow("bad3"),
makeSearchRow("good-tail"),
])
.mockResolvedValueOnce([makeSearchRow("d1")]);
vi.spyOn(AxelHttpClient, "create").mockResolvedValue({
searchLocation,
fetchPlace,
} as unknown as AxelHttpClient);
const result = await resolveAxelMothershipCandidates({
pickup: {
street: "99 Main St",
city: "Dallas",
state: "TX",
zip: "75201",
},
delivery: {
street: "1 Oak",
city: "LA",
state: "CA",
zip: "90001",
},
});
expect(result.pickup_candidates[0]?.selectable).toBe(false);
expect(result.pickup_candidates[3]?.selectable).toBe(true);
expect(fetchPlace).toHaveBeenCalledTimes(4);
});
});

@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAxelQuoteItems } from "@/lib/axel/quote-from-request";
import { AxelHttpClient } from "@/lib/axel/client";
import { pickBestSearchCandidate } from "@/lib/axel/pick-candidate"; import { pickBestSearchCandidate } from "@/lib/axel/pick-candidate";
import { buildAxelShipmentPayload } from "@/lib/axel/shipment"; import { buildAxelShipmentPayload } from "@/lib/axel/shipment";
import { parseQuoteCliArgs } from "@/lib/axel/parse-cli-args"; import { parseQuoteCliArgs } from "@/lib/axel/parse-cli-args";
import { AXEL_QUOTE_BODY } from "__tests__/fixtures/axel-quote";
describe("parseQuoteCliArgs", () => { describe("parseQuoteCliArgs", () => {
it("解析必填参数", () => { it("解析必填参数", () => {
@ -86,3 +89,49 @@ describe("buildAxelShipmentPayload", () => {
expect(Object.keys(shipment.cargo as object)).toHaveLength(1); expect(Object.keys(shipment.cargo as object)).toHaveLength(1);
}); });
}); });
describe("fetchAxelQuoteItems", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("已知 placeId 时每侧只 fetchPlace 一次", async () => {
const client = {
fetchPlace: vi
.fn()
.mockResolvedValueOnce({ placeId: "ChIJ-pickup" })
.mockResolvedValueOnce({ placeId: "ChIJ-delivery" }),
searchLocation: vi.fn(),
postQuote: vi.fn().mockResolvedValue(AXEL_QUOTE_BODY),
};
vi.spyOn(AxelHttpClient, "create").mockResolvedValue(
client as unknown as AxelHttpClient,
);
const items = await fetchAxelQuoteItems({
pickup: {
street: "1 Main St",
city: "Seattle",
state: "WA",
zip: "98101",
mothershipOptionId: "ChIJ-pickup",
},
delivery: {
street: "2 Oak Ave",
city: "Miami",
state: "FL",
zip: "33101",
mothershipOptionId: "ChIJ-delivery",
},
palletCount: 2,
weightLb: 500,
dimsIn: { l: 48, w: 40, h: 45 },
});
expect(items).toHaveLength(4);
expect(client.fetchPlace).toHaveBeenCalledTimes(2);
expect(client.searchLocation).not.toHaveBeenCalled();
expect(client.postQuote).toHaveBeenCalledTimes(1);
});
});

@ -0,0 +1,27 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import {
hostPublicDefaultCustomerId,
isHostPublicApiEnabled,
} from "@/lib/constants/host-public-api";
describe("host public api env", () => {
const prevEnabled = process.env.HOST_PUBLIC_API_ENABLED;
const prevCustomer = process.env.HOST_PUBLIC_DEFAULT_CUSTOMER_ID;
afterEach(() => {
process.env.HOST_PUBLIC_API_ENABLED = prevEnabled;
process.env.HOST_PUBLIC_DEFAULT_CUSTOMER_ID = prevCustomer;
});
it("is disabled by default", () => {
delete process.env.HOST_PUBLIC_API_ENABLED;
expect(isHostPublicApiEnabled()).toBe(false);
});
it("reads enabled flag and default customer", () => {
process.env.HOST_PUBLIC_API_ENABLED = "true";
process.env.HOST_PUBLIC_DEFAULT_CUSTOMER_ID = "CUST_MEIMEI";
expect(isHostPublicApiEnabled()).toBe(true);
expect(hostPublicDefaultCustomerId()).toBe("CUST_MEIMEI");
});
});

@ -0,0 +1,28 @@
import { afterEach, describe, expect, it } from "vitest";
import { isHostServiceAuthDisabled } from "@/lib/constants/host-service";
describe("isHostServiceAuthDisabled", () => {
const env = process.env;
afterEach(() => {
process.env = { ...env };
});
it("production 始终需鉴权", () => {
process.env.NODE_ENV = "production";
process.env.HOST_SERVICE_AUTH_DISABLED = "true";
expect(isHostServiceAuthDisabled()).toBe(false);
});
it("development 默认跳过宿主 Token", () => {
process.env.NODE_ENV = "development";
delete process.env.HOST_SERVICE_AUTH_DISABLED;
expect(isHostServiceAuthDisabled()).toBe(true);
});
it("development 可显式开启鉴权", () => {
process.env.NODE_ENV = "development";
process.env.HOST_SERVICE_AUTH_DISABLED = "false";
expect(isHostServiceAuthDisabled()).toBe(false);
});
});

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
filterQuotesForMotherShipUiDisplay,
getMotherShipRateOptionLabel,
getMotherShipServiceLevelLabel,
listMotherShipUiTabsFromQuotes,
listVisibleRateOptionsForTab,
normalizeMotherShipServiceLevel,
} from "@/lib/constants/mothership-ui-tiers";
const SAMPLE = [
{ service_level: "standard", rate_option: "lowest", raw: 235 },
{ service_level: "standard", rate_option: "bestValue", raw: 301 },
{ service_level: "standard", rate_option: "fastest", raw: 689 },
{ service_level: "guaranteed", rate_option: "lowest", raw: 408 },
{ service_level: "guaranteed", rate_option: "fastest", raw: 830 },
{ service_level: "dedicated", rate_option: "bestValue", raw: 4989 },
] as const;
describe("mothership-ui-tiers", () => {
it("normalizeMotherShipServiceLevel 识别专属卡车别名", () => {
expect(normalizeMotherShipServiceLevel("专属卡车")).toBe("dedicated");
expect(normalizeMotherShipServiceLevel("FTL")).toBe("dedicated");
});
it("Standard 档 lowest 与 bestValue 并存时均保留(与官网三档一致)", () => {
const ui = filterQuotesForMotherShipUiDisplay([...SAMPLE]);
expect(ui.some((q) => q.service_level === "standard" && q.rate_option === "lowest")).toBe(true);
expect(ui.some((q) => q.service_level === "standard" && q.rate_option === "bestValue")).toBe(true);
expect(ui.find((q) => q.service_level === "standard" && q.rate_option === "lowest")?.raw).toBe(235);
});
it("listMotherShipUiTabsFromQuotes 含 dedicated 第三 Tab", () => {
const ui = filterQuotesForMotherShipUiDisplay([...SAMPLE]);
expect(listMotherShipUiTabsFromQuotes(ui)).toEqual([
"standard",
"guaranteed",
"dedicated",
]);
});
it("dedicated Tab 展示 API 实际返回的子档(不白名单裁剪)", () => {
const ui = filterQuotesForMotherShipUiDisplay([...SAMPLE]);
expect(listVisibleRateOptionsForTab(ui, "dedicated")).toEqual(["bestValue"]);
const withExtra = filterQuotesForMotherShipUiDisplay([
...SAMPLE,
{ service_level: "dedicated", rate_option: "lowest", raw: 4000 },
]);
expect(listVisibleRateOptionsForTab(withExtra, "dedicated")).toEqual([
"bestValue",
"lowest",
]);
});
it("bestValue 显示为最优性价比", () => {
expect(getMotherShipRateOptionLabel("standard", "bestValue")).toBe("最优性价比");
expect(getMotherShipRateOptionLabel("standard", "lowest")).toBe("最低价格");
});
});

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
US_STATES,
filterUsStateOptions,
isValidUsStateCode,
normalizeUsStateCode,
} from "@/lib/frontend/us-states";
describe("us-states", () => {
it("包含全部 50 个州码", () => {
expect(US_STATES).toHaveLength(50);
expect(new Set(US_STATES).size).toBe(50);
});
it("normalizeUsStateCode 支持州码与州名", () => {
expect(normalizeUsStateCode("ca")).toBe("CA");
expect(normalizeUsStateCode("California")).toBe("CA");
expect(normalizeUsStateCode("new york")).toBe("NY");
expect(normalizeUsStateCode("XX")).toBeNull();
expect(normalizeUsStateCode("")).toBeNull();
});
it("filterUsStateOptions 可按州码或州名过滤", () => {
const byCode = filterUsStateOptions("tx");
expect(byCode.some((s) => s.code === "TX")).toBe(true);
const byName = filterUsStateOptions("carolina");
expect(byName.map((s) => s.code).sort()).toEqual(["NC", "SC"]);
expect(filterUsStateOptions("")).toHaveLength(50);
});
it("isValidUsStateCode 校验 2 位州码", () => {
expect(isValidUsStateCode("WY")).toBe(true);
expect(isValidUsStateCode("wy")).toBe(true);
expect(isValidUsStateCode("ZZ")).toBe(false);
});
});

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { roundHalfUp } from "@/lib/math"; import { formatPercentDisplay, roundHalfUp } from "@/lib/math";
describe("roundHalfUp", () => { describe("roundHalfUp", () => {
it("0.105×2 → 0.21(不是 0.20", () => { it("0.105×2 → 0.21(不是 0.20", () => {
@ -20,3 +20,11 @@ describe("roundHalfUp", () => {
expect(roundHalfUp(100.004, 2)).toBe(100); expect(roundHalfUp(100.004, 2)).toBe(100);
}); });
}); });
describe("formatPercentDisplay", () => {
it("支持 0.01% 展示", () => {
expect(formatPercentDisplay(0.01)).toBe("0.01%");
expect(formatPercentDisplay(10)).toBe("10%");
expect(formatPercentDisplay(10.5)).toBe("10.5%");
});
});

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { buildPriority1CalendarGrid } from "@/lib/priority1/calendar-grid";
describe("buildPriority1CalendarGrid", () => {
it("接受 MM/DD/YYYY 提货日并生成当月日历", () => {
const { monthLabel, cells } = buildPriority1CalendarGrid("07/15/2026", []);
expect(monthLabel).toBe("Jul");
expect(cells.length).toBeGreaterThan(28);
});
it("优先按 FTL 日历行里的实际日期落格", () => {
const { cells } = buildPriority1CalendarGrid("07/15/2026", [
{
rank: 1,
carrier: "FTL 日历报价",
carrierCode: "",
totalUsd: 1652.16,
markup_amount: 0,
final_total_usd: 1652.16,
transitDays: 0,
deliveryDate: "DAY:1",
expirationDate: null,
serviceLevel: "FTL Calendar",
quoteId: null,
caboUrl: null,
source: "ftl-calendar",
},
{
rank: 2,
carrier: "FTL 日历报价",
carrierCode: "",
totalUsd: 2178.47,
markup_amount: 0,
final_total_usd: 2178.47,
transitDays: 0,
deliveryDate: "DAY:6",
expirationDate: null,
serviceLevel: "FTL Calendar",
quoteId: null,
caboUrl: null,
source: "ftl-calendar",
},
]);
const day1 = cells.find((cell) => cell.inMonth && cell.date.getDate() === 1);
const day6 = cells.find((cell) => cell.inMonth && cell.date.getDate() === 6);
expect(day1?.priceUsd).toBe(1652.16);
expect(day6?.priceUsd).toBe(2178.47);
});
});

@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { p1DateFromIso, p1DateToIso } from "@/lib/priority1/date-format";
import { resolvePriority1UserMessage } from "@/lib/priority1/api-errors";
describe("p1 date format", () => {
it("converts MM/DD/YYYY to ISO and back", () => {
expect(p1DateToIso("07/15/2026")).toBe("2026-07-15");
expect(p1DateFromIso("2026-07-15")).toBe("07/15/2026");
});
});
describe("resolvePriority1UserMessage", () => {
it("keeps Chinese validation messages", () => {
expect(resolvePriority1UserMessage("VALIDATION_FAILED", "提货日须晚于今天")).toBe(
"提货日须晚于今天",
);
});
it("masks English framework errors", () => {
const msg = resolvePriority1UserMessage(
"INTERNAL_ERROR",
"Attempted to call p1DateToIso() from the server but p1DateToIso is on the client",
);
expect(msg).toContain("服务器处理失败");
expect(msg).toContain("服务配置异常");
expect(msg).not.toContain("client");
});
it("maps known error codes", () => {
expect(resolvePriority1UserMessage("UNAUTHORIZED", "")).toContain("未授权");
});
});

@ -0,0 +1,90 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("ensure-storage-state", () => {
let tmpDir: string;
afterEach(() => {
if (tmpDir && fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
vi.resetModules();
vi.restoreAllMocks();
delete process.env.RPA_STORAGE_STATE_PATH;
delete process.env.RPA_AUTO_BOOTSTRAP_STORAGE;
delete process.env.RPA_MOCK_MODE;
});
it("会话文件已存在时跳过 bootstrap", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-ensure-"));
const stateFile = path.join(tmpDir, "state.json");
fs.writeFileSync(stateFile, '{"cookies":[]}');
process.env.RPA_STORAGE_STATE_PATH = stateFile;
const launch = vi.fn();
vi.doMock("@/lib/rpa/browser-launch", () => ({ launchRpaBrowser: launch }));
const { ensureMothershipStorageState } = await import(
"@/lib/rpa/ensure-storage-state"
);
await ensureMothershipStorageState();
expect(launch).not.toHaveBeenCalled();
});
it("缺文件且关闭自动 bootstrap 时抛错", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-ensure-"));
const stateFile = path.join(tmpDir, "missing.json");
process.env.RPA_STORAGE_STATE_PATH = stateFile;
process.env.RPA_AUTO_BOOTSTRAP_STORAGE = "false";
const { ensureMothershipStorageState } = await import(
"@/lib/rpa/ensure-storage-state"
);
await expect(ensureMothershipStorageState()).rejects.toThrow(/record:mothership/);
});
it("缺文件时自动 bootstrap 并写入 storageState", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-ensure-"));
const stateFile = path.join(tmpDir, "state.json");
process.env.RPA_STORAGE_STATE_PATH = stateFile;
process.env.MOTHERSHIP_QUOTE_URLS = "https://www.mothership.com/";
const persistContext = vi.fn(async () => {
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
fs.writeFileSync(stateFile, '{"cookies":[{"name":"s","value":"1"}]}');
});
vi.doMock("@/lib/rpa/browser-launch", () => ({
launchRpaBrowser: vi.fn(async () => ({
newContext: vi.fn(async () => ({
newPage: vi.fn(async () => ({
close: vi.fn(async () => undefined),
})),
close: vi.fn(async () => undefined),
})),
close: vi.fn(async () => undefined),
})),
}));
vi.doMock("@/lib/rpa/storage-state", async () => {
const actual = await vi.importActual<typeof import("@/lib/rpa/storage-state")>(
"@/lib/rpa/storage-state",
);
return { ...actual, persistContext };
});
vi.doMock("@/workers/rpa/quote-page-adapter", () => ({
quotePageAdapter: {
resolveQuoteEntry: vi.fn(async () => ({ activeUrl: "https://x" })),
preCheck: vi.fn(async () => true),
},
}));
const { ensureMothershipStorageState } = await import(
"@/lib/rpa/ensure-storage-state"
);
await ensureMothershipStorageState();
expect(fs.existsSync(stateFile)).toBe(true);
expect(persistContext).toHaveBeenCalled();
});
});

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { forbidden } from "@/modules/auth/errors";
import { resolveEnvServiceToken } from "@/modules/auth/service-token-env";
const DEMO_TOKENS = JSON.stringify({
"demo-host-token": {
customerId: "CUST_001",
permissions: ["pricing:markup:write"],
},
});
describe("middleware host auth gate", () => {
it("env token 带 X-Customer-Id 时命中快路径", () => {
process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS;
const verified = resolveEnvServiceToken("demo-host-token", "CUST_001");
expect(verified?.customerId).toBe("CUST_001");
});
it("DB token 带 X-Customer-Id 时不应抛错,应返回 null 交给路由层 DB 校验", () => {
process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS;
const verified = resolveEnvServiceToken(
"chj_QUNFn-Le9biXM4UTFv5L4z9qouAKmBu7",
"CUST_002",
);
expect(verified).toBeNull();
});
it("env token 与客户 ID 不匹配时仍抛 forbidden", () => {
process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS;
expect(() =>
resolveEnvServiceToken("demo-host-token", "CUST_002"),
).toThrow(forbidden("customer_id 与令牌租户不匹配"));
});
});

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildAddressCandidatesCacheHash } from "@/modules/address/candidates-cache";
describe("buildAddressCandidatesCacheHash", () => {
const pickup = {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
};
const delivery = {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
};
it("相同地址生成相同 hash", () => {
const a = buildAddressCandidatesCacheHash({ pickup, delivery });
const b = buildAddressCandidatesCacheHash({ pickup, delivery });
expect(a).toBe(b);
expect(a).toMatch(/^[a-f0-9]{32}$/);
});
it("地址变化生成不同 hash", () => {
const a = buildAddressCandidatesCacheHash({ pickup, delivery });
const b = buildAddressCandidatesCacheHash({
pickup: { ...pickup, street: "999 Other St" },
delivery,
});
expect(a).not.toBe(b);
});
});

@ -1,13 +1,27 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/axel/candidates", () => ({ vi.mock("@/lib/axel/candidates", () => ({
resolveAxelMothershipCandidates: vi.fn(), resolveAxelMothershipCandidates: vi.fn(),
})); }));
vi.mock("@/modules/address/candidates-cache", () => ({
loadAddressCandidatesCache: vi.fn(),
saveAddressCandidatesCache: vi.fn(),
}));
import { resolveMothershipCandidates } from "@/modules/address/mothership-candidates"; import { resolveMothershipCandidates } from "@/modules/address/mothership-candidates";
import { resolveAxelMothershipCandidates } from "@/lib/axel/candidates"; import { resolveAxelMothershipCandidates } from "@/lib/axel/candidates";
import {
loadAddressCandidatesCache,
saveAddressCandidatesCache,
} from "@/modules/address/candidates-cache";
describe("resolveMothershipCandidates", () => { describe("resolveMothershipCandidates", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadAddressCandidatesCache).mockResolvedValue(null);
vi.mocked(saveAddressCandidatesCache).mockResolvedValue(undefined);
});
const input = { const input = {
pickup: { pickup: {
street: "5678 Distribution Dr", street: "5678 Distribution Dr",
@ -52,6 +66,40 @@ describe("resolveMothershipCandidates", () => {
const result = await resolveMothershipCandidates(input); const result = await resolveMothershipCandidates(input);
expect(resolveAxelMothershipCandidates).toHaveBeenCalledWith(input); expect(resolveAxelMothershipCandidates).toHaveBeenCalledWith(input);
expect(saveAddressCandidatesCache).toHaveBeenCalled();
expect(result.pickup_candidates[0].option_id).toBe("ChIJpickup"); expect(result.pickup_candidates[0].option_id).toBe("ChIJpickup");
}); });
it("命中缓存时不调用 axel", async () => {
process.env.RPA_MOCK_MODE = "false";
vi.mocked(loadAddressCandidatesCache).mockResolvedValue({
pickup_candidates: [
{
option_id: "ChIJcached-pickup",
display_label: "cached pickup",
formatted_address: "cached pickup",
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
],
delivery_candidates: [
{
option_id: "ChIJcached-delivery",
display_label: "cached delivery",
formatted_address: "cached delivery",
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
},
],
});
const result = await resolveMothershipCandidates(input);
expect(resolveAxelMothershipCandidates).not.toHaveBeenCalled();
expect(saveAddressCandidatesCache).not.toHaveBeenCalled();
expect(result.pickup_candidates[0].option_id).toBe("ChIJcached-pickup");
});
}); });

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
buildAlertPresentation,
formatCustomerAddressForAdmin,
formatSelectedAddressForAdmin,
resolveAlertRootCause,
} from "@/modules/alert/alert-presentation";
describe("alert-presentation", () => {
it("区分客户填写地址与联想确认地址", () => {
const addr = {
street: "123 Main St",
city: "Denver",
state: "CO",
zip: "80202",
formatted_address: "123 Main St, Denver, CO",
mothership_display_label: "123 Main St, Denver, CO 80202, USA",
};
expect(formatCustomerAddressForAdmin(addr)).toBe(
"123 Main St, Denver, CO, 80202",
);
expect(formatSelectedAddressForAdmin(addr)).toBe(
"123 Main St, Denver, CO 80202, USA",
);
});
it("将 browserType.launch 转为可读根因", () => {
const cause = resolveAlertRootCause("RPA_FAILED", {
reason: "browserType.launch: Executable doesn't exist",
});
expect(cause).toContain("无法启动浏览器");
expect(cause).not.toContain("{");
});
it("buildAlertPresentation 包含客户与货物字段", () => {
const presentation = buildAlertPresentation(
"RPA_FAILED",
{ reason: "test", customer_id: "CUST_001" },
{
customerId: "CUST_001",
pickupJson: {
street: "A",
city: "B",
state: "CO",
zip: "80000",
mothership_display_label: "A, B, CO",
},
deliveryJson: {
street: "C",
city: "D",
state: "MA",
zip: "02101",
mothership_display_label: "C, D, MA",
},
weightLb: 500,
palletCount: 2,
cargoType: "general_freight",
dimLIn: 48,
dimWIn: 40,
dimHIn: 48,
} as never,
);
expect(presentation.customer_id).toBe("CUST_001");
expect(presentation.pickup_customer).toContain("A");
expect(presentation.pickup_selected).toBe("A, B, CO");
expect(presentation.cargo_summary).toContain("2 托");
});
});

@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
generateServiceToken,
hashServiceToken,
} from "@/modules/customer/token-crypto";
vi.mock("@/lib/prisma", () => ({
prisma: {
hostCustomer: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
serviceApiKey: {
findFirst: vi.fn(),
},
},
}));
import { prisma } from "@/lib/prisma";
import { resolveDbServiceToken } from "@/modules/customer/customer-service";
import {
resetServiceTokenCache,
verifyServiceTokenAsync,
} from "@/modules/auth/service-token";
const DEMO_TOKENS = JSON.stringify({
"demo-host-token": {
customerId: "CUST_001",
permissions: ["pricing:markup:write"],
},
});
describe("verifyServiceTokenAsync", () => {
beforeEach(() => {
process.env.HOST_SERVICE_TOKENS = DEMO_TOKENS;
resetServiceTokenCache();
vi.clearAllMocks();
});
it("env token 仍可用", async () => {
const result = await verifyServiceTokenAsync("demo-host-token", "CUST_001");
expect(result.customerId).toBe("CUST_001");
});
it("DB token 校验通过", async () => {
const raw = generateServiceToken();
vi.mocked(prisma.serviceApiKey.findFirst).mockResolvedValue({
customerId: "CUST_004",
permissions: ["pricing:markup:write"],
isActive: true,
tokenHash: hashServiceToken(raw),
customer: { status: "active", isDeleted: false },
} as never);
const result = await verifyServiceTokenAsync(raw, "CUST_004");
expect(result.customerId).toBe("CUST_004");
expect(prisma.serviceApiKey.findFirst).toHaveBeenCalled();
});
it("无效 token 返回 401", async () => {
vi.mocked(prisma.serviceApiKey.findFirst).mockResolvedValue(null);
await expect(
verifyServiceTokenAsync("bad-token", "CUST_004"),
).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
});
});
describe("resolveDbServiceToken", () => {
it("按 hash 解析客户", async () => {
const raw = generateServiceToken();
vi.mocked(prisma.serviceApiKey.findFirst).mockResolvedValue({
customerId: "CUST_002",
permissions: ["pricing:markup:write"],
} as never);
const resolved = await resolveDbServiceToken(raw);
expect(resolved?.customerId).toBe("CUST_002");
});
});

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
ADDRESS_CANDIDATES_CACHE_TTL_SECONDS,
L1_TTL_SECONDS, L1_TTL_SECONDS,
L2_TTL_BASE_SECONDS, L2_TTL_BASE_SECONDS,
L2_TTL_JITTER_MAX_SECONDS, L2_TTL_JITTER_MAX_SECONDS,
@ -152,6 +153,18 @@ describe("modules/cache/redis-cache", () => {
const [, , , ttl] = redisMock.set.mock.calls[0]; const [, , , ttl] = redisMock.set.mock.calls[0];
expect(ttl).toBe(LOCK_TTL_SECONDS); expect(ttl).toBe(LOCK_TTL_SECONDS);
}); });
it("addr-cand 缓存 TTL 120s", async () => {
const cache = await loadCache();
await cache.setAddressCandidatesCache("hash-addr", {
pickup_candidates: [],
delivery_candidates: [],
});
const [key, , , ttl] = redisMock.set.mock.calls[0];
expect(key).toBe("addr-cand:hash-addr");
expect(ttl).toBe(ADDRESS_CANDIDATES_CACHE_TTL_SECONDS);
});
}); });
describe("modules/cache/rate-limiter", () => { describe("modules/cache/rate-limiter", () => {

@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_EMBED_PASSWORD,
hashEmbedPassword,
validateEmbedPassword,
compareEmbedPassword,
} from "@/modules/customer/embed-password";
describe("embed-password", () => {
it("默认演示密码为 123456", () => {
expect(DEFAULT_EMBED_PASSWORD).toBe("123456");
});
it("校验密码长度", () => {
expect(validateEmbedPassword("")).toMatch(/不能为空/);
expect(validateEmbedPassword("12345")).toMatch(/至少/);
expect(validateEmbedPassword("a".repeat(65))).toMatch(/不能超过/);
expect(validateEmbedPassword("123456")).toBeNull();
});
it("哈希后可验证", async () => {
const hash = await hashEmbedPassword("my-secret");
expect(await compareEmbedPassword("my-secret", hash)).toBe(true);
expect(await compareEmbedPassword("wrong", hash)).toBe(false);
});
});

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import {
generateServiceToken,
hashServiceToken,
tokenKeyPrefix,
} from "@/modules/customer/token-crypto";
describe("token-crypto", () => {
it("生成 chj_ 前缀 token", () => {
const token = generateServiceToken();
expect(token.startsWith("chj_")).toBe(true);
expect(token.length).toBeGreaterThan(20);
});
it("hash 稳定", () => {
const token = "chj_test_token";
expect(hashServiceToken(token)).toHaveLength(64);
expect(hashServiceToken(token)).toBe(hashServiceToken(token));
});
it("key prefix 用于列表展示", () => {
const token = generateServiceToken();
expect(tokenKeyPrefix(token).length).toBeGreaterThan(0);
});
});

@ -0,0 +1,42 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import {
EMBED_DEMO_DEFAULT_PASSWORD,
EMBED_DEMO_ROLE,
getEmbedDemoPassword,
signEmbedDemoToken,
verifyEmbedDemoToken,
} from "@/modules/embed-demo/auth";
describe("embed-demo auth", () => {
const prevSecret = process.env.JWT_SECRET;
const prevPassword = process.env.EMBED_DEMO_PASSWORD;
beforeEach(() => {
process.env.JWT_SECRET = "test-jwt-secret-for-embed-demo";
delete process.env.EMBED_DEMO_PASSWORD;
});
afterEach(() => {
if (prevSecret === undefined) {
delete process.env.JWT_SECRET;
} else {
process.env.JWT_SECRET = prevSecret;
}
if (prevPassword === undefined) {
delete process.env.EMBED_DEMO_PASSWORD;
} else {
process.env.EMBED_DEMO_PASSWORD = prevPassword;
}
});
it("默认演示密码为 123456", () => {
expect(getEmbedDemoPassword()).toBe(EMBED_DEMO_DEFAULT_PASSWORD);
});
it("签发与校验演示会话 JWT", async () => {
const token = await signEmbedDemoToken("CUST_002");
const payload = await verifyEmbedDemoToken(token);
expect(payload.sub).toBe("CUST_002");
expect(payload.role).toBe(EMBED_DEMO_ROLE);
});
});

@ -0,0 +1,220 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/modules/address/mothership-candidates", () => ({
resolveMothershipCandidates: vi.fn(),
}));
vi.mock("@/modules/host/public-session", () => ({
saveHostPublicSession: vi.fn(),
loadHostPublicSession: vi.fn(),
deleteHostPublicSession: vi.fn(),
}));
vi.mock("@/modules/quote/orchestrator", () => ({
submitQuote: vi.fn(),
}));
vi.mock("@/modules/host/wait-for-quote", () => ({
waitForQuoteDetail: vi.fn(),
HostQuoteWaitError: class HostQuoteWaitError extends Error {
code = "QUOTE_FAILED";
detail = undefined;
},
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: {
findFirst: vi.fn(),
},
},
}));
import { resolveMothershipCandidates } from "@/modules/address/mothership-candidates";
import {
deleteHostPublicSession,
loadHostPublicSession,
saveHostPublicSession,
} from "@/modules/host/public-session";
import {
hostPublicResolveCandidates,
hostPublicSubmitAndWait,
} from "@/modules/host/public-orchestrator";
import { waitForQuoteDetail } from "@/modules/host/wait-for-quote";
import { submitQuote } from "@/modules/quote/orchestrator";
import { prisma } from "@/lib/prisma";
const pickupCandidate = {
option_id: "pickup-opt-1",
display_label: "1234 Warehouse St, Los Angeles, CA, USA",
formatted_address: "1234 Warehouse St, Los Angeles, CA, USA",
street: "1234 Warehouse St",
city: "Los Angeles",
state: "CA",
zip: "90001",
selectable: true,
};
const deliveryCandidate = {
option_id: "delivery-opt-1",
display_label: "5678 Distribution Dr, Dallas, TX, USA",
formatted_address: "5678 Distribution Dr, Dallas, TX, USA",
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
selectable: true,
};
describe("hostPublicResolveCandidates", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(saveHostPublicSession).mockResolvedValue("session-uuid");
});
it("returns host_session_id and candidates", async () => {
vi.mocked(resolveMothershipCandidates).mockResolvedValue({
pickup_candidates: [pickupCandidate],
delivery_candidates: [deliveryCandidate],
requires_selection: false,
quote_session_id: "quote-session-1",
});
const result = await hostPublicResolveCandidates("CUST_002", {
pickup_address: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
},
delivery_address: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
cargo: {
weight: { value: 500, unit: "kg" },
dimensions: { length: 120, width: 100, height: 150, unit: "cm" },
pallet_count: 2,
cargo_type: "general_freight",
},
});
expect(result.host_session_id).toBe("session-uuid");
expect(result.pickup_candidates).toHaveLength(1);
expect(saveHostPublicSession).toHaveBeenCalledWith(
expect.objectContaining({ customer_id: "CUST_002" }),
);
});
});
describe("hostPublicSubmitAndWait", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadHostPublicSession).mockResolvedValue({
customer_id: "CUST_001",
cargo: {
weight: { value: 500, unit: "kg" },
dimensions: { length: 120, width: 100, height: 150, unit: "cm" },
pallet_count: 2,
cargo_type: "general_freight",
},
pickup_draft: {
street: "1234 Warehouse Blvd",
city: "Los Angeles",
state: "CA",
zip: "90001",
},
delivery_draft: {
street: "5678 Distribution Dr",
city: "Dallas",
state: "TX",
zip: "75201",
},
quote_session_id: "quote-session-1",
pickup_candidates: [pickupCandidate],
delivery_candidates: [deliveryCandidate],
created_at: new Date().toISOString(),
});
vi.mocked(submitQuote).mockResolvedValue({
quote_id: "QTE_test_001",
status: "processing",
});
vi.mocked(waitForQuoteDetail).mockResolvedValue({
quote_id: "QTE_test_001",
request_id: "req-1",
status: "done",
currency: "USD",
quotes: [{ final_total: 337.06 }],
});
vi.mocked(prisma.quoteRecord.findFirst).mockResolvedValue({
quoteId: "QTE_test_001",
requestId: "req-1",
status: "done",
sourceType: "rpa",
isRealtime: true,
currency: "USD",
confidenceScore: 0.95,
quotesJson: [{ final_total: 337.06 }],
validUntil: new Date("2026-06-30T10:00:00.000Z"),
createdAt: new Date("2026-06-30T09:55:00.000Z"),
palletCount: 2,
cargoType: "general_freight",
errorCode: null,
errorMessage: null,
isDeleted: false,
} as never);
});
it("submits quote and waits for final detail", async () => {
const detail = await hostPublicSubmitAndWait({
host_session_id: "session-uuid",
pickup_candidate: pickupCandidate,
delivery_candidate: deliveryCandidate,
});
expect(submitQuote).toHaveBeenCalledOnce();
expect(waitForQuoteDetail).toHaveBeenCalledWith(
"QTE_test_001",
"CUST_001",
);
expect(deleteHostPublicSession).toHaveBeenCalledWith("session-uuid");
expect(detail.status).toBe("done");
});
it("rejects candidate not in session", async () => {
await expect(
hostPublicSubmitAndWait({
host_session_id: "session-uuid",
pickup_candidate: { ...pickupCandidate, option_id: "unknown" },
delivery_candidate: deliveryCandidate,
}),
).rejects.toMatchObject({ code: "ADDRESS_SESSION_MISMATCH" });
});
it("submitQuote 已 done 时直接查一次 DB不走 waitForQuoteDetail", async () => {
vi.mocked(submitQuote).mockResolvedValue({
quote_id: "QTE_test_001",
status: "done",
source_type: "rpa",
is_realtime: true,
});
const detail = await hostPublicSubmitAndWait({
host_session_id: "session-uuid",
pickup_candidate: pickupCandidate,
delivery_candidate: deliveryCandidate,
});
expect(prisma.quoteRecord.findFirst).toHaveBeenCalledWith({
where: {
quoteId: "QTE_test_001",
customerId: "CUST_001",
isDeleted: false,
},
});
expect(waitForQuoteDetail).not.toHaveBeenCalled();
expect(detail.status).toBe("done");
});
});

@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getHostQuotePollIntervalMs,
HOST_QUOTE_POLL_INTERVAL_MS_DEFAULT,
} from "@/lib/constants/host-public-api";
describe("getHostQuotePollIntervalMs", () => {
beforeEach(() => {
vi.unstubAllEnvs();
});
it("默认 500ms", () => {
expect(HOST_QUOTE_POLL_INTERVAL_MS_DEFAULT).toBe(500);
expect(getHostQuotePollIntervalMs()).toBe(500);
});
it("支持 HOST_QUOTE_POLL_INTERVAL_MS 覆盖", () => {
vi.stubEnv("HOST_QUOTE_POLL_INTERVAL_MS", "800");
expect(getHostQuotePollIntervalMs()).toBe(800);
});
it("非法值回退默认", () => {
vi.stubEnv("HOST_QUOTE_POLL_INTERVAL_MS", "0");
expect(getHostQuotePollIntervalMs()).toBe(500);
});
});

@ -49,6 +49,12 @@ describe("applyMarkup", () => {
expect(result.finalTotal).toBe(130); expect(result.finalTotal).toBe(130);
}); });
it("0.01% 细粒度加价", () => {
const result = applyMarkup(1000, 1000, 0.01);
expect(result.markupAmount).toBe(0.1);
expect(result.finalTotal).toBe(1000.1);
});
it("固定金额加价", () => { it("固定金额加价", () => {
const result = applyMarkup(320, 335, { const result = applyMarkup(320, 335, {
type: "fixed", type: "fixed",

@ -30,4 +30,22 @@ describe("parseMarkupInput", () => {
message: "加价比例不能超过 30%", message: "加价比例不能超过 30%",
}); });
}); });
it("支持 0.01% 步长", () => {
const result = parseMarkupInput({ markup_percent: 0.01 });
expect(result).toEqual({
markupType: "percent",
markupPercent: 0.01,
markupFixedAmount: null,
});
});
it("百分比四舍五入到两位小数", () => {
const result = parseMarkupInput({ markup_percent: 1.235 });
expect(result).toEqual({
markupType: "percent",
markupPercent: 1.24,
markupFixedAmount: null,
});
});
}); });

@ -0,0 +1,87 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const mockCreate = vi.fn();
const mockUpdate = vi.fn();
const mockGenerateQuoteId = vi.fn();
const mockEnqueue = vi.fn();
const mockHandleConflict = vi.fn();
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: {
create: (...args: unknown[]) => mockCreate(...args),
update: (...args: unknown[]) => mockUpdate(...args),
},
},
}));
vi.mock("@/modules/quote/quote-id", () => ({
generateQuoteId: () => mockGenerateQuoteId(),
isQuoteIdUniqueConflict: (error: unknown) =>
typeof error === "object" &&
error !== null &&
(error as { code?: string }).code === "P2002",
handleQuoteIdConflict: (...args: unknown[]) => mockHandleConflict(...args),
}));
vi.mock("@/modules/priority1/rpa-queue", () => ({
enqueuePriority1QuoteJob: (...args: unknown[]) => mockEnqueue(...args),
}));
vi.mock("@/modules/metrics/collector", () => ({
safeRecord: (task: () => Promise<void>) => {
void task();
},
markQuoteStarted: vi.fn(),
}));
import { DEFAULT_PRIORITY1_DEMO } from "@/workers/rpa/priority1/demo-input";
import {
Priority1EnqueueError,
submitPriority1Quote,
} from "@/modules/priority1/orchestrator";
describe("submitPriority1Quote", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGenerateQuoteId.mockResolvedValue("QTE_20260701_0001");
mockCreate.mockResolvedValue({});
mockUpdate.mockResolvedValue({});
mockEnqueue.mockResolvedValue(undefined);
});
it("入队成功返回 processing", async () => {
const result = await submitPriority1Quote({
request_id: "req-1",
customer_id: "CUST_001",
priority1_input: DEFAULT_PRIORITY1_DEMO,
});
expect(result).toEqual({
quote_id: "QTE_20260701_0001",
status: "processing",
});
expect(mockEnqueue).toHaveBeenCalledOnce();
});
it("Redis 入队失败时抛出可读错误并标记 quote 失败", async () => {
mockEnqueue.mockRejectedValue(new Error("REDIS_URL 未配置"));
await expect(
submitPriority1Quote({
request_id: "req-2",
customer_id: "CUST_001",
priority1_input: DEFAULT_PRIORITY1_DEMO,
}),
).rejects.toBeInstanceOf(Priority1EnqueueError);
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { quoteId: "QTE_20260701_0001" },
data: expect.objectContaining({
status: "failed",
errorCode: "QUOTE_UNAVAILABLE",
}),
}),
);
});
});

@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
buildPriority1StoredQuotes,
inferPriority1DisplayMode,
parsePriority1StoredQuotes,
resolvePriority1StoredQuotes,
} from "@/modules/priority1/quote-storage";
import type { Priority1QuoteLine } from "@/workers/rpa/priority1/quote-extract";
import { buildPriority1CalendarGrid } from "@/lib/priority1/calendar-grid";
const ltlLine = (overrides: Partial<Priority1QuoteLine> = {}): Priority1QuoteLine => ({
rank: 1,
carrier: "Old Dominion Freight Line",
totalUsd: 557.04,
transitDays: 2,
serviceLevel: "STANDARD LTL",
source: "visible-dom",
...overrides,
});
describe("priority1 quote storage", () => {
it("infers ltl_cards for standard LTL lines", () => {
expect(inferPriority1DisplayMode([ltlLine()])).toBe("ltl_cards");
expect(inferPriority1DisplayMode([ltlLine()], "ltl")).toBe("ltl_cards");
});
it("infers ftl_calendar for FTL shipment or calendar source", () => {
expect(inferPriority1DisplayMode([ltlLine()], "ftl")).toBe("ftl_calendar");
expect(
inferPriority1DisplayMode([
ltlLine({ source: "ftl-calendar", carrier: "FTL 日历报价" }),
]),
).toBe("ftl_calendar");
});
it("builds stored quotes with markup applied", () => {
const stored = buildPriority1StoredQuotes(
[ltlLine({ totalUsd: 100 })],
{ type: "percent", percent: 10, fixedAmount: 0 },
"ltl",
);
expect(stored.provider).toBe("priority1");
expect(stored.display_mode).toBe("ltl_cards");
expect(stored.lines[0]!.final_total_usd).toBe(110);
expect(stored.lines[0]!.markup_amount).toBe(10);
});
it("round-trips parsePriority1StoredQuotes", () => {
const stored = buildPriority1StoredQuotes(
[ltlLine()],
{ type: "fixed", percent: 0, fixedAmount: 5 },
"ltl",
);
expect(parsePriority1StoredQuotes(stored)).toEqual(stored);
expect(parsePriority1StoredQuotes([{ rank: 1 }])).toBeNull();
});
it("converts legacy MotherShip tier array for display", () => {
const resolved = resolvePriority1StoredQuotes(
[
{
service_level: "standard",
rate_option: "lowest",
carrier: "Old Dominion",
transit_days: "2",
raw_total: 557.04,
final_total: 557.04,
markup_amount: 0,
},
],
{ priority1_shipment_type: "ltl" },
);
expect(resolved?.display_mode).toBe("ltl_cards");
expect(resolved?.lines[0]?.final_total_usd).toBe(557.04);
expect(resolved?.lines[0]?.source).toBe("legacy-tier");
});
it("does not treat MotherShip tier array as Priority1 without shipment meta", () => {
const resolved = resolvePriority1StoredQuotes(
[
{
service_level: "standard",
rate_option: "lowest",
carrier: "Frontline Freight",
transit_days: "3",
raw_total: 332.6,
final_total: 332.6,
markup_amount: 0,
},
],
{ zip: "90001" },
);
expect(resolved).toBeNull();
});
});
describe("priority1 calendar grid", () => {
it("places prices on consecutive days from pickup date", () => {
const lines = buildPriority1StoredQuotes(
[
ltlLine({ rank: 1, totalUsd: 1652.16 }),
ltlLine({ rank: 2, totalUsd: 1650.78 }),
],
{ type: "percent", percent: 0, fixedAmount: 0 },
"ftl",
).lines;
const { cells } = buildPriority1CalendarGrid("2026-07-01", lines);
const inMonth = cells.filter((c) => c.inMonth && c.priceUsd != null);
expect(inMonth.length).toBe(2);
expect(inMonth[0]!.date.getDate()).toBe(1);
expect(inMonth[0]!.priceUsd).toBe(1652.16);
expect(inMonth[1]!.date.getDate()).toBe(2);
});
});

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_PRIORITY1_DEMO } from "@/workers/rpa/priority1/demo-input";
import { hashPriority1Input } from "@/modules/priority1/hash";
import { priority1LinesToQuoteItems } from "@/modules/priority1/quote-mapper";
import { validatePriority1QuoteInput } from "@/modules/priority1/validation";
describe("priority1 validation", () => {
it("校验合法 LTL 提交", () => {
const parsed = validatePriority1QuoteInput({
request_id: "req-1",
customer_id: "CUST_001",
priority1_input: DEFAULT_PRIORITY1_DEMO,
});
expect(parsed.originZip).toBe("10118");
expect(parsed.phone).toHaveLength(10);
});
it("相同输入生成相同哈希", () => {
const a = hashPriority1Input(DEFAULT_PRIORITY1_DEMO);
const b = hashPriority1Input({ ...DEFAULT_PRIORITY1_DEMO });
expect(a).toBe(b);
expect(a).toHaveLength(32);
});
it("承运商列表映射为报价项", () => {
const items = priority1LinesToQuoteItems([
{
rank: 1,
carrier: "XPO",
carrierCode: "XPO",
totalUsd: 400,
transitDays: 3,
deliveryDate: null,
expirationDate: null,
serviceLevel: "Standard",
quoteId: 1,
caboUrl: null,
source: "api-all",
},
]);
expect(items[0]?.carrier).toBe("XPO");
expect(items[0]?.rawTotal).toBe(400);
});
});

@ -14,6 +14,7 @@ vi.mock("@/lib/prisma", () => ({
vi.mock("@/modules/pricing/engine", () => ({ vi.mock("@/modules/pricing/engine", () => ({
getMarkupPercent: vi.fn().mockResolvedValue(10), getMarkupPercent: vi.fn().mockResolvedValue(10),
getMarkupRule: vi.fn().mockResolvedValue({ type: "percent", percent: 10 }),
applyMarkupToQuotes: vi.fn((quotes: unknown[]) => applyMarkupToQuotes: vi.fn((quotes: unknown[]) =>
quotes.map((q) => ({ ...(q as object), markup_percent: 10 })), quotes.map((q) => ({ ...(q as object), markup_percent: 10 })),
), ),
@ -83,4 +84,19 @@ describe("handleRpaFailure", () => {
}), }),
); );
}); });
it("skipStaleFallback 时不使用 L3 缓存", async () => {
mockedGetL3.mockResolvedValue({ quotes: FOUR_TIERS });
const result = await handleRpaFailure(
"QTE_20260616_0001",
"abc123",
"CUST_001",
new Error("priority1 timeout"),
{ skipStaleFallback: true },
);
expect(result.status).toBe("failed");
expect(mockedGetL3).not.toHaveBeenCalled();
});
}); });

@ -3,6 +3,8 @@ import { submitQuote } from "@/modules/quote/orchestrator";
vi.mock("@/modules/cache/redis-cache", () => ({ vi.mock("@/modules/cache/redis-cache", () => ({
getL2: vi.fn(), getL2: vi.fn(),
setL2: vi.fn(),
setL3: vi.fn(),
})); }));
vi.mock("@/modules/quote/idempotency", () => ({ vi.mock("@/modules/quote/idempotency", () => ({
@ -21,10 +23,19 @@ vi.mock("@/modules/quote/rpa-queue", () => ({
})); }));
vi.mock("@/modules/pricing/engine", () => ({ vi.mock("@/modules/pricing/engine", () => ({
getMarkupPercent: vi.fn().mockResolvedValue(0), getMarkupRule: vi.fn().mockResolvedValue({ type: "percent", percent: 0, fixedAmount: 0 }),
applyMarkupToQuotes: vi.fn((quotes: unknown[]) => quotes), applyMarkupToQuotes: vi.fn((quotes: unknown[]) => quotes),
})); }));
vi.mock("@/lib/axel/quote-from-request", () => ({
fetchAxelQuoteItems: vi.fn(),
}));
vi.mock("@/lib/rpa/env", () => ({
isAxelDirectQuoteMode: vi.fn(() => true),
isInlineDirectQuoteEnabled: vi.fn(() => true),
}));
vi.mock("@/modules/metrics/collector", () => ({ vi.mock("@/modules/metrics/collector", () => ({
safeRecord: vi.fn(), safeRecord: vi.fn(),
recordPostTotal: vi.fn().mockResolvedValue(undefined), recordPostTotal: vi.fn().mockResolvedValue(undefined),
@ -41,6 +52,7 @@ vi.mock("@/lib/prisma", () => ({
})); }));
import { getL2 } from "@/modules/cache/redis-cache"; import { getL2 } from "@/modules/cache/redis-cache";
import { fetchAxelQuoteItems } from "@/lib/axel/quote-from-request";
import { checkIdempotency, saveIdempotency } from "@/modules/quote/idempotency"; import { checkIdempotency, saveIdempotency } from "@/modules/quote/idempotency";
import { generateQuoteId } from "@/modules/quote/quote-id"; import { generateQuoteId } from "@/modules/quote/quote-id";
import { enqueueQuoteJob } from "@/modules/quote/rpa-queue"; import { enqueueQuoteJob } from "@/modules/quote/rpa-queue";
@ -88,6 +100,7 @@ const FOUR_TIERS = [
const mockedCheckIdem = vi.mocked(checkIdempotency); const mockedCheckIdem = vi.mocked(checkIdempotency);
const mockedGetL2 = vi.mocked(getL2); const mockedGetL2 = vi.mocked(getL2);
const mockedFetchInline = vi.mocked(fetchAxelQuoteItems);
const mockedGenerateId = vi.mocked(generateQuoteId); const mockedGenerateId = vi.mocked(generateQuoteId);
const mockedCreate = vi.mocked(prisma.quoteRecord.create); const mockedCreate = vi.mocked(prisma.quoteRecord.create);
const mockedSaveIdem = vi.mocked(saveIdempotency); const mockedSaveIdem = vi.mocked(saveIdempotency);
@ -102,6 +115,7 @@ describe("submitQuote", () => {
mockedCreate.mockResolvedValue({} as never); mockedCreate.mockResolvedValue({} as never);
mockedSaveIdem.mockResolvedValue(undefined); mockedSaveIdem.mockResolvedValue(undefined);
mockedEnqueue.mockResolvedValue(undefined); mockedEnqueue.mockResolvedValue(undefined);
mockedFetchInline.mockRejectedValue(new Error("inline disabled for test"));
}); });
it("TC-105L1 命中 → 返回原 quote_id", async () => { it("TC-105L1 命中 → 返回原 quote_id", async () => {
@ -126,7 +140,57 @@ describe("submitQuote", () => {
expect(mockedEnqueue).not.toHaveBeenCalled(); expect(mockedEnqueue).not.toHaveBeenCalled();
}); });
it("首次询价 → processing + 入队", async () => { it("inline direct 成功 → done + 不入队", async () => {
mockedFetchInline.mockResolvedValue([
{
serviceLevel: "standard",
rateOption: "lowest",
carrier: "M",
transitDays: "5",
transitDescription: "x",
rawFreight: 320,
surcharges: 0,
rawTotal: 320,
},
{
serviceLevel: "standard",
rateOption: "fastest",
carrier: "M",
transitDays: "3",
transitDescription: "x",
rawFreight: 365,
surcharges: 0,
rawTotal: 365,
},
{
serviceLevel: "guaranteed",
rateOption: "lowest",
carrier: "M",
transitDays: "4",
transitDescription: "x",
rawFreight: 380,
surcharges: 0,
rawTotal: 380,
},
{
serviceLevel: "guaranteed",
rateOption: "fastest",
carrier: "M",
transitDays: "3",
transitDescription: "x",
rawFreight: 410,
surcharges: 0,
rawTotal: 410,
},
] as never);
const result = await submitQuote(VALID_BODY);
expect(result.status).toBe("done");
expect(result.source_type).toBe("rpa");
expect(mockedEnqueue).not.toHaveBeenCalled();
});
it("inline direct 失败 → processing + 入队", async () => {
const result = await submitQuote(VALID_BODY); const result = await submitQuote(VALID_BODY);
expect(result.status).toBe("processing"); expect(result.status).toBe("processing");
expect(mockedEnqueue).toHaveBeenCalled(); expect(mockedEnqueue).toHaveBeenCalled();

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
assertFourTiers, assertFourTiers,
normalizeToFourTiers, normalizeToFourTiers,
prepareMotherShipStorageQuotes,
} from "@/modules/quote/quote-completeness"; } from "@/modules/quote/quote-completeness";
const FOUR_TIERS = [ const FOUR_TIERS = [
@ -25,7 +26,7 @@ describe("assertFourTiers", () => {
).not.toThrow(); ).not.toThrow();
}); });
it("缺 guaranteed 整档 → 仅含 standard/lowest 仍通过", () => { it("缺 guaranteed 整档 → 仍通过", () => {
expect(() => expect(() =>
assertFourTiers( assertFourTiers(
FOUR_TIERS.filter((t) => t.service_level !== "guaranteed"), FOUR_TIERS.filter((t) => t.service_level !== "guaranteed"),
@ -40,6 +41,17 @@ describe("assertFourTiers", () => {
expect(() => assertFourTiers(incomplete)).not.toThrow(); expect(() => assertFourTiers(incomplete)).not.toThrow();
}); });
it("仅 bestValue 三 TabDenver→Boston 类路线)→ 通过", () => {
const tiers = [
{ service_level: "standard", rate_option: "bestValue" },
{ service_level: "guaranteed", rate_option: "bestValue" },
{ service_level: "dedicated", rate_option: "bestValue" },
];
expect(() => assertFourTiers(tiers)).not.toThrow();
const ui = prepareMotherShipStorageQuotes(tiers);
expect(ui).toHaveLength(3);
});
it("含 bestValue 扩展档 → 保留 API 返回的全部档位", () => { it("含 bestValue 扩展档 → 保留 API 返回的全部档位", () => {
const six = [ const six = [
...FOUR_TIERS, ...FOUR_TIERS,
@ -72,3 +84,19 @@ describe("assertFourTiers", () => {
expect(normalizeToFourTiers(tiers)).toHaveLength(6); expect(normalizeToFourTiers(tiers)).toHaveLength(6);
}); });
}); });
describe("prepareMotherShipStorageQuotes", () => {
it("含 dedicated 且 Standard 保留 absolute lowest", () => {
const ui = prepareMotherShipStorageQuotes([
{ service_level: "standard", rate_option: "lowest" },
{ service_level: "standard", rate_option: "bestValue" },
{ service_level: "standard", rate_option: "fastest" },
{ service_level: "guaranteed", rate_option: "lowest" },
{ service_level: "guaranteed", rate_option: "fastest" },
{ service_level: "dedicated", rate_option: "bestValue" },
]);
expect(ui.some((q) => q.service_level === "dedicated")).toBe(true);
expect(ui.some((q) => q.service_level === "standard" && q.rate_option === "lowest")).toBe(true);
expect(ui.some((q) => q.service_level === "standard" && q.rate_option === "bestValue")).toBe(true);
});
});

@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import type { QuoteRecord } from "@prisma/client";
import { PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE } from "@/modules/priority1/constants";
import { isPriority1ManualFollowupDetail } from "@/modules/priority1/quote-outcome";
import { serializeQuoteDetail } from "@/modules/quote/quote-serializer";
import { PRIORITY1_MANUAL_FOLLOWUP_MESSAGE } from "@/workers/rpa/priority1/quote-extract";
function baseRecord(overrides: Partial<QuoteRecord> = {}): QuoteRecord {
return {
id: 1,
quoteId: "Q-TEST-001",
requestId: "req-1",
customerId: "cust-1",
cargoHash: "hash",
status: "done",
sourceType: "rpa",
isRealtime: true,
confidenceScore: 0.95,
currency: "USD",
pickupJson: {},
deliveryJson: {},
weightLb: 100,
dimLIn: 48,
dimWIn: 40,
dimHIn: 48,
palletCount: 1,
cargoType: "general_freight",
quotesJson: null,
markupPercent: 0,
validUntil: null,
errorCode: PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE,
errorMessage: PRIORITY1_MANUAL_FOLLOWUP_MESSAGE,
isDeleted: false,
createdAt: new Date("2026-06-01T00:00:00Z"),
updatedAt: new Date("2026-06-01T00:00:00Z"),
...overrides,
} as QuoteRecord;
}
describe("Priority1 manual_followup quote detail", () => {
it("serializes done + PRIORITY1_MANUAL_FOLLOWUP as informational outcome", () => {
const detail = serializeQuoteDetail(baseRecord());
expect(detail.status).toBe("done");
expect(detail.error_code).toBe(PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE);
expect(detail.error_message).toBe(PRIORITY1_MANUAL_FOLLOWUP_MESSAGE);
expect(detail.quotes).toEqual([]);
expect(isPriority1ManualFollowupDetail(detail)).toBe(true);
});
it("serializes priority1 stored quotes without mothership tiers", () => {
const detail = serializeQuoteDetail(
baseRecord({
errorCode: null,
errorMessage: null,
quotesJson: {
provider: "priority1",
display_mode: "ltl_cards",
lines: [
{
rank: 1,
carrier: "Forward Air",
totalUsd: 557,
markup_amount: 0,
final_total_usd: 557,
serviceLevel: "STANDARD LTL",
source: "visible-dom",
},
],
},
pickupJson: {
zip: "32801",
priority1_shipment_type: "ltl",
priority1_pickup_date: "2026-07-14",
},
deliveryJson: { zip: "28202" },
}),
);
expect(detail.priority1?.display_mode).toBe("ltl_cards");
expect(detail.priority1?.lines).toHaveLength(1);
expect(detail.priority1?.shipment_meta?.origin_zip).toBe("32801");
expect(detail.quotes).toBeUndefined();
});
it("serializes legacy tier quotesJson as priority1 payload", () => {
const detail = serializeQuoteDetail(
baseRecord({
errorCode: null,
errorMessage: null,
quotesJson: [
{
service_level: "standard",
rate_option: "lowest",
carrier: "Forward Air",
transit_days: "3",
raw_total: 428.5,
final_total: 428.5,
markup_amount: 0,
},
],
pickupJson: { zip: "32801", priority1_shipment_type: "ltl" },
deliveryJson: { zip: "28202" },
}),
);
expect(detail.priority1?.lines).toHaveLength(1);
expect(detail.priority1?.lines[0]?.carrier).toBe("Forward Air");
});
it("MotherShip tier 数组在无 Priority1 上下文时序列化为 quotes 而非 priority1", () => {
const detail = serializeQuoteDetail(
baseRecord({
errorCode: null,
errorMessage: null,
quotesJson: [
{
service_level: "standard",
rate_option: "lowest",
carrier: "Frontline Freight",
transit_days: "3",
raw_freight: 300,
surcharges: 32.6,
raw_total: 332.6,
markup_percent: 0,
markup_amount: 0,
final_total: 332.6,
breakdown: [
{ item: "运费", amount: 300 },
{ item: "附加费", amount: 32.6 },
],
},
],
pickupJson: { zip: "90001" },
deliveryJson: { zip: "75201" },
}),
);
expect(detail.priority1).toBeUndefined();
expect(detail.quotes).toHaveLength(1);
expect(detail.quotes?.[0]).toMatchObject({
service_level: "standard",
rate_option: "lowest",
carrier: "Frontline Freight",
});
});
it("Priority1 询价无 priority1 数据时返回 failed 而非空结果", () => {
const detail = serializeQuoteDetail(
baseRecord({
errorCode: null,
errorMessage: null,
quotesJson: null,
pickupJson: { zip: "32801", priority1_shipment_type: "ltl" },
deliveryJson: { zip: "28202" },
}),
);
expect(detail.status).toBe("failed");
expect(detail.priority1).toBeUndefined();
expect(detail.quotes).toBeUndefined();
});
});

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { cmToIn, kgToLb } from "@/modules/quote/unit-converter"; import { cmToIn, kgToLb, toAxelWholeInches, toAxelWholePounds } from "@/modules/quote/unit-converter";
describe("unit-converter", () => { describe("unit-converter", () => {
it("227kg → 500.45lbROUND_HALF_UPJS 浮点 227×2.20462", () => { it("227kg → 500.45lbROUND_HALF_UPJS 浮点 227×2.20462", () => {
@ -10,6 +10,14 @@ describe("unit-converter", () => {
expect(cmToIn(122)).toBe(48.03); expect(cmToIn(122)).toBe(48.03);
}); });
it("120cm 转 axel 尺寸向上取整为 48in", () => {
expect(toAxelWholeInches(cmToIn(120))).toBe(48);
});
it("500kg 转 axel 重量向上取整为 1103lb", () => {
expect(toAxelWholePounds(kgToLb(500))).toBe(1103);
});
it("500lb 不变", () => { it("500lb 不变", () => {
expect(kgToLb(500 / 2.20462)).toBeCloseTo(500, 0); expect(kgToLb(500 / 2.20462)).toBeCloseTo(500, 0);
}); });

@ -0,0 +1,84 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const SCRIPT_PATH = join(process.cwd(), "scripts", "record-priority1.sh");
const TS_SCRIPT_PATH = join(process.cwd(), "scripts", "record-priority1.ts");
const FORBIDDEN_PATTERNS = [
/dashboard\.priority1\.com/i,
/api\.priority1\.com/i,
];
function isForbiddenPriority1QuoteUrl(url: string): boolean {
return FORBIDDEN_PATTERNS.some((pattern) => pattern.test(url));
}
describe("record-priority1.sh", () => {
const content = readFileSync(SCRIPT_PATH, "utf8");
it("存在且包含 headed 与 codegen 启动", () => {
expect(content).toContain("RPA_HEADLESS=false");
expect(content).toContain("playwright");
expect(content).toContain("codegen");
});
it("禁止 Cabo 后台与 API 门户 URL", () => {
expect(content).toContain("dashboard.priority1.com");
expect(content).toContain("api.priority1.com");
expect(content).toContain("check_url");
});
it("产出录制脚本与 storageState 路径", () => {
expect(content).toContain(".rpa/recordings");
expect(content).toContain("priority1-storage.json");
expect(content).toContain("--save-storage");
});
});
describe("record-priority1.ts (Windows 跨平台)", () => {
const content = readFileSync(TS_SCRIPT_PATH, "utf8");
it("存在且包含 headed codegen 与入口校验", () => {
expect(content).toContain('RPA_HEADLESS: "false"');
expect(content).toContain("playwright");
expect(content).toContain("codegen");
expect(content).toContain("isForbiddenPriority1QuoteUrl");
});
it("产出录制脚本与 storageState 路径", () => {
expect(content).toContain(".rpa/recordings");
expect(content).toContain("priority1-storage.json");
expect(content).toContain("--save-storage");
});
it("默认打开公开询价页", () => {
expect(content).toContain(
"https://www.priority1.com/instant-freight-quotes/",
);
});
});
describe("isForbiddenPriority1QuoteUrl", () => {
it("拒绝 Cabo 后台与 API 门户", () => {
expect(
isForbiddenPriority1QuoteUrl(
"https://dashboard.priority1.com/account/login",
),
).toBe(true);
expect(isForbiddenPriority1QuoteUrl("https://api.priority1.com/docs")).toBe(
true,
);
});
it("允许公开询价页与首页", () => {
expect(
isForbiddenPriority1QuoteUrl(
"https://www.priority1.com/instant-freight-quotes/",
),
).toBe(false);
expect(isForbiddenPriority1QuoteUrl("https://www.priority1.com/")).toBe(
false,
);
});
});

@ -39,17 +39,17 @@ describe("validateResult", () => {
expect(() => validateResult(items)).not.toThrow(); expect(() => validateResult(items)).not.toThrow();
}); });
it("仅 1 档 → RPA_DATA_INVALID", () => { it("仅 1 档 → 通过MotherShip 返回什么展示什么)", () => {
const items = [buildTier("standard", "lowest")]; const items = [buildTier("standard", "bestValue")];
expect(() => validateResult(items)).toThrow(RpaError); expect(() => validateResult(items)).not.toThrow();
}); });
it("缺 guaranteed 整档 → RPA_DATA_INVALID", () => { it("缺 guaranteed 整档 → 通过", () => {
const items = [ const items = [
buildTier("standard", "lowest"), buildTier("standard", "lowest"),
buildTier("standard", "fastest"), buildTier("standard", "fastest"),
]; ];
expect(() => validateResult(items)).toThrow(RpaError); expect(() => validateResult(items)).not.toThrow();
}); });
it("含 bestValue 扩展档 → 通过", () => { it("含 bestValue 扩展档 → 通过", () => {

@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
buildPriority1CanonicalTestCases,
generateRandomExpeditedCases,
generateRandomFtlCases,
generateRandomPriority1Cases,
parsePriority1DemoFromEnv,
PRIORITY1_CANONICAL_PATHS,
} from "@/workers/rpa/priority1/demo-input";
describe("priority1 demo-input", () => {
it("canonical 全矩阵 24 条", () => {
expect(PRIORITY1_CANONICAL_PATHS).toHaveLength(24);
const ltl = PRIORITY1_CANONICAL_PATHS.filter((p) => p.id.startsWith("P"));
const ftl = PRIORITY1_CANONICAL_PATHS.filter((p) => p.id.startsWith("F"));
const exp = PRIORITY1_CANONICAL_PATHS.filter((p) => p.id.startsWith("E"));
expect(ltl).toHaveLength(9);
expect(ftl).toHaveLength(11);
expect(exp).toHaveLength(4);
});
it("导出系统联调用例含 API 载荷", () => {
const cases = buildPriority1CanonicalTestCases({
customerId: "CUST_X",
requestIdPrefix: "req",
});
expect(cases).toHaveLength(24);
expect(cases[0]?.apiPayload.request_id).toBe("req-P01-ltl-residential");
expect(cases[0]?.apiPayload.customer_id).toBe("CUST_X");
expect(cases.find((c) => c.id === "E03-expedited-large")?.apiPayload.priority1_input.weightLb).toBe(
"10000",
);
});
it("相同 seed 生成相同用例", () => {
const a = generateRandomPriority1Cases(10, 42);
const b = generateRandomPriority1Cases(10, 42);
expect(a).toEqual(b);
expect(a).toHaveLength(10);
expect(a[0]?.id).toBe("P01");
});
it("起运与目的 ZIP 不相同", () => {
const cases = generateRandomPriority1Cases(10, 99);
for (const c of cases) {
expect(c.input.originZip).not.toBe(c.input.destinationZip);
expect(c.input.originZip).toMatch(/^\d{5}$/);
}
});
it("生成 FTL / Expedited 用例", () => {
const ftl = generateRandomFtlCases(3, 42);
const exp = generateRandomExpeditedCases(3, 42);
expect(ftl[0]?.input.shipmentType).toBe("ftl");
expect(exp[0]?.input.shipmentType).toBe("expedited");
expect(ftl[0]?.id).toBe("F01");
expect(exp[0]?.id).toBe("E01");
});
it("解析环境 JSON 覆盖默认字段", () => {
const demo = parsePriority1DemoFromEnv(
JSON.stringify({ originZip: "30303", weightLb: "999" }),
);
expect(demo.originZip).toBe("30303");
expect(demo.weightLb).toBe("999");
expect(demo.destinationZip).toBe("90017");
});
});

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_EXPEDITED_DEMO,
DEFAULT_FTL_FTL_EXPEDITED_DEMO,
PRIORITY1_CANONICAL_PATHS,
} from "@/workers/rpa/priority1/demo-input";
import {
validatePriority1FormConstraints,
} from "@/workers/rpa/priority1/form-constraints";
describe("priority1 form-constraints", () => {
it("24 条 canonical 无硬限制违规", () => {
const errors = PRIORITY1_CANONICAL_PATHS.flatMap((p) =>
validatePriority1FormConstraints(p.input).filter((v) => v.severity === "error"),
);
expect(errors).toEqual([]);
});
it("Expedited Large 超过 10000 lb 报错", () => {
const v = validatePriority1FormConstraints({
...DEFAULT_EXPEDITED_DEMO,
expeditedTrailer: "large_straight",
weightLb: "11000",
});
expect(v.some((x) => x.code === "WEIGHT_MAX")).toBe(true);
});
it("非 Large Straight 不可勾选 Liftgate", () => {
const v = validatePriority1FormConstraints({
...DEFAULT_EXPEDITED_DEMO,
expeditedTrailer: "sprinter_van",
expeditedLiftgate: true,
});
expect(v.some((x) => x.code === "LIFTGATE_TRAILER")).toBe(true);
});
it("FTL 内嵌 Expedited Large 重量上限 10000", () => {
const v = validatePriority1FormConstraints({
...DEFAULT_FTL_FTL_EXPEDITED_DEMO,
ftlExpeditedTrailer: "large_straight",
weightLb: "12000",
});
expect(v.some((x) => x.code === "WEIGHT_MAX")).toBe(true);
});
});

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
freightClassOptionMatches,
ltlAccessorialDisplayText,
ltlAccessorialIsPickupSection,
ltlAccessorialLabelMatches,
normalizeOpenDeckSubtypeQuery,
OPEN_DECK_TYPE_SELECT_SELECTOR,
openDeckSelectionOk,
} from "@/workers/rpa/priority1/form-interactions";
describe("priority1 form-interactions", () => {
it("Freight Class 选项匹配含 Not Sure", () => {
expect(freightClassOptionMatches("70", "70")).toBe(true);
expect(freightClassOptionMatches("I'm Not Sure", "Not Sure")).toBe(true);
expect(freightClassOptionMatches("400", "70")).toBe(false);
});
it("Open Deck 子类型别名归一", () => {
expect(normalizeOpenDeckSubtypeQuery("Flatbed")).toBe("Foot Flatbed");
expect(normalizeOpenDeckSubtypeQuery("48 Foot Flatbed")).toBe("48 Foot Flatbed");
expect(normalizeOpenDeckSubtypeQuery("Goose Neck RGN")).toBe("Removable Gooseneck");
});
it("Open Deck 选中判定", () => {
expect(openDeckSelectionOk("48 Foot Flatbed", "Flatbed")).toBe(true);
expect(openDeckSelectionOk("Removable Gooseneck", "Removable Gooseneck")).toBe(true);
expect(openDeckSelectionOk("", "Flatbed")).toBe(false);
});
it("Open Deck Type 录制 selector", () => {
expect(OPEN_DECK_TYPE_SELECT_SELECTOR).toBe("#PickupLocation3");
});
it("LTL 附加服务 label 匹配", () => {
expect(ltlAccessorialLabelMatches("Liftgate at Pickup", "Liftgate at Pickup")).toBe(true);
expect(ltlAccessorialLabelMatches(" Inside Pickup ", "Inside Pickup")).toBe(true);
expect(ltlAccessorialLabelMatches("Inside Delivery", "Inside Pickup")).toBe(false);
expect(ltlAccessorialLabelMatches("Liftgate at Delivery", "Liftgate at Pickup")).toBe(false);
});
it("LTL 附加 canonical → 页面文案", () => {
expect(ltlAccessorialDisplayText("Limited Access Pickup")).toBe("Limited Access");
expect(ltlAccessorialDisplayText("Inside Pickup")).toBe("Inside Pickup");
expect(ltlAccessorialIsPickupSection("Liftgate at Pickup")).toBe(true);
expect(ltlAccessorialIsPickupSection("Hazardous Material")).toBe(false);
});
});

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_FTL_DEMO,
DEFAULT_FTL_FTL_EXPEDITED_DEMO,
DEFAULT_PRIORITY1_DEMO,
PRIORITY1_CANONICAL_PATHS,
} from "@/workers/rpa/priority1/demo-input";
import {
buildPriority1FormPlan,
collectFieldOptionsCatalog,
formatCompleteFormLogicReference,
formatFormLogicReference,
getNextFields,
SCENARIO_CHEATSHEET,
STEP2_HASH_FALLBACK,
} from "@/workers/rpa/priority1/form-logic-map";
describe("priority1 form-logic-map", () => {
it("LTL 计划包含包装 Pallet 与 Step1 邮编", () => {
const plan = buildPriority1FormPlan(DEFAULT_PRIORITY1_DEMO);
const keys = plan.map((p) => p.field.key);
expect(keys).toContain("packaging");
expect(plan.find((p) => p.field.key === "packaging")?.value).toBe("Pallet");
expect(plan.find((p) => p.field.key === "originZip")?.value).toBe("10118");
});
it("FTL Dry Van 分支含拖车、货描、重量、托盘", () => {
const plan = buildPriority1FormPlan(DEFAULT_FTL_DEMO);
const labels = plan.map((p) => p.field.label);
expect(labels.some((l) => /Dry Van/i.test(l))).toBe(true);
expect(plan.find((p) => p.field.key === "commodity")?.value).toBe("Electronics");
expect(plan.find((p) => p.field.key === "weightLb")?.value).toBe("15000");
expect(plan.find((p) => p.field.key === "palletCount")?.value).toBe("18");
});
it("getNextFields FTL 未选拖车时只返回拖车卡", () => {
const next = getNextFields({ shipmentType: "ftl", step: 2 });
expect(next.every((f) => f.key.startsWith("ftlTrailer_"))).toBe(true);
});
it("Open Deck 分支追加子类型字段", () => {
const next = getNextFields({
shipmentType: "ftl",
step: 2,
ftlTrailer: "open_deck",
});
expect(next.some((f) => f.key === "openDeckSubtype")).toBe(true);
});
it("场景速查与哈希回退齐全", () => {
expect(SCENARIO_CHEATSHEET.length).toBeGreaterThanOrEqual(6);
expect(STEP2_HASH_FALLBACK.ftl).toContain("Truckload");
expect(formatFormLogicReference(DEFAULT_FTL_DEMO)).toContain("Dry Van");
});
it("FTL 内嵌 Expedited 计划不含托盘字段", () => {
const plan = buildPriority1FormPlan(DEFAULT_FTL_FTL_EXPEDITED_DEMO);
expect(plan.some((p) => p.field.key === "palletCount")).toBe(false);
});
it("canonical 路径覆盖三种主类型与全部 FTL 拖车", () => {
expect(PRIORITY1_CANONICAL_PATHS.length).toBeGreaterThanOrEqual(20);
const types = new Set(PRIORITY1_CANONICAL_PATHS.map((p) => p.input.shipmentType));
expect(types).toEqual(new Set(["ltl", "ftl", "expedited"]));
const ftlTrailers = PRIORITY1_CANONICAL_PATHS.filter(
(p) => p.input.shipmentType === "ftl",
).map((p) => p.input.ftlTrailer);
expect(new Set(ftlTrailers)).toEqual(
new Set(["dry_van", "open_deck", "temperature_controlled", "ftl_expedited"]),
);
});
it("完整参考含路径矩阵与字段目录", () => {
const md = formatCompleteFormLogicReference(PRIORITY1_CANONICAL_PATHS);
expect(md).toContain("路径矩阵");
expect(md).toContain("全字段可选值");
expect(md).toContain("P01-ltl-residential");
expect(collectFieldOptionsCatalog().some((r) => r.key === "packaging")).toBe(
true,
);
});
});

@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE } from "@/modules/priority1/constants";
import { PRIORITY1_MANUAL_FOLLOWUP_MESSAGE } from "@/workers/rpa/priority1/quote-extract";
const { prismaUpdate, runRpa, recordSuccess, closeCircuit } = vi.hoisted(
() => ({
prismaUpdate: vi.fn(),
runRpa: vi.fn(),
recordSuccess: vi.fn(),
closeCircuit: vi.fn(),
}),
);
vi.mock("@/lib/prisma", () => ({
prisma: {
quoteRecord: { update: prismaUpdate },
},
}));
vi.mock("@/workers/rpa/priority1/run-quote", () => ({
runPriority1QuoteRpa: runRpa,
}));
vi.mock("@/modules/cache/circuit-breaker", () => ({
isCircuitOpen: vi.fn().mockResolvedValue(false),
recordRpaFailure: vi.fn(),
recordRpaSuccess: recordSuccess,
closeCircuit: closeCircuit,
}));
vi.mock("@/workers/rpa/worker-state", () => ({
heartbeat: vi.fn(),
isWorkerPaused: vi.fn().mockResolvedValue(false),
}));
vi.mock("@/modules/metrics/collector", () => ({
recordPostDone: vi.fn(),
safeRecord: (fn: () => void) => fn(),
}));
vi.mock("@/modules/pricing/engine", () => ({
getMarkupRule: vi.fn(),
applyMarkupToQuotes: vi.fn(),
}));
vi.mock("@/modules/quote/fallback-orchestrator", () => ({
handleRpaFailure: vi.fn(),
}));
import { processPriority1QuoteJob } from "@/workers/rpa/priority1-job-handler";
const jobData = {
quoteId: "Q-P1-001",
requestId: "req-p1",
customerId: "cust-1",
cargoHash: "hash-p1",
input: {
shipmentType: "ftl" as const,
originZip: "98101",
destinationZip: "33101",
pickupDate: "07/01/2026",
phoneCountry: "US",
phone: "2065550100",
email: "test@example.com",
commodity: "General Freight",
weightLb: "5000",
ftlTrailer: "temperature_controlled" as const,
tempMinF: "32",
tempMaxF: "40",
palletCount: "1",
},
};
describe("processPriority1QuoteJob", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("persists manual_followup as done with PRIORITY1_MANUAL_FOLLOWUP", async () => {
runRpa.mockResolvedValue({
ok: true,
quotes: [],
quoteOutcome: "manual_followup",
message: PRIORITY1_MANUAL_FOLLOWUP_MESSAGE,
});
await processPriority1QuoteJob(jobData, "worker-1");
expect(prismaUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { quoteId: "Q-P1-001" },
data: expect.objectContaining({
status: "done",
errorCode: PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE,
errorMessage: PRIORITY1_MANUAL_FOLLOWUP_MESSAGE,
}),
}),
);
expect(recordSuccess).toHaveBeenCalled();
expect(closeCircuit).toHaveBeenCalled();
});
});

@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
isPickupDateAfterToday,
minPickupDateDisplay,
pickupDateValidationMessage,
} from "@/lib/priority1/pickup-date";
import { extractPriority1VisibleQuotes } from "@/workers/rpa/priority1/quote-extract";
const SAMPLE_SUMMARY = JSON.stringify({
quote_id: 42830001,
LTL_best_price_quote_carrier: "XPO",
LTL_best_price_quote_cost: "481.32",
LTL_best_price_quote_transit: 8,
LTL_second_rate_quote_carrier: "Estes",
LTL_second_rate_quote_cost: "503.68",
LTL_second_rate_quote_transit: 5,
});
describe("pickup date rules", () => {
it("requires date strictly after today", () => {
const min = minPickupDateDisplay();
expect(min).toMatch(/^\d{2}\/\d{2}\/\d{4}$/);
expect(pickupDateValidationMessage(min)).toBeNull();
expect(isPickupDateAfterToday(min)).toBe(true);
});
it("rejects today or past dates", () => {
const today = new Date();
const mm = String(today.getMonth() + 1).padStart(2, "0");
const dd = String(today.getDate()).padStart(2, "0");
const yyyy = today.getFullYear();
const todayDisplay = `${mm}/${dd}/${yyyy}`;
expect(pickupDateValidationMessage(todayDisplay)).toBe("提货日须晚于今天");
});
});
describe("parseVisibleQuoteCardsDom via extractPriority1VisibleQuotes", () => {
const sampleDom = `
YOUR INSTANT QUOTE OPTIONS
QUOTE 1
STANDARD LTL
Estes
Transit: 1 Business Days
Quote Expiration Date: 07-09-2026
Estimated Delivery Date: 07-15-2026
$426.19
QUOTE 2
STANDARD LTL
TForce Freight
Transit: 2 Business Days
Quote Expiration Date: 07-09-2026
Estimated Delivery Date: 07-16-2026
$1703.90
`;
it("无网络摘要时以可见 DOM 卡片为准", () => {
const lines = extractPriority1VisibleQuotes([], sampleDom);
expect(lines).toHaveLength(2);
expect(lines[0]!.carrier).toBe("Estes");
expect(lines[0]!.totalUsd).toBe(426.19);
expect(lines[0]!.expirationDate).toBe("07/09/2026");
expect(lines[0]!.deliveryDate).toBe("07/15/2026");
expect(lines[1]!.totalUsd).toBe(1703.9);
});
it("DOM 无承运商时从 Feathery 摘要按 QUOTE 序号补全承运商与金额", () => {
const dom = `
QUOTE 1
STANDARD LTL
Transit: 1 Business Days
Quote Expiration Date: 07-09-2026
Estimated Delivery Date: 07-15-2026
$999.99
QUOTE 2
STANDARD LTL
Transit: 2 Business Days
$888.88
`;
const lines = extractPriority1VisibleQuotes([SAMPLE_SUMMARY], dom);
expect(lines).toHaveLength(2);
expect(lines[0]!.carrier).toBe("XPO");
expect(lines[0]!.totalUsd).toBe(481.32);
expect(lines[0]!.expirationDate).toBe("07/09/2026");
expect(lines[1]!.carrier).toBe("Estes");
expect(lines[1]!.totalUsd).toBe(503.68);
});
});

@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
extractPriority1AllApiQuotes,
extractPriority1QuotesFromDomText,
extractPriority1QuotesFromNetworkBodies,
extractPriority1VisibleQuotes,
isPriority1ManualFollowupPage,
isPriority1QuotePageReady,
parseFtlCalendarQuotes,
PRIORITY1_MANUAL_FOLLOWUP_MESSAGE,
resolvePriority1SubmitOutcome,
} from "@/workers/rpa/priority1/quote-extract";
const SAMPLE_API = JSON.stringify({
data: {
id: 42830001,
rateQuotes: [
{
id: 1,
carrierName: "XPO",
carrierCode: "XPOL",
transitDays: 8,
deliveryDate: "2026-07-13T00:00:00",
expirationDate: "2026-06-27T07:00:30Z",
serviceLevelDescription: "Standard Rate",
rateQuoteDetail: { total: 481.32 },
},
{
id: 2,
carrierName: "Estes",
carrierCode: "EXLA",
transitDays: 5,
rateQuoteDetail: { total: 503.68 },
},
{
id: 3,
carrierName: "Hidden Carrier",
carrierCode: "HIDN",
transitDays: 4,
rateQuoteDetail: { total: 999.99 },
},
],
},
});
const SAMPLE_SUMMARY = JSON.stringify({
quote_id: 42830001,
cabo_url: "https://dashboard.priority1.com/ltl/quotes/details/42830001",
LTL_best_price_quote_carrier: "XPO",
LTL_best_price_quote_cost: "481.32",
LTL_best_price_quote_transit: 8,
LTL_second_rate_quote_carrier: "Estes",
LTL_second_rate_quote_cost: "503.68",
LTL_second_rate_quote_transit: 5,
});
const SAMPLE_DOM = `
QUOTE 1 Forward $560.27 STANDARD LTL Forward Quote Expiration Date 07-03-2026
2 Business Days
QUOTE 2 Go2 Logistics $2052.67 STANDARD LTL Go2 Logistics Quote Expiration Date
3 Business Days
`;
describe("priority1 quote-extract", () => {
it("全量 API 解析 rateQuotes", () => {
const lines = extractPriority1AllApiQuotes([SAMPLE_API]);
expect(lines.length).toBe(3);
expect(lines[0].carrier).toBe("XPO");
expect(lines[0].source).toBe("api-all");
});
it("同时有 API 与摘要时优先页面可见摘要", () => {
const lines = extractPriority1VisibleQuotes([SAMPLE_API, SAMPLE_SUMMARY]);
expect(lines.length).toBe(2);
expect(lines[0].carrier).toBe("XPO");
expect(lines[0].source).toBe("feathery-summary");
});
it("无摘要时回退 feathery 摘要字段", () => {
const lines = extractPriority1QuotesFromNetworkBodies([SAMPLE_SUMMARY]);
expect(lines.length).toBe(2);
expect(lines[1].carrier).toBe("Estes");
});
it("解析 QUOTE 卡片 DOM", () => {
const lines = extractPriority1QuotesFromDomText(SAMPLE_DOM);
expect(lines.length).toBe(2);
expect(lines[0].carrier).toBe("Forward");
expect(lines[0].totalUsd).toBe(560.27);
expect(lines[0].source).toBe("visible-dom");
});
it("解析 FTL 日历表格报价(无 $ 前缀)", () => {
const dom = `
YOUR INSTANT QUOTE OPTIONS
SHIPPING DETAILS From 32801 To 28202 Weight 15000 lb
Wed 1 1652.16 Thu 2 1652.16 Fri 3 1650.78
Mon 6 2178.47 Tue 7 1652.16
REQUEST A NEW QUOTE BOOK NOW IN CABO TMS
`;
expect(isPriority1QuotePageReady(dom)).toBe(true);
const lines = parseFtlCalendarQuotes(dom);
expect(lines.length).toBe(5);
expect(lines[0].source).toBe("ftl-calendar");
expect(lines[0].deliveryDate).toBe("DAY:1");
expect(lines[3].deliveryDate).toBe("DAY:6");
expect(lines.some((q) => q.totalUsd === 2178.47)).toBe(true);
});
it("识别 MORE INFORMATION NEEDED 人工跟进页", () => {
const dom = `
MORE INFORMATION NEEDED
A Priority1 freight specialist will reach out to confirm the missing details
Confirm Details & Lock In Your Rate
REQUEST NEW QUOTE
`;
expect(isPriority1ManualFollowupPage(dom)).toBe(true);
const resolved = resolvePriority1SubmitOutcome([], dom);
expect(resolved.outcome).toBe("manual_followup");
expect(resolved.message).toBe(PRIORITY1_MANUAL_FOLLOWUP_MESSAGE);
expect(resolved.quotes).toHaveLength(0);
});
});

@ -449,7 +449,7 @@ describe("decodeQuotePayloads", () => {
).toBe(768.88); ).toBe(768.88);
}); });
it("standard 缺 fastest 键时从 availableRates 显式 serviceType 补档", () => { it("rates 未声明 fastest 槽位时不从 availableRates 补档", () => {
const body = { const body = {
data: { data: {
rates: { rates: {
@ -475,8 +475,8 @@ describe("decodeQuotePayloads", () => {
expect( expect(
items.find( items.find(
(i) => i.serviceLevel === "standard" && i.rateOption === "fastest", (i) => i.serviceLevel === "standard" && i.rateOption === "fastest",
)?.rawFreight, ),
).toBe(768.88); ).toBeUndefined();
}); });
it("无契约响应返回 null", () => { it("无契约响应返回 null", () => {
@ -495,7 +495,7 @@ describe("decodeQuotePayloads", () => {
).toBeNull(); ).toBeNull();
}); });
it("sign-up-quote 页 axel/quote 契约不全时走 lenient", () => { it("rates 空块 + customRate 时不推断伪档位", () => {
const body = { const body = {
data: { data: {
rates: { standard: {}, guaranteed: {}, dedicated: {} }, rates: { standard: {}, guaranteed: {}, dedicated: {} },
@ -525,12 +525,7 @@ describe("decodeQuotePayloads", () => {
], ],
}), }),
); );
expect(decoded?.source).toBe("lenient"); expect(decoded).toBeNull();
expect(
decoded?.items.find(
(i) => i.serviceLevel === "standard" && i.rateOption === "lowest",
)?.rawFreight,
).toBe(612.72);
}); });
}); });
@ -541,7 +536,7 @@ describe("decodeEmbeddedJsonBodies", () => {
}); });
describe("decodeAxelQuoteBodyLenient", () => { describe("decodeAxelQuoteBodyLenient", () => {
it("rates 缺 standard/lowest 时从 availableRates customRate 推断", () => { it("rates 空块时不从 availableRates customRate 推断", () => {
const body = { const body = {
data: { data: {
rates: { rates: {
@ -569,15 +564,65 @@ describe("decodeAxelQuoteBodyLenient", () => {
}, },
}, },
}; };
const items = decodeAxelQuoteBodyLenient(body); expect(decodeAxelQuoteBodyLenient(body)).toBeNull();
expect(items).not.toBeNull(); });
it("解析 rates.dedicated.bestValue 专属卡车档", () => {
const body = {
data: {
rates: {
standard: {
lowest: { finalPrice: 299.62, days: 3, serviceType: "lowest" },
fastest: { finalPrice: 682.15, days: 2, serviceType: "fastest" },
},
guaranteed: {
lowest: { finalPrice: 411.98, days: 3, serviceType: "lowest" },
fastest: { finalPrice: 823.52, days: 2, serviceType: "fastest" },
},
dedicated: {
bestValue: {
finalPrice: 4989.2,
days: 2,
serviceType: "bestValue",
serviceLevel: "dedicated",
carrierInfo: { name: "Dedicated Carrier" },
},
},
},
availableRates: {},
},
};
const items = decodeAxelQuoteBodyLenient(body)!;
expect( expect(
items!.find((i) => i.serviceLevel === "standard" && i.rateOption === "lowest") items.find((i) => i.serviceLevel === "dedicated" && i.rateOption === "bestValue")
?.rawTotal, ?.rawTotal,
).toBe(927.48); ).toBe(4989.2);
expect(items.filter((i) => i.serviceLevel === "dedicated")).toHaveLength(1);
});
it("各 service_level 仅 bestValue 时仍可解码Denver→Boston", () => {
const body = {
data: {
rates: {
standard: {
bestValue: { finalPrice: 398.77, days: 4, serviceType: "bestValue" },
},
guaranteed: {
bestValue: { finalPrice: 511.12, days: 3, serviceType: "bestValue" },
},
dedicated: {
bestValue: { finalPrice: 5989.2, days: 2, serviceType: "bestValue" },
},
},
availableRates: {},
},
};
const items = validateQuoteSchema(decodeAxelQuoteBodyLenient(body)!);
expect(items).toHaveLength(3);
expect( expect(
items!.find((i) => i.serviceLevel === "standard" && i.rateOption === "fastest") items.find(
?.rawTotal, (i) => i.serviceLevel === "standard" && i.rateOption === "bestValue",
).toBe(1100.12); )?.rawFreight,
).toBe(398.77);
}); });
}); });

@ -0,0 +1,358 @@
# MotherShip 查询接口调用文档
本文档只说明 **MotherShip / Axel** 查询报价接口如何调用,适合直接发给第三方开发人员使用。
Base URL`https://if.dev.51track.vip`
---
## 1. 推荐接口
当前使用 **两步宿主接口**(须鉴权):
1. `POST /api/host/quote/candidates`
2. `POST /api/host/quote/submit`
特点:
- 第一步返回联想地址候选
- 第二步服务端内部等待并轮询
- 调用方**不需要自己轮询**
---
## 2. 鉴权(必传)
当前服务器已开启鉴权(`HOST_PUBLIC_API_ENABLED=false`**每次请求**须携带以下请求头:
```http
Authorization: Bearer <Service Token>
X-Customer-Id: CUST_001
Content-Type: application/json
```
| 字段 | 值 | 说明 |
|------|-----|------|
| `Authorization` | `Bearer <Service Token>` | 我方单独发放的 API Token |
| `X-Customer-Id` | `CUST_001` | 固定客户 ID须与 Token 绑定一致 |
**示例(联调 Token**
```http
Authorization: Bearer demo-host-token
X-Customer-Id: CUST_001
Content-Type: application/json
```
> 生产环境 Token 由我方另行发放,格式同上;请勿将 Token 写入前端页面或公开仓库。
### 鉴权错误
| HTTP | message | 说明 |
|------|---------|------|
| 401 | 缺少 Authorization 头 | 未带 `Authorization: Bearer ...` |
| 401 | Service Token 无效 | Token 拼写错误或不在白名单 |
| 401 | 令牌无效或已过期 | 误用了管理员 JWT应使用 Service Token |
### Apifox 环境变量建议
| 变量 | 示例值 |
|------|--------|
| `baseUrl` | `https://if.dev.51track.vip` |
| `token` | `demo-host-token`(联调)或我方发放的生产 Token |
| `customerId` | `CUST_001` |
在「Auth」或「Headers」中配置
- `Authorization``Bearer {{token}}`
- `X-Customer-Id``{{customerId}}`
- `Content-Type``application/json`
---
## 3. 第一步:获取联想地址候选
### 接口
```http
POST /api/host/quote/candidates
```
### 请求头示例
```http
Authorization: Bearer demo-host-token
X-Customer-Id: CUST_001
Content-Type: application/json
```
### 请求体
```json
{
"pickup_address": {
"street": "1234 Warehouse Blvd",
"city": "Los Angeles",
"state": "CA",
"zip": "90001"
},
"delivery_address": {
"street": "5678 Distribution Dr",
"city": "Dallas",
"state": "TX",
"zip": "75201"
},
"cargo": {
"weight": { "value": 500, "unit": "kg" },
"dimensions": { "length": 120, "width": 100, "height": 150, "unit": "cm" },
"pallet_count": 2,
"cargo_type": "general_freight"
}
}
```
### 字段说明
| 字段 | 说明 |
|------|------|
| `pickup_address` | 提货草稿地址 |
| `delivery_address` | 派送草稿地址 |
| `street/city/state` | 必填,`state` 为美国州二字码 |
| `zip` | 可选,建议传 |
| `cargo.weight.unit` | `kg``lb` |
| `cargo.dimensions.unit` | `cm``in` |
| `cargo.pallet_count` | 托盘数125 |
| `cargo.cargo_type` | `general_freight` 等 |
### 成功响应示例
```json
{
"code": 0,
"message": "ok",
"data": {
"host_session_id": "2dd352e6-9a1d-4563-8f13-ebff18aabdb9",
"pickup_candidates": [
{
"option_id": "Eisx...",
"display_label": "1234 Warehouse Street, Los Angeles, CA, USA",
"formatted_address": "1234 Warehouse Street, Los Angeles, CA, USA",
"street": "1234 Warehouse Street",
"city": "Los Angeles",
"state": "CA",
"zip": "90001",
"selectable": true
}
],
"delivery_candidates": [
{
"option_id": "EihE...",
"display_label": "Distribution Dr, Wilmer, Dallas, TX, USA",
"formatted_address": "Distribution Dr, Wilmer, Dallas, TX, USA",
"street": "5678 Distribution Dr, Wilmer",
"city": "Dallas",
"state": "TX",
"zip": "75201",
"selectable": true
}
],
"requires_selection": true
}
}
```
### 第一步调用后的处理
1. 保存 `data.host_session_id`(约 30 分钟有效)
2. 从 `pickup_candidates` 中选择 1 条 `selectable: true`
3. 从 `delivery_candidates` 中选择 1 条 `selectable: true`
4. 将选中的**完整候选对象**用于第二步请求
不要使用 `selectable: false` 的候选。
---
## 4. 第二步:提交候选并直接获取最终报价
### 接口
```http
POST /api/host/quote/submit
```
### 请求头
与第一步相同(须带 `Authorization``X-Customer-Id`)。
### 请求体
```json
{
"host_session_id": "2dd352e6-9a1d-4563-8f13-ebff18aabdb9",
"pickup_candidate": {
"option_id": "Eisx...",
"display_label": "1234 Warehouse Street, Los Angeles, CA, USA",
"formatted_address": "1234 Warehouse Street, Los Angeles, CA, USA",
"street": "1234 Warehouse Street",
"city": "Los Angeles",
"state": "CA",
"zip": "90001",
"selectable": true
},
"delivery_candidate": {
"option_id": "EihE...",
"display_label": "Distribution Dr, Wilmer, Dallas, TX, USA",
"formatted_address": "Distribution Dr, Wilmer, Dallas, TX, USA",
"street": "5678 Distribution Dr, Wilmer",
"city": "Dallas",
"state": "TX",
"zip": "75201",
"selectable": true
}
}
```
### 注意
- `host_session_id` 必须来自第一步返回值
- `pickup_candidate` / `delivery_candidate` 必须是第一步候选列表中的**完整对象**
- 第二步会等待 1030 秒,最长约 420 秒
- 第二步**不会**返回 `processing` 让你继续轮询;服务端会自己处理轮询
### 成功响应示例
```json
{
"code": 0,
"message": "ok",
"data": {
"quote_id": "QTE_20260630_0003",
"status": "done",
"currency": "USD",
"valid_until": "2026-06-30T10:30:00.000Z",
"quotes": [
{
"service_level": "standard",
"rate_option": "lowest",
"carrier": "Frontline Freight",
"transit_days": "Est. 5 business days",
"final_total": 337.06
}
]
}
}
```
### 应展示给用户的字段
| 字段 | 说明 |
|------|------|
| `quotes[].final_total` | 最终展示价USD |
| `quotes[].carrier` | 承运商 |
| `quotes[].transit_days` | 时效 |
| `valid_until` | 报价有效期 |
---
## 5. 调用步骤总结
### 业务步骤
1. 带鉴权头调 `POST /api/host/quote/candidates`
2. 取得 `host_session_id`
3. 让用户在候选地址中各选一条可用地址
4. 带鉴权头调 `POST /api/host/quote/submit`
5. 直接拿最终报价并展示
### 第三方系统需要做的事
- 在服务端保存 Token**不要**暴露在前端
- 每次请求携带 `Authorization` + `X-Customer-Id: CUST_001`
- 第一步展示候选地址选择 UI
- 第二步显示 Loading建议超时 ≥ 420 秒)
- 第二步返回后展示报价
### 第三方系统不需要做的事
- 不需要 `GET /api/quotes/{id}` 轮询
- 不需要自己做 kg/cm 到 lb/in 的换算
---
## 6. 单位说明
接口支持:
- 重量:`kg` / `lb`
- 尺寸:`cm` / `in`
例如:
```json
"weight": { "value": 500, "unit": "kg" },
"dimensions": { "length": 120, "width": 100, "height": 150, "unit": "cm" }
```
服务端会自动换算为 MotherShip 需要的英制单位。
---
## 7. Apifox 测试方法
### 环境变量
| 变量 | 值 |
|------|-----|
| `baseUrl` |https://if.dev.51track.vip |
| `token` | `demo-host-token` |
| `customerId` | `CUST_001` |
### 第一步
- 方法:`POST`
- URL`{{baseUrl}}/api/host/quote/candidates`
- Headers
- `Authorization``Bearer {{token}}`
- `X-Customer-Id``{{customerId}}`
- `Content-Type``application/json`
- BodyJSON可参考 `deploy/host-candidates-req.example.json`
### 第二步
- 方法:`POST`
- URL`{{baseUrl}}/api/host/quote/submit`
- Headers与第一步相同
- Body填入第一步返回的 `host_session_id` 和选中的完整候选对象
Apifox 请求超时建议:`420000` ms420 秒)
### cURL 示例(第一步)
```bash
curl -X POST "https://if.dev.51track.vip/api/host/quote/candidates" \
-H "Authorization: Bearer demo-host-token" \
-H "X-Customer-Id: CUST_001" \
-H "Content-Type: application/json" \
--data-binary @deploy/host-candidates-req.example.json
```
---
## 8. 常见错误
| code | HTTP | 说明 | 处理方式 |
|------|------|------|----------|
| `UNAUTHORIZED` | 401 | 缺少或无效 Token | 检查 `Authorization`、`X-Customer-Id` |
| `VALIDATION_FAILED` | 400 | 参数不合法 | 检查 JSON、字段名、单位值 |
| `SESSION_EXPIRED` | 404 | 会话过期 | 重新调第一步 |
| `ADDRESS_SESSION_MISMATCH` | 400 | 第二步候选与第一步不一致 | 使用第一步返回的完整候选对象 |
| `ADDRESS_NOT_SELECTABLE` | 400 | 选了不可用候选 | 改用 `selectable: true` 的候选 |
| `QUOTE_TIMEOUT` | 504 | 服务端等待超时 | 稍后重试 |
| `RATE_LIMITED` | 429 | 请求过于频繁 | 降低调用频率 |
---
## 9. 联系与支持
- Token 失效或需单独开通生产 Token请联系我方运维
- `X-Customer-Id` 当前固定为 `CUST_001`,须与发放的 Token 一致

@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { AdminLayout } from "@/components/layout/admin-layout"; import { AdminLayout } from "@/components/layout/admin-layout";
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@ -43,6 +42,25 @@ function toneClass(tone: "warning" | "error" | "neutral"): string {
return "bg-slate-100 text-text-secondary"; return "bg-slate-100 text-text-secondary";
} }
function DetailRow({
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div className="border-b border-border py-2">
<dt className="text-xs text-text-secondary">{label}</dt>
<dd className={`mt-0.5 text-sm ${mono ? "font-mono text-xs" : ""}`}>
{value}
</dd>
</div>
);
}
export default function AlertsPage() { export default function AlertsPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const [status, setStatus] = useState<DataPageStatus>("loading"); const [status, setStatus] = useState<DataPageStatus>("loading");
@ -96,7 +114,7 @@ export default function AlertsPage() {
<AdminLayout> <AdminLayout>
<PageHeader <PageHeader
title="预警中心" title="预警中心"
subtitle="处理 RPA 抓取、价格偏差与降级等系统预警" subtitle="查看查价失败根因、客户地址货物信息,便于追查问题"
action={ action={
<div className="flex gap-3"> <div className="flex gap-3">
<div className="w-40"> <div className="w-40">
@ -161,6 +179,7 @@ export default function AlertsPage() {
<tr> <tr>
<th className="px-4 py-3 font-medium"></th> <th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th> <th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th> <th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th> <th className="px-4 py-3 font-medium"></th>
</tr> </tr>
@ -182,6 +201,9 @@ export default function AlertsPage() {
{ALERT_TYPE_LABEL[row.alert_type]} {ALERT_TYPE_LABEL[row.alert_type]}
</span> </span>
</td> </td>
<td className="px-4 py-3 font-mono text-xs">
{row.customer_id ?? "—"}
</td>
<td className="px-4 py-3 font-mono text-xs"> <td className="px-4 py-3 font-mono text-xs">
{row.quote_id ?? "—"} {row.quote_id ?? "—"}
</td> </td>
@ -226,42 +248,67 @@ export default function AlertsPage() {
onClick={() => !resolving && setDetail(null)} onClick={() => !resolving && setDetail(null)}
> >
<div <div
className="h-full w-full max-w-md overflow-y-auto bg-surface p-6 shadow-modal" className="h-full w-full max-w-lg overflow-y-auto bg-surface p-6 shadow-modal"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 className="text-lg font-semibold"></h3> <h3 className="text-lg font-semibold"></h3>
<dl className="mt-4 space-y-2 text-sm"> <dl className="mt-4">
<div className="flex justify-between"> <DetailRow label="预警编号" value={detail.id} mono />
<dt className="text-text-secondary"></dt> <DetailRow
<dd className="font-mono">{detail.id}</dd> label="类型"
</div> value={ALERT_TYPE_LABEL[detail.alert_type]}
<div className="flex justify-between"> />
<dt className="text-text-secondary"></dt> <DetailRow
<dd className="font-mono">{detail.quote_id ?? "—"}</dd> label="触发时间"
</div> value={formatDateTime(detail.created_at)}
<div className="flex justify-between"> />
<dt className="text-text-secondary"></dt> <DetailRow
<dd>{formatDateTime(detail.created_at)}</dd> label="客户"
</div> value={detail.customer_id ?? "—"}
mono
/>
<DetailRow
label="报价单号"
value={detail.quote_id ?? "—"}
mono
/>
</dl> </dl>
<div className="mt-4">
<p className="mb-1 text-sm font-medium text-text-secondary"> <div className="mt-4 rounded-md border border-error/30 bg-error/5 p-3">
<p className="text-xs font-medium text-error"></p>
<p className="mt-1 text-sm leading-relaxed">
{detail.root_cause ?? "暂无详细说明"}
</p> </p>
<pre className="overflow-x-auto rounded-md bg-bg p-3 text-xs">
{JSON.stringify(detail.detail_json, null, 2)}
</pre>
</div> </div>
<div className="mt-4 space-y-3">
<p className="text-sm font-medium"></p>
<DetailRow
label="提货地址(客户填写)"
value={detail.pickup_customer ?? "—"}
/>
<DetailRow
label="提货地址(联想确认)"
value={detail.pickup_selected ?? "—"}
/>
<DetailRow
label="派送地址(客户填写)"
value={detail.delivery_customer ?? "—"}
/>
<DetailRow
label="派送地址(联想确认)"
value={detail.delivery_selected ?? "—"}
/>
<DetailRow
label="货物信息"
value={detail.cargo_summary ?? "—"}
/>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-3"> <div className="mt-6 flex flex-wrap justify-end gap-3">
<SecondaryButton disabled={resolving} onClick={() => setDetail(null)}> <SecondaryButton disabled={resolving} onClick={() => setDetail(null)}>
</SecondaryButton> </SecondaryButton>
{(detail.alert_type === "RPA_FAILED" ||
detail.alert_type === "RPA_CAPTCHA") && (
<Link href="/admin/rpa">
<SecondaryButton type="button"> RPA </SecondaryButton>
</Link>
)}
{detail.status === "open" && ( {detail.status === "open" && (
<PrimaryButton <PrimaryButton
loading={resolving} loading={resolving}

@ -0,0 +1,455 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { AdminLayout } from "@/components/layout/admin-layout";
import { PageHeader } from "@/components/layout/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { ErrorBanner } from "@/components/ui/error-banner";
import { SecondaryButton } from "@/components/ui/primary-button";
import { PrimaryButton } from "@/components/ui/primary-button";
import { InputField } from "@/components/ui/input-field";
import { Card } from "@/components/ui/card";
import { WarningBanner } from "@/components/ui/warning-banner";
import {
adminCreateCustomer,
adminGetCustomers,
adminRotateCustomerKey,
adminUpdateCustomer,
} from "@/lib/frontend/api-client";
import { formatDateTime } from "@/lib/frontend/format";
import { useAuth } from "@/hooks/use-auth";
import type { DataPageStatus, HostCustomerRecord } from "@/lib/frontend/types";
const PAGE_SIZE = 10;
export default function AdminCustomersPage() {
const { token } = useAuth();
const [status, setStatus] = useState<DataPageStatus>("loading");
const [rows, setRows] = useState<HostCustomerRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState("");
const [createRemark, setCreateRemark] = useState("");
const [createPassword, setCreatePassword] = useState("123456");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | undefined>();
const [passwordTarget, setPasswordTarget] = useState<HostCustomerRecord | null>(
null,
);
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState<string | undefined>();
const [savingPassword, setSavingPassword] = useState(false);
const [revealedKey, setRevealedKey] = useState<{
customer_id: string;
api_key: string;
} | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [rotatingId, setRotatingId] = useState<string | null>(null);
const load = useCallback(
async (p: number, kw: string) => {
if (!token) return;
setStatus("loading");
const res = await adminGetCustomers("", token, p, PAGE_SIZE, kw);
if (res.code !== 0) {
setStatus("error");
return;
}
setRows(res.data.list);
setTotal(res.data.total);
setStatus(res.data.total === 0 ? "empty" : "success");
},
[token],
);
useEffect(() => {
void load(page, keyword);
}, [load, page, keyword]);
const handleCreate = async () => {
if (!token) return;
const name = createName.trim();
if (!name) {
setCreateError("请填写客户名称");
return;
}
setCreating(true);
setCreateError(undefined);
const res = await adminCreateCustomer("", token, {
name,
remark: createRemark.trim() || undefined,
embed_password: createPassword.trim() || undefined,
});
setCreating(false);
if (res.code !== 0) {
setCreateError(res.message);
return;
}
setCreateOpen(false);
setCreateName("");
setCreateRemark("");
setCreatePassword("123456");
setRevealedKey({
customer_id: res.data.customer_id,
api_key: res.data.api_key,
});
void load(1, keyword);
setPage(1);
};
const handleToggleStatus = async (row: HostCustomerRecord) => {
if (!token) return;
setActionError(null);
const nextStatus = row.status === "active" ? "disabled" : "active";
const res = await adminUpdateCustomer("", token, row.customer_id, {
status: nextStatus,
});
if (res.code !== 0) {
setActionError(res.message);
return;
}
setRows((list) =>
list.map((item) =>
item.customer_id === row.customer_id ? res.data : item,
),
);
};
const openPasswordDrawer = (row: HostCustomerRecord) => {
setPasswordTarget(row);
setNewPassword("");
setConfirmPassword("");
setPasswordError(undefined);
};
const handleSavePassword = async () => {
if (!token || !passwordTarget) return;
if (newPassword.length < 6) {
setPasswordError("密码至少 6 位");
return;
}
if (newPassword !== confirmPassword) {
setPasswordError("两次输入的密码不一致");
return;
}
setSavingPassword(true);
setPasswordError(undefined);
const res = await adminUpdateCustomer("", token, passwordTarget.customer_id, {
embed_password: newPassword,
});
setSavingPassword(false);
if (res.code !== 0) {
setPasswordError(res.message);
return;
}
setRows((list) =>
list.map((item) =>
item.customer_id === passwordTarget.customer_id ? res.data : item,
),
);
setPasswordTarget(null);
};
const handleRotateKey = async (customerId: string) => {
if (!token) return;
setRotatingId(customerId);
setActionError(null);
const res = await adminRotateCustomerKey("", token, customerId);
setRotatingId(null);
if (res.code !== 0) {
setActionError(res.message);
return;
}
setRevealedKey({ customer_id: customerId, api_key: res.data.api_key });
void load(page, keyword);
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const activeKeyPrefix = (row: HostCustomerRecord) =>
row.api_keys.find((k) => k.is_active)?.key_prefix ?? "—";
return (
<AdminLayout>
<PageHeader
title="客户管理"
subtitle="在管理端创建客户并签发 API Key无需修改服务器 .env"
action={
<PrimaryButton onClick={() => setCreateOpen(true)}></PrimaryButton>
}
/>
{revealedKey && (
<div className="mb-4">
<WarningBanner>
<div className="space-y-2 text-left">
<p className="font-medium">
{revealedKey.customer_id} API Key
</p>
<p className="break-all font-mono text-sm">{revealedKey.api_key}</p>
<p className="text-xs text-text-secondary">
Authorization: Bearer &lt;Key&gt;Header X-Customer-Id:{" "}
{revealedKey.customer_id}
</p>
<SecondaryButton onClick={() => setRevealedKey(null)}>
</SecondaryButton>
</div>
</WarningBanner>
</div>
)}
{actionError && (
<div className="mb-4">
<ErrorBanner>{actionError}</ErrorBanner>
</div>
)}
<div className="mb-4 w-64">
<InputField
label="搜索"
placeholder="客户 ID 或名称"
value={keyword}
onChange={(e) => {
setPage(1);
setKeyword(e.target.value);
}}
/>
</div>
{status === "loading" && (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{status === "error" && (
<ErrorBanner
action={
<SecondaryButton onClick={() => void load(page, keyword)}>
</SecondaryButton>
}
>
</ErrorBanner>
)}
{status === "empty" && (
<Card className="p-8 text-center text-text-secondary">
</Card>
)}
{status === "success" && (
<div className="space-y-4">
<div className="overflow-x-auto rounded-md border border-border">
<table className="min-w-full text-sm">
<thead className="bg-bg text-left text-text-secondary">
<tr>
<th className="px-4 py-3 font-medium"> ID</th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium">Key </th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.customer_id} className="border-t border-border">
<td className="px-4 py-3 font-mono">{row.customer_id}</td>
<td className="px-4 py-3">{row.name}</td>
<td className="px-4 py-3">
{row.status === "active" ? "启用" : "停用"}
</td>
<td className="px-4 py-3 font-mono text-text-secondary">
{activeKeyPrefix(row)}
</td>
<td className="px-4 py-3 text-text-secondary">
{row.embed_password_set ? "已设置" : "未设置"}
</td>
<td className="px-4 py-3 text-text-secondary">
{row.remark || "—"}
</td>
<td className="px-4 py-3 text-text-secondary">
{formatDateTime(row.created_at)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<SecondaryButton
onClick={() => openPasswordDrawer(row)}
>
</SecondaryButton>
<SecondaryButton
onClick={() => void handleToggleStatus(row)}
>
{row.status === "active" ? "停用" : "启用"}
</SecondaryButton>
<SecondaryButton
loading={rotatingId === row.customer_id}
disabled={row.status !== "active"}
onClick={() => void handleRotateKey(row.customer_id)}
>
Key
</SecondaryButton>
<Link href={`/admin/markup?keyword=${encodeURIComponent(row.customer_id)}`}>
<SecondaryButton></SecondaryButton>
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-sm text-text-secondary">
<span>
{total} {page} / {totalPages}
</span>
<div className="flex gap-2">
<SecondaryButton
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
</SecondaryButton>
<SecondaryButton
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
</SecondaryButton>
</div>
</div>
</div>
)}
{createOpen && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => !creating && setCreateOpen(false)}
>
<div
className="h-full w-full max-w-md bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-text-secondary">
API Key宿 123456
</p>
<div className="mt-6 space-y-4">
<InputField
label="客户名称"
value={createName}
disabled={creating}
error={createError}
onChange={(e) => setCreateName(e.target.value)}
/>
<InputField
label="嵌入演示密码"
type="password"
autoComplete="new-password"
value={createPassword}
disabled={creating}
onChange={(e) => setCreatePassword(e.target.value)}
/>
<InputField
label="备注"
value={createRemark}
disabled={creating}
onChange={(e) => setCreateRemark(e.target.value)}
/>
</div>
<div className="mt-8 flex justify-end gap-3">
<SecondaryButton
disabled={creating}
onClick={() => setCreateOpen(false)}
>
</SecondaryButton>
<PrimaryButton loading={creating} onClick={() => void handleCreate()}>
Key
</PrimaryButton>
</div>
</div>
</div>
)}
{passwordTarget && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => !savingPassword && setPasswordTarget(null)}
>
<div
className="h-full w-full max-w-md bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-text-secondary">
{passwordTarget.customer_id}{passwordTarget.name}
/embed-demo 使
</p>
<form
className="mt-6 space-y-4"
autoComplete="off"
onSubmit={(e) => {
e.preventDefault();
void handleSavePassword();
}}
>
<InputField
label="新密码"
type="password"
autoComplete="new-password"
value={newPassword}
disabled={savingPassword}
error={passwordError}
onChange={(e) => setNewPassword(e.target.value)}
/>
<InputField
label="确认密码"
type="password"
autoComplete="new-password"
value={confirmPassword}
disabled={savingPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<div className="flex justify-end gap-3 pt-4">
<SecondaryButton
type="button"
disabled={savingPassword}
onClick={() => setPasswordTarget(null)}
>
</SecondaryButton>
<PrimaryButton type="submit" loading={savingPassword}>
</PrimaryButton>
</div>
</form>
</div>
</div>
)}
</AdminLayout>
);
}

@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { Suspense, useCallback, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { AdminLayout } from "@/components/layout/admin-layout"; import { AdminLayout } from "@/components/layout/admin-layout";
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@ -28,13 +29,16 @@ function formatMarkupSummary(row: MarkupConfig): string {
return row.markup_percent > 0 ? formatPercent(row.markup_percent) : "0%"; return row.markup_percent > 0 ? formatPercent(row.markup_percent) : "0%";
} }
export default function AdminMarkupPage() { function AdminMarkupPageContent() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const searchParams = useSearchParams();
const [status, setStatus] = useState<DataPageStatus>("loading"); const [status, setStatus] = useState<DataPageStatus>("loading");
const [rows, setRows] = useState<MarkupConfig[]>([]); const [rows, setRows] = useState<MarkupConfig[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState(
() => searchParams.get("keyword") ?? "",
);
const [editing, setEditing] = useState<MarkupConfig | null>(null); const [editing, setEditing] = useState<MarkupConfig | null>(null);
const [markupType, setMarkupType] = useState<MarkupType>("percent"); const [markupType, setMarkupType] = useState<MarkupType>("percent");
@ -244,7 +248,7 @@ export default function AdminMarkupPage() {
· {editing.customer_id} · {editing.customer_id}
</h3> </h3>
<p className="mt-1 text-sm text-text-secondary"> <p className="mt-1 text-sm text-text-secondary">
30% 30% 0.01%
</p> </p>
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
<SelectField <SelectField
@ -263,7 +267,7 @@ export default function AdminMarkupPage() {
<InputField <InputField
label="加价比例 (%)" label="加价比例 (%)"
type="number" type="number"
step="0.1" step="0.01"
min={0} min={0}
max={30} max={30}
value={percent} value={percent}
@ -304,3 +308,18 @@ export default function AdminMarkupPage() {
</AdminLayout> </AdminLayout>
); );
} }
export default function AdminMarkupPage() {
return (
<Suspense
fallback={
<AdminLayout>
<PageHeader title="加价配置" subtitle="按客户设置报价加价" />
<Skeleton className="h-64 w-full" />
</AdminLayout>
}
>
<AdminMarkupPageContent />
</Suspense>
);
}

@ -3,117 +3,194 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { AdminLayout } from "@/components/layout/admin-layout"; import { AdminLayout } from "@/components/layout/admin-layout";
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { ErrorBanner } from "@/components/ui/error-banner"; import { ErrorBanner } from "@/components/ui/error-banner";
import { SecondaryButton } from "@/components/ui/primary-button"; import { SecondaryButton } from "@/components/ui/primary-button";
import { adminGetQueueStats } from "@/lib/frontend/api-client"; import { SelectField } from "@/components/ui/select-field";
import { Card } from "@/components/ui/card";
import { formatDateTime } from "@/lib/frontend/format";
import { adminGetQueryLogs } from "@/lib/frontend/api-client";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import type { DataPageStatus, QueueStats } from "@/lib/frontend/types"; import type {
DataPageStatus,
QuoteQueryLogRecord,
QuoteQueryOutcome,
} from "@/lib/frontend/types";
const PAGE_SIZE = 20;
const OUTCOME_OPTIONS = [
{ value: "", label: "全部结果" },
{ value: "success", label: "成功" },
{ value: "failed", label: "失败" },
{ value: "stale", label: "降级缓存" },
{ value: "processing", label: "进行中" },
];
const OUTCOME_LABEL: Record<QuoteQueryOutcome, string> = {
success: "成功",
failed: "失败",
stale: "降级缓存",
processing: "进行中",
};
const BULL_BOARD_PATH = "/api/admin/queues"; function outcomeClass(outcome: QuoteQueryOutcome): string {
if (outcome === "success") return "text-success";
if (outcome === "failed") return "text-error";
if (outcome === "stale") return "text-warning";
return "text-text-secondary";
}
export default function QueuesAdminPage() { export default function QueryLogsPage() {
const { token } = useAuth(); const { token } = useAuth();
const [status, setStatus] = useState<DataPageStatus>("loading"); const [status, setStatus] = useState<DataPageStatus>("loading");
const [stats, setStats] = useState<QueueStats | null>(null); const [rows, setRows] = useState<QuoteQueryLogRecord[]>([]);
const [boardReady, setBoardReady] = useState(false); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [outcomeFilter, setOutcomeFilter] = useState("");
const load = useCallback(async () => { const load = useCallback(
async (p: number, outcome: string) => {
if (!token) return; if (!token) return;
setStatus("loading"); setStatus("loading");
const res = await adminGetQueueStats("", token); const res = await adminGetQueryLogs(
"",
token,
p,
PAGE_SIZE,
undefined,
outcome || undefined,
);
if (res.code !== 0) { if (res.code !== 0) {
setStatus("error"); setStatus("error");
return; return;
} }
setStats(res.data); setRows(res.data.list);
setStatus("success"); setTotal(res.data.total);
}, [token]); setStatus(res.data.total === 0 ? "empty" : "success");
},
[token],
);
useEffect(() => { useEffect(() => {
void load(); void load(page, outcomeFilter);
const id = setInterval(() => void load(), 10_000); }, [load, page, outcomeFilter]);
return () => clearInterval(id);
}, [load]);
useEffect(() => { const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (!token) {
setBoardReady(false);
return;
}
const encoded = encodeURIComponent(token);
document.cookie = `bull_board_auth=${encoded}; path=${BULL_BOARD_PATH}; max-age=3600; SameSite=Lax`;
setBoardReady(true);
return () => {
document.cookie = `bull_board_auth=; path=${BULL_BOARD_PATH}; max-age=0`;
setBoardReady(false);
};
}, [token]);
return ( return (
<AdminLayout> <AdminLayout>
<PageHeader <PageHeader
title="队列监控" title="查价记录"
subtitle="quote-rpa 队列深度、失败任务与延迟任务Bull Board" subtitle="最近 1000 条询价流水:时间、客户、地址、货物与报价结果"
action={
<div className="w-36">
<SelectField
label=""
aria-label="查询结果"
name="outcome"
value={outcomeFilter}
options={OUTCOME_OPTIONS}
onChange={(e) => {
setPage(1);
setOutcomeFilter(e.target.value);
}}
/>
</div>
}
/> />
{status === "loading" && <Skeleton className="mb-4 h-24 w-full" />} {status === "loading" && (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{status === "error" && ( {status === "error" && (
<ErrorBanner <ErrorBanner
action={<SecondaryButton onClick={() => void load()}></SecondaryButton>} action={<SecondaryButton onClick={() => void load(page, outcomeFilter)}></SecondaryButton>}
> >
</ErrorBanner> </ErrorBanner>
)} )}
{status === "empty" && (
{status === "success" && stats && (
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<Card>
<p className="text-sm text-text-secondary"></p>
<p className="mt-1 text-2xl font-semibold num">{stats.depth}</p>
</Card>
<Card> <Card>
<p className="text-sm text-text-secondary"></p> <p className="text-sm text-text-secondary"></p>
<p className="mt-1 text-2xl font-semibold num">{stats.waiting}</p>
</Card> </Card>
<Card> )}
<p className="text-sm text-text-secondary"></p>
<p className="mt-1 text-2xl font-semibold num">{stats.active}</p> {status === "success" && (
</Card> <div className="space-y-4">
<Card> <div className="overflow-x-auto rounded-lg border border-border bg-surface">
<p className="text-sm text-text-secondary"></p> <table className="min-w-full text-sm">
<p <thead className="border-b border-border bg-bg text-left text-text-secondary">
className={`mt-1 text-2xl font-semibold num ${ <tr>
stats.delayed > 0 ? "text-warning" : "" <th className="px-3 py-3 font-medium"></th>
}`} <th className="px-3 py-3 font-medium"></th>
> <th className="px-3 py-3 font-medium"></th>
{stats.delayed} <th className="px-3 py-3 font-medium"></th>
<th className="px-3 py-3 font-medium"></th>
<th className="px-3 py-3 font-medium"></th>
<th className="px-3 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.quote_id} className="border-b border-border align-top">
<td className="px-3 py-3 whitespace-nowrap text-text-secondary">
{formatDateTime(row.created_at)}
</td>
<td className="px-3 py-3 font-mono text-xs">{row.customer_id}</td>
<td className="max-w-[180px] px-3 py-3 text-xs leading-relaxed">
{row.pickup_selected}
</td>
<td className="max-w-[180px] px-3 py-3 text-xs leading-relaxed">
{row.delivery_selected}
</td>
<td className="px-3 py-3 text-xs">{row.cargo_summary}</td>
<td className="px-3 py-3">
<span className={`text-xs font-medium ${outcomeClass(row.outcome)}`}>
{OUTCOME_LABEL[row.outcome]}
</span>
{row.failure_reason && row.outcome !== "success" && (
<p className="mt-1 max-w-[200px] text-xs text-text-secondary leading-relaxed">
{row.failure_reason}
</p> </p>
</Card> )}
<Card> </td>
<p className="text-sm text-text-secondary"></p> <td className="px-3 py-3 whitespace-nowrap text-text-secondary">
<p {row.completed_at ? formatDateTime(row.completed_at) : "—"}
className={`mt-1 text-2xl font-semibold num ${ </td>
stats.failed > 0 ? "text-error" : "" </tr>
}`} ))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-text-secondary">
{total} 1000
</span>
<div className="flex gap-2">
<SecondaryButton
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
> >
{stats.failed}
</p> </SecondaryButton>
</Card> <span className="px-2 py-2 text-text-secondary">
{page} / {totalPages}
</span>
<SecondaryButton
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
</SecondaryButton>
</div>
</div>
</div> </div>
)} )}
<Card className="overflow-hidden p-0">
{!boardReady && <Skeleton className="h-[70vh] w-full rounded-none" />}
{boardReady && (
<iframe
title="Bull Board 队列面板"
src={BULL_BOARD_PATH}
className="h-[70vh] w-full border-0 bg-white"
/>
)}
</Card>
</AdminLayout> </AdminLayout>
); );
} }

@ -12,7 +12,7 @@ export const maxDuration = 120;
export async function POST(request: Request) { export async function POST(request: Request) {
let auth; let auth;
try { try {
auth = parseServiceAuth(request); auth = await parseServiceAuth(request);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus); return fail(error.code, error.message, error.httpStatus);

@ -9,7 +9,7 @@ import { AuthError } from "@/modules/auth/errors";
export async function POST(request: Request) { export async function POST(request: Request) {
let auth; let auth;
try { try {
auth = parseServiceAuth(request); auth = await parseServiceAuth(request);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus); return fail(error.code, error.message, error.httpStatus);

@ -9,7 +9,7 @@ import { AuthError } from "@/modules/auth/errors";
export async function POST(request: Request) { export async function POST(request: Request) {
let auth; let auth;
try { try {
auth = parseServiceAuth(request); auth = await parseServiceAuth(request);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus); return fail(error.code, error.message, error.httpStatus);

@ -11,7 +11,7 @@ export const maxDuration = 300;
export async function POST(request: Request) { export async function POST(request: Request) {
let auth; let auth;
try { try {
auth = parseServiceAuth(request); auth = await parseServiceAuth(request);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus); return fail(error.code, error.message, error.httpStatus);

@ -0,0 +1,34 @@
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
import { fail, ok } from "@/lib/response";
import { writeAudit } from "@/modules/audit/service";
import { AuthError } from "@/modules/auth/errors";
import { rotateHostCustomerApiKey } from "@/modules/customer/customer-service";
type RouteContext = {
params: Promise<{ customer_id: string }>;
};
export async function POST(request: Request, context: RouteContext) {
let auth;
try {
auth = parseAdminAuth(request);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
const { customer_id: customerId } = await context.params;
try {
const rotated = await rotateHostCustomerApiKey(customerId);
await writeAudit("customer:rotate_key", auth.userId, customerId, {
key_prefix: rotated.api_keys.find((k) => k.is_active)?.key_prefix,
});
return ok(rotated);
} catch (error) {
const message = error instanceof Error ? error.message : "轮换密钥失败";
return fail("VALIDATION_FAILED", message, 400);
}
}

@ -0,0 +1,87 @@
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
import { fail, ok } from "@/lib/response";
import { writeAudit } from "@/modules/audit/service";
import { AuthError } from "@/modules/auth/errors";
import {
updateHostCustomer,
} from "@/modules/customer/customer-service";
import type { HostCustomerStatus } from "@/modules/customer/types";
type RouteContext = {
params: Promise<{ customer_id: string }>;
};
export async function PATCH(request: Request, context: RouteContext) {
let auth;
try {
auth = parseAdminAuth(request);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
const { customer_id: customerId } = await context.params;
let body: unknown;
try {
body = await request.json();
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const payload = body as {
name?: unknown;
status?: unknown;
remark?: unknown;
embed_password?: unknown;
};
const input: {
name?: string;
status?: HostCustomerStatus;
remark?: string | null;
embed_password?: string;
} = {};
if (payload.name !== undefined) {
if (typeof payload.name !== "string") {
return fail("VALIDATION_FAILED", "客户名称无效", 400);
}
input.name = payload.name;
}
if (payload.status !== undefined) {
if (payload.status !== "active" && payload.status !== "disabled") {
return fail("VALIDATION_FAILED", "状态无效", 400);
}
input.status = payload.status;
}
if (payload.remark !== undefined) {
input.remark =
typeof payload.remark === "string" ? payload.remark : null;
}
if (payload.embed_password !== undefined) {
if (typeof payload.embed_password !== "string") {
return fail("VALIDATION_FAILED", "演示密码无效", 400);
}
input.embed_password = payload.embed_password;
}
try {
const updated = await updateHostCustomer(customerId, input);
const auditDetail: Record<string, unknown> = { ...input };
if (auditDetail.embed_password !== undefined) {
delete auditDetail.embed_password;
auditDetail.embed_password_updated = true;
}
await writeAudit("customer:update", auth.userId, customerId, auditDetail);
return ok(updated);
} catch (error) {
const message = error instanceof Error ? error.message : "更新客户失败";
return fail("VALIDATION_FAILED", message, 400);
}
}

@ -0,0 +1,109 @@
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
import { fail, ok } from "@/lib/response";
import { writeAudit } from "@/modules/audit/service";
import { AuthError } from "@/modules/auth/errors";
import {
createHostCustomer,
listHostCustomers,
} from "@/modules/customer/customer-service";
function parsePagination(searchParams: URLSearchParams): {
page: number;
size: number;
} | null {
const pageRaw = searchParams.get("page");
const sizeRaw = searchParams.get("size");
if (!pageRaw || !sizeRaw) {
return null;
}
const page = Number(pageRaw);
const size = Number(sizeRaw);
if (
!Number.isInteger(page) ||
!Number.isInteger(size) ||
page < 1 ||
size < 1 ||
size > 100
) {
return null;
}
return { page, size };
}
export async function GET(request: Request) {
try {
parseAdminAuth(request);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
const url = new URL(request.url);
const pagination = parsePagination(url.searchParams);
if (!pagination) {
return fail("VALIDATION_FAILED", "请提供有效的 page 和 size 参数", 400);
}
const keyword = url.searchParams.get("keyword") ?? undefined;
const data = await listHostCustomers({
page: pagination.page,
size: pagination.size,
keyword,
});
return ok(data);
}
export async function POST(request: Request) {
let auth;
try {
auth = parseAdminAuth(request);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
let body: unknown;
try {
body = await request.json();
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const name =
typeof (body as { name?: unknown }).name === "string"
? (body as { name: string }).name
: "";
const remark =
typeof (body as { remark?: unknown }).remark === "string"
? (body as { remark: string }).remark
: undefined;
const embedPassword =
typeof (body as { embed_password?: unknown }).embed_password === "string"
? (body as { embed_password: string }).embed_password
: undefined;
try {
const created = await createHostCustomer({
name,
remark,
embed_password: embedPassword,
});
await writeAudit("customer:create", auth.userId, created.customer_id, {
name: created.name,
key_prefix: created.api_keys[0]?.key_prefix,
});
return ok(created);
} catch (error) {
const message = error instanceof Error ? error.message : "创建客户失败";
return fail("VALIDATION_FAILED", message, 400);
}
}

@ -2,7 +2,7 @@ import { parseAdminAuth } from "@/lib/api/admin-auth-context";
import { fail, ok } from "@/lib/response"; import { fail, ok } from "@/lib/response";
import { writeAudit } from "@/modules/audit/service"; import { writeAudit } from "@/modules/audit/service";
import { AuthError } from "@/modules/auth/errors"; import { AuthError } from "@/modules/auth/errors";
import { isKnownCustomer } from "@/modules/auth/service-token"; import { isKnownCustomerAsync } from "@/modules/auth/service-token";
import { upsertMarkupConfig } from "@/modules/pricing/markup-service"; import { upsertMarkupConfig } from "@/modules/pricing/markup-service";
import { parseMarkupInput } from "@/modules/pricing/markup-validation"; import { parseMarkupInput } from "@/modules/pricing/markup-validation";
@ -30,7 +30,7 @@ export async function PUT(request: Request, context: RouteContext) {
const { customer_id: pathCustomerId } = await context.params; const { customer_id: pathCustomerId } = await context.params;
if (!isKnownCustomer(pathCustomerId)) { if (!(await isKnownCustomerAsync(pathCustomerId))) {
return fail("VALIDATION_FAILED", "客户不存在", 400); return fail("VALIDATION_FAILED", "客户不存在", 400);
} }

@ -0,0 +1,79 @@
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors";
import { requirePermission } from "@/modules/auth/rbac";
import {
listQuoteQueryLogs,
type QuoteQueryOutcome,
} from "@/modules/quote/query-log";
const VALID_OUTCOMES: readonly QuoteQueryOutcome[] = [
"processing",
"success",
"failed",
"stale",
];
function parsePagination(searchParams: URLSearchParams): {
page: number;
size: number;
} | null {
const pageRaw = searchParams.get("page");
const sizeRaw = searchParams.get("size");
if (!pageRaw || !sizeRaw) {
return null;
}
const page = Number(pageRaw);
const size = Number(sizeRaw);
if (
!Number.isInteger(page) ||
!Number.isInteger(size) ||
page < 1 ||
size < 1 ||
size > 100
) {
return null;
}
return { page, size };
}
/** GET /api/admin/query-logs — 查价记录(最多保留 1000 条) */
export async function GET(request: Request) {
let auth;
try {
auth = parseAdminAuth(request);
requirePermission(auth, "alert:read");
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
void auth;
const searchParams = new URL(request.url).searchParams;
const pagination = parsePagination(searchParams);
if (!pagination) {
return fail("VALIDATION_FAILED", "请提供有效的 page 和 size 参数", 400);
}
const customerId = searchParams.get("customer_id")?.trim() || undefined;
const outcomeRaw = searchParams.get("outcome")?.trim();
if (outcomeRaw && !VALID_OUTCOMES.includes(outcomeRaw as QuoteQueryOutcome)) {
return fail("VALIDATION_FAILED", "无效的查询结果筛选", 400);
}
const data = await listQuoteQueryLogs({
page: pagination.page,
size: pagination.size,
customerId,
outcome: outcomeRaw as QuoteQueryOutcome | undefined,
});
return ok(data);
}

@ -3,7 +3,7 @@ import { prisma } from "@/lib/prisma";
import { fail, ok } from "@/lib/response"; import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors"; import { AuthError } from "@/modules/auth/errors";
import { requirePermission } from "@/modules/auth/rbac"; import { requirePermission } from "@/modules/auth/rbac";
import { serializeAlert } from "@/modules/alert/serializer"; import { serializeAlertEnriched } from "@/modules/alert/serializer";
import type { AlertStatus, AlertType } from "@/modules/alert/types"; import type { AlertStatus, AlertType } from "@/modules/alert/types";
const VALID_ALERT_TYPES: readonly AlertType[] = [ const VALID_ALERT_TYPES: readonly AlertType[] = [
@ -110,8 +110,10 @@ export async function GET(request: Request) {
}), }),
]); ]);
const list = await Promise.all(records.map((row) => serializeAlertEnriched(row)));
return ok({ return ok({
list: records.map(serializeAlert), list,
total, total,
page, page,
size, size,

@ -0,0 +1,75 @@
import { cookies } from "next/headers";
import { fail, ok } from "@/lib/response";
import { isKnownCustomerAsync } from "@/modules/auth/service-token";
import { isKnownCustomer } from "@/modules/auth/service-token-env";
import { isDbCustomerActive, verifyCustomerEmbedPassword } from "@/modules/customer/customer-service";
import {
EMBED_DEMO_COOKIE,
signEmbedDemoToken,
} from "@/modules/embed-demo/auth";
import {
defaultMarkupDto,
serializeMarkupConfig,
} from "@/modules/pricing/markup-service";
import { prisma } from "@/lib/prisma";
async function isEmbedCustomerAllowed(customerId: string): Promise<boolean> {
if (isKnownCustomer(customerId)) {
return true;
}
return isDbCustomerActive(customerId);
}
async function loadMarkup(customerId: string) {
const markupRow = await prisma.markupConfig.findFirst({
where: { customerId, isDeleted: false },
});
return markupRow
? serializeMarkupConfig(markupRow)
: defaultMarkupDto(customerId);
}
/** POST /api/embed-demo/login — 客户 ID + 演示密码 */
export async function POST(request: Request) {
let body: { customer_id?: unknown; password?: unknown };
try {
body = (await request.json()) as { customer_id?: unknown; password?: unknown };
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const customerId =
typeof body.customer_id === "string" ? body.customer_id.trim() : "";
const password = typeof body.password === "string" ? body.password : "";
if (!customerId) {
return fail("VALIDATION_FAILED", "请填写客户编号", 400);
}
if (!password) {
return fail("VALIDATION_FAILED", "请填写密码", 400);
}
if (!(await isKnownCustomerAsync(customerId))) {
return fail("UNAUTHORIZED", "客户编号或密码错误", 401);
}
if (!(await isEmbedCustomerAllowed(customerId))) {
return fail("FORBIDDEN", "客户已停用", 403);
}
if (!(await verifyCustomerEmbedPassword(customerId, password))) {
return fail("UNAUTHORIZED", "客户编号或密码错误", 401);
}
const token = await signEmbedDemoToken(customerId);
const cookieStore = await cookies();
cookieStore.set(EMBED_DEMO_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
return ok({
customer_id: customerId,
markup: await loadMarkup(customerId),
});
}

@ -0,0 +1,16 @@
import { cookies } from "next/headers";
import { ok } from "@/lib/response";
import { EMBED_DEMO_COOKIE } from "@/modules/embed-demo/auth";
/** POST /api/embed-demo/logout */
export async function POST() {
const cookieStore = await cookies();
cookieStore.set(EMBED_DEMO_COOKIE, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 0,
});
return ok({ logged_out: true });
}

@ -0,0 +1,53 @@
import { cookies } from "next/headers";
import { fail, ok } from "@/lib/response";
import {
EMBED_DEMO_COOKIE,
verifyEmbedDemoToken,
} from "@/modules/embed-demo/auth";
import { isKnownCustomer } from "@/modules/auth/service-token-env";
import { isDbCustomerActive } from "@/modules/customer/customer-service";
import {
defaultMarkupDto,
serializeMarkupConfig,
} from "@/modules/pricing/markup-service";
import { prisma } from "@/lib/prisma";
async function isEmbedCustomerAllowed(customerId: string): Promise<boolean> {
if (isKnownCustomer(customerId)) {
return true;
}
return isDbCustomerActive(customerId);
}
/** GET /api/embed-demo/me — 当前演示会话与加价配置 */
export async function GET() {
const cookieStore = await cookies();
const raw = cookieStore.get(EMBED_DEMO_COOKIE)?.value;
if (!raw) {
return fail("UNAUTHORIZED", "未登录", 401);
}
let customerId: string;
try {
const payload = await verifyEmbedDemoToken(raw);
customerId = payload.sub;
} catch {
return fail("UNAUTHORIZED", "演示会话无效或已过期", 401);
}
if (!(await isEmbedCustomerAllowed(customerId))) {
return fail("FORBIDDEN", "客户已停用", 403);
}
const markupRow = await prisma.markupConfig.findFirst({
where: { customerId, isDeleted: false },
});
const markup = markupRow
? serializeMarkupConfig(markupRow)
: defaultMarkupDto(customerId);
return ok({
customer_id: customerId,
markup,
});
}

@ -0,0 +1,57 @@
import { parseServiceAuth } from "@/lib/api/auth-context";
import { enforceRateLimits } from "@/lib/api/rate-limit";
import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors";
import {
hostPublicResolveCandidates,
HostPublicError,
HostQuoteWaitError,
} from "@/modules/host/public-orchestrator";
import { hostCandidatesBodySchema } from "@/modules/host/validation";
import { RpaError } from "@/modules/rpa/errors";
export const maxDuration = 300;
export async function POST(request: Request) {
let auth;
try {
auth = await parseServiceAuth(request);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
const rateLimited = await enforceRateLimits(request, auth.customerId);
if (rateLimited) {
return rateLimited;
}
let body: unknown;
try {
body = await request.json();
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const parsed = hostCandidatesBodySchema.safeParse(body);
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "参数无效";
return fail("VALIDATION_FAILED", message, 400);
}
try {
const result = await hostPublicResolveCandidates(auth.customerId, parsed.data);
return ok(result);
} catch (error) {
if (error instanceof HostPublicError) {
return fail(error.code, error.message, 400);
}
if (error instanceof RpaError) {
return fail(error.code, error.message, 503);
}
console.error("[host/quote/candidates]", error);
return fail("INTERNAL_ERROR", "地址联想失败", 503);
}
}

@ -0,0 +1,68 @@
import { parseServiceAuth } from "@/lib/api/auth-context";
import { enforceRateLimits } from "@/lib/api/rate-limit";
import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors";
import {
hostPublicSubmitAndWait,
HostPublicError,
HostQuoteWaitError,
} from "@/modules/host/public-orchestrator";
import { hostSubmitBodySchema } from "@/modules/host/validation";
import { ValidationError } from "@/modules/quote/types";
/** RPA 实时查价 + 内部轮询,与 QUOTE_TIMEOUT_MS 对齐 */
export const maxDuration = 420;
export async function POST(request: Request) {
let auth;
try {
auth = await parseServiceAuth(request);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
const rateLimited = await enforceRateLimits(request, auth.customerId);
if (rateLimited) {
return rateLimited;
}
let body: unknown;
try {
body = await request.json();
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const parsed = hostSubmitBodySchema.safeParse(body);
if (!parsed.success) {
const message = parsed.error.issues[0]?.message ?? "参数无效";
return fail("VALIDATION_FAILED", message, 400);
}
try {
const detail = await hostPublicSubmitAndWait(parsed.data);
return ok(detail);
} catch (error) {
if (error instanceof HostPublicError) {
const status = error.code === "SESSION_EXPIRED" ? 404 : 400;
return fail(error.code, error.message, status);
}
if (error instanceof HostQuoteWaitError) {
if (error.code === "QUOTE_TIMEOUT") {
return fail(error.code, error.message, 504);
}
if (error.detail) {
return ok(error.detail);
}
return fail(error.code, error.message, 422);
}
if (error instanceof ValidationError) {
return fail("VALIDATION_FAILED", error.message, 400);
}
console.error("[host/quote/submit]", error);
return fail("INTERNAL_ERROR", "询价处理失败", 500);
}
}

@ -2,7 +2,7 @@ import { parseServiceAuth } from "@/lib/api/auth-context";
import { fail, ok } from "@/lib/response"; import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors"; import { AuthError } from "@/modules/auth/errors";
import { requirePermission } from "@/modules/auth/rbac"; import { requirePermission } from "@/modules/auth/rbac";
import { isKnownCustomer } from "@/modules/auth/service-token"; import { isKnownCustomerAsync } from "@/modules/auth/service-token";
import { upsertMarkupConfig } from "@/modules/pricing/markup-service"; import { upsertMarkupConfig } from "@/modules/pricing/markup-service";
import { parseMarkupInput } from "@/modules/pricing/markup-validation"; import { parseMarkupInput } from "@/modules/pricing/markup-validation";
@ -21,7 +21,7 @@ type RouteContext = {
export async function PUT(request: Request, context: RouteContext) { export async function PUT(request: Request, context: RouteContext) {
let auth; let auth;
try { try {
auth = parseServiceAuth(request); auth = await parseServiceAuth(request);
requirePermission(auth, "pricing:markup:write"); requirePermission(auth, "pricing:markup:write");
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
@ -32,7 +32,7 @@ export async function PUT(request: Request, context: RouteContext) {
const { customer_id: pathCustomerId } = await context.params; const { customer_id: pathCustomerId } = await context.params;
if (!isKnownCustomer(pathCustomerId)) { if (!(await isKnownCustomerAsync(pathCustomerId))) {
return fail("VALIDATION_FAILED", "客户不存在", 400); return fail("VALIDATION_FAILED", "客户不存在", 400);
} }

@ -33,7 +33,7 @@ function parsePagination(searchParams: URLSearchParams): {
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
parseServiceAuth(request); await parseServiceAuth(request);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus); return fail(error.code, error.message, error.httpStatus);

@ -0,0 +1,71 @@
import { assertCustomerMatch, parseServiceAuth } from "@/lib/api/auth-context";
import { enforceRateLimits } from "@/lib/api/rate-limit";
import { fail, ok } from "@/lib/response";
import { AuthError } from "@/modules/auth/errors";
import { submitPriority1Quote, Priority1EnqueueError } from "@/modules/priority1/orchestrator";
import { resolvePriority1UserMessage } from "@/lib/priority1/api-errors";
import { ValidationError, QuoteIdConflictError } from "@/modules/quote/types";
export async function POST(request: Request) {
let auth;
try {
auth = await parseServiceAuth(request);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
const rateLimited = await enforceRateLimits(request, auth.customerId);
if (rateLimited) return rateLimited;
let body: unknown;
try {
body = await request.json();
} catch {
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
}
const bodyCustomerId =
typeof body === "object" &&
body !== null &&
"customer_id" in body &&
typeof (body as { customer_id: unknown }).customer_id === "string"
? (body as { customer_id: string }).customer_id
: null;
if (!bodyCustomerId) {
return fail("VALIDATION_FAILED", "请填写客户标识", 400);
}
try {
assertCustomerMatch(auth, bodyCustomerId);
} catch (error) {
if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus);
}
throw error;
}
try {
const result = await submitPriority1Quote(body);
return ok(result);
} catch (error) {
if (error instanceof ValidationError) {
return fail("VALIDATION_FAILED", error.message, 400);
}
if (error instanceof QuoteIdConflictError) {
return fail(error.code, error.message, 500);
}
if (error instanceof Priority1EnqueueError) {
return fail(error.code, error.message, 503);
}
console.error("[POST /api/priority1/quotes] 异常:", error);
const message = resolvePriority1UserMessage(
"INTERNAL_ERROR",
error instanceof Error ? error.message : undefined,
);
return fail("INTERNAL_ERROR", message, 500);
}
}

@ -12,7 +12,7 @@ type RouteContext = {
export async function GET(request: Request, context: RouteContext) { export async function GET(request: Request, context: RouteContext) {
let auth; let auth;
try { try {
auth = parseServiceAuth(request); auth = await parseServiceAuth(request);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus); return fail(error.code, error.message, error.httpStatus);

@ -34,7 +34,7 @@ function parsePagination(searchParams: URLSearchParams): {
export async function GET(request: Request) { export async function GET(request: Request) {
let auth; let auth;
try { try {
auth = parseServiceAuth(request); auth = await parseServiceAuth(request);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus); return fail(error.code, error.message, error.httpStatus);

@ -13,7 +13,7 @@ import {
type RawQuoteTier, type RawQuoteTier,
} from "@/modules/pricing/engine"; } from "@/modules/pricing/engine";
import { getConfidenceScore } from "@/modules/quote/confidence"; import { getConfidenceScore } from "@/modules/quote/confidence";
import { assertFourTiers } from "@/modules/quote/quote-completeness"; import { prepareMotherShipStorageQuotes } from "@/modules/quote/quote-completeness";
import { overwriteL1, setL3 } from "@/modules/cache/redis-cache"; import { overwriteL1, setL3 } from "@/modules/cache/redis-cache";
type TierInput = { type TierInput = {
@ -98,8 +98,9 @@ export async function POST(request: Request) {
rawQuotes.push(tier); rawQuotes.push(tier);
} }
let uiQuotes: RawQuoteTier[];
try { try {
assertFourTiers(rawQuotes); uiQuotes = prepareMotherShipStorageQuotes(rawQuotes);
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "四档报价不完整"; error instanceof Error ? error.message : "四档报价不完整";
@ -122,9 +123,9 @@ export async function POST(request: Request) {
} }
const markupRule = await getMarkupRule(record.customerId); const markupRule = await getMarkupRule(record.customerId);
const markedQuotes = applyMarkupToQuotes(rawQuotes, markupRule); const markedQuotes = applyMarkupToQuotes(uiQuotes, markupRule);
const validUntil = new Date(Date.now() + QUOTE_VALIDITY_MS); const validUntil = new Date(Date.now() + QUOTE_VALIDITY_MS);
const cachePayload = { quotes: rawQuotes }; const cachePayload = { quotes: uiQuotes };
await setL3(record.cargoHash, cachePayload); await setL3(record.cargoHash, cachePayload);

@ -12,7 +12,7 @@ import {
export async function POST(request: Request) { export async function POST(request: Request) {
let auth; let auth;
try { try {
auth = parseServiceAuth(request); auth = await parseServiceAuth(request);
} catch (error) { } catch (error) {
if (error instanceof AuthError) { if (error instanceof AuthError) {
return fail(error.code, error.message, error.httpStatus); return fail(error.code, error.message, error.httpStatus);

@ -2,12 +2,17 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Warning, ChartLine, Robot } from "@phosphor-icons/react"; import {
Warning,
Users,
Percent,
ListMagnifyingGlass,
} from "@phosphor-icons/react";
import { AdminLayout } from "@/components/layout/admin-layout"; import { AdminLayout } from "@/components/layout/admin-layout";
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { adminGetMetrics } from "@/lib/frontend/api-client"; import { adminGetAlerts } from "@/lib/frontend/api-client";
interface EntryCard { interface EntryCard {
href: string; href: string;
@ -23,9 +28,9 @@ export default function DashboardPage() {
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
void adminGetMetrics("", token).then((res) => { void adminGetAlerts("", token, 1, 1, undefined, "open").then((res) => {
if (res.code === 0) { if (res.code === 0) {
setOpenAlerts(res.data.open_alerts_count); setOpenAlerts(res.data.total);
} }
}); });
}, [token]); }, [token]);
@ -34,21 +39,27 @@ export default function DashboardPage() {
{ {
href: "/admin/alerts", href: "/admin/alerts",
title: "预警中心", title: "预警中心",
desc: "处理 RPA 失败、价格偏差与降级预警", desc: "查看查价失败根因与客户地址货物信息",
icon: Warning, icon: Warning,
badge: openAlerts, badge: openAlerts,
}, },
{ {
href: "/admin/rpa", href: "/admin/queues",
title: "RPA 状态", title: "查价记录",
desc: "查看 Worker 在线状态、队列深度与熔断", desc: "最近询价流水、成功失败与客户查询详情",
icon: Robot, icon: ListMagnifyingGlass,
}, },
{ {
href: "/admin/dashboard", href: "/admin/customers",
title: "指标看板", title: "客户管理",
desc: "API/RPA 成功率、缓存命中率与 P95 延迟", desc: "新增客户、签发 API Key 与状态管理",
icon: ChartLine, icon: Users,
},
{
href: "/admin/markup",
title: "加价配置",
desc: "按客户设置报价加价比例",
icon: Percent,
}, },
]; ];

@ -1,23 +1,69 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { AppLayout } from "@/components/layout/app-layout"; import { AppLayout } from "@/components/layout/app-layout";
import { EmbeddedQuoteWidget } from "@/components/embed/embedded-quote-widget"; import { EmbeddedQuoteWidget } from "@/components/embed/embedded-quote-widget";
import { EmbedDemoLogin } from "@/components/embed/embed-demo-login";
const DEMO_TOKEN = "demo-host-token"; import { Card } from "@/components/ui/card";
const DEMO_CUSTOMER = "CUST_001"; import { SecondaryButton } from "@/components/ui/primary-button";
import { Skeleton } from "@/components/ui/skeleton";
import {
embedDemoLogout,
type EmbedDemoSession,
} from "@/lib/frontend/api-client";
export default function EmbedDemoPage() { export default function EmbedDemoPage() {
const [booting, setBooting] = useState(true);
const [session, setSession] = useState<EmbedDemoSession | null>(null);
useEffect(() => {
void (async () => {
await embedDemoLogout("");
setBooting(false);
})();
}, []);
const handleLogout = async () => {
await embedDemoLogout("");
setSession(null);
};
if (booting) {
return (
<AppLayout title="宿主嵌入演示">
<Skeleton className="h-64 w-full" />
</AppLayout>
);
}
if (!session) {
return ( return (
<AppLayout title="宿主嵌入演示"> <AppLayout title="宿主嵌入演示">
<EmbedDemoLogin
onSuccess={(data) => {
setSession(data);
}}
/>
</AppLayout>
);
}
return (
<AppLayout title="宿主嵌入演示">
<Card className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-text-secondary"></p>
<p className="text-lg font-semibold">{session.customer_id}</p>
</div>
<SecondaryButton onClick={() => void handleLogout()}>
退
</SecondaryButton>
</Card>
<p className="mb-4 text-sm text-text-secondary"> <p className="mb-4 text-sm text-text-secondary">
宿GD-11使 Service Token 宿
POST /api/quotes GET /api/quotes/{"{id}"}
</p> </p>
<EmbeddedQuoteWidget <EmbeddedQuoteWidget customerId={session.customer_id} apiBaseUrl="" />
serviceToken={DEMO_TOKEN}
customerId={DEMO_CUSTOMER}
apiBaseUrl=""
/>
</AppLayout> </AppLayout>
); );
} }

@ -14,8 +14,8 @@ export default function LoginPageClient() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { login } = useAuth(); const { login } = useAuth();
const [username, setUsername] = useState("admin_demo"); const [username, setUsername] = useState("");
const [password, setPassword] = useState("Demo@123"); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -48,17 +48,22 @@ export default function LoginPageClient() {
<Card> <Card>
<h1 className="mb-1 text-xl font-semibold"></h1> <h1 className="mb-1 text-xl font-semibold"></h1>
<p className="mb-4 text-sm text-text-secondary"> <p className="mb-4 text-sm text-text-secondary">
RPA
</p> </p>
{error && ( {error && (
<div className="mb-4"> <div className="mb-4">
<ErrorBanner>{error}</ErrorBanner> <ErrorBanner>{error}</ErrorBanner>
</div> </div>
)} )}
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4"> <form
onSubmit={(e) => void handleSubmit(e)}
className="space-y-4"
autoComplete="off"
>
<InputField <InputField
label="用户名" label="用户名"
name="username" name="username"
autoComplete="off"
value={username} value={username}
disabled={loading} disabled={loading}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
@ -67,6 +72,7 @@ export default function LoginPageClient() {
label="密码" label="密码"
name="password" name="password"
type="password" type="password"
autoComplete="new-password"
value={password} value={password}
disabled={loading} disabled={loading}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
@ -75,9 +81,6 @@ export default function LoginPageClient() {
</PrimaryButton> </PrimaryButton>
</form> </form>
<p className="mt-4 text-xs text-text-secondary">
admin_demo / Demo@123
</p>
</Card> </Card>
</div> </div>
</div> </div>

@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { Truck } from "@phosphor-icons/react";
import { InputField } from "@/components/ui/input-field";
import { PrimaryButton } from "@/components/ui/primary-button";
import { ErrorBanner } from "@/components/ui/error-banner";
import { Card } from "@/components/ui/card";
import { embedDemoLogin, type EmbedDemoSession } from "@/lib/frontend/api-client";
interface EmbedDemoLoginProps {
onSuccess: (session: EmbedDemoSession) => void;
}
export function EmbedDemoLogin({ onSuccess }: EmbedDemoLoginProps) {
const [customerId, setCustomerId] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (loading) return;
setLoading(true);
setError(null);
const res = await embedDemoLogin("", customerId.trim(), password);
setLoading(false);
if (res.code !== 0) {
setError(res.message || "客户编号或密码错误");
return;
}
onSuccess(res.data);
};
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="w-full max-w-md">
<div className="mb-6 flex items-center justify-center gap-2 text-primary">
<Truck size={28} weight="fill" />
<span className="text-xl font-semibold">宿</span>
</div>
<Card>
<h1 className="mb-1 text-lg font-semibold"></h1>
<p className="mb-4 text-sm text-text-secondary">
使
</p>
{error && (
<div className="mb-4">
<ErrorBanner>{error}</ErrorBanner>
</div>
)}
<form
onSubmit={(e) => void handleSubmit(e)}
className="space-y-4"
autoComplete="off"
>
<InputField
label="客户编号"
name="customer_id"
placeholder="例如 CUST_002"
autoComplete="off"
value={customerId}
disabled={loading}
onChange={(e) => setCustomerId(e.target.value)}
/>
<InputField
label="密码"
name="password"
type="password"
autoComplete="new-password"
value={password}
disabled={loading}
onChange={(e) => setPassword(e.target.value)}
/>
<PrimaryButton type="submit" loading={loading} className="w-full">
</PrimaryButton>
</form>
</Card>
</div>
</div>
);
}

@ -24,11 +24,17 @@ import { QuoteResultPanel } from "@/components/quote/quote-result-panel";
import { AddressDisambiguationModal } from "@/components/quote/address-disambiguation-modal"; import { AddressDisambiguationModal } from "@/components/quote/address-disambiguation-modal";
import { PrimaryButton } from "@/components/ui/primary-button"; import { PrimaryButton } from "@/components/ui/primary-button";
import { ErrorBanner } from "@/components/ui/error-banner"; import { ErrorBanner } from "@/components/ui/error-banner";
import {
QuoteProviderSwitch,
type QuoteProviderId,
} from "@/components/embed/quote-provider-switch";
import { Priority1QuoteWidget } from "@/components/priority1/priority1-quote-widget";
export interface EmbeddedQuoteWidgetProps { export interface EmbeddedQuoteWidgetProps {
serviceToken: string;
customerId: string; customerId: string;
apiBaseUrl?: string; apiBaseUrl?: string;
/** 未传时使用 Cookie 演示会话鉴权(/embed-demo */
serviceToken?: string;
} }
const FORM_ID = "embedded-quote-form"; const FORM_ID = "embedded-quote-form";
@ -38,6 +44,7 @@ export function EmbeddedQuoteWidget({
customerId, customerId,
apiBaseUrl = "", apiBaseUrl = "",
}: EmbeddedQuoteWidgetProps) { }: EmbeddedQuoteWidgetProps) {
const [provider, setProvider] = useState<QuoteProviderId>("mothership");
const [status, setStatus] = useState<QuotePageStatus>("idle"); const [status, setStatus] = useState<QuotePageStatus>("idle");
const [quote, setQuote] = useState<QuoteDetail | null>(null); const [quote, setQuote] = useState<QuoteDetail | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -251,15 +258,37 @@ export function EmbeddedQuoteWidget({
submitLocked || submitLocked ||
disambiguationOpen; disambiguationOpen;
const switchDisabled =
status === "validating" ||
status === "processing" ||
status === "resolving_address";
return ( return (
<div className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-6"> <div className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-6">
<div className="mb-4"> <div className="mb-4">
<h2 className="text-lg font-semibold text-text-primary"></h2> <h2 className="text-lg font-semibold text-text-primary"></h2>
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
</p> </p>
</div> </div>
<QuoteProviderSwitch
value={provider}
onChange={(next) => {
setProvider(next);
reset();
}}
disabled={switchDisabled}
/>
{provider === "priority1" ? (
<Priority1QuoteWidget
serviceToken={serviceToken}
customerId={customerId}
apiBaseUrl={apiBaseUrl}
/>
) : (
<>
{error && status === "idle" && ( {error && status === "idle" && (
<div className="mb-4"> <div className="mb-4">
<ErrorBanner>{error}</ErrorBanner> <ErrorBanner>{error}</ErrorBanner>
@ -317,6 +346,8 @@ export function EmbeddedQuoteWidget({
} }
onCancel={handleDisambiguationCancel} onCancel={handleDisambiguationCancel}
/> />
</>
)}
</div> </div>
); );
} }

@ -0,0 +1,42 @@
"use client";
export type QuoteProviderId = "mothership" | "priority1";
interface QuoteProviderSwitchProps {
value: QuoteProviderId;
onChange: (value: QuoteProviderId) => void;
disabled?: boolean;
}
/** 卡派查价 — Mothership / Priority1 数据源切换 */
export function QuoteProviderSwitch({
value,
onChange,
disabled,
}: QuoteProviderSwitchProps) {
const tabs: Array<{ id: QuoteProviderId; label: string; desc: string }> = [
{ id: "mothership", label: "MotherShip", desc: "四档实时报价" },
{ id: "priority1", label: "Priority1", desc: "官网模拟填表" },
];
return (
<div className="mb-6 flex gap-1 rounded-md bg-bg p-1">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
disabled={disabled}
onClick={() => onChange(tab.id)}
className={`flex flex-1 flex-col items-center rounded-sm px-3 py-2 text-center transition-colors sm:flex-row sm:justify-center sm:gap-2 ${
value === tab.id
? "bg-surface text-primary shadow-card"
: "text-text-secondary hover:text-text-primary"
} ${disabled ? "cursor-not-allowed opacity-50" : ""}`}
>
<span className="text-sm font-semibold">{tab.label}</span>
<span className="text-xs text-text-secondary">{tab.desc}</span>
</button>
))}
</div>
);
}

@ -9,11 +9,10 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/dashboard", label: "工作台" }, { href: "/dashboard", label: "工作台" },
{ href: "/admin/customers", label: "客户管理" },
{ href: "/admin/markup", label: "加价配置" }, { href: "/admin/markup", label: "加价配置" },
{ href: "/admin/alerts", label: "预警中心" }, { href: "/admin/alerts", label: "预警中心" },
{ href: "/admin/rpa", label: "RPA 状态" }, { href: "/admin/queues", label: "查价记录" },
{ href: "/admin/dashboard", label: "指标看板" },
{ href: "/admin/queues", label: "队列监控" },
]; ];
interface AdminLayoutProps { interface AdminLayoutProps {

@ -0,0 +1,282 @@
"use client";
import type { ReactNode } from "react";
import { Truck, Van } from "@phosphor-icons/react";
import type { Priority1ShipmentType } from "@/workers/rpa/priority1/shipment-types";
interface FeatheryCardProps {
label: string;
description?: string;
selected: boolean;
disabled?: boolean;
onClick: () => void;
}
/** 仿 Priority1 Feathery 卡片:选中黑底白字 */
export function FeatheryCard({
label,
description,
selected,
disabled,
onClick,
}: FeatheryCardProps) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`flex min-h-[88px] flex-col items-start justify-center rounded-md border-2 px-4 py-3 text-left transition-colors ${
selected
? "border-black bg-black text-white"
: "border-neutral-300 bg-white text-neutral-900 hover:border-neutral-500"
} ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
>
<span className="text-sm font-semibold">{label}</span>
{description ? (
<span
className={`mt-1 text-xs ${selected ? "text-neutral-300" : "text-neutral-500"}`}
>
{description}
</span>
) : null}
</button>
);
}
export function FeatheryCardGrid({
children,
cols = 3,
}: {
children: ReactNode;
cols?: 2 | 3;
}) {
return (
<div
className={`grid gap-3 ${cols === 2 ? "sm:grid-cols-2" : "sm:grid-cols-2 lg:grid-cols-3"}`}
>
{children}
</div>
);
}
function ShipmentIcon({
type,
selected,
}: {
type: Priority1ShipmentType;
selected: boolean;
}) {
const color = selected ? "text-white" : "text-neutral-900";
const size = 48;
if (type === "expedited") {
return <Van size={size} weight="regular" className={color} aria-hidden />;
}
return <Truck size={size} weight="regular" className={color} aria-hidden />;
}
/** Step1 运输类型三卡 — 对齐官网布局 */
export function ShipmentTypeCard({
title,
description,
type,
selected,
disabled,
onSelect,
}: {
title: string;
description: string;
type: Priority1ShipmentType;
selected: boolean;
disabled?: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onSelect}
className={`relative flex min-h-[220px] flex-col items-center rounded-sm border px-4 py-5 text-center transition-colors ${
selected
? "border-black bg-black text-white"
: "border-neutral-300 bg-white text-neutral-900 hover:border-neutral-500"
} ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
>
<span
className={`absolute left-3 top-3 h-4 w-4 rounded-full border-2 ${
selected
? "border-[#FFD200] bg-[#FFD200]"
: "border-neutral-400 bg-white"
}`}
aria-hidden
/>
<div className="mb-3 mt-2">
<ShipmentIcon type={type} selected={selected} />
</div>
<span className="text-base font-semibold">{title}</span>
<p
className={`mt-2 text-xs leading-relaxed ${
selected ? "text-neutral-200" : "text-neutral-600"
}`}
>
{description}
</p>
</button>
);
}
export function P1FieldLabel({
children,
required,
htmlFor,
}: {
children: ReactNode;
required?: boolean;
htmlFor?: string;
}) {
return (
<label
htmlFor={htmlFor}
className="mb-1 block text-sm italic text-neutral-900"
>
{children}
{required ? <span className="text-[#c41230]"> *</span> : null}
</label>
);
}
export function P1SectionTitle({
step,
title,
}: {
step: 1 | 2;
title: string;
}) {
return (
<div className="mb-4 border-b border-neutral-200 pb-3">
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">
Step {step}
</p>
<h3 className="mt-1 text-lg font-semibold text-neutral-900">{title}</h3>
</div>
);
}
/** Step 2 进度条 — 对齐官网「Step 2 of 2」黑条 */
export function P1Step2Progress({ label }: { label: string }) {
return (
<div className="mb-4">
<p className="text-xs font-medium text-neutral-600">{label}</p>
<div className="mt-2 h-1.5 w-full rounded-sm bg-black" aria-hidden />
</div>
);
}
/** 总重量输入 — 右侧「磅」后缀 */
export function P1WeightField({
id,
label,
required,
value,
disabled,
error,
unitLabel = "磅",
onChange,
}: {
id: string;
label: string;
required?: boolean;
value: string;
disabled?: boolean;
error?: string;
unitLabel?: string;
onChange: (v: string) => void;
}) {
return (
<div className="space-y-1">
<P1FieldLabel required={required} htmlFor={id}>
{label}
</P1FieldLabel>
<div className="flex">
<input
id={id}
type="text"
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`h-11 min-w-0 flex-1 rounded-l-sm border border-r-0 bg-neutral-100 px-3 text-sm focus:border-neutral-600 focus:outline-none disabled:opacity-60 ${
error ? "border-red-500" : "border-neutral-400"
}`}
/>
<span className="flex h-11 shrink-0 items-center rounded-r-sm bg-neutral-700 px-3 text-sm font-medium text-white">
{unitLabel}
</span>
</div>
{error ? <p className="text-xs text-red-600">{error}</p> : null}
</div>
);
}
/** 官网黄色 GET INSTANT QUOTES 主按钮 */
export function P1InstantQuotesButton({
loading,
disabled,
children,
onClick,
type = "button",
className = "",
}: {
loading?: boolean;
disabled?: boolean;
children: ReactNode;
onClick?: () => void;
type?: "button" | "submit";
className?: string;
}) {
return (
<button
type={type}
disabled={disabled || loading}
onClick={onClick}
className={`w-full rounded-sm bg-[#FFD200] px-6 py-4 text-center text-base font-black uppercase tracking-wide text-black shadow-sm transition-opacity hover:bg-[#f5c800] disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
>
{loading ? "处理中…" : children}
</button>
);
}
/** @deprecated 使用 P1InstantQuotesButton */
export function P1SubmitButton({
loading,
disabled,
children,
onClick,
}: {
loading?: boolean;
disabled?: boolean;
children: ReactNode;
onClick?: () => void;
}) {
return (
<P1InstantQuotesButton loading={loading} disabled={disabled} onClick={onClick}>
{children}
</P1InstantQuotesButton>
);
}
/** Step1/2 底部固定 CTA 栏 */
export function P1StickyActionBar({
children,
hint,
}: {
children: ReactNode;
hint?: string;
}) {
return (
<div className="sticky bottom-0 z-10 -mx-4 mt-6 border-t border-neutral-200 bg-neutral-50/95 px-4 py-4 backdrop-blur-sm md:-mx-6 md:px-6">
{children}
<p className="mt-2 text-center text-xs text-neutral-500">
{hint ?? "填写完成后点击按钮进入下一步或提交询价"}
</p>
</div>
);
}

@ -0,0 +1,55 @@
"use client";
import { P1_COMMON_UI } from "@/lib/priority1/ui-labels";
import { p1DateFromIso, p1DateToIso } from "@/lib/priority1/date-format";
import { minPickupDateIso } from "@/lib/priority1/pickup-date";
import { P1FieldLabel } from "@/components/priority1/feathery-card";
interface P1DateFieldProps {
label: string;
/** MM/DD/YYYY */
value: string;
disabled?: boolean;
error?: string;
onChange: (displayValue: string) => void;
}
/** 提货日 — 原生日历 + MM/DD/YYYY 存储 */
export function P1DateField({
label,
value,
disabled,
error,
onChange,
}: P1DateFieldProps) {
const iso = p1DateToIso(value);
const minIso = minPickupDateIso();
return (
<div className="space-y-1">
<P1FieldLabel required htmlFor="p1-pickup-date">
{label}
</P1FieldLabel>
<input
id="p1-pickup-date"
type="date"
disabled={disabled}
min={minIso}
value={iso}
onChange={(e) => {
const next = p1DateFromIso(e.target.value);
if (next) onChange(next);
}}
className={`h-11 w-full max-w-xs rounded-sm border bg-neutral-100 px-3 text-sm focus:border-neutral-600 focus:outline-none focus:ring-1 focus:ring-neutral-400 disabled:opacity-60 ${
error ? "border-red-500" : "border-neutral-400"
}`}
/>
{value ? (
<p className="text-xs text-neutral-500">{value}</p>
) : (
<p className="text-xs text-neutral-500">{P1_COMMON_UI.pickupDateHint}</p>
)}
{error ? <p className="text-xs text-red-600">{error}</p> : null}
</div>
);
}

@ -0,0 +1,155 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { P1FieldLabel } from "@/components/priority1/feathery-card";
import {
filterPhoneCountries,
findPhoneCountry,
type PhoneCountry,
} from "@/lib/priority1/phone-countries";
interface P1PhoneFieldProps {
country: string;
value: string;
disabled?: boolean;
error?: string;
onCountryChange: (code: string) => void;
onChange: (digits: string) => void;
}
function formatUsPhoneDisplay(digits: string): string {
const d = digits.replace(/\D/g, "").slice(0, 10);
if (d.length <= 3) return d;
if (d.length <= 6) return `${d.slice(0, 3)} ${d.slice(3)}`;
return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6)}`;
}
function formatPhoneDisplay(country: PhoneCountry, digits: string): string {
if (country.code === "US" || country.dial === "+1") {
return formatUsPhoneDisplay(digits);
}
return digits.replace(/\D/g, "").slice(0, 15);
}
function maxPhoneDigits(country: PhoneCountry): number {
if (country.code === "US" || (country.dial === "+1" && country.code !== "DO")) {
return 10;
}
return 15;
}
/** 电话 — 可搜索国家列表AZ+ 自动显示区号前缀 */
export function P1PhoneField({
country,
value,
disabled,
error,
onCountryChange,
onChange,
}: P1PhoneFieldProps) {
const listId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const selected = findPhoneCountry(country);
const filtered = filterPhoneCountries(search);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
const pickCountry = (c: PhoneCountry) => {
onCountryChange(c.code);
setOpen(false);
setSearch("");
};
return (
<div className="space-y-1" ref={rootRef}>
<P1FieldLabel required htmlFor="p1-phone">
</P1FieldLabel>
<div
className={`flex overflow-visible rounded-sm border bg-neutral-100 ${
error ? "border-red-500" : "border-neutral-400"
}`}
>
<div className="relative shrink-0">
<button
type="button"
disabled={disabled}
aria-expanded={open}
aria-controls={listId}
onClick={() => setOpen((v) => !v)}
className="flex h-11 min-w-[7.5rem] items-center gap-1 border-r border-neutral-300 bg-white px-2 text-left text-sm focus:outline-none disabled:opacity-60"
>
<span className="truncate font-medium">{selected.dial}</span>
<span className="text-neutral-400" aria-hidden>
</span>
</button>
{open ? (
<div
id={listId}
className="absolute left-0 top-full z-30 mt-1 w-72 rounded-sm border border-neutral-300 bg-white shadow-lg"
>
<div className="border-b border-neutral-200 p-2">
<input
type="search"
autoFocus
value={search}
placeholder="搜索国家或区号…"
onChange={(e) => setSearch(e.target.value)}
className="h-9 w-full rounded-sm border border-neutral-300 px-2 text-sm focus:border-neutral-600 focus:outline-none"
/>
</div>
<ul className="max-h-56 overflow-y-auto py-1" role="listbox">
{filtered.map((c) => (
<li key={c.code}>
<button
type="button"
role="option"
aria-selected={c.code === selected.code}
onClick={() => pickCountry(c)}
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-neutral-100 ${
c.code === selected.code ? "bg-neutral-50 font-medium" : ""
}`}
>
<span className="truncate">{c.name}</span>
<span className="ml-2 shrink-0 text-neutral-500">{c.dial}</span>
</button>
</li>
))}
{filtered.length === 0 ? (
<li className="px-3 py-2 text-sm text-neutral-500"></li>
) : null}
</ul>
</div>
) : null}
</div>
<div className="flex min-w-0 flex-1 items-center gap-1 px-2 text-sm text-neutral-600">
<span className="shrink-0 font-medium">{selected.dial}</span>
<input
id="p1-phone"
type="tel"
disabled={disabled}
value={formatPhoneDisplay(selected, value)}
placeholder={selected.code === "US" ? "753 640 7420" : "电话号码"}
onChange={(e) =>
onChange(
e.target.value.replace(/\D/g, "").slice(0, maxPhoneDigits(selected)),
)
}
className="h-11 min-w-0 flex-1 bg-transparent focus:outline-none disabled:opacity-60"
/>
</div>
</div>
{error ? <p className="text-xs text-red-600">{error}</p> : null}
</div>
);
}

@ -0,0 +1,153 @@
"use client";
import { buildPriority1CalendarGrid, weekdayLabelsZh } from "@/lib/priority1/calendar-grid";
import { P1_RESULT_UI } from "@/lib/priority1/ui-labels";
import type { Priority1DisplayLine } from "@/modules/priority1/quote-storage";
export type Priority1ShipmentSummary = {
originZip: string;
destinationZip: string;
pickupDate: string;
weightLb?: number;
itemsLabel?: string;
};
type Props = {
lines: Priority1DisplayLine[];
shipment: Priority1ShipmentSummary;
onRequestNewQuote?: () => void;
};
function formatUsd(n: number): string {
return new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(n);
}
function lineDisplayUsd(line: Priority1DisplayLine): number {
return line.totalUsd > 0 ? line.totalUsd : line.final_total_usd;
}
export function Priority1FtlScheduleCalendar({
lines,
shipment,
onRequestNewQuote,
}: Props) {
const { monthLabel, cells } = buildPriority1CalendarGrid(
shipment.pickupDate,
lines,
);
const weekdays = weekdayLabelsZh();
const lowest = lines.length
? Math.min(...lines.map((l) => lineDisplayUsd(l)))
: null;
return (
<div className="flex min-h-[520px] bg-black text-white">
<aside className="flex w-[280px] shrink-0 flex-col border-r border-neutral-800 bg-neutral-950 p-4">
<div className="mb-4 flex h-36 items-center justify-center rounded border border-neutral-800 bg-neutral-900 text-xs text-neutral-500">
线{shipment.originZip} {shipment.destinationZip}
</div>
<div className="mb-4 rounded border border-neutral-800 bg-neutral-900 p-3 text-sm">
<p className="mb-2 text-xs font-bold tracking-wider text-neutral-400">
{P1_RESULT_UI.shippingDetails}
</p>
<dl className="space-y-1 text-neutral-200">
<div className="flex justify-between gap-2">
<dt className="text-neutral-500">{P1_RESULT_UI.from}</dt>
<dd>{shipment.originZip}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-neutral-500">{P1_RESULT_UI.to}</dt>
<dd>{shipment.destinationZip}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-neutral-500">{P1_RESULT_UI.date}</dt>
<dd>{shipment.pickupDate}</dd>
</div>
{shipment.weightLb != null && shipment.weightLb > 0 ? (
<div className="flex justify-between gap-2">
<dt className="text-neutral-500">{P1_RESULT_UI.weight}</dt>
<dd>{shipment.weightLb} lb</dd>
</div>
) : null}
{shipment.itemsLabel ? (
<div className="flex justify-between gap-2">
<dt className="text-neutral-500">{P1_RESULT_UI.items}</dt>
<dd className="text-right">{shipment.itemsLabel}</dd>
</div>
) : null}
</dl>
</div>
{onRequestNewQuote ? (
<div className="mt-auto">
<button
type="button"
onClick={onRequestNewQuote}
className="w-full border border-neutral-600 bg-neutral-800 py-3 text-xs font-bold tracking-wide text-white hover:bg-neutral-700"
>
{P1_RESULT_UI.requestNewQuote}
</button>
</div>
) : null}
</aside>
<div className="flex flex-1 flex-col p-6">
<h2 className="mb-4 text-2xl font-bold tracking-wide">
{P1_RESULT_UI.ftlTitle}
</h2>
<div className="mb-6 rounded border border-neutral-800 bg-neutral-900/80 p-4 text-sm text-neutral-300">
{P1_RESULT_UI.ftlIntro}
{lowest != null ? (
<span className="mt-1 block text-[#f5d000]">
{P1_RESULT_UI.ftlLowest(formatUsd(lowest))}
</span>
) : null}
</div>
<div className="rounded border border-neutral-300 bg-white p-4 text-black">
<div className="mb-3 flex items-baseline justify-between">
<h3 className="text-xl font-bold text-[#c9a000]">{P1_RESULT_UI.schedule}</h3>
<span className="text-2xl font-light text-neutral-700">{monthLabel}</span>
</div>
<div className="grid grid-cols-7 gap-px bg-neutral-200">
{weekdays.map((d) => (
<div
key={d}
className="bg-white py-2 text-center text-xs font-semibold text-neutral-600"
>
{d}
</div>
))}
{cells.map((cell) => {
const day = cell.date.getDate();
const price =
cell.line != null
? lineDisplayUsd(cell.line)
: cell.priceUsd;
const hasPrice = cell.inMonth && price != null && price > 0;
return (
<div
key={cell.date.toISOString()}
className={`min-h-[72px] bg-white p-1 text-center ${
cell.inMonth ? "" : "opacity-40"
}`}
>
<div className="text-sm font-medium text-neutral-800">{day}</div>
{hasPrice ? (
<div className="mt-1 text-xs font-semibold text-neutral-900">
{formatUsd(price!)}
</div>
) : cell.inMonth ? (
<div className="mt-1 text-[10px] text-neutral-400"></div>
) : null}
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

@ -0,0 +1,113 @@
"use client";
import { P1_RESULT_UI } from "@/lib/priority1/ui-labels";
import type { Priority1DisplayLine } from "@/modules/priority1/quote-storage";
type Props = {
lines: Priority1DisplayLine[];
};
function carrierInitials(carrier: string): string {
const parts = carrier.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
}
return carrier.slice(0, 2).toUpperCase();
}
function formatUsd(n: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
}).format(n);
}
function formatDisplayDate(raw?: string | null): string {
if (!raw) return P1_RESULT_UI.dateUnknown;
const trimmed = raw.trim();
const dash = trimmed.match(/^(\d{2})-(\d{2})-(\d{4})$/);
if (dash) return `${dash[1]}-${dash[2]}-${dash[3]}`;
const slash = trimmed.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (slash) return `${slash[1]}-${slash[2]}-${slash[3]}`;
const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (iso) return `${iso[2]}-${iso[3]}-${iso[1]}`;
return trimmed;
}
function transitLabel(days?: number): string {
if (days == null || days <= 0) return P1_RESULT_UI.transitUnknown;
return P1_RESULT_UI.transit(days);
}
function serviceLabel(level?: string): string {
if (!level?.trim()) return P1_RESULT_UI.standardLtl;
if (/standard\s*ltl/i.test(level)) return P1_RESULT_UI.standardLtl;
return level;
}
function QuoteCard({ line }: { line: Priority1DisplayLine }) {
const displayUsd = line.totalUsd > 0 ? line.totalUsd : line.final_total_usd;
return (
<article className="flex overflow-hidden rounded border border-neutral-700 bg-neutral-900">
<div className="flex w-[34%] min-w-[140px] flex-col items-center justify-center gap-3 bg-neutral-800 px-4 py-6">
<div className="flex h-16 w-16 items-center justify-center rounded bg-white text-lg font-bold text-neutral-900">
{carrierInitials(line.carrier)}
</div>
<p className="text-3xl font-bold tracking-tight text-[#f5d000]">
{formatUsd(displayUsd)}
</p>
</div>
<div className="flex flex-1 flex-col gap-3 p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold tracking-wide text-white">
{serviceLabel(line.serviceLevel)}
</h3>
<span
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-neutral-500 text-[10px] text-neutral-300"
title="服务说明"
aria-hidden
>
i
</span>
</div>
<span className="rounded bg-neutral-800 px-2 py-0.5 text-[10px] font-semibold tracking-wider text-neutral-300">
{P1_RESULT_UI.quoteBadge(line.rank)}
</span>
</div>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-base text-white">{line.carrier}</p>
<p className="text-sm text-neutral-300">{transitLabel(line.transitDays)}</p>
</div>
<div className="space-y-1 text-sm text-neutral-300">
<p>
{P1_RESULT_UI.expiration}{formatDisplayDate(line.expirationDate)}
</p>
<p>
{P1_RESULT_UI.delivery}{formatDisplayDate(line.deliveryDate)}
</p>
</div>
</div>
</article>
);
}
export function Priority1LtlQuoteOptions({ lines }: Props) {
const sorted = [...lines].sort((a, b) => a.rank - b.rank);
return (
<div className="space-y-5 bg-black p-6 text-white">
<h2 className="text-2xl font-bold tracking-wide">{P1_RESULT_UI.title}</h2>
<div className="rounded border border-neutral-800 bg-neutral-900/80 p-4 text-sm leading-relaxed text-neutral-300">
{P1_RESULT_UI.intro}
</div>
<div className="space-y-4">
{sorted.map((line) => (
<QuoteCard key={`${line.rank}-${line.carrier}-${line.totalUsd}`} line={line} />
))}
</div>
</div>
);
}

@ -0,0 +1,180 @@
"use client";
import { CountdownTimer } from "@/components/quote/countdown-timer";
import { ErrorBanner } from "@/components/ui/error-banner";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { WarningBanner } from "@/components/ui/warning-banner";
import type { QuoteDetail } from "@/lib/frontend/types";
import { PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE } from "@/modules/priority1/constants";
import { Priority1FtlScheduleCalendar } from "@/components/priority1/priority1-ftl-schedule-calendar";
import type { Priority1ShipmentSummary } from "@/components/priority1/priority1-ftl-schedule-calendar";
import { Priority1LtlQuoteOptions } from "@/components/priority1/priority1-ltl-quote-options";
type Props = {
quote: QuoteDetail | null;
loading: boolean;
error: string | null;
shipmentContext?: Priority1ShipmentSummary;
onRequestNewQuote?: () => void;
};
export function Priority1QuoteResultPanel({
quote,
loading,
error,
shipmentContext,
onRequestNewQuote,
}: Props) {
if (loading) {
return (
<div className="flex min-h-[320px] flex-col items-center justify-center gap-4 rounded-lg border border-neutral-200 bg-white p-8">
<LoadingSpinner />
<p className="text-sm text-neutral-600"> Priority1 </p>
</div>
);
}
if (error) {
return (
<ErrorBanner>
{error.trim() || "暂时无法获取报价,请稍后重试。"}
</ErrorBanner>
);
}
if (!quote) {
return (
<div className="flex min-h-[320px] items-center justify-center rounded-lg border border-dashed border-neutral-300 bg-neutral-50 p-8 text-sm text-neutral-500">
Priority1
</div>
);
}
if (quote.status === "processing") {
return (
<div className="flex min-h-[320px] flex-col items-center justify-center gap-4 rounded-lg border border-neutral-200 bg-white p-8">
<LoadingSpinner />
<p className="text-sm text-neutral-600"></p>
</div>
);
}
if (quote.status === "failed") {
const message =
quote.error_message?.trim() ||
"报价失败,请稍后重试。";
return (
<ErrorBanner
action={
onRequestNewQuote ? (
<button
type="button"
onClick={onRequestNewQuote}
className="text-sm font-medium text-error underline"
>
</button>
) : undefined
}
>
{message}
</ErrorBanner>
);
}
if (quote.error_code === PRIORITY1_MANUAL_FOLLOWUP_ERROR_CODE) {
return (
<WarningBanner
action={
onRequestNewQuote ? (
<button
type="button"
onClick={onRequestNewQuote}
className="text-sm font-medium text-amber-900 underline"
>
</button>
) : undefined
}
>
<p className="font-medium"></p>
<p className="mt-1">
{quote.error_message ??
"Priority1 未返回可展示的即时报价,已记录任务供人工处理。"}
</p>
</WarningBanner>
);
}
if (quote.status === "expired") {
return (
<WarningBanner
action={
onRequestNewQuote ? (
<button
type="button"
onClick={onRequestNewQuote}
className="text-sm font-medium text-amber-900 underline"
>
</button>
) : undefined
}
>
<p className="font-medium"></p>
<p className="mt-1"></p>
</WarningBanner>
);
}
const p1 = quote.priority1;
if (!p1 || p1.lines.length === 0) {
const legacyHint =
quote.source_type === "stale"
? "本次使用了历史缓存降级,数据格式可能不兼容,请重新询价。"
: quote.quotes && quote.quotes.length > 0
? "服务端返回了旧版报价格式,请重新部署 worker 与 API 后重试。"
: "RPA 未抓取到报价金额,请核对重量/等级是否合理后重试。";
return (
<WarningBanner
action={
onRequestNewQuote ? (
<button
type="button"
onClick={onRequestNewQuote}
className="text-sm font-medium text-amber-900 underline"
>
</button>
) : undefined
}
>
<p className="font-medium"></p>
<p className="mt-1">{legacyHint}</p>
</WarningBanner>
);
}
const validUntil = quote.valid_until;
return (
<div className="overflow-hidden rounded-lg border border-neutral-800">
{validUntil ? (
<div className="flex items-center justify-between border-b border-neutral-200 bg-neutral-50 px-4 py-2 text-xs text-neutral-600">
<span></span>
<CountdownTimer validUntil={validUntil} />
</div>
) : null}
{p1.display_mode === "ftl_calendar" && shipmentContext ? (
<Priority1FtlScheduleCalendar
lines={p1.lines}
shipment={shipmentContext}
onRequestNewQuote={onRequestNewQuote}
/>
) : (
<Priority1LtlQuoteOptions lines={p1.lines} />
)}
</div>
);
}

@ -0,0 +1,232 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import type { Priority1DemoInput } from "@/workers/rpa/priority1/demo-input";
import type { QuoteDetail, QuotePageStatus } from "@/lib/frontend/types";
import { SUBMIT_LOCK_MS } from "@/lib/frontend/constants";
import { resolvePriority1UserMessage } from "@/lib/priority1/api-errors";
import { hostCreatePriority1Quote, hostGetQuote } from "@/lib/frontend/api-client";
import { pollQuoteUntilDone } from "@/hooks/use-quote-polling";
import { formatQuoteErrorMessage } from "@/modules/quote/quote-error-messages";
import { isPriority1ManualFollowupDetail } from "@/modules/priority1/quote-outcome";
import { uuidV4 } from "@/lib/frontend/format";
import { Priority1Simulator } from "@/components/priority1/priority1-simulator";
import { Priority1QuoteResultPanel } from "@/components/priority1/priority1-quote-result-panel";
import type { Priority1ShipmentSummary } from "@/components/priority1/priority1-ftl-schedule-calendar";
export interface Priority1QuoteWidgetProps {
serviceToken?: string;
customerId: string;
apiBaseUrl?: string;
}
function toShipmentSummary(input: Priority1DemoInput): Priority1ShipmentSummary {
const weight = Number(input.weightLb);
return {
originZip: input.originZip,
destinationZip: input.destinationZip,
pickupDate: input.pickupDate,
weightLb: Number.isFinite(weight) && weight > 0 ? weight : undefined,
itemsLabel:
input.shipmentType === "ltl" ? input.packaging : input.commodity,
};
}
function shipmentFromQuote(
quote: QuoteDetail,
fallback?: Priority1ShipmentSummary,
): Priority1ShipmentSummary | undefined {
const meta = quote.priority1?.shipment_meta;
if (!meta?.origin_zip || !meta.destination_zip) {
return fallback;
}
return {
originZip: meta.origin_zip,
destinationZip: meta.destination_zip,
pickupDate: meta.pickup_date ?? fallback?.pickupDate ?? "",
weightLb: meta.weight_lb ?? fallback?.weightLb,
itemsLabel: meta.commodity ?? fallback?.itemsLabel,
};
}
/** Priority1 模拟填表 + RPA 真实询价 */
export function Priority1QuoteWidget({
serviceToken,
customerId,
apiBaseUrl = "",
}: Priority1QuoteWidgetProps) {
const [status, setStatus] = useState<QuotePageStatus>("idle");
const [quote, setQuote] = useState<QuoteDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [submitLocked, setSubmitLocked] = useState(false);
const [lastInput, setLastInput] = useState<Priority1DemoInput | null>(null);
const reset = useCallback(() => {
setStatus("idle");
setQuote(null);
setError(null);
}, []);
const finishQuote = useCallback((detail: QuoteDetail) => {
setQuote(detail);
if (detail.status === "failed") {
setStatus("error");
setError(
resolvePriority1UserMessage(
detail.error_code,
detail.error_message ??
formatQuoteErrorMessage(detail.error_code),
),
);
return;
}
if (detail.status === "expired") {
setStatus("expired");
return;
}
if (isPriority1ManualFollowupDetail(detail)) {
setStatus("manual_followup");
setError(null);
return;
}
setStatus(detail.is_realtime === false ? "fallback" : "success");
setError(null);
}, []);
const handleSubmit = useCallback(
async (input: Priority1DemoInput) => {
if (submitLocked) return;
setSubmitLocked(true);
setTimeout(() => setSubmitLocked(false), SUBMIT_LOCK_MS);
setLastInput(input);
setStatus("validating");
setError(null);
const created = await hostCreatePriority1Quote(
apiBaseUrl,
serviceToken,
{
request_id: uuidV4(),
customer_id: customerId,
priority1_input: input,
},
);
if (created.code !== 0) {
setStatus("error");
setError(resolvePriority1UserMessage(created.code, created.message));
return;
}
const { quote_id } = created.data;
setStatus("processing");
const pollResult = await pollQuoteUntilDone(
async () => {
try {
const res = await hostGetQuote(
apiBaseUrl,
serviceToken,
customerId,
quote_id,
);
if (res.code !== 0) {
return { ok: false, errorMessage: res.message };
}
return { ok: true, data: res.data };
} catch {
return { ok: false };
}
},
{
fastAfterMs: 5_000,
fastIntervalMs: 600,
},
);
if (pollResult.type === "done") {
finishQuote(pollResult.quote);
return;
}
setStatus("error");
setError(
pollResult.type === "timeout"
? "Priority1 询价超时,请稍后重试"
: resolvePriority1UserMessage("INTERNAL_ERROR", pollResult.message),
);
},
[
apiBaseUrl,
serviceToken,
customerId,
submitLocked,
finishQuote,
],
);
const formDisabled =
status === "validating" ||
status === "processing" ||
submitLocked;
const shipmentContext = useMemo(() => {
if (quote) {
return shipmentFromQuote(
quote,
lastInput ? toShipmentSummary(lastInput) : undefined,
);
}
return lastInput ? toShipmentSummary(lastInput) : undefined;
}, [quote, lastInput]);
const showWideResult =
quote?.priority1?.display_mode === "ftl_calendar" &&
quote.priority1.lines.length > 0;
return (
<div className="grid gap-6">
<div className={showWideResult ? "" : "lg:grid lg:grid-cols-5 lg:gap-6"}>
<div className={showWideResult ? "" : "lg:col-span-3"}>
<p className="mb-3 text-sm text-text-secondary">
Priority1 1 2
</p>
<Priority1Simulator
disabled={formDisabled}
loading={status === "validating" || status === "processing"}
onSubmit={(input) => void handleSubmit(input)}
/>
</div>
{!showWideResult ? (
<div className="mt-6 lg:col-span-2 lg:mt-0">
<Priority1QuoteResultPanel
quote={quote}
loading={status === "validating" || status === "processing"}
error={
status === "error"
? error?.trim() || "Priority1 询价失败,请稍后重试"
: null
}
shipmentContext={shipmentContext}
onRequestNewQuote={reset}
/>
</div>
) : null}
</div>
{showWideResult ? (
<Priority1QuoteResultPanel
quote={quote}
loading={status === "validating" || status === "processing"}
error={
status === "error"
? error?.trim() || "Priority1 询价失败,请稍后重试"
: null
}
shipmentContext={shipmentContext}
onRequestNewQuote={reset}
/>
) : null}
</div>
);
}

@ -0,0 +1,833 @@
"use client";
import { useCallback, useState } from "react";
import type { Priority1DemoInput } from "@/workers/rpa/priority1/demo-input";
import { DEFAULT_PRIORITY1_DEMO } from "@/workers/rpa/priority1/demo-input";
import type {
ExpeditedTrailerType,
FtlAdditionalService,
FtlTrailerType,
Priority1ShipmentType,
} from "@/workers/rpa/priority1/shipment-types";
import {
P1_COMMON_UI,
P1_DELIVERY_ACCESSORIALS,
P1_EXP_TRAILERS,
P1_EXPEDITED_UI,
P1_FREQUENCY_OPTIONS,
P1_FREIGHT_CLASS_OPTIONS,
P1_FTL_ADDITIONAL,
P1_FTL_LIFTGATE_SERVICE,
P1_FTL_TRAILERS,
P1_FTL_UI,
P1_LTL_UI,
P1_LOCATION_OPTIONS,
P1_OPEN_DECK_OPTIONS,
P1_PACKAGING_OPTIONS,
P1_PICKUP_ACCESSORIALS,
P1_SHIPMENT_CARDS,
P1_SPECIAL_HANDLING,
P1_STEP2_HEADING,
P1_STEP2_SUBTITLE,
} from "@/lib/priority1/ui-labels";
import {
FeatheryCard,
FeatheryCardGrid,
P1FieldLabel,
P1InstantQuotesButton,
P1Step2Progress,
P1StickyActionBar,
P1WeightField,
ShipmentTypeCard,
} from "@/components/priority1/feathery-card";
import { P1DateField } from "@/components/priority1/p1-date-field";
import { P1PhoneField } from "@/components/priority1/p1-phone-field";
import { findPhoneCountry } from "@/lib/priority1/phone-countries";
import { pickupDateValidationMessage } from "@/lib/priority1/pickup-date";
import { SelectField } from "@/components/ui/select-field";
export interface Priority1SimulatorProps {
disabled?: boolean;
loading?: boolean;
onSubmit: (input: Priority1DemoInput) => void;
}
function toggleInList(list: string[], value: string): string[] {
return list.includes(value)
? list.filter((v) => v !== value)
: [...list, value];
}
function toggleService(
list: FtlAdditionalService[],
svc: FtlAdditionalService,
): FtlAdditionalService[] {
return list.includes(svc) ? list.filter((s) => s !== svc) : [...list, svc];
}
function P1FeatherySelect({
id,
label,
required,
value,
disabled,
options,
onChange,
}: {
id: string;
label: string;
required?: boolean;
value: string;
disabled?: boolean;
options: Array<{ value: string; label: string }>;
onChange: (v: string) => void;
}) {
return (
<div className="space-y-1">
<P1FieldLabel required={required} htmlFor={id}>
{label}
</P1FieldLabel>
<select
id={id}
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-11 w-full rounded-sm border border-neutral-400 bg-neutral-100 px-3 text-sm focus:border-neutral-600 focus:outline-none disabled:opacity-60"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
function P1AccessorialGrid({
legend,
options,
selected,
disabled,
onToggle,
}: {
legend: string;
options: Array<{ value: string; label: string }>;
selected: string[];
disabled?: boolean;
onToggle: (value: string) => void;
}) {
return (
<fieldset className="space-y-2">
<legend className="text-sm font-medium italic text-neutral-900">{legend}</legend>
<div className="grid gap-2 sm:grid-cols-2">
{options.map((opt) => (
<label
key={opt.value}
className="flex cursor-pointer items-center gap-2 rounded border border-neutral-300 bg-neutral-50 px-3 py-2 text-sm"
>
<input
type="checkbox"
disabled={disabled}
checked={selected.includes(opt.value)}
onChange={() => onToggle(opt.value)}
/>
{opt.label}
</label>
))}
</div>
</fieldset>
);
}
function P1TextInput({
id,
label,
required,
value,
disabled,
error,
type = "text",
onChange,
}: {
id: string;
label: string;
required?: boolean;
value: string;
disabled?: boolean;
error?: string;
type?: string;
onChange: (v: string) => void;
}) {
return (
<div className="space-y-1">
<P1FieldLabel required={required} htmlFor={id}>
{label}
</P1FieldLabel>
<input
id={id}
type={type}
disabled={disabled}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`h-11 w-full rounded-sm border bg-neutral-100 px-3 text-sm focus:border-neutral-600 focus:outline-none disabled:opacity-60 ${
error ? "border-red-500" : "border-neutral-400"
}`}
/>
{error ? <p className="text-xs text-red-600">{error}</p> : null}
</div>
);
}
/** 仿 Priority1 两步动态表单(与 form-logic-map 分支一致) */
export function Priority1Simulator({
disabled,
loading,
onSubmit,
}: Priority1SimulatorProps) {
const [step, setStep] = useState<1 | 2>(1);
const [form, setForm] = useState<Priority1DemoInput>({
...DEFAULT_PRIORITY1_DEMO,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const patch = useCallback((partial: Partial<Priority1DemoInput>) => {
setForm((prev) => ({ ...prev, ...partial }));
}, []);
const validateStep1 = (): boolean => {
const next: Record<string, string> = {};
if (!/^\d{5}$/.test(form.originZip.trim())) {
next.originZip = "请输入 5 位起运邮编";
}
if (!/^\d{5}$/.test(form.destinationZip.trim())) {
next.destinationZip = "请输入 5 位目的邮编";
}
if (form.originZip === form.destinationZip) {
next.destinationZip = "起运与目的邮编不能相同";
}
if (!/^\d{2}\/\d{2}\/\d{4}$/.test(form.pickupDate.trim())) {
next.pickupDate = "请选择提货日";
} else {
const pickupErr = pickupDateValidationMessage(form.pickupDate);
if (pickupErr) next.pickupDate = pickupErr;
}
if (!form.email.trim()) next.email = "请填写邮箱";
const phoneDigits = form.phone.replace(/\D/g, "");
const country = findPhoneCountry(form.phoneCountry);
if (country.code === "US" || country.code === "CA") {
if (phoneDigits.length !== 10) next.phone = "请填写 10 位电话号码";
} else if (phoneDigits.length < 6 || phoneDigits.length > 15) {
next.phone = "请填写有效电话号码";
}
setErrors(next);
return Object.keys(next).length === 0;
};
const validateStep2 = (): boolean => {
const next: Record<string, string> = {};
if (form.shipmentType === "ltl" && !form.weightLb.trim()) {
next.weightLb = "请填写总重量";
}
if (form.shipmentType === "ftl" || form.shipmentType === "expedited") {
if (!form.commodity.trim()) next.commodity = "请填写货描";
if (!form.weightLb.trim()) next.weightLb = "请填写总重量";
}
setErrors(next);
return Object.keys(next).length === 0;
};
const handleStep1Next = () => {
if (!validateStep1()) return;
setErrors({});
setStep(2);
window.scrollTo({ top: 0, behavior: "smooth" });
};
const handleFinalSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateStep2()) return;
onSubmit({
...form,
phone: form.phone.replace(/\D/g, "").slice(-10),
});
};
return (
<div className="relative rounded-md border border-neutral-200 bg-white p-4 md:p-6">
<div className="mb-6 flex items-center gap-2 text-xs font-medium text-neutral-500">
<span
className={`rounded-full px-2.5 py-1 ${step === 1 ? "bg-black text-white" : "bg-neutral-200"}`}
>
{P1_COMMON_UI.step1}
</span>
<span className="h-px flex-1 bg-neutral-300" />
<span
className={`rounded-full px-2.5 py-1 ${step === 2 ? "bg-black text-white" : "bg-neutral-200"}`}
>
{P1_COMMON_UI.step2}
</span>
</div>
{step === 1 ? (
<div className="space-y-6 pb-2">
<div>
<P1FieldLabel required>{P1_COMMON_UI.shipmentType}</P1FieldLabel>
<div className="mt-2 grid gap-3 md:grid-cols-3">
{P1_SHIPMENT_CARDS.map((card) => (
<ShipmentTypeCard
key={card.id}
type={card.id}
title={card.title}
description={card.description}
selected={form.shipmentType === card.id}
disabled={disabled}
onSelect={() => patch({ shipmentType: card.id })}
/>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<P1TextInput
id="origin-zip"
label={P1_COMMON_UI.originZip}
required
value={form.originZip}
disabled={disabled}
error={errors.originZip}
onChange={(v) => patch({ originZip: v })}
/>
<P1TextInput
id="destination-zip"
label={P1_COMMON_UI.destZip}
required
value={form.destinationZip}
disabled={disabled}
error={errors.destinationZip}
onChange={(v) => patch({ destinationZip: v })}
/>
</div>
<div>
<P1FieldLabel required htmlFor="shipment-frequency">
{P1_COMMON_UI.frequency}
</P1FieldLabel>
<select
id="shipment-frequency"
disabled={disabled}
value={form.shipmentFrequency}
onChange={(e) => patch({ shipmentFrequency: e.target.value })}
className="mt-1 h-11 w-full max-w-md rounded-sm border border-neutral-400 bg-neutral-100 px-3 text-sm focus:border-neutral-600 focus:outline-none disabled:opacity-60"
>
{P1_FREQUENCY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<P1DateField
label={P1_COMMON_UI.pickupDate}
value={form.pickupDate}
disabled={disabled}
error={errors.pickupDate}
onChange={(v) => patch({ pickupDate: v })}
/>
<P1TextInput
id="business-email"
label={P1_COMMON_UI.email}
required
type="email"
value={form.email}
disabled={disabled}
error={errors.email}
onChange={(v) => patch({ email: v })}
/>
<P1PhoneField
country={form.phoneCountry}
value={form.phone}
disabled={disabled}
error={errors.phone}
onCountryChange={(c) => patch({ phoneCountry: c })}
onChange={(v) => patch({ phone: v })}
/>
<P1StickyActionBar>
<P1InstantQuotesButton
disabled={disabled}
loading={loading}
onClick={handleStep1Next}
>
{P1_COMMON_UI.getQuotes}
</P1InstantQuotesButton>
</P1StickyActionBar>
</div>
) : (
<form onSubmit={handleFinalSubmit} className="space-y-6 pb-2">
<div className="mb-2">
<h3 className="text-lg font-bold uppercase tracking-wide text-neutral-900">
{P1_STEP2_HEADING[form.shipmentType]}
</h3>
{form.shipmentType !== "ltl" ? (
<p className="mt-1 text-sm text-neutral-600">
{form.shipmentType === "expedited"
? P1_EXPEDITED_UI.subtitle
: form.shipmentType === "ftl"
? P1_FTL_UI.subtitle
: P1_STEP2_SUBTITLE}
</p>
) : null}
<P1Step2Progress label={P1_COMMON_UI.step2Of2} />
</div>
{form.shipmentType === "ltl" ? (
<>
<P1FieldLabel required>{P1_LTL_UI.freightSection}</P1FieldLabel>
<div className="flex flex-wrap items-end gap-2">
<div className="grid min-w-[12rem] flex-1 grid-cols-3 gap-2">
<P1TextInput
id="length"
label={P1_LTL_UI.length}
required
value={form.lengthIn}
disabled={disabled}
onChange={(v) => patch({ lengthIn: v })}
/>
<P1TextInput
id="width"
label={P1_LTL_UI.width}
required
value={form.widthIn}
disabled={disabled}
onChange={(v) => patch({ widthIn: v })}
/>
<P1TextInput
id="height"
label={P1_LTL_UI.height}
required
value={form.heightIn}
disabled={disabled}
onChange={(v) => patch({ heightIn: v })}
/>
</div>
<span className="mb-0 flex h-11 shrink-0 items-center rounded-sm bg-neutral-700 px-3 text-sm font-medium text-white">
{P1_LTL_UI.dimensionUnit}
</span>
<div className="min-w-[10rem] flex-1">
<P1WeightField
id="weight"
label={P1_LTL_UI.totalWeight}
required
value={form.weightLb}
disabled={disabled}
error={errors.weightLb}
unitLabel={P1_LTL_UI.weightUnit}
onChange={(v) => patch({ weightLb: v })}
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<P1FeatherySelect
id="packaging"
label={P1_LTL_UI.packaging}
required
value={form.packaging}
disabled={disabled}
options={P1_PACKAGING_OPTIONS}
onChange={(v) => patch({ packaging: v })}
/>
<P1TextInput
id="pallets"
label={P1_LTL_UI.palletCount}
required
value={form.palletCount}
disabled={disabled}
onChange={(v) => patch({ palletCount: v })}
/>
</div>
<P1FeatherySelect
id="freight-class"
label={P1_LTL_UI.freightClass}
required
value={form.freightClass}
disabled={disabled}
options={P1_FREIGHT_CLASS_OPTIONS}
onChange={(v) => patch({ freightClass: v })}
/>
<P1FieldLabel required>{P1_LTL_UI.pickupSection}</P1FieldLabel>
<P1FeatherySelect
id="pickup-location"
label={P1_LTL_UI.pickupLocation}
required
value={form.pickupLocation}
disabled={disabled}
options={P1_LOCATION_OPTIONS}
onChange={(v) => patch({ pickupLocation: v })}
/>
<P1AccessorialGrid
legend={P1_LTL_UI.pickupServices}
options={P1_PICKUP_ACCESSORIALS}
selected={form.pickupServices}
disabled={disabled}
onToggle={(value) =>
patch({
pickupServices: toggleInList(form.pickupServices, value),
})
}
/>
<P1FieldLabel required>{P1_LTL_UI.deliverySection}</P1FieldLabel>
<P1FeatherySelect
id="delivery-location"
label={P1_LTL_UI.deliveryLocation}
required
value={form.deliveryLocation}
disabled={disabled}
options={P1_LOCATION_OPTIONS}
onChange={(v) => patch({ deliveryLocation: v })}
/>
<P1AccessorialGrid
legend={P1_LTL_UI.deliveryServices}
options={P1_DELIVERY_ACCESSORIALS}
selected={form.deliveryServices}
disabled={disabled}
onToggle={(value) =>
patch({
deliveryServices: toggleInList(
form.deliveryServices,
value,
),
})
}
/>
</>
) : null}
{form.shipmentType === "ftl" ? (
<>
<P1FieldLabel required>{P1_FTL_UI.selectTrailer}</P1FieldLabel>
<FeatheryCardGrid cols={2}>
{P1_FTL_TRAILERS.map((t) => (
<FeatheryCard
key={t.id}
label={t.label}
description={t.desc}
selected={form.ftlTrailer === t.id}
disabled={disabled}
onClick={() => {
const next = t.id as FtlTrailerType;
const partial: Partial<Priority1DemoInput> = {
ftlTrailer: next,
};
if (next === "ftl_expedited") {
partial.ftlAdditionalServices = [];
}
patch(partial);
}}
/>
))}
</FeatheryCardGrid>
{form.ftlTrailer === "open_deck" ? (
<SelectField
label={P1_FTL_UI.openDeckType}
name="openDeckSubtype"
required
value={form.openDeckSubtype}
disabled={disabled}
options={P1_OPEN_DECK_OPTIONS}
onChange={(e) => patch({ openDeckSubtype: e.target.value })}
/>
) : null}
{form.ftlTrailer === "ftl_expedited" ? (
<>
<P1FieldLabel required>
{P1_FTL_UI.selectExpeditedTrailer}
</P1FieldLabel>
<FeatheryCardGrid cols={3}>
{P1_EXP_TRAILERS.map((t) => (
<FeatheryCard
key={t.id}
label={t.label}
description={t.desc}
selected={form.ftlExpeditedTrailer === t.id}
disabled={disabled}
onClick={() => {
const next = t.id as ExpeditedTrailerType;
patch({
ftlExpeditedTrailer: next,
...(next !== "large_straight"
? { expeditedLiftgate: false }
: {}),
});
}}
/>
))}
</FeatheryCardGrid>
{form.ftlExpeditedTrailer === "large_straight" ? (
<>
<P1FieldLabel>{P1_FTL_UI.additionalServices}</P1FieldLabel>
<FeatheryCardGrid cols={2}>
<FeatheryCard
label={P1_FTL_LIFTGATE_SERVICE.label}
description={P1_FTL_LIFTGATE_SERVICE.desc}
selected={form.expeditedLiftgate}
disabled={disabled}
onClick={() =>
patch({
expeditedLiftgate: !form.expeditedLiftgate,
})
}
/>
</FeatheryCardGrid>
</>
) : null}
<P1DateField
label={P1_COMMON_UI.pickupDate}
value={form.pickupDate}
disabled={disabled}
error={errors.pickupDate}
onChange={(v) => patch({ pickupDate: v })}
/>
<p className="text-sm font-semibold text-neutral-800">
{P1_FTL_UI.freightSection}
</p>
<P1TextInput
id="commodity"
label={P1_FTL_UI.commodity}
required
value={form.commodity}
disabled={disabled}
error={errors.commodity}
onChange={(v) => patch({ commodity: v })}
/>
<fieldset className="space-y-2">
<legend className="text-sm font-medium italic text-neutral-900">
{P1_FTL_UI.specialHandling}
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{P1_SPECIAL_HANDLING.map((opt) => (
<label
key={opt.value}
className="flex cursor-pointer items-center gap-2 rounded border border-neutral-300 bg-neutral-50 px-3 py-2 text-sm"
>
<input
type="checkbox"
disabled={disabled}
checked={form.specialHandlingServices.includes(
opt.value,
)}
onChange={() =>
patch({
specialHandlingServices: toggleInList(
form.specialHandlingServices,
opt.value,
),
})
}
/>
{opt.label}
</label>
))}
</div>
</fieldset>
<P1WeightField
id="ftl-weight"
label={P1_FTL_UI.totalWeight}
required
value={form.weightLb}
disabled={disabled}
error={errors.weightLb}
unitLabel={P1_FTL_UI.weightUnit}
onChange={(v) => patch({ weightLb: v })}
/>
</>
) : (
<>
<P1FieldLabel>{P1_FTL_UI.additionalServices}</P1FieldLabel>
<FeatheryCardGrid cols={3}>
{P1_FTL_ADDITIONAL.map((s) => (
<FeatheryCard
key={s.id}
label={s.label}
description={s.desc}
selected={form.ftlAdditionalServices.includes(s.id)}
disabled={disabled}
onClick={() =>
patch({
ftlAdditionalServices: toggleService(
form.ftlAdditionalServices,
s.id,
),
})
}
/>
))}
</FeatheryCardGrid>
{form.ftlTrailer === "temperature_controlled" ? (
<div className="grid gap-4 sm:grid-cols-2">
<P1TextInput
id="temp-min"
label={P1_FTL_UI.tempMin}
required
value={form.tempMinF}
disabled={disabled}
onChange={(v) => patch({ tempMinF: v })}
/>
<P1TextInput
id="temp-max"
label={P1_FTL_UI.tempMax}
required
value={form.tempMaxF}
disabled={disabled}
onChange={(v) => patch({ tempMaxF: v })}
/>
</div>
) : null}
<p className="text-sm font-semibold text-neutral-800">
{P1_FTL_UI.freightSection}
</p>
<P1TextInput
id="commodity"
label={P1_FTL_UI.commodity}
required
value={form.commodity}
disabled={disabled}
error={errors.commodity}
onChange={(v) => patch({ commodity: v })}
/>
<div className="grid gap-4 sm:grid-cols-2">
<P1WeightField
id="ftl-weight"
label={P1_FTL_UI.totalWeight}
required
value={form.weightLb}
disabled={disabled}
error={errors.weightLb}
unitLabel={P1_FTL_UI.weightUnit}
onChange={(v) => patch({ weightLb: v })}
/>
<P1TextInput
id="ftl-pallets"
label={P1_FTL_UI.palletCount}
required
value={form.palletCount}
disabled={disabled}
onChange={(v) => patch({ palletCount: v })}
/>
</div>
</>
)}
</>
) : null}
{form.shipmentType === "expedited" ? (
<>
<P1FieldLabel required>{P1_EXPEDITED_UI.selectTrailer}</P1FieldLabel>
<FeatheryCardGrid cols={2}>
{P1_EXP_TRAILERS.map((t) => (
<FeatheryCard
key={t.id}
label={t.label}
description={t.desc}
selected={form.expeditedTrailer === t.id}
disabled={disabled}
onClick={() => {
const next = t.id as ExpeditedTrailerType;
patch({
expeditedTrailer: next,
...(next !== "large_straight"
? { expeditedLiftgate: false }
: {}),
});
}}
/>
))}
</FeatheryCardGrid>
{form.expeditedTrailer === "large_straight" ? (
<>
<P1FieldLabel>{P1_EXPEDITED_UI.additionalServices}</P1FieldLabel>
<FeatheryCardGrid cols={2}>
<FeatheryCard
label={P1_FTL_LIFTGATE_SERVICE.label}
description={P1_FTL_LIFTGATE_SERVICE.desc}
selected={form.expeditedLiftgate}
disabled={disabled}
onClick={() =>
patch({ expeditedLiftgate: !form.expeditedLiftgate })
}
/>
</FeatheryCardGrid>
</>
) : null}
<p className="text-sm font-semibold text-neutral-800">
{P1_EXPEDITED_UI.freightSection}
</p>
<div className="grid gap-4 sm:grid-cols-2">
<P1TextInput
id="commodity"
label={P1_EXPEDITED_UI.commodity}
required
value={form.commodity}
disabled={disabled}
error={errors.commodity}
onChange={(v) => patch({ commodity: v })}
/>
<P1WeightField
id="exp-weight"
label={P1_EXPEDITED_UI.totalWeight}
required
value={form.weightLb}
disabled={disabled}
error={errors.weightLb}
unitLabel={P1_FTL_UI.weightUnit}
onChange={(v) => patch({ weightLb: v })}
/>
</div>
</>
) : null}
<P1StickyActionBar>
<div className="flex flex-col gap-3 sm:flex-row">
<button
type="button"
disabled={disabled}
className="rounded-sm border border-neutral-400 px-4 py-3 text-sm text-neutral-700 hover:bg-neutral-100 sm:w-auto"
onClick={() => {
setStep(1);
window.scrollTo({ top: 0, behavior: "smooth" });
}}
>
{P1_COMMON_UI.backStep1}
</button>
<div className="flex-1">
<P1InstantQuotesButton
type="submit"
loading={loading}
disabled={disabled}
>
{P1_COMMON_UI.getQuotes}
</P1InstantQuotesButton>
</div>
</div>
</P1StickyActionBar>
</form>
)}
</div>
);
}

@ -8,20 +8,17 @@ import type {
RateOption, RateOption,
ServiceLevel, ServiceLevel,
} from "@/lib/frontend/types"; } from "@/lib/frontend/types";
import {
filterQuotesForMotherShipUiDisplay,
getMotherShipRateOptionLabel,
getMotherShipServiceLevelLabel,
listMotherShipUiTabsFromQuotes,
listVisibleRateOptionsForTab,
resolveDefaultRateOptionForTab,
} from "@/lib/constants/mothership-ui-tiers";
import { formatUSD, formatPercent } from "@/lib/frontend/format"; import { formatUSD, formatPercent } from "@/lib/frontend/format";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
const LEVEL_LABEL: Record<ServiceLevel, string> = {
standard: "标准",
guaranteed: "保证送达",
};
const RATE_LABEL: Record<RateOption, string> = {
lowest: "最低价",
fastest: "最快",
bestValue: "最优性价比",
};
const RATE_ORDER: RateOption[] = ["lowest", "bestValue", "fastest"]; const RATE_ORDER: RateOption[] = ["lowest", "bestValue", "fastest"];
interface QuoteCardProps { interface QuoteCardProps {
@ -70,23 +67,44 @@ export function QuoteCard({
defaultLevel = "standard", defaultLevel = "standard",
defaultRate = "lowest", defaultRate = "lowest",
}: QuoteCardProps) { }: QuoteCardProps) {
const displayQuotes = useMemo(
() => filterQuotesForMotherShipUiDisplay(quote.quotes ?? []),
[quote.quotes],
);
const serviceLevels = useMemo(
() => listMotherShipUiTabsFromQuotes(displayQuotes),
[displayQuotes],
);
const [level, setLevel] = useState<ServiceLevel>(defaultLevel); const [level, setLevel] = useState<ServiceLevel>(defaultLevel);
const [rate, setRate] = useState<RateOption>(defaultRate); const [rate, setRate] = useState<RateOption>(defaultRate);
const quotes = quote.quotes ?? [];
const item = findTier(quotes, level, rate) ?? quotes[0];
const availableRates = useMemo( const availableRates = useMemo(
() => () => listVisibleRateOptionsForTab(displayQuotes, level) as RateOption[],
RATE_ORDER.filter((rt) => [displayQuotes, level],
quotes.some((q) => q.service_level === level && q.rate_option === rt),
),
[quotes, level],
); );
useEffect(() => { useEffect(() => {
if (serviceLevels.length > 0 && !serviceLevels.includes(level as string)) {
setLevel(serviceLevels[0] as ServiceLevel);
}
}, [serviceLevels, level]);
useEffect(() => {
const preferred = resolveDefaultRateOptionForTab(displayQuotes, level);
if (availableRates.length > 0 && !availableRates.includes(rate)) { if (availableRates.length > 0 && !availableRates.includes(rate)) {
setRate(availableRates[0]!); setRate(preferred as RateOption);
} }
}, [availableRates, rate]); }, [availableRates, rate, displayQuotes, level]);
const item =
findTier(displayQuotes, level, rate) ??
findTier(
displayQuotes,
level,
resolveDefaultRateOptionForTab(displayQuotes, level) as RateOption,
) ??
displayQuotes.find((q) => q.service_level === level);
if (!item) { if (!item) {
return ( return (
@ -106,18 +124,18 @@ export function QuoteCard({
<div className="mb-4 flex flex-wrap items-center justify-between gap-2"> <div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex gap-1 rounded-md bg-bg p-1"> <div className="flex gap-1 rounded-md bg-bg p-1">
{(["standard", "guaranteed"] as ServiceLevel[]).map((lv) => ( {serviceLevels.map((lv) => (
<button <button
key={lv} key={lv}
type="button" type="button"
onClick={() => setLevel(lv)} onClick={() => setLevel(lv as ServiceLevel)}
className={`rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${ className={`rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${
level === lv level === lv
? "bg-surface text-primary shadow-card" ? "bg-surface text-primary shadow-card"
: "text-text-secondary" : "text-text-secondary"
}`} }`}
> >
{LEVEL_LABEL[lv]} {getMotherShipServiceLevelLabel(lv)}
</button> </button>
))} ))}
</div> </div>
@ -125,7 +143,13 @@ export function QuoteCard({
</div> </div>
<div className="mb-3 flex gap-1 rounded-md bg-bg p-1"> <div className="mb-3 flex gap-1 rounded-md bg-bg p-1">
{availableRates.map((rt) => ( {(availableRates.length > 0 ? availableRates : RATE_ORDER).map((rt) => {
if (!displayQuotes.some(
(q) => q.service_level === level && q.rate_option === rt,
)) {
return null;
}
return (
<button <button
key={rt} key={rt}
type="button" type="button"
@ -136,9 +160,10 @@ export function QuoteCard({
: "text-text-secondary" : "text-text-secondary"
}`} }`}
> >
{RATE_LABEL[rt]} {getMotherShipRateOptionLabel(level, rt)}
</button> </button>
))} );
})}
</div> </div>
<div className="flex items-end justify-between gap-4"> <div className="flex items-end justify-between gap-4">

@ -11,7 +11,6 @@ import type {
UnitDim, UnitDim,
UnitWeight, UnitWeight,
} from "@/lib/frontend/types"; } from "@/lib/frontend/types";
import { US_STATES } from "@/lib/frontend/constants";
import { uuidV4 } from "@/lib/frontend/format"; import { uuidV4 } from "@/lib/frontend/format";
import { import {
searchAddressSuggestions, searchAddressSuggestions,
@ -21,8 +20,8 @@ import {
import { formatAddressDisplayLabel } from "@/lib/frontend/format-address"; import { formatAddressDisplayLabel } from "@/lib/frontend/format-address";
import { InputField } from "@/components/ui/input-field"; import { InputField } from "@/components/ui/input-field";
import { SelectField } from "@/components/ui/select-field"; import { SelectField } from "@/components/ui/select-field";
import { StateComboboxField } from "@/components/ui/state-combobox-field";
const STATE_OPTIONS = US_STATES.map((s) => ({ value: s, label: s })); import { isValidUsStateCode } from "@/lib/frontend/us-states";
interface AddressSectionProps { interface AddressSectionProps {
title: string; title: string;
@ -93,13 +92,14 @@ function AddressSection({
refreshSuggestions(); refreshSuggestions();
}} }}
/> />
<SelectField <StateComboboxField
label="州" label="州"
name={`${prefix}_state`} name={`${prefix}_state`}
value={state} value={state}
disabled={disabled} disabled={disabled}
options={STATE_OPTIONS} error={errors[`${prefix}_state`]}
onChange={(e) => onStateChange(e.target.value)} onChange={onStateChange}
onBlur={() => onBlurField(`${prefix}_state`)}
/> />
</div> </div>
{suggestions.length > 0 && !selected && ( {suggestions.length > 0 && !selected && (
@ -244,13 +244,17 @@ export function QuoteForm({
} }
} }
if ( if (
["p_street", "p_city", "d_street", "d_city", "length", "width", "height"].includes( ["p_street", "p_city", "p_state", "d_street", "d_city", "d_state", "length", "width", "height"].includes(
key, key,
) )
) { ) {
const v = form[key as keyof FormState]; const v = form[key as keyof FormState];
if (typeof v === "string" && !v.trim()) return "请填写该字段"; if (typeof v === "string" && !v.trim()) return "请填写该字段";
} }
if (key === "p_state" || key === "d_state") {
const v = form[key as "p_state" | "d_state"];
if (!isValidUsStateCode(v)) return "请填写有效的美国州码2 位字母)";
}
return undefined; return undefined;
}; };
@ -263,8 +267,10 @@ export function QuoteForm({
const keys = [ const keys = [
"p_street", "p_street",
"p_city", "p_city",
"p_state",
"d_street", "d_street",
"d_city", "d_city",
"d_state",
"weight", "weight",
"length", "length",
"width", "width",

@ -18,6 +18,10 @@ interface QuoteResultPanelProps {
onRetry: () => void; onRetry: () => void;
onExpire: () => void; onExpire: () => void;
onExpiredConfirm: () => void; onExpiredConfirm: () => void;
/** 轮询阶段提示(如 Priority1 填表较慢) */
processingHint?: string;
/** 空闲态副文案 */
idleHint?: string;
} }
const STATUS_LABEL: Partial<Record<QuotePageStatus, string>> = { const STATUS_LABEL: Partial<Record<QuotePageStatus, string>> = {
@ -59,6 +63,8 @@ export function QuoteResultPanel({
onRetry, onRetry,
onExpire, onExpire,
onExpiredConfirm, onExpiredConfirm,
processingHint,
idleHint,
}: QuoteResultPanelProps) { }: QuoteResultPanelProps) {
if (status === "resolving_address") { if (status === "resolving_address") {
return ( return (
@ -75,6 +81,12 @@ export function QuoteResultPanel({
title={ title={
STATUS_LABEL[status] ?? STATUS_LABEL.processing! STATUS_LABEL[status] ?? STATUS_LABEL.processing!
} }
hint={
status === "processing"
? processingHint ??
"通常 10~30 秒;若超过 2 分钟仍在等待,请查看 RPA Worker 日志或重启 worker"
: undefined
}
/> />
); );
} }
@ -109,6 +121,18 @@ export function QuoteResultPanel({
); );
} }
if (status === "manual_followup") {
return (
<WarningBanner
action={
<SecondaryButton onClick={onRetry}></SecondaryButton>
}
>
{error ?? "表单已提交,站点判定需人工确认细节后报价"}
</WarningBanner>
);
}
if ((status === "success" || status === "fallback") && quote) { if ((status === "success" || status === "fallback") && quote) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -137,7 +161,7 @@ export function QuoteResultPanel({
</p> </p>
<p className="mt-1 text-sm text-text-secondary"> <p className="mt-1 text-sm text-text-secondary">
MotherShip {idleHint ?? "提交后将返回 MotherShip 全部可用运价档位"}
</p> </p>
</Card> </Card>
); );

@ -0,0 +1,143 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
import {
filterUsStateOptions,
formatUsStateOption,
normalizeUsStateCode,
} from "@/lib/frontend/us-states";
interface StateComboboxFieldProps {
label: string;
name: string;
value: string;
disabled?: boolean;
error?: string;
onChange: (code: string) => void;
onBlur?: () => void;
}
export function StateComboboxField({
label,
name,
value,
disabled,
error,
onChange,
onBlur,
}: StateComboboxFieldProps) {
const listId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [draft, setDraft] = useState(value);
const [open, setOpen] = useState(false);
useEffect(() => {
setDraft(value);
}, [value]);
const options = filterUsStateOptions(draft);
const commitDraft = () => {
const normalized = normalizeUsStateCode(draft);
if (normalized) {
onChange(normalized);
setDraft(normalized);
} else {
onChange(draft.trim().toUpperCase());
}
onBlur?.();
};
const selectOption = (code: string, labelText: string) => {
onChange(code);
setDraft(labelText);
setOpen(false);
onBlur?.();
};
useEffect(() => {
const onDocMouseDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDocMouseDown);
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, []);
return (
<div ref={rootRef} className="relative space-y-1">
<label htmlFor={name} className="block text-sm font-medium text-text-primary">
{label}
</label>
<input
id={name}
name={name}
type="text"
autoComplete="off"
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
list={listId}
disabled={disabled}
value={draft}
placeholder="输入州码或选择,如 CA"
onChange={(e) => {
const next = e.target.value;
setDraft(next);
setOpen(true);
const normalized = normalizeUsStateCode(next);
if (normalized) onChange(normalized);
}}
onFocus={() => setOpen(true)}
onBlur={() => {
window.setTimeout(() => {
if (!rootRef.current?.contains(document.activeElement)) {
commitDraft();
setOpen(false);
}
}, 120);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (e.key === "Enter" && open && options.length > 0) {
e.preventDefault();
const first = options[0];
selectOption(first.code, formatUsStateOption(first));
}
}}
className={`h-10 w-full rounded-sm border px-3 text-sm transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:bg-bg disabled:opacity-60 ${
error ? "border-error" : "border-border"
}`}
/>
{open && !disabled && options.length > 0 && (
<ul
id={listId}
role="listbox"
className="absolute z-20 mt-1 max-h-48 w-full overflow-y-auto rounded-md border border-border bg-surface shadow-card"
>
{options.map((entry) => {
const labelText = formatUsStateOption(entry);
return (
<li key={entry.code} role="option">
<button
type="button"
className="w-full px-3 py-2 text-left text-sm hover:bg-bg"
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectOption(entry.code, labelText)}
>
{labelText}
</button>
</li>
);
})}
</ul>
)}
{error && <p className="text-xs text-error">{error}</p>}
</div>
);
}

@ -2,15 +2,17 @@ import { type ReactNode } from "react";
interface WarningBannerProps { interface WarningBannerProps {
children: ReactNode; children: ReactNode;
action?: ReactNode;
} }
export function WarningBanner({ children }: WarningBannerProps) { export function WarningBanner({ children, action }: WarningBannerProps) {
return ( return (
<div <div
role="status" role="status"
className="rounded-md border-l-4 border-warning bg-amber-50 p-4 text-sm text-warning" className="flex flex-wrap items-start justify-between gap-3 rounded-md border-l-4 border-warning bg-amber-50 p-4 text-sm text-warning"
> >
{children} <div>{children}</div>
{action}
</div> </div>
); );
} }

@ -1051,6 +1051,14 @@
- 执行记录 1`quote-completeness`/`quote-schema-validator`/`payload-decode` 支持 4~6 档 - 执行记录 1`quote-completeness`/`quote-schema-validator`/`payload-decode` 支持 4~6 档
- 执行记录 2`quote-card` 动态 rate tab单测 axel fixture 含 standard.bestValue - 执行记录 2`quote-card` 动态 rate tab单测 axel fixture 含 standard.bestValue
### Step 11.20 MotherShip 官网 Widget 档位对齐 + 专属卡车
- **根因**:仅解析 standard/guaranteedStandard 默认展示 API absolute lowest 与官网「最低价格」(bestValue) 不一致;`rates.dedicated` 未接入
- **修复**`lib/constants/mothership-ui-tiers.ts` 可扩展 Tab 注册表;`payload-decode` 动态解析 rates 顶层键;`prepareMotherShipStorageQuotes` 统一入库/API/前端过滤
- **状态**Done
- 执行记录 1`dedicated` 档解析 + `quote-card` 三 TabStandard 并存 lowest/bestValue 时隐藏 absolute lowest
- 执行记录 2`mothership-ui-tiers.test.ts` + `quote-capture` dedicated 用例;`tsc --noEmit` 通过
### Step 11.19 「已选择」地址展示逻辑修复 ### Step 11.19 「已选择」地址展示逻辑修复
- **根因**`quote-form` 在本地 mock 联想点选后即显示「已选择」,未等 MotherShip 弹窗确认 - **根因**`quote-form` 在本地 mock 联想点选后即显示「已选择」,未等 MotherShip 弹窗确认
@ -1656,3 +1664,317 @@
- 执行记录 1`extractTiersFromRates(rates, availableRates)` + `listAllowedRateOptionsForSupplement` - 执行记录 1`extractTiersFromRates(rates, availableRates)` + `listAllowedRateOptionsForSupplement`
- 执行记录 2单测 id 引用 fastest / 显式 fastest 补档通过 - 执行记录 2单测 id 引用 fastest / 显式 fastest 补档通过
### Step 11.83 MotherShip 会话自动 bootstrap
- **需求**:缺 `.rpa/mothership-storage.json` 时不要求用户手动 `record:mothership`
- **修复**`ensureMothershipStorageState` headless 访问 `MOTHERSHIP_QUOTE_URLS` 并 `persistContext`worker 启动与 axel 直连前自动调用
- **状态**Done
- 执行记录 1新增 `lib/rpa/ensure-storage-state.ts``AxelHttpClient.create()` + worker bootstrap
- 执行记录 2单测 ensure-storage-state`RPA_AUTO_BOOTSTRAP_STORAGE=false` 可关
### Step 11.84 Priority1 FTL 人机协作录制
- **需求**FTL 整车自动填至卡住,人工补全并记录操作直至报价
- **输出**`human-assist-recorder.ts`、`runPriority1HumanAssistChain`、`npm run visual:priority1-human-assist`
- **状态**Done
- 执行记录 1Step 2 软自动(失败 warn 不 throw+ DOM 事件录制 + 报价轮询保存 `.rpa/recordings/priority1-human-*.json/js`
- 执行记录 2人机模式强制 headedCLI 默认 F01-ftl-dry-van-full
### Step 11.85 Priority1 全矩阵 benchmark 失败项修复
- **根因**Freight Class 观测读到错误 selectOpen Deck evaluate `__name`FTL/Expedited Feathery checkbox headless 无效
- **修复**`selectFreightClass` 优先 `#freight-type`evaluate 改 function 声明;`forceFeatheryCheckbox`Expedited cardText 放宽
- **状态**Done
- 执行记录 1`form-interactions.ts` + `visual-priority1-full-chain.ts` + `shipment-types.ts`
- 执行记录 2单测 freightClassOptionMatches复跑 `benchmark:priority1-canonical-matrix`
### Step 11.86 P08 LTL 全附加服务人工录制
- **需求**:记录人工勾选 11 项 LTL 附加服务并提交至报价
- **输出**`runPriority1P08AccessorialRecording`、`npm run record:priority1-p08-accessorials`
- **状态**Done
- 执行记录 1`P08_LTL_ACCESSORIAL_LABELS` + `humanAssistHandoff: ltlAccessorials` 跳过自动勾选
- 执行记录 22026-07-01 人工实机 11 项全勾并出报价(`quoteDetected: true`P08 `verified: recorded`
### Step 11.86b P08 录制器事件捕获修复
- **需求**:纯人工模式保存时 `events` 为空导致无法回放 11 项勾选
- **修复**`resolveInteractive` 向上解析 label/checkbox轮询累积事件出报价且无事件时用清单生成回放脚本
- **状态**Done
- 执行记录 1`human-assist-recorder.browser.js` + `run-manual-recording.ts` 累积与 fallback
- 执行记录 2回填 `priority1-P08-ltl-all-accessorials-manual-2026-07-01T0553.json/js`
### Step 11.86c P08 自动检测通过
- **需求**benchmark / canonical inspect 自动勾选 11 项 LTL 附加并出报价
- **修复**`toggleLtlAccessorial` 对齐人工录制getByLabel + force + DOM 校验);`isLtlAccessorialChecked`
- **状态**Done
- 执行记录 1`form-interactions.ts` 重写附加勾选与勾选态读取
- 执行记录 2单测 `ltlAccessorialLabelMatches``PRIORITY1_MATRIX_FILTER=P08` 验证 batch=d3e9b973 11/11 勾选 + 4 条报价
### Step 11.88 FTL 第二类卡住修复 + 人机录制入口
- **根因**`selectFtlAdditionalService` 对隐藏 `#cb_as_ftl*` 调用 `locator.isChecked()`Playwright 默认 90s×5 次重试 → 看似卡死
- **修复**`isFeatheryCheckboxChecked` + `isFeatheryCardSelected` 替代 isChecked`npm run record:priority1-ftl-path`
- **状态**Done
- 执行记录 1`form-interactions.ts` FTL 附加服务勾选与观测
- 执行记录 2`record-priority1-ftl-path.ts` 自动填至 handoff + 人工节点录制
### Step 11.89 F01 FTL 人工录制 handoff
- **需求**Dry Van 后 Full FTL 自动点不到,改人工录制 Additional Services 及后续
- **输出**`humanAssistHandoff: ftlAdditionalServices` + `runPriority1FtlPathRecording`
- **状态**Done
- 执行记录 1`fillStep2Ftl` 跳过 Additional Services`printFtlAdditionalHandoff` 清单
- 执行记录 2`runHumanAssistPhase` Ctrl+C/浏览器关闭保存;`npm run record:priority1-ftl-path`
### Step 11.90 FTL 日历报价检测 + F01 人工录制验收
- **需求**FTL 报价为日历表格无 `$`F01 人工录制 Full FTL 节点
- **输出**`parseFtlCalendarQuotes``priority1-F01-ftl-dry-van-full-human-2026-07-01T0715.json`
- **状态**Done
- 执行记录 1`quote-extract.ts` FTL 日历页识别;录制 40+ DOM 事件含 Full FTL
- 执行记录 2`selectFtlAdditionalService` 优先 `getByText(label, exact)` 对齐人工录制
### Step 11.91 F04 Open Deck Type #PickupLocation3
- **需求**F04 人工录制;批量 F04F07 卡在 Open Deck Type 未找到选项
- **输出**`OPEN_DECK_TYPE_SELECT_SELECTOR``priority1-F04-ftl-open-deck-flatbed-human-2026-07-01T0747.json`
- **状态**Done
- 执行记录 1根因 `EXCLUDED_SELECT_ID_RE` 误排除 `#PickupLocation3`;优先 selectOption 该节点
- 执行记录 2含 deck 选项的 select 不再被 PickupLocation 规则跳过F04 `verified: recorded`
### Step 11.92 无即时报价 → 人工跟进终态
- **需求**F08F11 等提交后出现 MORE INFORMATION NEEDED不应记为失败
- **输出**`isPriority1ManualFollowupPage``quoteOutcome: manual_followup`;标准文案
- **状态**Done
- 执行记录 1`quote-extract.ts` 识别人工跟进页;`message` 返回「表单已提交,站点判定需人工确认细节后报价」
- 执行记录 2`visual-priority1-full-chain` + benchmark `manual_followup` 计为成功
### Step 11.93 全矩阵 24 条系统联调清单
- **需求**:确认 24 条 canonical 路线供 Priority1 模块系统测试
- **输出**`buildPriority1CanonicalTestCases``export:priority1-canonical-test-matrix`
- **状态**Done
- 执行记录 124 条全部 `verified: recorded`;预期终态 instant_quote / manual_followup 映射
- 执行记录 2产出 `.rpa/priority1-canonical-test-matrix.json` 含 POST /api/priority1/quotes 载荷
### Step 11.94 填写规则与重量硬限制
- **需求**:明确互斥/重量上限E03 10000 lb、F11 内嵌 Expedited 修正
- **输出**`form-constraints.ts``.rpa/priority1-form-constraints.md`
- **状态**Done
- 执行记录 1`PRIORITY1_WEIGHT_LIMITS` Expedited Large ≤10000Liftgate 仅 Large Straight
- 执行记录 2benchmark/export 前置 `validatePriority1FormConstraints` 校验
### Step 11.95 Priority1 系统查询链路接入生产
- **需求**24 条 canonical 跑通后,人工可在系统内提交 Priority1 询价并拿到即时报价或人工跟进终态
- **输出**`run-quote.ts` 接入 `visual-priority1-full-chain`job-handler `manual_followup`serializer + UI
- **状态**Done
- 执行记录 1worker `runPriority1QuoteRpa` 非 mock 时调用可视全链 RPA`PRIORITY1_MANUAL_FOLLOWUP` 记为 done
- 执行记录 2`Priority1QuoteWidget` + `QuoteResultPanel` 展示人工跟进API 校验叠加 `form-constraints`
### Step 12.0 Priority1 服务端日期校验与中文报错
- **需求**:修复 p1DateToIso client/server 边界错误;用户可见报错中文化
- **输出**`date-format.ts``api-errors.ts`route/widget 使用 `resolvePriority1UserMessage`
- **状态**Done
- 执行记录 1`pickup-date.ts` 改引服务端安全的 `date-format`,不再经 `ui-labels`use client
- 执行记录 2API/前端将英文框架异常转为中文提示,保留业务校验原文
### Step 11.99 Priority1 询价处理失败修复
- **需求**POST /api/priority1/quotes 返回「Priority1 询价处理失败」
- **输出**orchestrator 入队失败可读错误markQuoteStarted 改 safeRecordquote_id 冲突重试
- **状态**Done
- 执行记录 1移除提交前 await markQuoteStartedRedis 异常不再阻断入队前流程)
- 执行记录 2入队失败标记 quote failed + API 返回 Redis/队列具体原因
### Step 11.98 Priority1 报价界面中文化与数据修正
- **需求**:中文展示、移除 Cabo TMS 按钮、补全日期、官网金额、提货日仅可选今天之后
- **输出**`P1_RESULT_UI`DOM 日期解析;`pickup-date.ts`;优先可见卡片金额
- **状态**Done
- 执行记录 1`extractPriority1VisibleQuotes` 优先 QUOTE 卡片 DOM展示 `totalUsd` 官网价
- 执行记录 2`P1DateField` min=明天;校验「提货日须晚于今天」
### Step 11.99 Priority1 承运商与金额解析修正
- **需求**:页面显示「未解析承运商」、金额与官网不一致
- **输出**`quote-extract.ts` Feathery 摘要优先;按卡片顺序对齐 rankenrich 回填承运商
- **状态**Done
- 执行记录 1`mergeLtlSummaryWithDomCards` 以摘要金额/承运商为准DOM 仅补日期时效
- 执行记录 2`enrichDomCardsFromNetwork` 未解析承运商时按序号从摘要回填;增强 DOM 承运商正则
### Step 11.100 Priority1 FTL 日历日期与金额落格修正
- **需求**FTL 结果页显示 `Invalid Date`,且日历金额/日期位置与官网不一致
- **输出**`calendar-grid.ts` 兼容 `MM/DD/YYYY``parseFtlCalendarQuotes` 提取实际日历日;按真实日期落格
- **状态**Done
- 执行记录 1`buildPriority1CalendarGrid` 新增日期解析与 `DAY:n` 落格,修复空日历/Invalid Date
- 执行记录 2`priority1-quote-extract.test.ts` 与 `calendar-grid.test.ts` 覆盖 FTL 日历解析与渲染
### Step 11.97 Priority1 500lb 无报价与 RPA 提速
- **需求**:改重量后「暂无报价」;询价耗时过长
- **输出**`waitForQuotes` 去除壳子就绪早退;`legacy-tier` 兼容workerFast 跳过截图/停留
- **状态**Done
- 执行记录 1`resolvePriority1StoredQuotes` 兼容旧 tier报价轮询仅在有金额时结束
- 执行记录 2worker `RPA_DWELL_MS=0` + `workerFast`;前端 5s 后 600ms 轮询
### Step 11.96 Priority1 官网风格报价展示
- **需求**Priority1 结果不再复用 MotherShip `QuoteResultPanel`LTL 卡片列表 + FTL 日历两种界面与官网一致
- **输出**`quote-storage.ts``priority1-ltl-quote-options``priority1-ftl-schedule-calendar``priority1-quote-result-panel`serializer `priority1` 字段
- **状态**Done
- 执行记录 1worker `quotesJson` 存 `provider:priority1` 完整行GET 报价返回 `priority1.display_mode` 与加价后 `lines`
- 执行记录 2`Priority1QuoteWidget` 接入专用结果面板FTL 全宽日历 + 侧边运单摘要
### Step 11.99 管理端客户管理与 DB API Key
- **需求**:多客户无需改 .env 即可新增租户并签发 Key加价按客户隔离
- **输出**`host_customer`/`service_api_key` 表;`/admin/customers``verifyServiceTokenAsync`
- **状态**Done
- 执行记录 1管理端创建客户自动生成 `chj_` Keyenv `HOST_SERVICE_TOKENS` 继续兼容
- 执行记录 2middleware env 快路径 + 路由 DB 校验;加价列表合并 DB 客户
### Step 11.98 加价比例支持 0.01% 步长
- **需求**:加价配置百分比需支持 0.01% 精度
- **输出**`markup-validation.ts``prisma` DECIMAL(5,2) 迁移;管理端输入 step=0.01`formatPercentDisplay`
- **状态**Done
- 执行记录 1`parsePercentValue` 改为两位小数;`markup_config`/`quote_record.markup_percent` 扩精度
- 执行记录 2管理端与报价 breakdown 展示支持 0.01%;单测覆盖 0.01% 加价计算
### Step 11.97 MotherShip 报价序列化误走 Priority1 legacy 修复
- **需求**:服务器 MotherShip 询价成功但前端显示「暂无报价数据」
- **输出**`modules/priority1/quote-storage.ts``quote-serializer-priority1` 单测
- **状态**Done
- 执行记录 1`resolvePriority1StoredQuotes` 仅在 `pickupJson.priority1_shipment_type` 存在时才做 legacy-tier 转换
- 执行记录 2MotherShip tier 数组恢复序列化为 `quotes` 字段;线上 host/submit 与 embed `QuoteCard` 可正常展示
### Step 11.87 查价项目.md 同步(部署 + 宿主 API + 公制取整)
- **需求**:系统经宝塔部署、宿主公开 API、Direct 生产配置、axel 整数边界后,更新新人入口文档
- **输出**`docs/查价项目.md` v1.3(方式 A/B、BLK-012~014、ADR-6/7、宝塔部署、公制样例
- **状态**Done
- 执行记录 1更新 AI 交接、关键 API、环境变量、排障与 Docker 双部署路径
- 执行记录 2补充 `toAxelWhole*` 精度边界说明与 `chajia-neibu-2026` 内网 token
---
## Phase 12 — 报价提速与 API 稳定性
> 改动清单见 `docs/报价提速与稳定性-改动清单.md`。按 P0 → P1 → P2 → P3 顺序执行。
### Step 12.1 报价去重 fetchPlace + 并行 resolve
- **输入**P1-1`lib/axel/quote-from-request.ts`
- **输出**:每侧 place 最多 1 次 HTTPpickup/delivery `Promise.all`
- **验证**:单测 + `probe:human-doc-system` 10/10
- **状态**Done
- 执行记录 1`resolveSide` 改为返回已解析 `place`,已知 `placeId` 与 search 命中路径均只 `fetchPlace` 1 次pickup/delivery 改并行解析
- 执行记录 2新增 `__tests__/lib/axel/quote.test.ts` 调用次数断言;`npm run lint` 与 `npx vitest run __tests__/lib/axel/quote.test.ts` 通过
### Step 12.2 候选 verify Top-K
- **输入**P1-2`lib/axel/candidates.ts` + env `AXEL_CANDIDATE_VERIFY_TOP_K`
- **验证**:候选 API 耗时下降;至少 1 条 selectable
- **状态**Done
- 执行记录 1`getAxelCandidateVerifyTopK()` 默认 3`verifyCandidates` 仅前 K 条 `fetchPlace`,其余 `selectable: true`
- 执行记录 2`candidates.test.ts` 断言 fetchPlace 次数;`deploy/.env.bt-host-mysql.example` 增 `AXEL_CANDIDATE_VERIFY_TOP_K=3`
### Step 12.3 host submit 轮询 500ms
- **输入**P1-3`modules/host/wait-for-quote.ts`
- **验证**submit 完成后 <1s 返回
- **状态**Done
- 执行记录 1`getHostQuotePollIntervalMs()` 默认 500ms`waitForQuoteDetail` 不再使用 embed 的 2s 间隔
- 执行记录 2`wait-for-quote.test.ts` 覆盖 env 解析;模板增 `HOST_QUOTE_POLL_INTERVAL_MS=500`
### Step 12.4 地址候选 Redis 短缓存
- **输入**P1-4TTL 120s
- **验证**:同址二次候选 <500ms
- **状态**Done
- 执行记录 1`addr-cand:{md5}` 键;`mothership-candidates` 读写缓存Redis 失败时静默降级
- 执行记录 2`candidates-cache.test.ts` + `mothership-candidates.test.ts` 缓存命中用例;`cache.test.ts` TTL 120s
### Step 12.5 inline direct quoteAPI 快乐路径)
- **输入**P2-1`orchestrator` + `RPA_INLINE_DIRECT_QUOTE`
- **验证**`inline-direct 成功`;无 queue 等待10/10
- **状态**Done
- 执行记录 1`submitQuote` 在 L2 miss 后优先尝试 inline `fetchAxelQuoteItems`;成功直接写 L2/L3 + `quoteRecord.done` + L1 幂等
- 执行记录 2失败仅 warn 并无损回退 BullMQ`orchestrator.test.ts` 覆盖 success/fallback`RPA_INLINE_DIRECT_QUOTE=true` 写入部署模板
### Step 12.6 host submit 同步 done 免轮询
- **输入**P2-2`public-orchestrator.ts`
- **验证**inline 成功时 0 次 DB 轮询
- **状态**Done
- 执行记录 1`hostPublicSubmitAndWait` 在 `submitQuote().status === done` 时直接 `findFirst + serializeQuoteDetail`,不再走 `waitForQuoteDetail`
- 执行记录 2`public-orchestrator.test.ts` 新增直返路径断言;`npm run lint` 与 host 聚焦测试通过
### Step 12.9 解码器按 MotherShip 实际档位展示(取消 standard/lowest 硬门槛)
- **根因**:部分路线 axel/quote 仅返回 `rates.*.bestValue``hasMinimumTiers` / `normalizeQuoteItems` 强制 `standard/lowest` 导致整单 `RPA_DATA_INVALID`
- **验证**Denver→Boston 仅 bestValue 三档可解码、入库、展示;`quote-capture` / `quote-completeness` 单测通过
- **状态**Done
- 执行记录 1`payload-decode` / `quote-schema-validator` / `quote-completeness` 改为「至少 1 条有效运价」即通过
- 执行记录 2新增 Denver→Boston bestValue-only 用例;`mothership.validate` 放宽单档与缺 guaranteed 场景
### Step 12.7 Priority1 分队列
- **输入**P3-1独立队列/worker
- **验证**:生产 MotherShip 不被 Priority1 阻塞
- **状态**:待执行
### Step 12.8 HTTP worker 并发(可选)
- **输入**P3-2
- **验证**3 并发报价成功率不降
- **状态**:待执行
### Step 12.10 管理端预警增强与查价记录
- **输入**:预警详情需中文根因、客户/地址/货物;取消 RPA 状态与指标看板;队列监控改为查价流水(最多 1000 条)
- **验证**:预警详情无 JSON 根因;查价记录 API 分页;导航仅保留查价记录
- **状态**Done
- 执行记录 1新增 `quote_query_log` 表与 `query-log` 模块;全链路写入 outcome`alert-presentation` 中文根因
- 执行记录 2重写 `/admin/alerts` 详情与 `/admin/queues` 查价记录页;移除 RPA/指标看板导航
### Step 12.12 管理端与嵌入演示登录门控
- **输入**:管理端不再预填/展示演示账号;嵌入演示须客户 ID + 密码登录后按客户查价
- **验证**`/login` 空表单;`/embed-demo` 登录 CUST_002 可查价Cookie 会话可调用 host API
- **状态**Done
- 执行记录 1`embed-demo` JWT Cookie + middleware 无 Bearer 时注入 `x-customer-id``EmbeddedQuoteWidget` 支持无 Token
- 执行记录 2管理端 `login-client` 清空默认值seed 密码改为 `kj123456``auth.test.ts` 覆盖演示会话
### Step 12.13 管理员密码同步 + 嵌入演示去掉加价展示
- **根因**`seed.ts` upsert `update: {}` 不更新已有 `admin_demo` 密码;嵌入演示页展示加价易误解
- **验证**`prisma db seed` 后 `admin_demo/kj123456` 可登录;`/embed-demo` 仅显示客户编号
- **状态**Done
- 执行记录 1`seed` 更新 `passwordHash``install-bt-host-mysql.sh` 部署后自动 `db seed``chajia.sql` 同步新哈希
- 执行记录 2移除 embed-demo 加价配置文案;服务器可单独执行 `npx prisma db seed` 立即改密
### Step 12.14 登录不保存账号密码
- **输入**:管理端与嵌入演示每次进入须手动填写账号密码;禁止浏览器记住
- **验证**:刷新 `/embed-demo` 须重新登录;关闭浏览器后管理端须重新登录
- **状态**Done
- 执行记录 1登录表单 `autoComplete=off`;管理端 Token 改 `sessionStorage` 并清理旧 `localStorage`
- 执行记录 2嵌入演示进入页清除 Cookie、取消自动恢复会话演示 Cookie 改为浏览器会话级
### Step 12.15 客户管理设置嵌入演示密码
- **输入**:管理端按客户设置 /embed-demo 登录密码;嵌入演示按客户校验
- **验证**客户管理可设置密码CUST_002 改密后旧密码失效
- **状态**Done
- 执行记录 1`host_customer.embed_password_hash` 迁移;`verifyCustomerEmbedPassword` 按客户 bcrypt 校验
- 执行记录 2客户管理「设置密码」与新建时密码字段仅 .env 客户仍走全局 `EMBED_DEMO_PASSWORD`

@ -0,0 +1,80 @@
# =============================================================================
# 宝塔部署模板 — 192.168.2.14 + MySQL(chajia)
# 首次部署cp deploy/.env.bt-host-mysql.example .env 再 bash deploy/install-bt-host-mysql.sh
# 更新代码:勿覆盖服务器已有 .env上传包不含根目录 .env
# =============================================================================
APP_PORT=30325
PUBLIC_HOST=192.168.2.14
MYSQL_USER=chajia
MYSQL_PASSWORD=7WGSADmDRTh6firE
MYSQL_HOST=host.docker.internal
MYSQL_PORT=3306
MYSQL_DATABASE=chajia
DATABASE_URL=mysql://chajia:7WGSADmDRTh6firE@host.docker.internal:3306/chajia
JWT_SECRET=K8mNx2pQ9vL4wR7yT1zA6bC0dE5fH3jG
HOST_SERVICE_TOKENS={"chajia-neibu-2026":{"customerId":"CUST_001","permissions":["pricing:markup:write"]},"demo-host-token":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}
CUSTOMER_REGISTRY=CUST_001
# 美美与共等宿主:两步公开接口(免 Token上线前建议改 false 并配 Token
HOST_PUBLIC_API_ENABLED=true
HOST_PUBLIC_DEFAULT_CUSTOMER_ID=CUST_001
# embed-demo 构建时写入前端(须为 HOST_SERVICE_TOKENS 中的 key
NEXT_PUBLIC_EMBED_DEMO_SERVICE_TOKEN=chajia-neibu-2026
NEXT_PUBLIC_EMBED_DEMO_CUSTOMER_ID=CUST_001
MOTHERSHIP_QUOTE_URLS=https://www.mothership.com/
RPA_MOCK_MODE=false
RPA_ADDRESS_MODE=real
RPA_QUOTE_MODE=direct
# API 侧 direct 成功时直接返回 done失败再回退 BullMQ
RPA_INLINE_DIRECT_QUOTE=true
# 生产环境关闭 Widget 浏览器填表回退(直连失败时快速报错,避免卡 6~12 分钟)
RPA_DISABLE_WIDGET_QUOTE_FALLBACK=true
RPA_HEADLESS=true
RPA_BROWSER_LOCALE=en-US
RPA_USE_PATCHRIGHT=false
RPA_USE_STEALTH=false
RPA_QUEUE_ENABLED=true
RPA_PARKED_SESSION=false
RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json
RPA_AUTO_BOOTSTRAP_STORAGE=true
RPA_PLACE_FIX_MODE=event
RPA_PLACE_DIAG=false
MOTHERSHIP_EMAIL=
MOTHERSHIP_PASSWORD=
# 地址候选仅对前 K 条做 fetchPlace 校验(其余默认可选,加快联想)
AXEL_CANDIDATE_VERIFY_TOP_K=3
# 宿主 submit 服务端轮询间隔毫秒embed 前端仍为 2s
HOST_QUOTE_POLL_INTERVAL_MS=500
MOTHERSHIP_QUOTE_API_URL_PATTERNS=services.mothership.com/axel/quote
RPA_QUOTE_NETWORK_VISIBILITY=false
RPA_QUOTE_DEBUG_RAW=false
RPA_QUOTE_NETWORK_SNIFF=false
# Priority1 RPA 生产提速worker 填表)
RPA_DWELL_MS=0
RPA_SLOW_MO_MS=0
RPA_QUOTE_POLL_MS=600
RPA_QUOTE_WAIT_MS=90000
RPA_PRIORITY1_WORKER_FAST=true
RPA_SELECTOR_PICKUP_SECTION='#react-quoter-landing-plugin >> text=Pick up from,#react-quoter-landing-plugin >> text=Where to pick up,#react-quoter-landing-plugin >> text=从何处取货'
RPA_SELECTOR_DELIVERY_SECTION='#react-quoter-landing-plugin >> text=Deliver to,#react-quoter-landing-plugin >> text=Delivery to,#react-quoter-landing-plugin >> text=送货至'
RPA_SELECTOR_QUOTE_WIDGET='#react-quoter-landing-plugin'
RPA_SELECTOR_PICKUP_STREET='#react-quoter-landing-plugin >> placeholder=Search by address,#react-quoter-landing-plugin >> role=textbox[name="Search by address"],#react-quoter-landing-plugin >> role=textbox[name="Search address"],#react-quoter-landing-plugin >> role=textbox[name="搜索地址"]'
RPA_SELECTOR_DELIVERY_STREET='#react-quoter-landing-plugin >> placeholder=Search by address,#react-quoter-landing-plugin >> role=textbox[name="Search by address"],#react-quoter-landing-plugin >> role=textbox[name="Search address"],#react-quoter-landing-plugin >> role=textbox[name="搜索地址"]'
RPA_SELECTOR_ADDRESS_SUGGESTION='#react-quoter-landing-plugin >> [role="listbox"] [role="option"],#react-quoter-landing-plugin >> li,.pac-item,[role="option"]'
RPA_SELECTOR_CARGO_SECTION='#react-quoter-landing-plugin >> filter:text=Shipment details>>nth=4,#react-quoter-landing-plugin >> filter:text=Freight details>>nth=4,#react-quoter-landing-plugin >> filter:text=货运详情>>nth=4'
RPA_SELECTOR_SUBMIT='role=button[name="GET QUOTE"],role=button[name="Get freight quote"],role=button[name="Get a freight quote"],role=button[name="获取货运报价"]'
RPA_SELECTOR_TRANSIT='text=business days'
# REDIS_URL 由 docker-compose.bt-host-mysql.yml 自动注入 redis://redis:6379

@ -0,0 +1,72 @@
# =============================================================================
# 查价中台 — 生产环境配置模板
# 使用:复制到项目根目录并重命名为 .env
# cp deploy/.env.production.example .env
# 将下方所有「请填写」项改为真实值后执行bash deploy/install.sh
# =============================================================================
# ── 必填:安全与租户 ──────────────────────────────────────────────────────────
# 【请填写】MySQL root 密码(强密码,勿使用 changeme
MYSQL_ROOT_PASSWORD=CHANGE_ME_STRONG_PASSWORD
# 【请填写】管理端 JWT 密钥(随机 32+ 字符openssl rand -base64 32
JWT_SECRET=CHANGE_ME_RANDOM_JWT_SECRET
# 【请填写】对外 API 域名(仅文档/自检用,不含 https://
PUBLIC_DOMAIN=quote.example.com
# 【请填写】给外部系统调用的 Service TokenJSON 单行,勿换行)
# 格式:{"你的token字符串":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}
# 外部系统请求头Authorization: Bearer <token> X-Customer-Id: CUST_001
HOST_SERVICE_TOKENS={"CHANGE_ME_EXTERNAL_API_TOKEN":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}
# 【请填写】客户 ID 列表(逗号分隔,须与 HOST_SERVICE_TOKENS 中 customerId 一致)
CUSTOMER_REGISTRY=CUST_001
# ── 应用端口(一般无需改)──────────────────────────────────────────────────────
# 仅绑定 127.0.0.1,由 Nginx 反代;勿直接暴露公网
APP_PORT=3000
# ── RPA / Mothership生产必填──────────────────────────────────────────────
MOTHERSHIP_QUOTE_URLS=https://www.mothership.com/
RPA_MOCK_MODE=false
RPA_ADDRESS_MODE=real
RPA_QUOTE_MODE=direct
RPA_HEADLESS=true
RPA_BROWSER_LOCALE=en-US
RPA_USE_PATCHRIGHT=false
RPA_USE_STEALTH=false
RPA_QUEUE_ENABLED=true
RPA_PARKED_SESSION=false
RPA_STORAGE_STATE_PATH=.rpa/mothership-storage.json
RPA_AUTO_BOOTSTRAP_STORAGE=true
RPA_PLACE_FIX_MODE=event
RPA_PLACE_DIAG=false
# 【若 Mothership 跳转登录页则必填】否则可留空
MOTHERSHIP_EMAIL=
MOTHERSHIP_PASSWORD=
MOTHERSHIP_QUOTE_API_URL_PATTERNS=services.mothership.com/axel/quote
RPA_QUOTE_NETWORK_VISIBILITY=false
RPA_QUOTE_DEBUG_RAW=false
RPA_QUOTE_NETWORK_SNIFF=false
# Selector与开发环境默认一致Mothership 改版时再更新)
RPA_SELECTOR_PICKUP_SECTION=#react-quoter-landing-plugin >> text=Pick up from,#react-quoter-landing-plugin >> text=Where to pick up,#react-quoter-landing-plugin >> text=从何处取货
RPA_SELECTOR_DELIVERY_SECTION=#react-quoter-landing-plugin >> text=Deliver to,#react-quoter-landing-plugin >> text=Delivery to,#react-quoter-landing-plugin >> text=送货至
RPA_SELECTOR_QUOTE_WIDGET=#react-quoter-landing-plugin
RPA_SELECTOR_PICKUP_STREET=#react-quoter-landing-plugin >> placeholder=Search by address,#react-quoter-landing-plugin >> role=textbox[name="Search by address"],#react-quoter-landing-plugin >> role=textbox[name="Search address"],#react-quoter-landing-plugin >> role=textbox[name="搜索地址"]
RPA_SELECTOR_DELIVERY_STREET=#react-quoter-landing-plugin >> placeholder=Search by address,#react-quoter-landing-plugin >> role=textbox[name="Search by address"],#react-quoter-landing-plugin >> role=textbox[name="Search address"],#react-quoter-landing-plugin >> role=textbox[name="搜索地址"]
RPA_SELECTOR_ADDRESS_SUGGESTION=#react-quoter-landing-plugin >> [role="listbox"] [role="option"],#react-quoter-landing-plugin >> li,.pac-item,[role="option"]
RPA_SELECTOR_CARGO_SECTION=#react-quoter-landing-plugin >> filter:text=Shipment details>>nth=4,#react-quoter-landing-plugin >> filter:text=Freight details>>nth=4,#react-quoter-landing-plugin >> filter:text=货运详情>>nth=4
RPA_SELECTOR_SUBMIT=role=button[name="GET QUOTE"],role=button[name="Get freight quote"],role=button[name="Get a freight quote"],role=button[name="获取货运报价"]
RPA_SELECTOR_TRANSIT=text=business days
# ── 以下由 docker-compose 自动注入勿改install.sh 会校验)────────────────────
# DATABASE_URL=mysql://root:...@mysql:3306/chajia
# REDIS_URL=redis://redis:6379

2
deploy/.gitignore vendored

@ -0,0 +1,2 @@
# 生产部署本地覆盖(勿提交真实 .env
.env

@ -0,0 +1,167 @@
# 外部系统 API 对接说明
> 完整对接指南(含美美与共示例、前端职责、自测方法)见 **[第三方对接指南](./第三方对接指南.md)**。
Base URL`https://<你的域名>`(示例:`https://if.dev.51track.vip`
## 响应格式
```json
{ "code": 0, "message": "ok", "data": { } }
```
`code !== 0` 时为业务错误;询价业务失败时 HTTP 可能仍为 200`data.status === "failed"`。
---
## 方式 A宿主两步接口推荐
**接口路径不变**,鉴权由服务器 `.env` 控制:
| 模式 | `HOST_PUBLIC_API_ENABLED` | 请求头 |
|------|---------------------------|--------|
| 公开(联调/内网) | `true` | 只需 `Content-Type: application/json` |
| **鉴权(对接第三方)** | `false` | 见下方 Token 头 |
| 步骤 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 1 | POST | `/api/host/quote/candidates` | 地址 + 货物 → 联想候选 + `host_session_id` |
| 2 | POST | `/api/host/quote/submit` | 确认候选 → **同步返回最终报价**(服务端内部轮询) |
**公开模式** 无需 `Authorization` / `X-Customer-Id`。只需:
```http
Content-Type: application/json
```
**鉴权模式** 须携带Token 由我方在 `HOST_SERVICE_TOKENS` 中配置后发放):
```http
Authorization: Bearer <Service Token>
X-Customer-Id: <Customer ID>
Content-Type: application/json
```
服务端 `.env` 示例(为美美与共单独发 Token
```env
HOST_PUBLIC_API_ENABLED=false
HOST_SERVICE_TOKENS={"meimei-2026":{"customerId":"CUST_001","permissions":["pricing:markup:write"]}}
CUSTOMER_REGISTRY=CUST_001
```
### 1. 联想地址
**请求体:** 见 [`host-candidates-req.example.json`](./host-candidates-req.example.json)
**成功响应 `data` 字段:**
| 字段 | 说明 |
|------|------|
| `host_session_id` | UUID第二步必传30 分钟有效 |
| `pickup_candidates` | 提货候选列表 |
| `delivery_candidates` | 派送候选列表 |
| `requires_selection` | 是否需用户从多条候选中选择 |
候选对象字段:`option_id`、`display_label`、`formatted_address`、`street`、`city`、`state`、`zip`、`selectable`、`unavailable_reason`
### 2. 确认并取报价
```json
{
"host_session_id": "第一步返回的 UUID",
"pickup_candidate": { "option_id": "...", "display_label": "...", "formatted_address": "...", "street": "...", "city": "...", "state": "CA", "zip": "90001" },
"delivery_candidate": { "option_id": "...", "display_label": "...", "formatted_address": "...", "street": "...", "city": "...", "state": "TX", "zip": "75201" }
}
```
`pickup_candidate` / `delivery_candidate` 须为第一步返回列表中的完整对象(`option_id` 须匹配)。
**成功响应 `data`** 与 `GET /api/quotes/{id}` 相同,主要字段:
| 字段 | 说明 |
|------|------|
| `status` | `done` 成功;`failed` 失败 |
| `quotes[]` | 多档报价 |
| `quotes[].final_total` | **对客户展示价**USD含加价 |
| `quotes[].carrier` | 承运商 |
| `quotes[].transit_days` | 时效 |
| `error_message` | `status=failed` 时的原因 |
**注意:**
- 第二步 HTTP 连接可能保持 **10420 秒**Nginx 须设 `proxy_read_timeout 420s;`
- 宿主**无需**再调 `GET /api/quotes/{id}` 轮询
### 货物字段(第一步 `cargo`
| 字段 | 类型 | 说明 |
|------|------|------|
| `weight.value` | number | 单托重量 |
| `weight.unit` | `lb` \| `kg` | 重量单位 |
| `dimensions.length/width/height` | number | 单托尺寸 |
| `dimensions.unit` | `in` \| `cm` | 尺寸单位 |
| `pallet_count` | number | 托盘数 125 |
| `cargo_type` | string | 见下方枚举 |
**货物类型:** `general_freight` · `machinery` · `furniture` · `electronics` · `building_materials` · `auto_parts` · `food_nonperishable` · `apparel` · `other`
### 方式 A 错误码
| code | HTTP | 说明 |
|------|------|------|
| `VALIDATION_FAILED` | 400 | 参数无效;若 message 为「请求体格式无效」多为 JSON 格式错误 |
| `UNAUTHORIZED` | 401 | 鉴权模式下缺少或无效的 `Authorization` |
| `SESSION_EXPIRED` | 404 | `host_session_id` 过期或无效 |
| `ADDRESS_SESSION_MISMATCH` | 400 | 候选不在本次联想结果中 |
| `ADDRESS_NOT_SELECTABLE` | 400 | 选了不可用的候选 |
| `QUOTE_TIMEOUT` | 504 | 服务端轮询超时 |
| `RATE_LIMITED` | 429 | 限流 |
---
## 方式 BToken 三步接口(旧流程,需客户端轮询)
**前置条件:** 在请求头携带 Token与方式 A 鉴权模式相同)。
```http
Authorization: Bearer <Service Token>
X-Customer-Id: <Customer ID>
Content-Type: application/json
```
Token 在 `.env``HOST_SERVICE_TOKENS` 中配置。
| 步骤 | 方法 | 路径 |
|------|------|------|
| 1 | POST | `/api/addresses/mothership-candidates` |
| 2 | POST | `/api/quotes` |
| 3 | GET | `/api/quotes/{quote_id}`(每 2 秒轮询,最长约 7 分钟) |
第二步 body 须含 `customer_id`、`request_id`UUID v4、经 MotherShip 确认的 `pickup_address` / `delivery_address` 及货物字段。详情见 [第三方对接指南 §6](./第三方对接指南.md#6-方式btoken-三步接口)。
---
## 快速自测(方式 A
**Windows PowerShell推荐用 JSON 文件,避免引号转义问题):**
```powershell
curl.exe -s -X POST "https://if.dev.51track.vip/api/host/quote/candidates" `
-H "Content-Type: application/json" `
--data-binary "@deploy/host-candidates-req.example.json"
```
返回 `"code":0` 且含 `host_session_id` 即成功。
**Linux / macOS**
```bash
curl -s -X POST "https://if.dev.51track.vip/api/host/quote/candidates" \
-H "Content-Type: application/json" \
--data-binary "@deploy/host-candidates-req.example.json"
```
---
*文档版本2026-06-30*

@ -0,0 +1,175 @@
# 查价中台 — 服务器部署指南
将本项目**整个目录**上传到 Linux 服务器后,按下列步骤操作。
## 一、服务器要求
| 项 | 建议 |
|----|------|
| 系统 | Ubuntu 22.04 / Debian 12 |
| CPU | 2 核+ |
| 内存 | **4GB+**RPA 浏览器占内存) |
| 磁盘 | 20GB+ |
| 软件 | Docker 24+、Docker Compose V2 |
| 网络 | 可访问 `www.mothership.com` |
```bash
# 安装 Docker如未安装
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
# 重新登录后生效
```
## 二、上传与配置(人工填写)
```bash
cd /opt/chajia # 你的项目目录
# 1. 复制环境变量模板
cp deploy/.env.production.example .env
# 2. 编辑 .env — 必须修改以下项(搜索 CHANGE_ME
nano .env
```
### 必须人工填写的项
| 变量 | 说明 |
|------|------|
| `MYSQL_ROOT_PASSWORD` | 数据库强密码 |
| `JWT_SECRET` | 随机密钥,例:`openssl rand -base64 32` |
| `PUBLIC_DOMAIN` | 对外域名,如 `quote.yourcompany.com` |
| `HOST_SERVICE_TOKENS` | 给外部系统的 API TokenJSON 单行) |
| `CUSTOMER_REGISTRY` | 客户 ID与 token 中 `customerId` 一致 |
| `MOTHERSHIP_EMAIL` / `MOTHERSHIP_PASSWORD` | 仅当 Mothership 要求登录时填写 |
### 可选修改
| 变量 | 说明 |
|------|------|
| `APP_PORT` | 默认 3000仅本机监听 |
| `RPA_SELECTOR_*` | Mothership 页面改版时需更新 |
## 三、一键部署
```bash
chmod +x deploy/install.sh
bash deploy/install.sh
```
脚本将自动:构建镜像 → 启动 MySQL/Redis/Web/Worker/Scheduler → 数据库迁移 → 种子数据。
### 配置 Nginx 反代(对外提供 HTTPS API
**仅 HTTP内网或临时测试**
```bash
# 先改 deploy/nginx/chajia.http.conf 里的 server_name或设置 .env 的 PUBLIC_DOMAIN
bash deploy/install.sh --nginx-http
```
**HTTPS生产推荐**
```bash
# 1. 安装 nginx、certbot
sudo apt install -y nginx certbot python3-certbot-nginx
# 2. 先 HTTP 部署拿到证书
bash deploy/install.sh --nginx-http
sudo certbot certonly --nginx -d quote.yourcompany.com
# 3. 切换 HTTPS 配置install 会按 PUBLIC_DOMAIN 替换域名)
bash deploy/install.sh --nginx-ssl
```
或手动复制并修改:
- `deploy/nginx/chajia.http.conf``/etc/nginx/sites-available/chajia`
- `deploy/nginx/chajia.ssl.conf` → 有证书后使用
## 四、部署后检查清单(人工)
- [ ] `docker compose -f deploy/docker-compose.yml ps` 五个服务均为 `Up`
- [ ] `curl -I http://127.0.0.1:3000` 返回 200/307
- [ ] 浏览器打开 `https://你的域名/admin/alerts` 能进管理端
- [ ] **修改默认管理员密码**seed 默认:`admin_demo` / `Demo@123`
- [ ] 用外部 Token 调 `POST /api/addresses/mothership-candidates` 返回 `code: 0`
- [ ] 完整询价:`POST /api/quotes` → 轮询 `GET /api/quotes/{id}` 得到 4 档报价
- [ ] 防火墙:只开放 **80/443****不要**暴露 3306/6379/3000
## 五、给外部系统的调用说明
**Base URL** `https://你的域名`
**请求头:**
```http
Authorization: Bearer <HOST_SERVICE_TOKENS token>
X-Customer-Id: CUST_001
Content-Type: application/json
```
**流程:**
1. `POST /api/addresses/mothership-candidates` — 地址联想
2. `POST /api/quotes` — 提交询价
3. `GET /api/quotes/{quote_id}` — 每 2 秒轮询,最长约 30 秒
详细字段见 `docs/查价系统-PRD.md` 第 4、5 章,或 [`deploy/API.md`](./API.md)。
**第三方完整对接文档:** [`deploy/第三方对接指南.md`](./第三方对接指南.md)(含自测脚本 `scripts/test-host-api.ps1` / `.sh`)。
## 六、常用运维命令
```bash
# 查看日志
docker compose -f deploy/docker-compose.yml logs -f next-app
docker compose -f deploy/docker-compose.yml logs -f rpa-worker
# 重启
docker compose -f deploy/docker-compose.yml restart
# 停止
docker compose -f deploy/docker-compose.yml down
# 更新代码后重新部署
git pull
bash deploy/install.sh
```
## 七、目录说明
```
deploy/
├── .env.production.example # 生产环境变量模板 → 复制为项目根 .env
├── docker-compose.yml # 生产 ComposeMySQL/Redis 不对外暴露)
├── install.sh # 一键部署脚本
├── nginx/
│ ├── chajia.http.conf # HTTP 反代
│ └── chajia.ssl.conf # HTTPS 反代
└── README.md # 本文件
```
## 八、故障排查
| 现象 | 处理 |
|------|------|
| 询价一直 `processing` | 查 `rpa-worker` 日志;确认 `RPA_MOCK_MODE=false` 且 Worker 在运行 |
| 地址联想 503 | Worker 未就绪或 Mothership 不可达 |
| RPA 登录失败 | 填写 `MOTHERSHIP_EMAIL/PASSWORD`;或检查 `.rpa` 卷内 storageState |
| 迁移失败 | `docker compose -f deploy/docker-compose.yml run --rm rpa-worker npx prisma migrate deploy` |
## 九、安全提醒
- 勿将 `.env` 提交到 Git
- 每个外部系统使用独立 Service Token
- 生产环境 `RPA_MOCK_MODE` 必须为 `false`
- 定期备份 `mysql_data` 卷:`docker volume inspect chajia-prod_mysql_data`
## 十、使用宝塔 / 已有 MySQL不用 Docker 内置库)
若数据库已在宝塔创建(库名 `chajia`,字符集 `utf8mb4`
1. 上传并导入 [`deploy/database/chajia.sql`](../database/chajia.sql)(或 `chajia.sql.gz`
2. 详见 [`deploy/database/README.md`](../database/README.md)
3. 在 `.env` 设置 `DATABASE_URL` 指向宝塔 MySQL并调整 compose **去掉** `mysql` 服务或勿启动它

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save