Merge branch 'forgejo-dev' into newfeat

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-11-09 10:15:38 +07:00
commit c57a75e36a
50 changed files with 2760 additions and 961 deletions

114
components/TmplHome.tsx Normal file
View file

@ -0,0 +1,114 @@
// components/TemplateFormClient.tsx
'use client';
import { useState, type ChangeEvent } from 'react';
import Link from "next/link";
import DependentDropdown, { type DropdownData } from '@/components/utils/DependentDropdown';
interface TemplateFormClientProps {
templateData: DropdownData;
}
export default function TemplateFormClient({ templateData }: TemplateFormClientProps) {
const handleTmplSelect = (template: string, data: string) => {
// Handle selection if needed
};
return (
<div className="mt-5 grid gap-5 sm:grid-cols-2">
{/* ส่วน Template & Data แสดงรายการไฟล์จริงจากโฟลเดอร์ */}
<div className="flex gap-4 rounded-xl border border-line bg-white p-6">
<div>
<h3 className="font-display text-subhead text-ink">Template & Data</h3>
<div className="mt-1.5 text-sm leading-relaxed text-ink-soft">
{Object.entries(templateData).map(([tid, jsonList]) => (
<div key={tid} className="mb-3">
<b>Template: </b>
<Link href={`/tmpl/${tid}.docx`} className="text-primary transition hover:underline">
{tid}.docx
</Link>
<br />
<b>Data: </b>
{jsonList.map((fid, idx) => (
<span key={fid}>
<Link href={`/tmpl/${fid}.json`} className="text-primary transition hover:underline">
{fid}.json
</Link>
{idx < jsonList.length - 1 ? ', ' : ''}
</span>
))}
</div>
))}
<div className="mt-2">
นำไฟล์ไปทดสอบกับ <Link href="/api/swagger/" target="_top" className="text-primary transition hover:underline">API</Link>
</div>
</div>
</div>
</div>
{/* ส่วน Form (POST) */}
<div className="flex gap-4 rounded-xl border border-line bg-white p-6">
<div>
<h3 className="font-display text-subhead text-ink">Form (POST)</h3>
<div className="mt-1.5 text-sm leading-relaxed text-ink-soft">
<form action="/docedit/api/template/download-docx" method="POST">
<DependentDropdown data={templateData} name1='tid' name2='fid'
label1="Template(tid):" label2="ข้อมูล(fid):" onSelectChange={handleTmplSelect} />
<div className="mb-2"></div>
<div>
<label htmlFor="uid">uid:
<input type="text" name="uid" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="john" />
</label>
</div>
<div>
<label htmlFor="uname">uname:
<input type="text" name="uname" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="John Doe" />
</label>
</div>
<button
type="submit"
style={{
padding: '10px 20px',
backgroundColor: '#0070f3',
color: '#fff',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
}}
>
ดาวน์โหลดเอกสาร MS Word
</button>
</form>
</div>
</div>
</div>
{/* ส่วน Link (GET) */}
<div className="flex gap-4 rounded-xl border border-line bg-white p-6">
<div>
<h3 className="font-display text-subhead text-ink">Link (GET)</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
{Object.entries(templateData).map(([tid, jsonList]) => {
// jsonList คืออาเรย์ของชื่อไฟล์ json (fid) ที่ผูกอยู่กับ template (tid) นี้
return jsonList.map((fid) => {
const linkHref = `/docedit/api/template/download-docx?tid=${tid}&fid=${fid}&uid=john&uname=John%20Doe`;
const displayLabel = `${fid}_john_xxx.docx`; // หรือปรับรูปแบบข้อความแสดงผลตามต้องการ
return (
<span key={`${tid}-${fid}`}>
<a href={linkHref} className="text-primary transition hover:underline">
{displayLabel}
</a>
<br />
</span>
);
});
})}
</p>
</div>
</div>
</div>
);
}

View file

@ -114,6 +114,10 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
/** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */
const [noAccessNotice, setNoAccessNotice] = useState<string | null>(null);
/** no-access ที่ "ลองใหม่" ช่วยได้ — เฉพาะเคสอ่านไฟล์ไม่สำเร็จ (เช่น office.js มาช้า
* บนเครื่องช้า หรือ Word สะดุดชั่วคราว) กดแล้ววน detect() ใหม่ · เคสอื่น (ticket
* หมดอายุ/ลายเซ็นไม่ผ่าน/ขอ token ไม่ได้) retry ไม่ช่วย ต้องขอไฟล์ใหม่/เน็ตกลับมา */
const [noAccessRetryable, setNoAccessRetryable] = useState(false);
const [starting, setStarting] = useState(false);
const [saving, setSaving] = useState(false);
@ -183,6 +187,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
const detect = useCallback(async () => {
setPhase("checking");
setBootError(null);
setNoAccessRetryable(false);
try {
let properties: metadataService.TrackedDocumentProperties | null = null;
try {
@ -191,7 +196,8 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
properties = null;
}
if (!properties) {
setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง");
setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองกด \"ลองใหม่\" หรือปิดแล้วเปิด task pane อีกครั้ง");
setNoAccessRetryable(true);
setPhase("no-access");
return;
}
@ -206,6 +212,10 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
}
setUsername(authService.getUsername() ?? null);
// auto-open (2026-11-05) — sign-in ผ่าน = ไฟล์ e-Court: tag ให้ taskpane เด้งเองครั้งถัดไป
// ที่เปิดไฟล์นี้ · fire-and-forget (service กลืน error ทั้งหมด — ไฟล์ทั่วไปไม่มาทางนี้อยู่แล้ว)
void metadataService.enableAutoOpenTaskpane();
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
const id = tracked ? properties.documentTrackingId : undefined;
if (!id) {
@ -469,6 +479,15 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
<p className="text-sm text-ink-soft">
{noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
</p>
{noAccessRetryable ? (
<button
type="button"
className="focus-ring w-fit rounded-md border border-error/40 bg-white px-2 py-1.5 text-xs text-error-ink"
onClick={() => void detect()}
>
ลองใหม่
</button>
) : null}
</div>
) : null}

View file

@ -22,8 +22,41 @@ import TaskpaneApp from "./TaskpaneApp";
/** office.js self-host จาก postinstall scripts/fetch-office-js.mjs (intranet ไม่ใช้ CDN) */
const OFFICE_JS_SRC = withBasePath("/wordaddin/office-js/office.js");
/** Office.onReady callback ไม่ยิงภายใน 5 วิหลังฉีด script ก็ proceed (กัน webview แปลก ๆ — โหลดสำเร็จแต่ onReady เงียบ) */
/**
* ช่วง poll ของ fallback — ทุก 5 วิเช็คว่า global `Office` โผล่แล้วหรือยัง:
* โผล่แล้วแต่ onReady เงียบ (webview แปลก ๆ) = proceed ตามเจตนาเดิม · ยังไม่โผล่
* (เครื่อง/เน็ตช้า — script ยังโหลดไม่เสร็จ) = รอต่อรอบหน้า ไม่ proceed บอด ๆ
*/
const ONREADY_FALLBACK_MS = 5_000;
/** เพดานรวมหลัง inject script — ครบแล้วยังไม่มี Office = ไปหน้า error (มีปุ่มลองใหม่) */
const ONREADY_HARD_CAP_MS = 60_000;
/**
* office.js **ลบ `history.pushState`/`replaceState` ทิ้ง** ใน taskpane (set เป็น null —
* กัน add-in พา host ไปที่อื่น: office-js#429) แต่ App Router ของ Next ต้องใช้ History API
* ตอน mount ไม่งั้นพัง "window.history.replaceState is not a function" — เก็บตัวจริงไว้
* ก่อนฉีด script แล้วคืนคืนหลัง office.js โหลด/พร้อม (workaround มาตรฐานของ SPA ใน
* add-in — taskpane เป็น iframe ของตัวเอง Next ใช้ history เฉพาะ SPA state ภายใน)
*/
const nativeHistory: { pushState?: History["pushState"]; replaceState?: History["replaceState"] } =
typeof window === "undefined"
? {}
: {
pushState: typeof window.history.pushState === "function" ? window.history.pushState.bind(window.history) : undefined,
replaceState:
typeof window.history.replaceState === "function" ? window.history.replaceState.bind(window.history) : undefined,
};
/** คืน History API ที่ office.js ลบทิ้ง — idempotent (มีอยู่แล้ว = ไม่แตะ) */
function restoreHistoryApi(): void {
if (typeof window === "undefined") return;
if (typeof window.history.pushState !== "function" && nativeHistory.pushState) {
window.history.pushState = nativeHistory.pushState;
}
if (typeof window.history.replaceState !== "function" && nativeHistory.replaceState) {
window.history.replaceState = nativeHistory.replaceState;
}
}
/**
* โหลด office.js ครั้งเดียวต่อ page เป็น singleton promise ระดับ module —
@ -39,16 +72,44 @@ function ensureOfficeJs(): Promise<boolean> {
if (!officeReadyPromise) {
officeReadyPromise = new Promise<boolean>((resolve) => {
if (typeof Office !== "undefined") {
void Office.onReady(() => resolve(true));
restoreHistoryApi(); // office.js evaluate ไปแล้ว — คืนก่อนให้ router ใช้ต่อได้
void Office.onReady(() => {
restoreHistoryApi(); // host handshake อาจลบซ้ำตอน initialize
resolve(true);
});
return;
}
const script = document.createElement("script");
const fallbackTimer = setTimeout(() => resolve(true), ONREADY_FALLBACK_MS);
const startedAt = Date.now();
let fallbackTimer: ReturnType<typeof setTimeout>;
const armFallback = () => {
fallbackTimer = setTimeout(() => {
// Office โผล่แล้ว (script evaluate แล้ว) = proceed ได้เลย ไม่รอ onReady —
// กัน webview ที่โหลดสำเร็จแต่ callback เงียบ (เจตนาเดิมของ fallback)
if (typeof Office !== "undefined") {
restoreHistoryApi(); // office.js evaluate แล้วอาจลบ history ทิ้ง — คืนก่อน proceed
resolve(true);
return;
}
// ยังไม่มี Office global = เครื่องช้า script ยังไม่โหลดเสร็จ — รอต่อ (re-arm)
// จนครบเพดานค่อยยอมแพ้ไปหน้า error กัน proceed แล้ว Word.run พังกลางคัน
if (Date.now() - startedAt >= ONREADY_HARD_CAP_MS) {
officeReadyPromise = null;
script.remove();
resolve(false);
return;
}
armFallback();
}, ONREADY_FALLBACK_MS);
};
armFallback(); // ตั้งก่อนเสียบ src เหมือนเดิม — ครอบทั้ง network fetch + evaluate + รอ onReady
const onReady = () => {
// เคลียร์ timer เฉพาะเมื่อ callback ของ Office.onReady ยิงจริง — ถ้าเคลียร์ตั้งแต่ onload
// แล้ว onReady เงียบ fallback จะตายไปก่อนทำหน้าที่ (ค้าง loading ตลอดไป)
restoreHistoryApi(); // script evaluate แล้ว — office.js ลบ history ตรงนี้ คืนก่อน router ใช้
void Office.onReady(() => {
clearTimeout(fallbackTimer);
restoreHistoryApi(); // handshake ของ host อาจลบซ้ำ — คืนอีกรอบก่อน mount TaskpaneApp
resolve(true);
});
};