feat(wordaddin): port Word add-in taskpane from poc
พอร์ต taskpane ของ Word add-in "e-Court Documents Versioning" จาก D:\word-addin-poc มาเป็น sibling app ที่ route /docedit/wordaddin: - wordaddin/ (client-only zone): services พอร์ตดิบทั้งชุด (authService/versioningService เป็น mock รอ backend จริง) + models + config - components/wordaddin/: WordAddinGate ฉีด office.js self-host (singleton กัน StrictMode double-inject + fallback timer ครอบเคส onReady เงียบ) และ TaskpaneApp state machine แปลง JSX เป็น Genesis design system - scripts/fetch-office-js.mjs (postinstall ตัวที่สาม): ดาวน์โหลด Office.js runtime ล่าสุดจาก CDN Microsoft ไป public/wordaddin/office-js/ (gitignore สร้างใหม่ได้ — npm @microsoft/office-js ตกรุ่นไม่มี Document.close) - middleware/basePath/root page: เปิด /wordaddin เป็น public (auth แยกจาก แอป note), root /docedit redirect ไป taskpane ยกเว้นลิงก์ e-Court N8 (?fileUrl=&fileId=&typeId=) ที่ยังส่งต่อ /note ตามเดิม - docker/web.Dockerfile รัน fetch-office-js ตอน build + npm scripts dev:https สำหรับ sideload ตาม docs/WORDADDIN-DEV.md Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
7a146d48be
commit
c401f4deb7
37 changed files with 11216 additions and 17 deletions
122
wordaddin/services/apiClient.ts
Normal file
122
wordaddin/services/apiClient.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { config } from "../config";
|
||||
import * as authService from "./authService";
|
||||
|
||||
/**
|
||||
* fetch wrapper ของ add-in — แนบ Bearer ทุก request
|
||||
* โดน 401 → silent refresh แล้ว retry ครั้งเดียว; ยัง 401 → AuthRequiredError
|
||||
* (UI จับ error นี้แล้วพากลับหน้า sign-in อย่างเนียน — AC-9)
|
||||
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\apiClient.ts
|
||||
* (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError
|
||||
* ถูก TaskpaneApp ใช้อยู่แล้ว)
|
||||
*/
|
||||
|
||||
/** บอกว่าต้องกลับไป login ใหม่ (ไม่มี token / refresh ไม่ได้ / 401 ซ้ำ) */
|
||||
export class AuthRequiredError extends Error {
|
||||
constructor(message = "เซสชันหมดอายุ — ต้อง login ใหม่") {
|
||||
super(message);
|
||||
this.name = "AuthRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
/** backend ตอบมาแต่ status ไม่สำเร็จ — message อ่านได้จาก body ถ้ามี */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyInit | null;
|
||||
}
|
||||
|
||||
function toUrl(path: string): string {
|
||||
return path.startsWith("http") ? path : `${config.apiBaseUrl}${path}`;
|
||||
}
|
||||
|
||||
async function doFetch(
|
||||
path: string,
|
||||
options: ApiRequestOptions,
|
||||
accessToken: string
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(options.headers ?? {});
|
||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
return fetch(toUrl(path), {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
body: options.body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* request พร้อม Bearer + retry-on-401 ครั้งเดียว
|
||||
* Response ที่ได้คืนไปให้ caller เช็ค ok เอง (ยกเว้น 401 ที่ refresh ไม่ช่วย → AuthRequiredError)
|
||||
*/
|
||||
export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> {
|
||||
const accessToken = await authService.getAccessToken();
|
||||
if (!accessToken) {
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
|
||||
let response = await doFetch(path, options, accessToken);
|
||||
if (response.status === 401) {
|
||||
const refreshedToken = await authService.refreshAccessToken();
|
||||
if (!refreshedToken) {
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
response = await doFetch(path, options, refreshedToken);
|
||||
if (response.status === 401) {
|
||||
throw new AuthRequiredError();
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */
|
||||
export async function readErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const text = (await response.text()).trim();
|
||||
if (!text) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown; title?: unknown };
|
||||
const detail = parsed.detail ?? parsed.message ?? parsed.title;
|
||||
if (typeof detail === "string" && detail) {
|
||||
return detail;
|
||||
}
|
||||
} catch {
|
||||
// plain text — ใช้ตรงๆ
|
||||
}
|
||||
return text;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** GET แล้ว parse JSON — fail โยน ApiError พร้อม message จาก backend */
|
||||
export async function apiGetJson<T>(path: string, fallbackError: string): Promise<T> {
|
||||
const response = await apiFetch(path);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/** POST (form/json) แล้ว parse JSON — ใช้กับ upload version ที่ตอบ 201 */
|
||||
export async function apiSendExpectJson<T>(
|
||||
path: string,
|
||||
options: ApiRequestOptions,
|
||||
fallbackError: string
|
||||
): Promise<T> {
|
||||
const response = await apiFetch(path, options);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue