1199 lines
31 KiB
TypeScript
1199 lines
31 KiB
TypeScript
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;
|
|
roleCodes?: string[];
|
|
roles?: UserRole[];
|
|
}
|
|
|
|
export interface UserRole {
|
|
roleId?: number;
|
|
roleCode?: string;
|
|
roleName?: string;
|
|
}
|
|
|
|
export interface PlatformConfig {
|
|
projectName?: string;
|
|
logoUrl?: string;
|
|
iconUrl?: string;
|
|
loginBgUrl?: string;
|
|
icpInfo?: string;
|
|
copyrightInfo?: string;
|
|
systemDescription?: string;
|
|
}
|
|
|
|
export interface WecomExchangePayload {
|
|
ticket: string;
|
|
}
|
|
|
|
export interface WecomJsSdkConfig {
|
|
corpId: string;
|
|
timestamp: number;
|
|
nonceStr: string;
|
|
signature: string;
|
|
}
|
|
|
|
export interface DashboardStat {
|
|
name: string;
|
|
value?: number;
|
|
metricKey: string;
|
|
}
|
|
|
|
export interface DashboardTodo {
|
|
id: string;
|
|
title?: string;
|
|
bizType?: string;
|
|
bizId?: string;
|
|
priority?: string;
|
|
status?: string;
|
|
dueDate?: string;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
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;
|
|
photoUrls?: string[];
|
|
bizType?: "sales" | "channel" | "opportunity";
|
|
bizId?: number;
|
|
bizName?: string;
|
|
userName?: string;
|
|
deptName?: string;
|
|
}
|
|
|
|
export interface WorkDailyReport {
|
|
id: number;
|
|
date?: string;
|
|
submitTime?: string;
|
|
workContent?: string;
|
|
lineItems?: WorkReportLineItem[];
|
|
tomorrowPlan?: string;
|
|
planItems?: WorkTomorrowPlanItem[];
|
|
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;
|
|
photoUrls?: string[];
|
|
}
|
|
|
|
export interface WorkCheckInExportRow {
|
|
checkinDate?: string;
|
|
checkinTime?: string;
|
|
userName?: string;
|
|
deptName?: string;
|
|
bizType?: "sales" | "channel" | "opportunity" | string;
|
|
bizName?: string;
|
|
locationText?: string;
|
|
longitude?: number;
|
|
latitude?: number;
|
|
photoUrls?: string[];
|
|
remark?: string;
|
|
status?: string;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
export interface WorkDailyReportExportRow {
|
|
reportDate?: string;
|
|
submitTime?: string;
|
|
userName?: string;
|
|
deptName?: string;
|
|
sourceType?: string;
|
|
status?: string;
|
|
workContent?: string;
|
|
tomorrowPlan?: string;
|
|
score?: number;
|
|
comment?: string;
|
|
reviewerName?: string;
|
|
reviewedAt?: string;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
lineItems?: WorkReportLineItem[];
|
|
planItems?: WorkTomorrowPlanItem[];
|
|
}
|
|
|
|
export interface WorkOverview {
|
|
todayCheckIn?: WorkCheckIn;
|
|
todayReport?: WorkDailyReport;
|
|
suggestedWorkContent?: string;
|
|
history?: WorkHistoryItem[];
|
|
}
|
|
|
|
export interface WorkHistoryPage {
|
|
items?: WorkHistoryItem[];
|
|
hasMore?: boolean;
|
|
page?: number;
|
|
size?: number;
|
|
}
|
|
|
|
export interface WorkExportQuery {
|
|
startDate?: string;
|
|
endDate?: string;
|
|
keyword?: string;
|
|
deptName?: string;
|
|
bizType?: string;
|
|
status?: string;
|
|
}
|
|
|
|
export interface CreateWorkCheckInPayload {
|
|
locationText: string;
|
|
remark?: string;
|
|
longitude?: number;
|
|
latitude?: number;
|
|
photoUrls?: string[];
|
|
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;
|
|
}
|
|
|
|
export interface CreateWorkDailyReportPayload {
|
|
workContent: string;
|
|
lineItems: WorkReportLineItem[];
|
|
planItems: WorkTomorrowPlanItem[];
|
|
tomorrowPlan: string;
|
|
sourceType?: string;
|
|
}
|
|
|
|
export interface OpportunityFollowUp {
|
|
id: number;
|
|
opportunityId?: number;
|
|
date?: string;
|
|
type?: string;
|
|
content?: string;
|
|
latestProgress?: string;
|
|
communicationTime?: string;
|
|
communicationContent?: string;
|
|
nextAction?: string;
|
|
user?: string;
|
|
}
|
|
|
|
export interface OpportunityItem {
|
|
id: number;
|
|
ownerUserId?: number;
|
|
code?: string;
|
|
name?: string;
|
|
client?: string;
|
|
owner?: string;
|
|
updatedAt?: string;
|
|
projectLocation?: string;
|
|
operatorCode?: string;
|
|
operatorName?: string;
|
|
amount?: number;
|
|
date?: string;
|
|
confidence?: string;
|
|
stageCode?: string;
|
|
stage?: string;
|
|
type?: string;
|
|
archived?: boolean;
|
|
pushedToOms?: boolean;
|
|
product?: string;
|
|
source?: string;
|
|
salesExpansionId?: number;
|
|
salesExpansionName?: string;
|
|
channelExpansionId?: number;
|
|
channelExpansionName?: string;
|
|
preSalesId?: number;
|
|
preSalesName?: string;
|
|
competitorName?: string;
|
|
latestProgress?: string;
|
|
nextPlan?: string;
|
|
notes?: string;
|
|
followUps?: OpportunityFollowUp[];
|
|
}
|
|
|
|
export interface OpportunityOverview {
|
|
items?: OpportunityItem[];
|
|
}
|
|
|
|
export interface OpportunityDictOption {
|
|
label?: string;
|
|
value?: string;
|
|
}
|
|
|
|
export interface OpportunityMeta {
|
|
stageOptions?: OpportunityDictOption[];
|
|
operatorOptions?: OpportunityDictOption[];
|
|
projectLocationOptions?: OpportunityDictOption[];
|
|
opportunityTypeOptions?: OpportunityDictOption[];
|
|
confidenceOptions?: OpportunityDictOption[];
|
|
}
|
|
|
|
export interface OmsPreSalesOption {
|
|
userId: number;
|
|
loginName?: string;
|
|
userName?: string;
|
|
}
|
|
|
|
export interface CreateOpportunityPayload {
|
|
opportunityName: string;
|
|
customerName: string;
|
|
projectLocation?: string;
|
|
operatorName?: string;
|
|
amount: number;
|
|
expectedCloseDate: string;
|
|
confidencePct: string;
|
|
stage?: string;
|
|
opportunityType?: string;
|
|
productType?: string;
|
|
source?: string;
|
|
salesExpansionId?: number;
|
|
channelExpansionId?: number;
|
|
competitorName?: string;
|
|
pushedToOms?: boolean;
|
|
description?: string;
|
|
}
|
|
|
|
export interface PushOpportunityToOmsPayload {
|
|
preSalesId?: number;
|
|
preSalesName?: 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;
|
|
visitStartTime?: string;
|
|
evaluationContent?: string;
|
|
nextPlan?: string;
|
|
}
|
|
|
|
export interface SalesExpansionItem {
|
|
id: number;
|
|
ownerUserId?: number;
|
|
owner?: string;
|
|
type: "sales";
|
|
employeeNo?: string;
|
|
name?: string;
|
|
officeCode?: string;
|
|
officeName?: string;
|
|
phone?: string;
|
|
email?: string;
|
|
targetDept?: string;
|
|
dept?: string;
|
|
industryCode?: string;
|
|
industry?: string;
|
|
title?: string;
|
|
intentLevel?: string;
|
|
intent?: string;
|
|
stageCode?: string;
|
|
stage?: string;
|
|
hasExp?: boolean;
|
|
inProgress?: boolean;
|
|
active?: boolean;
|
|
employmentStatus?: string;
|
|
expectedJoinDate?: string;
|
|
updatedAt?: string;
|
|
notes?: string;
|
|
relatedProjects?: RelatedProjectSummary[];
|
|
followUps?: ExpansionFollowUp[];
|
|
}
|
|
|
|
export interface RelatedProjectSummary {
|
|
opportunityId: number;
|
|
opportunityCode?: string;
|
|
opportunityName?: string;
|
|
amount?: number;
|
|
}
|
|
|
|
export interface ChannelExpansionItem {
|
|
id: number;
|
|
ownerUserId?: number;
|
|
owner?: string;
|
|
type: "channel";
|
|
channelCode?: string;
|
|
name?: string;
|
|
provinceCode?: string;
|
|
province?: string;
|
|
cityCode?: string;
|
|
city?: string;
|
|
officeAddress?: string;
|
|
channelIndustryCode?: string;
|
|
channelIndustry?: string;
|
|
certificationLevel?: string;
|
|
annualRevenue?: string;
|
|
revenue?: string;
|
|
size?: number;
|
|
primaryContactName?: string;
|
|
primaryContactTitle?: string;
|
|
primaryContactMobile?: string;
|
|
establishedDate?: string;
|
|
intentLevel?: string;
|
|
intent?: string;
|
|
hasDesktopExp?: boolean;
|
|
channelAttributeCode?: string;
|
|
channelAttribute?: string;
|
|
internalAttributeCode?: string;
|
|
internalAttribute?: string;
|
|
stageCode?: string;
|
|
stage?: string;
|
|
landed?: boolean;
|
|
expectedSignDate?: string;
|
|
updatedAt?: string;
|
|
notes?: string;
|
|
contacts?: ChannelExpansionContact[];
|
|
relatedProjects?: ChannelRelatedProjectSummary[];
|
|
followUps?: ExpansionFollowUp[];
|
|
}
|
|
|
|
export interface ChannelExpansionContact {
|
|
id?: number;
|
|
name?: string;
|
|
mobile?: string;
|
|
title?: string;
|
|
}
|
|
|
|
export interface ChannelRelatedProjectSummary {
|
|
opportunityId: number;
|
|
opportunityCode?: string;
|
|
opportunityName?: string;
|
|
amount?: number;
|
|
}
|
|
|
|
export interface ExpansionOverview {
|
|
salesItems?: SalesExpansionItem[];
|
|
channelItems?: ChannelExpansionItem[];
|
|
}
|
|
|
|
export interface ExpansionDictOption {
|
|
label?: string;
|
|
value?: string;
|
|
}
|
|
|
|
export interface ExpansionMeta {
|
|
officeOptions?: ExpansionDictOption[];
|
|
industryOptions?: ExpansionDictOption[];
|
|
provinceOptions?: ExpansionDictOption[];
|
|
certificationLevelOptions?: ExpansionDictOption[];
|
|
channelAttributeOptions?: ExpansionDictOption[];
|
|
internalAttributeOptions?: ExpansionDictOption[];
|
|
nextChannelCode?: string;
|
|
}
|
|
|
|
export interface ExpansionDuplicateCheck {
|
|
duplicated?: boolean;
|
|
message?: string;
|
|
}
|
|
|
|
export interface CreateSalesExpansionPayload {
|
|
employeeNo: string;
|
|
candidateName: string;
|
|
officeName?: string;
|
|
mobile?: string;
|
|
email?: string;
|
|
targetDept?: string;
|
|
industry?: string;
|
|
title?: string;
|
|
intentLevel?: string;
|
|
stage?: string;
|
|
hasDesktopExp?: boolean;
|
|
inProgress?: boolean;
|
|
employmentStatus?: string;
|
|
expectedJoinDate?: string;
|
|
remark?: string;
|
|
}
|
|
|
|
export interface CreateChannelExpansionPayload {
|
|
channelCode?: string;
|
|
officeAddress?: string;
|
|
channelIndustry?: string[];
|
|
channelName: string;
|
|
province?: string;
|
|
city?: string;
|
|
certificationLevel?: string;
|
|
annualRevenue?: number;
|
|
staffSize?: number;
|
|
contactEstablishedDate?: string;
|
|
intentLevel?: string;
|
|
hasDesktopExp?: boolean;
|
|
channelAttribute?: string[];
|
|
channelAttributeCustom?: string;
|
|
internalAttribute?: string[];
|
|
stage?: string;
|
|
remark?: string;
|
|
contacts?: ChannelExpansionContact[];
|
|
}
|
|
|
|
export interface UpdateSalesExpansionPayload extends CreateSalesExpansionPayload {}
|
|
|
|
export interface UpdateChannelExpansionPayload extends CreateChannelExpansionPayload {}
|
|
|
|
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,
|
|
city: payload.city,
|
|
certificationLevel: payload.certificationLevel,
|
|
channelIndustry: encodeExpansionMultiValue(payload.channelIndustry),
|
|
channelAttribute: encodeExpansionMultiValue(payload.channelAttribute, channelAttributeCustom),
|
|
internalAttribute: encodeExpansionMultiValue(payload.internalAttribute),
|
|
};
|
|
}
|
|
|
|
export interface CreateExpansionFollowUpPayload {
|
|
followUpType: string;
|
|
content: string;
|
|
nextAction?: string;
|
|
followUpTime: string;
|
|
visitStartTime?: string;
|
|
evaluationContent?: string;
|
|
nextPlan?: string;
|
|
}
|
|
|
|
interface ApiEnvelope<T> {
|
|
code: string | number;
|
|
msg: string;
|
|
data: T;
|
|
}
|
|
|
|
interface ApiErrorBody {
|
|
code?: string | number;
|
|
msg?: string;
|
|
message?: string;
|
|
}
|
|
|
|
const LOGIN_PATH = "/login";
|
|
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 isSuccessCode(code: unknown) {
|
|
return code === "0" || code === "200" || code === 0 || code === 200;
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
function tryParseApiBody<T>(rawText: string, contentType?: string | null): (ApiEnvelope<T> & ApiErrorBody) | null {
|
|
const normalizedText = rawText.trim();
|
|
const normalizedContentType = (contentType || "").toLowerCase();
|
|
const looksLikeJson = normalizedContentType.includes("application/json")
|
|
|| normalizedContentType.includes("+json")
|
|
|| normalizedText.startsWith("{")
|
|
|| normalizedText.startsWith("[");
|
|
|
|
if (!normalizedText || !looksLikeJson) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(normalizedText) as ApiEnvelope<T> & ApiErrorBody;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractHtmlErrorMessage(rawText: string) {
|
|
const titleMatch = rawText.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
if (titleMatch?.[1]?.trim()) {
|
|
return titleMatch[1].trim();
|
|
}
|
|
|
|
const headingMatch = rawText.match(/<h[1-6][^>]*>([^<]+)<\/h[1-6]>/i);
|
|
if (headingMatch?.[1]?.trim()) {
|
|
return headingMatch[1].trim();
|
|
}
|
|
|
|
const textOnly = rawText
|
|
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
|
|
return textOnly || null;
|
|
}
|
|
|
|
function buildApiErrorMessage(rawText: string, status: number, body?: ApiErrorBody | null) {
|
|
const directMessage = body?.msg?.trim() || body?.message?.trim();
|
|
if (directMessage) {
|
|
return directMessage;
|
|
}
|
|
|
|
const normalizedText = rawText.trim();
|
|
if (!normalizedText) {
|
|
return `请求失败(${status})`;
|
|
}
|
|
|
|
if (normalizedText.startsWith("<")) {
|
|
return extractHtmlErrorMessage(normalizedText) || `请求失败(${status})`;
|
|
}
|
|
|
|
return normalizedText.length > 300 ? normalizedText.slice(0, 300) : normalizedText;
|
|
}
|
|
|
|
async function request<T>(input: string, init?: RequestInit, withAuth = false): Promise<T> {
|
|
const headers = new Headers(init?.headers);
|
|
if (!headers.has("Content-Type") && init?.body && !(init.body instanceof FormData)) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
|
|
if (withAuth) {
|
|
applyAuthHeaders(headers);
|
|
}
|
|
|
|
const response = await fetch(input, {
|
|
...init,
|
|
headers,
|
|
});
|
|
|
|
if (response.status === 401 || response.status === 403) {
|
|
handleUnauthorizedResponse();
|
|
throw new Error("登录已失效,请重新登录");
|
|
}
|
|
|
|
const rawText = await response.text();
|
|
const body = tryParseApiBody<T>(rawText, response.headers.get("content-type"));
|
|
|
|
if (!response.ok) {
|
|
throw new Error(buildApiErrorMessage(rawText, response.status, body));
|
|
}
|
|
|
|
if (!body) {
|
|
throw new Error(rawText.trim() ? buildApiErrorMessage(rawText, response.status, null) : "接口返回为空");
|
|
}
|
|
|
|
if (!isSuccessCode(body.code)) {
|
|
throw new Error(buildApiErrorMessage(rawText, response.status, body));
|
|
}
|
|
|
|
return body.data;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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");
|
|
clearCachedAuthContext();
|
|
}
|
|
|
|
export function persistLogin(payload: TokenResponse, username: string) {
|
|
clearCachedAuthContext();
|
|
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 function getStoredCurrentUserId() {
|
|
return getStoredUserId();
|
|
}
|
|
|
|
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 exchangeWecomTicket(payload: WecomExchangePayload) {
|
|
return request<TokenResponse>("/api/wecom/sso/exchange", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export async function getWecomJsSdkConfig(url: string) {
|
|
const params = new URLSearchParams({ url });
|
|
return request<WecomJsSdkConfig>(`/api/wecom/sso/js-sdk-config?${params.toString()}`, undefined, true);
|
|
}
|
|
|
|
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() {
|
|
return getCachedAuthedRequest<UserProfile>(
|
|
CURRENT_USER_CACHE_KEY,
|
|
async () => {
|
|
const profile = await request<UserProfile>("/api/sys/api/users/me", undefined, true);
|
|
persistStoredUserProfile(profile);
|
|
return profile;
|
|
},
|
|
);
|
|
}
|
|
|
|
export async function refreshCurrentUser() {
|
|
clearCachedAuthContext();
|
|
const profile = await request<UserProfile>("/api/sys/api/users/me", undefined, true);
|
|
persistStoredUserProfile(profile);
|
|
return profile;
|
|
}
|
|
|
|
export async function getDashboardHome() {
|
|
return request<DashboardHome>("/api/dashboard/home", undefined, true);
|
|
}
|
|
|
|
export async function completeDashboardTodo(todoId: string) {
|
|
return request<void>(`/api/dashboard/todos/${todoId}/complete`, {
|
|
method: "POST",
|
|
}, true);
|
|
}
|
|
|
|
export async function getProfileOverview() {
|
|
return getCachedAuthedRequest<ProfileOverview>(
|
|
PROFILE_OVERVIEW_CACHE_KEY,
|
|
() => request<ProfileOverview>("/api/profile/overview", undefined, true),
|
|
);
|
|
}
|
|
|
|
export async function updateCurrentUserProfile(payload: UpdateCurrentUserProfilePayload) {
|
|
const result = await request<boolean>("/api/sys/api/users/profile", {
|
|
method: "PUT",
|
|
body: JSON.stringify(payload),
|
|
}, true);
|
|
clearCachedAuthContext();
|
|
return result;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
export async function getWorkHistory(type: "checkin" | "report", page = 1, size = 8) {
|
|
const params = new URLSearchParams({
|
|
type,
|
|
page: String(page),
|
|
size: String(size),
|
|
});
|
|
return request<WorkHistoryPage>(`/api/work/history?${params.toString()}`, undefined, true);
|
|
}
|
|
|
|
function buildWorkExportQuery(params?: WorkExportQuery) {
|
|
const searchParams = new URLSearchParams();
|
|
Object.entries(params ?? {}).forEach(([key, value]) => {
|
|
const normalized = value?.trim();
|
|
if (normalized) {
|
|
searchParams.set(key, normalized);
|
|
}
|
|
});
|
|
const query = searchParams.toString();
|
|
return query ? `?${query}` : "";
|
|
}
|
|
|
|
export async function getWorkCheckInExportData(params?: WorkExportQuery) {
|
|
return request<WorkCheckInExportRow[]>(`/api/work/checkins/export-data${buildWorkExportQuery(params)}`, undefined, true);
|
|
}
|
|
|
|
export async function getWorkDailyReportExportData(params?: WorkExportQuery) {
|
|
return request<WorkDailyReportExportRow[]>(`/api/work/daily-reports/export-data${buildWorkExportQuery(params)}`, undefined, true);
|
|
}
|
|
|
|
export async function reverseWorkGeocode(latitude: number, longitude: number) {
|
|
return request<string>(`/api/work/reverse-geocode?lat=${latitude}&lon=${longitude}`, undefined, true);
|
|
}
|
|
|
|
export async function saveWorkCheckIn(payload: CreateWorkCheckInPayload) {
|
|
return request<number>("/api/work/checkins", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
}, true);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
export async function getOpportunityMeta() {
|
|
return request<OpportunityMeta>("/api/opportunities/meta", undefined, true);
|
|
}
|
|
|
|
export async function getOpportunityOmsPreSalesOptions() {
|
|
return request<OmsPreSalesOption[]>("/api/opportunities/oms/pre-sales", undefined, true);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
export async function pushOpportunityToOms(opportunityId: number, payload?: PushOpportunityToOmsPayload) {
|
|
return request<number>(`/api/opportunities/${opportunityId}/push-oms`, {
|
|
method: "POST",
|
|
body: JSON.stringify(payload ?? {}),
|
|
}, true);
|
|
}
|
|
|
|
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 getExpansionCityOptions(provinceName: string) {
|
|
const params = new URLSearchParams({ provinceName });
|
|
return request<ExpansionDictOption[]>(`/api/expansion/areas/cities?${params.toString()}`, undefined, true);
|
|
}
|
|
|
|
export async function checkSalesExpansionDuplicate(employeeNo: string, excludeId?: number) {
|
|
const params = new URLSearchParams({ employeeNo });
|
|
if (excludeId) {
|
|
params.set("excludeId", String(excludeId));
|
|
}
|
|
return request<ExpansionDuplicateCheck>(`/api/expansion/sales/duplicate-check?${params.toString()}`, undefined, true);
|
|
}
|
|
|
|
export async function checkChannelExpansionDuplicate(channelName: string, excludeId?: number) {
|
|
const params = new URLSearchParams({ channelName });
|
|
if (excludeId) {
|
|
params.set("excludeId", String(excludeId));
|
|
}
|
|
return request<ExpansionDuplicateCheck>(`/api/expansion/channel/duplicate-check?${params.toString()}`, 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",
|
|
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
|
|
}, 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",
|
|
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
|
|
}, 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);
|
|
}
|
|
|
|
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.
|
|
}
|
|
});
|
|
}
|
|
|
|
function persistStoredUserProfile(profile: UserProfile) {
|
|
try {
|
|
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
|
} catch {
|
|
// Ignore session storage failures and keep request cache only.
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|