587 lines
15 KiB
TypeScript
587 lines
15 KiB
TypeScript
/**
|
|
* API client for the FastAPI backend (Ops-Core).
|
|
* Base URL is read from NEXT_PUBLIC_API_BASE (default http://localhost:8000).
|
|
*/
|
|
|
|
const getBase = (): string => {
|
|
if (typeof window !== "undefined") {
|
|
return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
|
}
|
|
return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
|
};
|
|
|
|
export const apiBase = getBase;
|
|
|
|
// --------------- Types (mirror FastAPI schemas) ---------------
|
|
|
|
export interface CustomerRead {
|
|
id: number;
|
|
name: string;
|
|
contact_info: string | null;
|
|
tags: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface CustomerCreate {
|
|
name: string;
|
|
contact_info?: string | null;
|
|
tags?: string | null;
|
|
}
|
|
|
|
export interface CustomerUpdate {
|
|
name?: string;
|
|
contact_info?: string | null;
|
|
tags?: string | null;
|
|
}
|
|
|
|
export interface RequirementAnalyzeRequest {
|
|
customer_id: number;
|
|
raw_text: string;
|
|
}
|
|
|
|
export interface RequirementAnalyzeResponse {
|
|
project_id: number;
|
|
ai_solution_md: string;
|
|
ai_solution_json: Record<string, unknown>;
|
|
}
|
|
|
|
export interface QuoteGenerateResponse {
|
|
quote_id: number;
|
|
project_id: number;
|
|
total_amount: number;
|
|
excel_path: string;
|
|
pdf_path: string;
|
|
}
|
|
|
|
export interface ContractGenerateRequest {
|
|
delivery_date: string;
|
|
extra_placeholders?: Record<string, string>;
|
|
}
|
|
|
|
export interface ContractGenerateResponse {
|
|
project_id: number;
|
|
contract_path: string;
|
|
}
|
|
|
|
export interface FinanceSyncResult {
|
|
id: number;
|
|
month: string;
|
|
type: string;
|
|
file_name: string;
|
|
file_path: string;
|
|
}
|
|
|
|
export interface FinanceSyncResponse {
|
|
status: string;
|
|
new_files: number;
|
|
details: FinanceSyncResult[];
|
|
}
|
|
|
|
export interface FinanceRecordRead {
|
|
id: number;
|
|
month: string;
|
|
type: string;
|
|
file_name: string;
|
|
file_path: string;
|
|
amount: number | null;
|
|
billing_date: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface TemplateInfo {
|
|
name: string;
|
|
type: "excel" | "word";
|
|
size: number;
|
|
uploaded_at: number;
|
|
}
|
|
|
|
export interface AIConfig {
|
|
id?: string;
|
|
name?: string;
|
|
provider: string;
|
|
api_key: string;
|
|
base_url: string;
|
|
model_name: string;
|
|
temperature: number;
|
|
system_prompt_override: string;
|
|
}
|
|
|
|
export interface AIConfigListItem {
|
|
id: string;
|
|
name: string;
|
|
provider: string;
|
|
model_name: string;
|
|
base_url: string;
|
|
api_key_configured: boolean;
|
|
is_active: boolean;
|
|
}
|
|
|
|
export interface AIConfigCreate {
|
|
name?: string;
|
|
provider?: string;
|
|
api_key?: string;
|
|
base_url?: string;
|
|
model_name?: string;
|
|
temperature?: number;
|
|
system_prompt_override?: string;
|
|
}
|
|
|
|
export interface AIConfigUpdate {
|
|
name?: string;
|
|
provider?: string;
|
|
api_key?: string;
|
|
base_url?: string;
|
|
model_name?: string;
|
|
temperature?: number;
|
|
system_prompt_override?: string;
|
|
}
|
|
|
|
export interface ProjectRead {
|
|
id: number;
|
|
customer_id: number;
|
|
raw_requirement: string;
|
|
ai_solution_md: string | null;
|
|
status: string;
|
|
created_at: string;
|
|
customer?: CustomerRead;
|
|
quotes?: { id: number; total_amount: string; file_path: string }[];
|
|
}
|
|
|
|
export interface ProjectUpdate {
|
|
raw_requirement?: string | null;
|
|
ai_solution_md?: string | null;
|
|
status?: string;
|
|
}
|
|
|
|
// --------------- Request helper ---------------
|
|
|
|
async function request<T>(
|
|
path: string,
|
|
options: RequestInit & { params?: Record<string, string> } = {}
|
|
): Promise<T> {
|
|
const { params, ...init } = options;
|
|
const base = apiBase();
|
|
let url = `${base}${path}`;
|
|
if (params && Object.keys(params).length > 0) {
|
|
const search = new URLSearchParams(params).toString();
|
|
url += (path.includes("?") ? "&" : "?") + search;
|
|
}
|
|
|
|
const res = await fetch(url, {
|
|
...init,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...init.headers,
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
let detail: string = text;
|
|
try {
|
|
const j = JSON.parse(text);
|
|
const raw = j.detail ?? text;
|
|
if (Array.isArray(raw) && raw.length > 0) {
|
|
detail = raw.map((x: { msg?: string }) => x.msg ?? JSON.stringify(x)).join("; ");
|
|
} else {
|
|
detail = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
}
|
|
} catch {
|
|
// keep text
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
|
|
const contentType = res.headers.get("content-type");
|
|
if (contentType?.includes("application/json")) {
|
|
return res.json() as Promise<T>;
|
|
}
|
|
return undefined as T;
|
|
}
|
|
|
|
/** Download a file from the backend and return a Blob (for Excel/PDF/Zip). */
|
|
export async function downloadBlob(path: string): Promise<Blob> {
|
|
const base = apiBase();
|
|
const url = path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
const res = await fetch(url, { credentials: "include" });
|
|
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
return res.blob();
|
|
}
|
|
|
|
/**
|
|
* Trigger download in the browser (Excel, PDF, or Zip).
|
|
* path: backend-returned path e.g. "data/quotes/quote_project_1.xlsx" -> we request /data/quotes/quote_project_1.xlsx
|
|
*/
|
|
export function downloadFile(path: string, filename: string): void {
|
|
const base = apiBase();
|
|
const normalized = path.startsWith("/") ? path : `/${path}`;
|
|
const url = `${base}${normalized}`;
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.rel = "noopener noreferrer";
|
|
a.target = "_blank";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
|
|
/** Same as downloadFile but using fetch + Blob for consistent CORS and filename control. */
|
|
export async function downloadFileAsBlob(
|
|
path: string,
|
|
filename: string,
|
|
mime?: string
|
|
): Promise<void> {
|
|
const blob = await downloadBlob(path);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// --------------- Customers ---------------
|
|
|
|
export const customersApi = {
|
|
list: (params?: { q?: string }) =>
|
|
request<CustomerRead[]>(
|
|
params?.q ? `/customers/?q=${encodeURIComponent(params.q)}` : "/customers/"
|
|
),
|
|
get: (id: number) => request<CustomerRead>(`/customers/${id}`),
|
|
create: (body: CustomerCreate) =>
|
|
request<CustomerRead>("/customers/", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
update: (id: number, body: CustomerUpdate) =>
|
|
request<CustomerRead>(`/customers/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
delete: (id: number) =>
|
|
request<void>(`/customers/${id}`, { method: "DELETE" }),
|
|
};
|
|
|
|
// --------------- Projects ---------------
|
|
|
|
export const projectsApi = {
|
|
analyze: (body: RequirementAnalyzeRequest) =>
|
|
request<RequirementAnalyzeResponse>("/projects/analyze", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
|
|
update: (projectId: number, body: ProjectUpdate) =>
|
|
request<ProjectRead>(`/projects/${projectId}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
|
|
generateQuote: (projectId: number, template?: string | null) =>
|
|
request<QuoteGenerateResponse>(
|
|
`/projects/${projectId}/generate_quote${template ? `?template=${encodeURIComponent(template)}` : ""}`,
|
|
{ method: "POST" }
|
|
),
|
|
|
|
generateContract: (projectId: number, body: ContractGenerateRequest) =>
|
|
request<ContractGenerateResponse>(
|
|
`/projects/${projectId}/generate_contract`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}
|
|
),
|
|
};
|
|
|
|
export async function listProjects(params?: {
|
|
customer_tag?: string;
|
|
}): Promise<ProjectRead[]> {
|
|
const searchParams: Record<string, string> = {};
|
|
if (params?.customer_tag?.trim()) searchParams.customer_tag = params.customer_tag.trim();
|
|
return request<ProjectRead[]>("/projects/", {
|
|
params: searchParams,
|
|
});
|
|
}
|
|
|
|
export async function getProject(projectId: number): Promise<ProjectRead> {
|
|
return request<ProjectRead>(`/projects/${projectId}`);
|
|
}
|
|
|
|
// --------------- Settings / Templates ---------------
|
|
|
|
export const templatesApi = {
|
|
list: () => request<TemplateInfo[]>("/settings/templates"),
|
|
|
|
upload: async (file: File): Promise<{ name: string; path: string }> => {
|
|
const base = apiBase();
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
const res = await fetch(`${base}/settings/templates/upload`, {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
let detail = text;
|
|
try {
|
|
const j = JSON.parse(text);
|
|
detail = j.detail ?? text;
|
|
} catch {
|
|
// keep text
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
return res.json();
|
|
},
|
|
};
|
|
|
|
// --------------- AI Settings ---------------
|
|
|
|
export const aiSettingsApi = {
|
|
get: () => request<AIConfig>("/settings/ai"),
|
|
list: () => request<AIConfigListItem[]>("/settings/ai/list"),
|
|
getById: (id: string) => request<AIConfig>(`/settings/ai/${id}`),
|
|
create: (body: AIConfigCreate) =>
|
|
request<AIConfig>("/settings/ai", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
update: (id: string, body: AIConfigUpdate) =>
|
|
request<AIConfig>(`/settings/ai/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
delete: (id: string) =>
|
|
request<void>(`/settings/ai/${id}`, { method: "DELETE" }),
|
|
activate: (id: string) =>
|
|
request<AIConfig>(`/settings/ai/${id}/activate`, { method: "POST" }),
|
|
test: (configId?: string) =>
|
|
request<{ status: string; message: string }>(
|
|
configId ? `/settings/ai/test?config_id=${encodeURIComponent(configId)}` : "/settings/ai/test",
|
|
{ method: "POST" }
|
|
),
|
|
};
|
|
|
|
// --------------- Email Configs (multi-account sync) ---------------
|
|
|
|
export interface EmailConfigRead {
|
|
id: string;
|
|
host: string;
|
|
port: number;
|
|
user: string;
|
|
mailbox: string;
|
|
active: boolean;
|
|
}
|
|
|
|
export interface EmailConfigCreate {
|
|
host: string;
|
|
port?: number;
|
|
user: string;
|
|
password: string;
|
|
mailbox?: string;
|
|
active?: boolean;
|
|
}
|
|
|
|
export interface EmailConfigUpdate {
|
|
host?: string;
|
|
port?: number;
|
|
user?: string;
|
|
password?: string;
|
|
mailbox?: string;
|
|
active?: boolean;
|
|
}
|
|
|
|
export interface EmailFolder {
|
|
raw: string;
|
|
decoded: string;
|
|
}
|
|
|
|
export const emailConfigsApi = {
|
|
list: () => request<EmailConfigRead[]>("/settings/email"),
|
|
create: (body: EmailConfigCreate) =>
|
|
request<EmailConfigRead>("/settings/email", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
update: (id: string, body: EmailConfigUpdate) =>
|
|
request<EmailConfigRead>(`/settings/email/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
delete: (id: string) =>
|
|
request<void>(`/settings/email/${id}`, { method: "DELETE" }),
|
|
/** List mailbox folders for an account (to pick custom label). Use decoded as mailbox value. */
|
|
listFolders: (configId: string) =>
|
|
request<{ folders: EmailFolder[] }>(`/settings/email/${configId}/folders`),
|
|
};
|
|
|
|
// --------------- Cloud Docs (快捷入口) ---------------
|
|
|
|
export interface CloudDocLinkRead {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
}
|
|
|
|
export interface CloudDocLinkCreate {
|
|
name: string;
|
|
url: string;
|
|
}
|
|
|
|
export interface CloudDocLinkUpdate {
|
|
name?: string;
|
|
url?: string;
|
|
}
|
|
|
|
export const cloudDocsApi = {
|
|
list: () => request<CloudDocLinkRead[]>("/settings/cloud-docs"),
|
|
create: (body: CloudDocLinkCreate) =>
|
|
request<CloudDocLinkRead>("/settings/cloud-docs", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
update: (id: string, body: CloudDocLinkUpdate) =>
|
|
request<CloudDocLinkRead>(`/settings/cloud-docs/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
delete: (id: string) =>
|
|
request<void>(`/settings/cloud-docs/${id}`, { method: "DELETE" }),
|
|
};
|
|
|
|
// --------------- Cloud Doc Config (API 凭证) ---------------
|
|
|
|
export interface CloudDocConfigRead {
|
|
feishu: { app_id: string; app_secret_configured: boolean };
|
|
yuque: { token_configured: boolean; default_repo: string };
|
|
tencent: { client_id: string; client_secret_configured: boolean };
|
|
}
|
|
|
|
export interface CloudDocConfigUpdate {
|
|
feishu?: { app_id?: string; app_secret?: string };
|
|
yuque?: { token?: string; default_repo?: string };
|
|
tencent?: { client_id?: string; client_secret?: string };
|
|
}
|
|
|
|
export const cloudDocConfigApi = {
|
|
get: () => request<CloudDocConfigRead>("/settings/cloud-doc-config"),
|
|
update: (body: CloudDocConfigUpdate) =>
|
|
request<CloudDocConfigRead>("/settings/cloud-doc-config", {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
};
|
|
|
|
// --------------- Push to Cloud ---------------
|
|
|
|
export interface PushToCloudRequest {
|
|
platform: "feishu" | "yuque" | "tencent";
|
|
title?: string;
|
|
body_md?: string;
|
|
}
|
|
|
|
export interface PushToCloudResponse {
|
|
url: string;
|
|
cloud_doc_id: string;
|
|
}
|
|
|
|
export function pushProjectToCloud(
|
|
projectId: number,
|
|
body: PushToCloudRequest
|
|
): Promise<PushToCloudResponse> {
|
|
return request<PushToCloudResponse>(`/projects/${projectId}/push-to-cloud`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
// --------------- Portal Links (快捷门户) ---------------
|
|
|
|
export interface PortalLinkRead {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
}
|
|
|
|
export interface PortalLinkCreate {
|
|
name: string;
|
|
url: string;
|
|
}
|
|
|
|
export interface PortalLinkUpdate {
|
|
name?: string;
|
|
url?: string;
|
|
}
|
|
|
|
export const portalLinksApi = {
|
|
list: () => request<PortalLinkRead[]>("/settings/portal-links"),
|
|
create: (body: PortalLinkCreate) =>
|
|
request<PortalLinkRead>("/settings/portal-links", {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
update: (id: string, body: PortalLinkUpdate) =>
|
|
request<PortalLinkRead>(`/settings/portal-links/${id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
delete: (id: string) =>
|
|
request<void>(`/settings/portal-links/${id}`, { method: "DELETE" }),
|
|
};
|
|
|
|
// --------------- Finance ---------------
|
|
|
|
export const financeApi = {
|
|
sync: () =>
|
|
request<FinanceSyncResponse>("/finance/sync", { method: "POST" }),
|
|
|
|
/** Upload invoice (PDF/image); returns created record with AI-extracted amount/date. */
|
|
uploadInvoice: async (file: File): Promise<FinanceRecordRead> => {
|
|
const base = apiBase();
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
const res = await fetch(`${base}/finance/upload`, {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
let detail = text;
|
|
try {
|
|
const j = JSON.parse(text);
|
|
detail = j.detail ?? text;
|
|
} catch {
|
|
// keep text
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
return res.json();
|
|
},
|
|
|
|
/** Update amount/billing_date of a record (e.g. after manual review). */
|
|
updateRecord: (id: number, body: { amount?: number | null; billing_date?: string | null }) =>
|
|
request<FinanceRecordRead>(`/finance/records/${id}`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
|
|
/** List distinct months with records (YYYY-MM). */
|
|
listMonths: () => request<string[]>("/finance/months"),
|
|
|
|
/** List records for a month (YYYY-MM). */
|
|
listRecords: (month: string) =>
|
|
request<FinanceRecordRead[]>(`/finance/records?month=${encodeURIComponent(month)}`),
|
|
|
|
/** Returns the URL to download the zip (or use downloadFile with the path). */
|
|
getDownloadUrl: (month: string) => `${apiBase()}/finance/download/${month}`,
|
|
|
|
/** Download monthly zip as blob and trigger save. */
|
|
downloadMonth: async (month: string): Promise<void> => {
|
|
const path = `/finance/download/${month}`;
|
|
await downloadFileAsBlob(path, `finance_${month}.zip`, "application/zip");
|
|
},
|
|
};
|