unis_crm/frontend/src/lib/auth.ts

957 lines
24 KiB
TypeScript
Raw Normal View History

2026-03-20 08:39:07 +00:00
export interface CaptchaResponse {
captchaId: string;
imageBase64: string;
}
export interface TenantInfo {
tenantId: number;
tenantCode: string;
tenantName: string;
}
export interface TokenResponse {
accessToken: string;
refreshToken: string;
accessExpiresInMinutes: number;
refreshExpiresInDays: number;
availableTenants?: TenantInfo[];
}
export interface LoginPayload {
username: string;
password: string;
tenantCode?: string;
captchaId?: string;
captchaCode?: string;
}
export interface UserProfile {
userId: number;
tenantId: number;
username: string;
displayName: string;
email?: string;
phone?: string;
status?: number;
isAdmin?: boolean;
isPlatformAdmin?: boolean;
pwdResetRequired?: number;
}
export interface PlatformConfig {
projectName?: string;
logoUrl?: string;
iconUrl?: string;
loginBgUrl?: string;
icpInfo?: string;
copyrightInfo?: string;
systemDescription?: string;
}
export interface DashboardStat {
name: string;
value?: number;
metricKey: string;
}
export interface DashboardTodo {
2026-03-26 09:29:55 +00:00
id: string;
2026-03-20 08:39:07 +00:00
title?: string;
bizType?: string;
2026-03-26 09:29:55 +00:00
bizId?: string;
2026-03-20 08:39:07 +00:00
priority?: string;
status?: string;
dueDate?: string;
createdAt?: string;
2026-03-26 09:29:55 +00:00
updatedAt?: string;
2026-03-20 08:39:07 +00:00
}
export interface DashboardActivity {
id: number;
bizType?: string;
bizId?: number;
actionType?: string;
title?: string;
content?: string;
operatorUserId?: number;
operatorName?: string;
createdAt?: string;
timeText?: string;
}
export interface DashboardHome {
userId?: number;
realName?: string;
jobTitle?: string;
deptName?: string;
onboardingDays?: number;
stats?: DashboardStat[];
todos?: DashboardTodo[];
activities?: DashboardActivity[];
}
export interface ProfileOverview {
userId?: number;
monthlyOpportunityCount?: number;
monthlyExpansionCount?: number;
averageScore?: number;
onboardingDays?: number;
realName?: string;
jobTitle?: string;
deptName?: string;
accountStatus?: string;
}
export interface UpdateCurrentUserProfilePayload {
userId?: number;
username?: string;
displayName?: string;
email?: string;
phone?: string;
pwdResetRequired?: number;
isPlatformAdmin?: boolean;
orgId?: number;
}
export interface UpdateCurrentUserPasswordPayload {
oldPassword: string;
newPassword: string;
}
export interface WorkCheckIn {
id: number;
date?: string;
time?: string;
locationText?: string;
remark?: string;
status?: string;
longitude?: number;
latitude?: number;
2026-03-23 01:03:27 +00:00
photoUrls?: string[];
2026-03-26 09:29:55 +00:00
bizType?: "sales" | "channel" | "opportunity";
bizId?: number;
bizName?: string;
userName?: string;
deptName?: string;
2026-03-20 08:39:07 +00:00
}
export interface WorkDailyReport {
id: number;
date?: string;
submitTime?: string;
workContent?: string;
2026-03-26 09:29:55 +00:00
lineItems?: WorkReportLineItem[];
2026-03-20 08:39:07 +00:00
tomorrowPlan?: string;
2026-03-26 09:29:55 +00:00
planItems?: WorkTomorrowPlanItem[];
2026-03-20 08:39:07 +00:00
sourceType?: string;
status?: string;
score?: number;
comment?: string;
}
export interface WorkHistoryItem {
id: number;
type?: string;
date?: string;
time?: string;
content?: string;
status?: string;
score?: number;
comment?: string;
2026-03-23 01:03:27 +00:00
photoUrls?: string[];
2026-03-20 08:39:07 +00:00
}
export interface WorkOverview {
todayCheckIn?: WorkCheckIn;
todayReport?: WorkDailyReport;
suggestedWorkContent?: string;
history?: WorkHistoryItem[];
}
2026-03-26 09:29:55 +00:00
export interface WorkHistoryPage {
items?: WorkHistoryItem[];
hasMore?: boolean;
page?: number;
size?: number;
}
2026-03-20 08:39:07 +00:00
export interface CreateWorkCheckInPayload {
locationText: string;
remark?: string;
longitude?: number;
latitude?: number;
2026-03-23 01:03:27 +00:00
photoUrls?: string[];
2026-03-26 09:29:55 +00:00
bizType?: "sales" | "channel" | "opportunity";
bizId?: number;
bizName?: string;
userName?: string;
deptName?: string;
}
export interface WorkReportLineItem {
workDate: string;
bizType: "sales" | "channel" | "opportunity";
bizId: number;
bizName?: string;
editorText?: string;
content: string;
visitStartTime?: string;
evaluationContent?: string;
nextPlan?: string;
latestProgress?: string;
communicationTime?: string;
communicationContent?: string;
}
export interface WorkTomorrowPlanItem {
content: string;
2026-03-20 08:39:07 +00:00
}
export interface CreateWorkDailyReportPayload {
workContent: string;
2026-03-26 09:29:55 +00:00
lineItems: WorkReportLineItem[];
planItems: WorkTomorrowPlanItem[];
2026-03-20 08:39:07 +00:00
tomorrowPlan: string;
sourceType?: string;
}
export interface OpportunityFollowUp {
id: number;
opportunityId?: number;
date?: string;
type?: string;
content?: string;
2026-03-26 09:29:55 +00:00
latestProgress?: string;
communicationTime?: string;
communicationContent?: string;
nextAction?: string;
2026-03-20 08:39:07 +00:00
user?: string;
}
export interface OpportunityItem {
id: number;
code?: string;
name?: string;
client?: string;
owner?: string;
2026-03-26 09:29:55 +00:00
projectLocation?: string;
operatorCode?: string;
operatorName?: string;
2026-03-20 08:39:07 +00:00
amount?: number;
date?: string;
confidence?: number;
2026-03-26 09:29:55 +00:00
stageCode?: string;
2026-03-20 08:39:07 +00:00
stage?: string;
type?: string;
2026-03-27 09:05:41 +00:00
archived?: boolean;
2026-03-20 08:39:07 +00:00
pushedToOms?: boolean;
product?: string;
source?: string;
2026-03-26 09:29:55 +00:00
salesExpansionId?: number;
salesExpansionName?: string;
channelExpansionId?: number;
channelExpansionName?: string;
competitorName?: string;
latestProgress?: string;
nextPlan?: string;
2026-03-20 08:39:07 +00:00
notes?: string;
followUps?: OpportunityFollowUp[];
}
export interface OpportunityOverview {
items?: OpportunityItem[];
}
2026-03-26 09:29:55 +00:00
export interface OpportunityDictOption {
label?: string;
value?: string;
}
export interface OpportunityMeta {
stageOptions?: OpportunityDictOption[];
operatorOptions?: OpportunityDictOption[];
}
2026-03-20 08:39:07 +00:00
export interface CreateOpportunityPayload {
opportunityName: string;
customerName: string;
2026-03-26 09:29:55 +00:00
projectLocation?: string;
operatorName?: string;
2026-03-20 08:39:07 +00:00
amount: number;
expectedCloseDate: string;
confidencePct: number;
stage?: string;
opportunityType?: string;
productType?: string;
source?: string;
2026-03-26 09:29:55 +00:00
salesExpansionId?: number;
channelExpansionId?: number;
competitorName?: string;
2026-03-20 08:39:07 +00:00
pushedToOms?: boolean;
description?: string;
}
export interface CreateOpportunityFollowUpPayload {
followUpType: string;
content: string;
nextAction?: string;
followUpTime: string;
}
export interface ExpansionFollowUp {
id: number;
bizId?: number;
bizType?: string;
date?: string;
type?: string;
content?: string;
user?: string;
2026-03-26 09:29:55 +00:00
visitStartTime?: string;
evaluationContent?: string;
nextPlan?: string;
2026-03-20 08:39:07 +00:00
}
export interface SalesExpansionItem {
id: number;
type: "sales";
2026-03-26 09:29:55 +00:00
employeeNo?: string;
2026-03-20 08:39:07 +00:00
name?: string;
2026-03-26 09:29:55 +00:00
officeCode?: string;
officeName?: string;
2026-03-20 08:39:07 +00:00
phone?: string;
email?: string;
2026-03-26 09:29:55 +00:00
targetDept?: string;
2026-03-20 08:39:07 +00:00
dept?: string;
2026-03-26 09:29:55 +00:00
industryCode?: string;
2026-03-20 08:39:07 +00:00
industry?: string;
title?: string;
intentLevel?: string;
intent?: string;
stageCode?: string;
stage?: string;
hasExp?: boolean;
inProgress?: boolean;
active?: boolean;
employmentStatus?: string;
expectedJoinDate?: string;
notes?: string;
2026-03-26 09:29:55 +00:00
relatedProjects?: RelatedProjectSummary[];
2026-03-20 08:39:07 +00:00
followUps?: ExpansionFollowUp[];
}
2026-03-26 09:29:55 +00:00
export interface RelatedProjectSummary {
opportunityId: number;
opportunityCode?: string;
opportunityName?: string;
amount?: number;
}
2026-03-20 08:39:07 +00:00
export interface ChannelExpansionItem {
id: number;
type: "channel";
2026-03-26 09:29:55 +00:00
channelCode?: string;
2026-03-20 08:39:07 +00:00
name?: string;
province?: string;
2026-03-26 09:29:55 +00:00
officeAddress?: string;
channelIndustry?: string;
2026-03-20 08:39:07 +00:00
annualRevenue?: string;
revenue?: string;
size?: number;
2026-03-26 09:29:55 +00:00
primaryContactName?: string;
primaryContactTitle?: string;
primaryContactMobile?: string;
establishedDate?: string;
intentLevel?: string;
intent?: string;
hasDesktopExp?: boolean;
2026-03-27 09:05:41 +00:00
channelAttributeCode?: string;
2026-03-26 09:29:55 +00:00
channelAttribute?: string;
2026-03-27 09:05:41 +00:00
internalAttributeCode?: string;
2026-03-26 09:29:55 +00:00
internalAttribute?: string;
2026-03-20 08:39:07 +00:00
stageCode?: string;
stage?: string;
landed?: boolean;
expectedSignDate?: string;
notes?: string;
2026-03-26 09:29:55 +00:00
contacts?: ChannelExpansionContact[];
relatedProjects?: ChannelRelatedProjectSummary[];
2026-03-20 08:39:07 +00:00
followUps?: ExpansionFollowUp[];
}
2026-03-26 09:29:55 +00:00
export interface ChannelExpansionContact {
id?: number;
name?: string;
mobile?: string;
title?: string;
}
export interface ChannelRelatedProjectSummary {
opportunityId: number;
opportunityCode?: string;
opportunityName?: string;
amount?: number;
}
2026-03-20 08:39:07 +00:00
export interface ExpansionOverview {
salesItems?: SalesExpansionItem[];
channelItems?: ChannelExpansionItem[];
}
2026-03-26 09:29:55 +00:00
export interface ExpansionDictOption {
label?: string;
value?: string;
2026-03-20 08:39:07 +00:00
}
export interface ExpansionMeta {
2026-03-26 09:29:55 +00:00
officeOptions?: ExpansionDictOption[];
industryOptions?: ExpansionDictOption[];
channelAttributeOptions?: ExpansionDictOption[];
internalAttributeOptions?: ExpansionDictOption[];
nextChannelCode?: string;
2026-03-20 08:39:07 +00:00
}
export interface CreateSalesExpansionPayload {
2026-03-26 09:29:55 +00:00
employeeNo: string;
2026-03-20 08:39:07 +00:00
candidateName: string;
2026-03-26 09:29:55 +00:00
officeName?: string;
2026-03-20 08:39:07 +00:00
mobile?: string;
email?: string;
2026-03-26 09:29:55 +00:00
targetDept?: string;
2026-03-20 08:39:07 +00:00
industry?: string;
title?: string;
intentLevel?: string;
stage?: string;
hasDesktopExp?: boolean;
inProgress?: boolean;
employmentStatus?: string;
expectedJoinDate?: string;
remark?: string;
}
export interface CreateChannelExpansionPayload {
2026-03-26 09:29:55 +00:00
channelCode?: string;
officeAddress?: string;
channelIndustry?: string;
2026-03-20 08:39:07 +00:00
channelName: string;
province?: string;
annualRevenue?: number;
staffSize?: number;
2026-03-26 09:29:55 +00:00
contactEstablishedDate?: string;
intentLevel?: string;
hasDesktopExp?: boolean;
2026-03-27 09:05:41 +00:00
channelAttribute?: string[];
channelAttributeCustom?: string;
internalAttribute?: string[];
2026-03-20 08:39:07 +00:00
stage?: string;
remark?: string;
2026-03-26 09:29:55 +00:00
contacts?: ChannelExpansionContact[];
2026-03-20 08:39:07 +00:00
}
export interface UpdateSalesExpansionPayload extends CreateSalesExpansionPayload {}
export interface UpdateChannelExpansionPayload extends CreateChannelExpansionPayload {}
2026-03-27 09:05:41 +00:00
const EXPANSION_MULTI_VALUE_CUSTOM_PREFIX = "__custom__:";
function normalizeExpansionMultiValues(values?: string[]) {
return Array.from(new Set((values ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value))));
}
export function encodeExpansionMultiValue(values?: string[], customText?: string) {
const normalizedValues = normalizeExpansionMultiValues(values);
const normalizedCustomText = customText?.trim();
if (normalizedCustomText) {
normalizedValues.push(`${EXPANSION_MULTI_VALUE_CUSTOM_PREFIX}${encodeURIComponent(normalizedCustomText)}`);
}
return normalizedValues.join(",");
}
export function decodeExpansionMultiValue(rawValue?: string) {
const result = {
values: [] as string[],
customText: "",
};
if (!rawValue?.trim()) {
return result;
}
rawValue
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.forEach((item) => {
if (item.startsWith(EXPANSION_MULTI_VALUE_CUSTOM_PREFIX)) {
result.customText = decodeURIComponent(item.slice(EXPANSION_MULTI_VALUE_CUSTOM_PREFIX.length));
return;
}
result.values.push(item);
});
result.values = Array.from(new Set(result.values));
return result;
}
function serializeChannelExpansionPayload(payload: CreateChannelExpansionPayload) {
const { channelAttributeCustom, ...rest } = payload;
return {
...rest,
channelAttribute: encodeExpansionMultiValue(payload.channelAttribute, channelAttributeCustom),
internalAttribute: encodeExpansionMultiValue(payload.internalAttribute),
};
}
2026-03-20 08:39:07 +00:00
export interface CreateExpansionFollowUpPayload {
followUpType: string;
content: string;
nextAction?: string;
followUpTime: string;
2026-03-26 09:29:55 +00:00
visitStartTime?: string;
evaluationContent?: string;
nextPlan?: string;
2026-03-20 08:39:07 +00:00
}
interface ApiEnvelope<T> {
code: string;
msg: string;
data: T;
}
interface ApiErrorBody {
code?: string;
msg?: string;
message?: string;
}
const LOGIN_PATH = "/login";
2026-03-26 09:29:55 +00:00
const USER_CONTEXT_CACHE_TTL_MS = 2 * 60 * 1000;
const CURRENT_USER_CACHE_KEY = "auth-cache:current-user";
const PROFILE_OVERVIEW_CACHE_KEY = "auth-cache:profile-overview";
const memoryRequestCache = new Map<string, { expiresAt: number; value: unknown }>();
const inFlightRequestCache = new Map<string, Promise<unknown>>();
function applyAuthHeaders(headers: Headers) {
const token = localStorage.getItem("accessToken");
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const userId = getStoredUserId();
if (userId !== undefined) {
headers.set("X-User-Id", String(userId));
}
return headers;
}
function handleUnauthorizedResponse() {
clearAuth();
window.location.href = `${LOGIN_PATH}?timeout=1`;
}
2026-03-20 08:39:07 +00:00
async function request<T>(input: string, init?: RequestInit, withAuth = false): Promise<T> {
const headers = new Headers(init?.headers);
2026-03-23 01:03:27 +00:00
if (!headers.has("Content-Type") && init?.body && !(init.body instanceof FormData)) {
2026-03-20 08:39:07 +00:00
headers.set("Content-Type", "application/json");
}
if (withAuth) {
2026-03-26 09:29:55 +00:00
applyAuthHeaders(headers);
2026-03-20 08:39:07 +00:00
}
const response = await fetch(input, {
...init,
headers,
});
if (response.status === 401 || response.status === 403) {
2026-03-26 09:29:55 +00:00
handleUnauthorizedResponse();
2026-03-20 08:39:07 +00:00
throw new Error("登录已失效,请重新登录");
}
let body: (ApiEnvelope<T> & ApiErrorBody) | null = null;
try {
body = (await response.json()) as ApiEnvelope<T> & ApiErrorBody;
} catch {
if (!response.ok) {
throw new Error(`请求失败(${response.status})`);
}
}
if (!response.ok) {
throw new Error(body?.msg || body?.message || `请求失败(${response.status})`);
}
if (!body) {
throw new Error("接口返回为空");
}
if (body.code !== "0") {
throw new Error(body.msg || "请求失败");
}
return body.data;
}
2026-03-26 09:29:55 +00:00
export async function fetchWithAuth(input: string, init?: RequestInit) {
const response = await fetch(input, {
...init,
headers: applyAuthHeaders(new Headers(init?.headers)),
});
if (response.status === 401 || response.status === 403) {
handleUnauthorizedResponse();
throw new Error("登录已失效,请重新登录");
}
return response;
}
2026-03-20 08:39:07 +00:00
export function isAuthed() {
return Boolean(localStorage.getItem("accessToken"));
}
export function clearAuth() {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
localStorage.removeItem("username");
localStorage.removeItem("availableTenants");
localStorage.removeItem("activeTenantId");
sessionStorage.removeItem("userProfile");
2026-03-26 09:29:55 +00:00
clearCachedAuthContext();
2026-03-20 08:39:07 +00:00
}
export function persistLogin(payload: TokenResponse, username: string) {
2026-03-26 09:29:55 +00:00
clearCachedAuthContext();
2026-03-20 08:39:07 +00:00
localStorage.setItem("accessToken", payload.accessToken);
localStorage.setItem("refreshToken", payload.refreshToken);
localStorage.setItem("username", username);
if (payload.availableTenants) {
localStorage.setItem("availableTenants", JSON.stringify(payload.availableTenants));
try {
const tokenPayload = JSON.parse(atob(payload.accessToken.split(".")[1]));
if (tokenPayload?.tenantId !== undefined) {
localStorage.setItem("activeTenantId", String(tokenPayload.tenantId));
}
} catch {
localStorage.removeItem("activeTenantId");
}
}
}
function getStoredUserId() {
try {
const rawProfile = sessionStorage.getItem("userProfile");
if (rawProfile) {
const profile = JSON.parse(rawProfile) as Partial<UserProfile>;
if (typeof profile.userId === "number" && Number.isFinite(profile.userId)) {
return profile.userId;
}
}
} catch {
// Ignore invalid session cache and fall back to token parsing.
}
try {
const token = localStorage.getItem("accessToken");
if (!token) {
return undefined;
}
const tokenPayload = JSON.parse(atob(token.split(".")[1])) as { userId?: number };
if (typeof tokenPayload.userId === "number" && Number.isFinite(tokenPayload.userId)) {
return tokenPayload.userId;
}
} catch {
return undefined;
}
return undefined;
}
export async function fetchCaptcha() {
return request<CaptchaResponse>("/api/sys/auth/captcha");
}
export async function login(payload: LoginPayload) {
return request<TokenResponse>("/api/sys/auth/login", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function getSystemParamValue(key: string, defaultValue?: string) {
const params = new URLSearchParams({ key });
if (defaultValue !== undefined) {
params.set("defaultValue", defaultValue);
}
return request<string>(`/api/sys/api/params/value?${params.toString()}`);
}
export async function getOpenPlatformConfig() {
return request<PlatformConfig>("/api/sys/api/open/platform/config");
}
export async function getCurrentUser() {
2026-03-26 09:29:55 +00:00
return getCachedAuthedRequest<UserProfile>(
CURRENT_USER_CACHE_KEY,
() => request<UserProfile>("/api/sys/api/users/me", undefined, true),
);
2026-03-20 08:39:07 +00:00
}
export async function getDashboardHome() {
return request<DashboardHome>("/api/dashboard/home", undefined, true);
}
2026-03-26 09:29:55 +00:00
export async function completeDashboardTodo(todoId: string) {
return request<void>(`/api/dashboard/todos/${todoId}/complete`, {
method: "POST",
}, true);
}
2026-03-20 08:39:07 +00:00
export async function getProfileOverview() {
2026-03-26 09:29:55 +00:00
return getCachedAuthedRequest<ProfileOverview>(
PROFILE_OVERVIEW_CACHE_KEY,
() => request<ProfileOverview>("/api/profile/overview", undefined, true),
);
2026-03-20 08:39:07 +00:00
}
export async function updateCurrentUserProfile(payload: UpdateCurrentUserProfilePayload) {
2026-03-26 09:29:55 +00:00
const result = await request<boolean>("/api/sys/api/users/profile", {
2026-03-20 08:39:07 +00:00
method: "PUT",
body: JSON.stringify(payload),
}, true);
2026-03-26 09:29:55 +00:00
clearCachedAuthContext();
return result;
2026-03-20 08:39:07 +00:00
}
export async function updateCurrentUserPassword(payload: UpdateCurrentUserPasswordPayload) {
return request<boolean>("/api/sys/api/users/password", {
method: "PUT",
body: JSON.stringify(payload),
}, true);
}
export async function updateUserProfileById(userId: number, payload: UpdateCurrentUserProfilePayload) {
return request<boolean>(`/api/sys/api/users/${userId}`, {
method: "PUT",
body: JSON.stringify(payload),
}, true);
}
export async function getWorkOverview() {
return request<WorkOverview>("/api/work/overview", undefined, true);
}
2026-03-26 09:29:55 +00:00
export async function getWorkHistory(type: "checkin" | "report", page = 1, size = 8) {
const params = new URLSearchParams({
type,
page: String(page),
size: String(size),
2026-03-23 07:21:09 +00:00
});
2026-03-26 09:29:55 +00:00
return request<WorkHistoryPage>(`/api/work/history?${params.toString()}`, undefined, true);
2026-03-23 07:21:09 +00:00
}
2026-03-26 09:29:55 +00:00
export async function reverseWorkGeocode(latitude: number, longitude: number) {
return request<string>(`/api/work/reverse-geocode?lat=${latitude}&lon=${longitude}`, undefined, true);
2026-03-20 08:39:07 +00:00
}
export async function saveWorkCheckIn(payload: CreateWorkCheckInPayload) {
return request<number>("/api/work/checkins", {
method: "POST",
body: JSON.stringify(payload),
}, true);
}
2026-03-23 01:03:27 +00:00
export async function uploadWorkCheckInPhoto(file: File) {
const formData = new FormData();
formData.append("file", file);
return request<string>("/api/work/checkin-photos", {
method: "POST",
body: formData,
}, true);
}
2026-03-20 08:39:07 +00:00
export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload) {
return request<number>("/api/work/daily-reports", {
method: "POST",
body: JSON.stringify(payload),
}, true);
}
export async function getOpportunityOverview(keyword?: string, stage?: string) {
const params = new URLSearchParams();
if (keyword && keyword.trim()) {
params.set("keyword", keyword.trim());
}
if (stage && stage.trim() && stage !== "全部") {
params.set("stage", stage.trim());
}
const query = params.toString();
return request<OpportunityOverview>(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true);
}
2026-03-26 09:29:55 +00:00
export async function getOpportunityMeta() {
return request<OpportunityMeta>("/api/opportunities/meta", undefined, true);
}
2026-03-20 08:39:07 +00:00
export async function createOpportunity(payload: CreateOpportunityPayload) {
return request<number>("/api/opportunities", {
method: "POST",
body: JSON.stringify(payload),
}, true);
}
export async function updateOpportunity(opportunityId: number, payload: CreateOpportunityPayload) {
return request<number>(`/api/opportunities/${opportunityId}`, {
method: "PUT",
body: JSON.stringify(payload),
}, true);
}
2026-03-26 09:29:55 +00:00
export async function pushOpportunityToOms(opportunityId: number) {
return request<number>(`/api/opportunities/${opportunityId}/push-oms`, {
method: "POST",
}, true);
}
2026-03-20 08:39:07 +00:00
export async function createOpportunityFollowUp(opportunityId: number, payload: CreateOpportunityFollowUpPayload) {
return request<number>(`/api/opportunities/${opportunityId}/followups`, {
method: "POST",
body: JSON.stringify(payload),
}, true);
}
export async function getExpansionOverview(keyword?: string) {
const params = new URLSearchParams();
if (keyword && keyword.trim()) {
params.set("keyword", keyword.trim());
}
const query = params.toString();
return request<ExpansionOverview>(`/api/expansion/overview${query ? `?${query}` : ""}`, undefined, true);
}
export async function getExpansionMeta() {
return request<ExpansionMeta>("/api/expansion/meta", undefined, true);
}
export async function createSalesExpansion(payload: CreateSalesExpansionPayload) {
return request<number>("/api/expansion/sales", {
method: "POST",
body: JSON.stringify(payload),
}, true);
}
export async function createChannelExpansion(payload: CreateChannelExpansionPayload) {
return request<number>("/api/expansion/channel", {
method: "POST",
2026-03-27 09:05:41 +00:00
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
2026-03-20 08:39:07 +00:00
}, true);
}
export async function updateSalesExpansion(id: number, payload: UpdateSalesExpansionPayload) {
return request<void>(`/api/expansion/sales/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
}, true);
}
export async function updateChannelExpansion(id: number, payload: UpdateChannelExpansionPayload) {
return request<void>(`/api/expansion/channel/${id}`, {
method: "PUT",
2026-03-27 09:05:41 +00:00
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
2026-03-20 08:39:07 +00:00
}, true);
}
export async function createExpansionFollowUp(
bizType: "sales" | "channel",
bizId: number,
payload: CreateExpansionFollowUpPayload,
) {
return request<number>(`/api/expansion/${bizType}/${bizId}/followups`, {
method: "POST",
body: JSON.stringify(payload),
}, true);
}
2026-03-26 09:29:55 +00:00
function readCachedValue<T>(cacheKey: string) {
const memoryValue = memoryRequestCache.get(cacheKey);
if (memoryValue && memoryValue.expiresAt > Date.now()) {
return memoryValue.value as T;
}
memoryRequestCache.delete(cacheKey);
try {
const raw = sessionStorage.getItem(cacheKey);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw) as { expiresAt?: number; value?: T };
if (!parsed.expiresAt || parsed.expiresAt <= Date.now()) {
sessionStorage.removeItem(cacheKey);
return null;
}
memoryRequestCache.set(cacheKey, { expiresAt: parsed.expiresAt, value: parsed.value });
return parsed.value ?? null;
} catch {
sessionStorage.removeItem(cacheKey);
return null;
}
}
function writeCachedValue<T>(cacheKey: string, value: T, ttlMs = USER_CONTEXT_CACHE_TTL_MS) {
const payload = {
expiresAt: Date.now() + ttlMs,
value,
};
memoryRequestCache.set(cacheKey, payload);
try {
sessionStorage.setItem(cacheKey, JSON.stringify(payload));
} catch {
// Ignore session cache failures and keep in-memory cache only.
}
}
function clearCachedAuthContext() {
[CURRENT_USER_CACHE_KEY, PROFILE_OVERVIEW_CACHE_KEY].forEach((cacheKey) => {
memoryRequestCache.delete(cacheKey);
inFlightRequestCache.delete(cacheKey);
try {
sessionStorage.removeItem(cacheKey);
} catch {
// Ignore session cache cleanup failures.
}
});
}
async function getCachedAuthedRequest<T>(
cacheKey: string,
fetcher: () => Promise<T>,
ttlMs = USER_CONTEXT_CACHE_TTL_MS,
) {
const cachedValue = readCachedValue<T>(cacheKey);
if (cachedValue) {
return cachedValue;
}
const inFlight = inFlightRequestCache.get(cacheKey);
if (inFlight) {
return inFlight as Promise<T>;
}
const requestPromise = fetcher()
.then((result) => {
writeCachedValue(cacheKey, result, ttlMs);
inFlightRequestCache.delete(cacheKey);
return result;
})
.catch((error) => {
inFlightRequestCache.delete(cacheKey);
throw error;
});
inFlightRequestCache.set(cacheKey, requestPromise);
return requestPromise;
}