phase 6 เบื้องต้น
This commit is contained in:
parent
1bb850c6b8
commit
93570d38d8
20 changed files with 1715 additions and 34 deletions
|
|
@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
|
||||
|
||||
**สถานะปัจจุบัน: Phase 0–5 เสร็จ** (backend ครบ + add-in ครบทั้ง services และ task pane UI — sideload ทดสอบจริงบน Word for Mac แล้ว แก้ปัญหาตกมุม 4 รอบ) — งานต่อคือ phase 6 (web portal), 7 (tests ครบชุด), 8 (ไล่ acceptance criteria) ตามแผน
|
||||
**สถานะปัจจุบัน: Phase 0–6 เสร็จ** (backend + add-in + web portal ครบ — ตรวจอัตโนมัติผ่านทั้งหมด เหลือยืนยันด้วยมือ: click-through บน browser จริง + ส่วน sideload ที่เหลือ) — งานต่อคือ phase 7 (tests ครบชุด), 8 (README + ไล่ acceptance criteria) ตามแผน
|
||||
|
||||
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
|
||||
|
||||
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
|
||||
> สถานะ: Phase 0–5 เสร็จ (backend ครบ + add-in ครบทั้ง services และ UI — เหลือตรวจ sideload บน Word จริง) — ต่อไป: phase 6 (web portal)
|
||||
> สถานะ: Phase 0–6 เสร็จ (backend + add-in + web portal — ตรวจอัตโนมัติผ่านทั้งหมด เหลือยืนยันด้วยมือ: click-through บน browser จริง + ส่วน sideload ที่เหลือ) — ต่อไป: phase 7 (tests ครบชุด), 8 (README + AC)
|
||||
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
|
||||
|
||||
---
|
||||
|
|
@ -397,22 +397,34 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
|||
**เป้า:** เว็บ Nuxt + Tailwind สำหรับจัดการ template และสร้างเอกสาร ทุกหน้ารองรับ light/dark theme
|
||||
|
||||
### 6.1 พื้นฐาน
|
||||
- [ ] `useAuth.ts` — OIDC PKCE + **browser redirect ปกติ** (top-level page ใช้ได้เลย ไม่ต้องมี Dialog API), รับ code ที่ route callback, แลก token, silent refresh, token ใน memory
|
||||
- [ ] `useApi.ts` — fetch wrapper + Bearer + auto refresh ตอน 401
|
||||
- [ ] `useTheme.ts` — toggle light/dark (สลับ class บน `<html>`), persist ใน localStorage, default ตาม `prefers-color-scheme`
|
||||
- [ ] Layout หลัก: header + theme toggle + สถานะ login (ทุกหน้าใช้ร่วม)
|
||||
- [x] `useAuth.ts` — OIDC PKCE + **browser redirect ปกติ** (top-level page ใช้ได้เลย ไม่ต้องมี Dialog API), รับ code ที่ route callback, แลก token, silent refresh, token ใน memory
|
||||
- [x] `useApi.ts` — fetch wrapper + Bearer + auto refresh ตอน 401
|
||||
- [x] `useTheme.ts` — toggle light/dark (สลับ class บน `<html>`), persist ใน localStorage, default ตาม `prefers-color-scheme`
|
||||
- [x] Layout หลัก: header + theme toggle + สถานะ login (ทุกหน้าใช้ร่วม)
|
||||
|
||||
### 6.2 จัดการ template (FR-9 UI)
|
||||
- [ ] `templates/index.vue` — ตารางรายการ (name, description, จำนวน fields, active) + ปุ่ม deactivate/activate
|
||||
- [ ] `templates/new.vue` + `templates/[id]/edit.vue` — upload/แทนที่ไฟล์ .docx → โชว์ **field list ที่ระบบ detect อัตโนมัติ** → ฟอร์มตั้ง label/type (text/number/date)/required/ลำดับ → บันทึก
|
||||
- [x] `templates/index.vue` — ตารางรายการ (name, description, จำนวน fields, active) + ปุ่ม deactivate/activate
|
||||
- [x] `templates/new.vue` + `templates/[id]/edit.vue` — upload/แทนที่ไฟล์ .docx → โชว์ **field list ที่ระบบ detect อัตโนมัติ** → ฟอร์มตั้ง label/type (text/number/date)/required/ลำดับ → บันทึก
|
||||
|
||||
### 6.3 สร้างเอกสาร (FR-10 UI)
|
||||
- [ ] `index.vue` — card รายการ template ที่ active → เลือกแล้วไปหน้าสร้าง
|
||||
- [ ] `documents/create/[templateId].vue` — **dynamic form สร้างจาก field defs** (input type ตาม field type: text/number/date picker), validate required/type ฝั่ง client + แสดง error จาก backend
|
||||
- [ ] กดสร้าง → แสดงผล: documentId, version 1 + **ปุ่มดาวน์โหลด .docx** + คำแนะนำสั้นๆ ("เปิดไฟล์นี้ใน Word แล้วใช้ add-in ต่อ")
|
||||
- [x] `index.vue` — card รายการ template ที่ active → เลือกแล้วไปหน้าสร้าง
|
||||
- [x] `documents/create/[templateId].vue` — **dynamic form สร้างจาก field defs** (input type ตาม field type: text/number/date picker), validate required/type ฝั่ง client + แสดง error จาก backend
|
||||
- [x] กดสร้าง → แสดงผล: documentId, version 1 + **ปุ่มดาวน์โหลด .docx** + คำแนะนำสั้นๆ ("เปิดไฟล์นี้ใน Word แล้วใช้ add-in ต่อ")
|
||||
|
||||
หมายเหตุจากการทำจริง (2026-08-20):
|
||||
- โครงไฟล์ตาม Nuxt 4 (dir `app/`): `app/composables`, `app/utils`, `app/components`, `app/plugins` auto-import — **import ข้ามโฟลเดอร์ต้องใช้ alias `~/utils/...`** (relative `./oidc` จาก composables ไม่ resolve — เจอตอน build แรก)
|
||||
- **นโยบาย session ของเว็บ (กติกา #4 + แผนข้อ 5):** access/refresh/id token เก็บใน memory เท่านั้น; สิ่งที่ persist มีแค่ refresh token + id token (ไว้ restore หลัง reload และ logout แบบมี `id_token_hint`) ใน sessionStorage key `wv-session` + PKCE pending (`wv-oidc-pending`) ต้องข้าม navigation จึงจำเป็น — sessionStorage อายุสั้นตามที่แผนอนุญาต
|
||||
- restore session ทำใน `app/plugins/auth.client.ts` (**async plugin** → รอผลก่อน render หน้าแรก กัน UI กระพริบไปหน้า login); สถานะ login ใน header ครอบ `<ClientOnly>` กัน hydration mismatch; ทุกหน้าครอบเนื้อหาด้วย `AuthGate` (ยังไม่ login → ปุ่มเข้าระบบ)
|
||||
- `useApi`: Bearer ทุก request, 401 → force refresh → retry ครั้งเดียว; refresh พัง → เคลียร์ session แล้ว AuthGate สลับไปหน้า login เอง (พฤติกรรมเดียวกับ AC-9 ของ add-in) — และ `filenameFrom()` อ่านชื่อไฟล์จาก `Content-Disposition` รองรับ `filename*=UTF-8` ของ .NET (ชื่อไทย)
|
||||
- ตรวจอัตโนมัติผ่านหมด (2026-08-20): `npm run build` exit 0 · SSR ทุก route (/, /templates, /templates/new, /templates/{id}/edit, /auth/callback ±query, /documents/create/{id}) ตอบ 200 พร้อมข้อความถูกต้อง · **เดิน PKCE จริงด้วยพารามิเตอร์เหมือน `useAuth` ทุกตัว** (redirect `/auth/callback`, scope=openid, S256) → Keycloak คืน code+state ตรง → แลก token ได้ `aud=word-versioning-web` + refresh/id token → ยิง `GET /api/templates` 200 → CORS preflight จาก 5173 ได้ 204 + allow-origin ถูก → refresh_token grant ผ่าน (path ที่ restoreSession ใช้) · **จำลอง request ทั้งลำดับที่หน้าเว็บส่ง** (multipart สร้าง template → PUT field defs → generate → download) → docx ค่าครบ + `SavedByAddIn`/`DocumentTrackingId` stamp ถูก
|
||||
- 🩹 **รัน backend เจอ 500 "No password has been provided"**: `dotnet run --no-launch-profile` ตัด `ASPNETCORE_ENVIRONMENT=Development` (ปกติตั้งโดย launchSettings) ทิ้ง → ไปอ่าน appsettings.json ที่ DB password ว่าง — ต้องใส่ env นี้เองทุกครั้งที่ใช้ `--no-launch-profile`
|
||||
- ดาวน์โหลดในเว็บใช้ blob + `a[download]` ได้ตามปกติ (browser จริง — ต่างจาก webview ของ Word ที่ต้องใช้ presigned link ตามกติกา #12)
|
||||
- ⚠️ **เหลือตรวจด้วยมือ (ทำแทนไม่ได้):** เปิด browser จริง → login ผ่าน Keycloak → สร้าง/แก้ template → กรอกฟอร์มสร้างเอกสาร → ดาวน์โหลด → เปิดใน Word แล้ว add-in เห็น v1 ทันที (AC-11/12 เต็มรูปแบบ) + theme toggle จำค่าหลัง reload — ใน dev DB มี template "สัญญาทดสอบเว็บ" (เกิดจากสคริปต์ตรวจ) ไว้ click-through ได้ทันที และลบผ่านหน้าเว็บได้เอง
|
||||
|
||||
**DoD:** login ผ่าน Keycloak บนเว็บได้ → สร้าง/แก้ template จากไฟล์จริง detect fields ถูก → กรอก form สร้างเอกสาร ได้ docx ครบค่า → เปิดใน Word แล้ว add-in รู้จักทันทีเห็น v1 (AC-11, 12 เต็มรูปแบบ) + theme toggle ทำงานทุกหน้าและจำค่าได้หลัง reload
|
||||
|
||||
> 🟡 implement + ตรวจอัตโนมัติผ่านหมด (2026-08-20 — รายละเอียดในหมายเหตุ) · **ส่วน "เปิดใน Word แล้ว add-in รู้จักทันที" ต้องยืนยันด้วยมือ** ร่วมกับ AC checklist ที่เหลือใน Phase 8
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Tests
|
||||
|
|
|
|||
9
web/.env.example
Normal file
9
web/.env.example
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# ค่า default ของ local dev อยู่ใน nuxt.config.ts (runtimeConfig.public) — ไฟล์นี้ใช้ override ผ่าน env
|
||||
# คัดลอกเป็น .env แล้วแก้ได้เลย (Nuxt อ่าน NUXT_PUBLIC_* อัตโนมัติ)
|
||||
|
||||
# backend API (ไม่มี slash ท้าย)
|
||||
NUXT_PUBLIC_API_BASE_URL=http://localhost:5075
|
||||
|
||||
# Keycloak realm (ผ่าน https:8443 ด้วย cert ของ office-addin-dev-certs)
|
||||
NUXT_PUBLIC_KEYCLOAK_AUTHORITY=https://localhost:8443/realms/word-versioning
|
||||
NUXT_PUBLIC_KEYCLOAK_CLIENT_ID=word-versioning-web
|
||||
|
|
@ -1,22 +1,73 @@
|
|||
<script setup lang="ts">
|
||||
const { initTheme, toggle, isDark } = useTheme()
|
||||
const { ready, isAuthenticated, username, login, logout } = useAuth()
|
||||
|
||||
onMounted(() => initTheme())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-900 dark:text-slate-100">
|
||||
<header class="flex items-center justify-between border-b border-slate-200 px-6 py-4 dark:border-slate-700">
|
||||
<NuxtLink to="/" class="text-lg font-semibold">Word Versioning</NuxtLink>
|
||||
<header class="border-b border-slate-200 dark:border-slate-700">
|
||||
<div class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-3 px-6 py-4">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<NuxtLink to="/" class="text-lg font-semibold">e-Court Documents Versioning</NuxtLink>
|
||||
<span class="hidden text-xs text-slate-500 dark:text-slate-400 sm:inline">web portal</span>
|
||||
</div>
|
||||
|
||||
<nav class="flex items-center gap-2 text-sm">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="rounded-md px-2.5 py-1.5 text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
สร้างเอกสาร
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/templates"
|
||||
class="rounded-md px-2.5 py-1.5 text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
จัดการ template
|
||||
</NuxtLink>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
|
||||
class="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
|
||||
@click="toggle"
|
||||
>
|
||||
{{ isDark ? '☀️ Light' : '🌙 Dark' }}
|
||||
</button>
|
||||
|
||||
<!-- สถานะ login รู้ผลหลัง restore session (client) — จึงต้อง ClientOnly กัน hydration mismatch -->
|
||||
<ClientOnly>
|
||||
<template #fallback>
|
||||
<span class="inline-block w-24 text-right text-xs text-slate-400">…</span>
|
||||
</template>
|
||||
<template v-if="ready">
|
||||
<span v-if="isAuthenticated" class="max-w-40 truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ username }}
|
||||
</span>
|
||||
<button
|
||||
v-if="isAuthenticated"
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
|
||||
@click="logout"
|
||||
>
|
||||
ออกจากระบบ
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="rounded-md bg-sky-600 px-3 py-1.5 font-medium text-white hover:bg-sky-700"
|
||||
@click="login()"
|
||||
>
|
||||
เข้าสู่ระบบ
|
||||
</button>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mx-auto max-w-3xl p-6">
|
||||
|
||||
<main class="mx-auto max-w-5xl p-6">
|
||||
<NuxtPage />
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
28
web/app/components/AlertBox.vue
Normal file
28
web/app/components/AlertBox.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script setup lang="ts">
|
||||
/** กล่องข้อความ (error/success/info) ใช้ร่วมทุกหน้า */
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?: 'error' | 'success' | 'info'
|
||||
}>(),
|
||||
{ type: 'info' },
|
||||
)
|
||||
|
||||
const styles = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'error':
|
||||
return 'border-red-300 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-300'
|
||||
case 'success':
|
||||
return 'border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
default:
|
||||
return 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-300'
|
||||
}
|
||||
})
|
||||
const icon = computed(() => (props.type === 'error' ? '⚠️' : props.type === 'success' ? '✅' : 'ℹ️'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="alert" class="rounded-md border px-4 py-3 text-sm" :class="styles">
|
||||
<span class="mr-2" aria-hidden="true">{{ icon }}</span>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
31
web/app/components/AuthGate.vue
Normal file
31
web/app/components/AuthGate.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
/** ครอบเนื้อหาหน้าที่ต้อง login — ยังไม่พร้อมแสดง spinner, ยังไม่ login แสดงปุ่มเข้าระบบ */
|
||||
const { ready, isAuthenticated, login } = useAuth()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!ready" class="flex items-center justify-center gap-3 py-16 text-sm text-slate-500 dark:text-slate-400">
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300" />
|
||||
กำลังตรวจสอบสถานะการเข้าสู่ระบบ…
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!isAuthenticated"
|
||||
class="rounded-lg border border-slate-200 bg-white p-8 text-center dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
<h2 class="text-lg font-semibold">ต้องเข้าสู่ระบบก่อนใช้งานหน้านี้</h2>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
ระบบยืนยันตัวตนผ่าน Keycloak (realm <span class="font-mono">word-versioning</span>)
|
||||
— กดปุ่มด้านล่างเพื่อเปิดหน้าเข้าสู่ระบบ
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-4 rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
|
||||
@click="login()"
|
||||
>
|
||||
เข้าสู่ระบบด้วย Keycloak
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<slot v-else />
|
||||
</template>
|
||||
108
web/app/composables/useApi.ts
Normal file
108
web/app/composables/useApi.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* fetch wrapper ของ web portal — แนบ Bearer ทุก request,
|
||||
* โดน 401 → force refresh token แล้ว retry ครั้งเดียว (ยัง 401/refresh พัง → throw ให้ UI กลับไปหน้า login)
|
||||
*/
|
||||
import { AuthError } from './useAuth'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** ดึงข้อความ error จาก response ของ backend (string ตรงๆ หรือ ProblemDetails) */
|
||||
async function toApiError(res: Response): Promise<ApiError> {
|
||||
const fallback = `เรียก API ไม่สำเร็จ (HTTP ${res.status})`
|
||||
try {
|
||||
const text = (await res.text()).trim()
|
||||
if (!text) return new ApiError(res.status, fallback)
|
||||
try {
|
||||
const json = JSON.parse(text) as { title?: string; detail?: string }
|
||||
const msg = json.detail ?? json.title
|
||||
return new ApiError(res.status, msg ?? text)
|
||||
} catch {
|
||||
return new ApiError(res.status, text) // backend คืน message เป็น string เปล่าๆ (เช่น BadRequest)
|
||||
}
|
||||
} catch {
|
||||
return new ApiError(res.status, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
const config = useRuntimeConfig()
|
||||
const base = (config.public.apiBaseUrl as string).replace(/\/+$/, '')
|
||||
const { getAccessToken } = useAuth()
|
||||
|
||||
async function request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const doFetch = async (forceRefresh: boolean): Promise<Response> => {
|
||||
let token: string
|
||||
try {
|
||||
token = await getAccessToken(forceRefresh)
|
||||
} catch (err) {
|
||||
// refresh token ตาย — AuthError พร้อมข้อความชี้นำให้ login ใหม่ (สถานะ auth ถูกเคลียร์ไปแล้ว)
|
||||
throw err instanceof AuthError ? new ApiError(401, err.message) : err
|
||||
}
|
||||
return fetch(base + path, {
|
||||
...init,
|
||||
headers: { ...(init.headers as Record<string, string> | undefined), Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
let res = await doFetch(false)
|
||||
if (res.status === 401) res = await doFetch(true) // retry ครั้งเดียว
|
||||
return res
|
||||
}
|
||||
|
||||
/** เรียก API ที่คืน JSON (หรือ 204 → undefined) — !ok จะ throw ApiError */
|
||||
async function apiJson<T>(path: string, init: RequestInit = {}): Promise<T | undefined> {
|
||||
const res = await request(path, init)
|
||||
if (!res.ok) throw await toApiError(res)
|
||||
if (res.status === 204) return undefined
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
/** upload multipart — ห้าม set Content-Type เอง (browser ต้องใส่ boundary ให้) */
|
||||
async function apiUpload<T>(path: string, form: FormData, method: 'POST' | 'PUT' = 'POST'): Promise<T> {
|
||||
return (await apiJson<T>(path, { method, body: form })) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* ดาวน์โหลดไฟล์ผ่าน Bearer แล้วเซฟด้วย anchor download
|
||||
* (เว็บรันใน browser จริง — ไม่ใช่ webview ของ Word จึงใช้ blob download ได้ ต่างจาก add-in)
|
||||
*/
|
||||
async function downloadFile(path: string, fallbackName: string): Promise<void> {
|
||||
const res = await request(path)
|
||||
if (!res.ok) throw await toApiError(res)
|
||||
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filenameFrom(res.headers.get('Content-Disposition'), fallbackName)
|
||||
document.body.append(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000)
|
||||
}
|
||||
|
||||
return { request, apiJson, apiUpload, downloadFile }
|
||||
}
|
||||
|
||||
/** เอาชื่อไฟล์จาก Content-Disposition (รองรับ filename*=UTF-8 ที่ .NET ใส่มาสำหรับชื่อไทย) */
|
||||
function filenameFrom(header: string | null, fallback: string): string {
|
||||
if (!header) return fallback
|
||||
const utf8 = header.match(/filename\*=(?:UTF-8'')?([^;]+)/i)
|
||||
if (utf8) {
|
||||
try {
|
||||
return decodeURIComponent(utf8[1].replace(/^"|"$/g, ''))
|
||||
} catch {
|
||||
/* ผ่านต่อ */
|
||||
}
|
||||
}
|
||||
const plain = header.match(/filename=(?:"([^"]+)"|([^;]+))/i)
|
||||
if (plain) return (plain[1] ?? plain[2]).trim()
|
||||
return fallback
|
||||
}
|
||||
257
web/app/composables/useAuth.ts
Normal file
257
web/app/composables/useAuth.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* FR-8 ฝั่งเว็บ — OIDC authorization code + PKCE S256 ด้วย browser redirect ปกติ
|
||||
* (เว็บเป็น top-level page จึงไม่ต้องใช้ Office Dialog API แบบ add-in)
|
||||
*
|
||||
* การเก็บค่าตามกติกา:
|
||||
* - access/refresh/id token เก็บใน memory ของแท็บ (useState) เท่านั้น — ห้าม localStorage
|
||||
* - สิ่งที่ต้องข้าม navigation: code_verifier + state (ตอนรอ redirect กลับ) และ refresh token
|
||||
* (ไว้ restore session หลัง reload) — ใช้ sessionStorage อายุสั้นตามที่แผนอนุญาต
|
||||
*/
|
||||
import { buildAuthorizeUrl, createCodeChallenge, generateCodeVerifier, generateState, parseJwtPayload } from '~/utils/oidc'
|
||||
import type { TokenResponse } from '~/utils/oidc'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
const PENDING_KEY = 'wv-oidc-pending'
|
||||
const SESSION_KEY = 'wv-session'
|
||||
|
||||
interface PendingLogin {
|
||||
verifier: string
|
||||
state: string
|
||||
returnTo: string
|
||||
}
|
||||
|
||||
/** ส่วนที่ persist ใน sessionStorage — ไม่มี access token */
|
||||
interface StoredSession {
|
||||
refreshToken: string
|
||||
idToken?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
interface MemoryTokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
idToken?: string
|
||||
/** epoch ms ที่ token หมดอายุ */
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
tokens: MemoryTokens | null
|
||||
username: string | null
|
||||
/** false ระหว่าง restore session ตอน boot — กัน UI กระพริบไปหน้า login ก่อนรู้ผล */
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
export interface CallbackResult {
|
||||
ok: boolean
|
||||
returnTo?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class AuthError extends Error {}
|
||||
|
||||
// dedupe concurrent refresh (หลาย request เจอ 401 พร้อมกัน) — ใช้เฉพาะฝั่ง client
|
||||
let refreshInFlight: Promise<MemoryTokens> | null = null
|
||||
|
||||
export function useAuth() {
|
||||
const config = useRuntimeConfig()
|
||||
const authority = config.public.keycloakAuthority as string
|
||||
const clientId = config.public.keycloakClientId as string
|
||||
|
||||
const state = useState<AuthState>('auth', () => ({ tokens: null, username: null, ready: false }))
|
||||
const isAuthenticated = computed(() => state.value.tokens !== null)
|
||||
|
||||
function readPending(): PendingLogin | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PENDING_KEY)
|
||||
return raw ? (JSON.parse(raw) as PendingLogin) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(session: StoredSession | null) {
|
||||
if (session === null) sessionStorage.removeItem(SESSION_KEY)
|
||||
else sessionStorage.setItem(SESSION_KEY, JSON.stringify(session))
|
||||
}
|
||||
|
||||
function readSession(): StoredSession | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SESSION_KEY)
|
||||
return raw ? (JSON.parse(raw) as StoredSession) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** บันทึก token ลง memory + persist เฉพาะ refresh/id token ลง sessionStorage */
|
||||
function setTokens(res: TokenResponse) {
|
||||
const claims = parseJwtPayload(res.access_token)
|
||||
const expiresAt = res.expires_in
|
||||
? Date.now() + res.expires_in * 1000
|
||||
: typeof claims?.exp === 'number'
|
||||
? claims.exp * 1000
|
||||
: Date.now() + 60_000
|
||||
const username =
|
||||
(typeof claims?.preferred_username === 'string' && claims.preferred_username) ||
|
||||
(typeof claims?.sub === 'string' && claims.sub) ||
|
||||
state.value.username ||
|
||||
readSession()?.username ||
|
||||
''
|
||||
|
||||
const tokens: MemoryTokens = {
|
||||
accessToken: res.access_token,
|
||||
refreshToken: res.refresh_token ?? state.value.tokens?.refreshToken ?? readSession()?.refreshToken ?? '',
|
||||
idToken: res.id_token ?? state.value.tokens?.idToken ?? readSession()?.idToken,
|
||||
expiresAt,
|
||||
}
|
||||
if (!tokens.refreshToken) throw new AuthError('Keycloak ไม่ได้ส่ง refresh_token กลับมา')
|
||||
|
||||
state.value.tokens = tokens
|
||||
state.value.username = username || null
|
||||
writeSession({ refreshToken: tokens.refreshToken, idToken: tokens.idToken, username: state.value.username })
|
||||
}
|
||||
|
||||
/** เข้าสู่ระบบ — สร้าง PKCE pair แล้ว redirect ไป Keycloak (เต็มหน้า) */
|
||||
async function login(returnTo?: string) {
|
||||
if (!import.meta.client) return
|
||||
const verifier = generateCodeVerifier()
|
||||
const pending: PendingLogin = {
|
||||
verifier,
|
||||
state: generateState(),
|
||||
returnTo: returnTo ?? new URL(window.location.href).pathname,
|
||||
}
|
||||
sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending))
|
||||
|
||||
const challenge = await createCodeChallenge(verifier)
|
||||
const url = buildAuthorizeUrl({
|
||||
authorizeEndpoint: `${authority}/protocol/openid-connect/auth`,
|
||||
clientId,
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
state: pending.state,
|
||||
codeChallenge: challenge,
|
||||
})
|
||||
window.location.assign(url)
|
||||
}
|
||||
|
||||
async function postTokenEndpoint(body: URLSearchParams): Promise<TokenResponse> {
|
||||
const res = await fetch(`${authority}/protocol/openid-connect/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!res.ok) {
|
||||
let detail = ''
|
||||
try {
|
||||
const json = (await res.json()) as { error_description?: string; error?: string }
|
||||
detail = json.error_description ?? json.error ?? ''
|
||||
} catch {
|
||||
/* body ไม่ใช่ JSON — ใช้ status อย่างเดียว */
|
||||
}
|
||||
throw new AuthError(detail || `Keycloak ตอบ HTTP ${res.status}`)
|
||||
}
|
||||
return (await res.json()) as TokenResponse
|
||||
}
|
||||
|
||||
/** รับ code จากหน้า /auth/callback — validate state แล้วแลก token (เรียกจาก onMounted ของหน้า callback) */
|
||||
async function handleCallback(query: LocationQuery): Promise<CallbackResult> {
|
||||
if (typeof query.error === 'string')
|
||||
return { ok: false, error: `การเข้าสู่ระบบถูกปฏิเสธ: ${query.error_description ?? query.error}` }
|
||||
if (typeof query.code !== 'string' || typeof query.state !== 'string')
|
||||
return { ok: false, error: 'ไม่พบ authorization code ใน URL' }
|
||||
|
||||
const pending = readPending()
|
||||
sessionStorage.removeItem(PENDING_KEY)
|
||||
if (pending === null)
|
||||
return { ok: false, error: 'ไม่พบข้อมูลการเข้าสู่ระบบที่ค้างอยู่ (แท็บอาจถูกปิดระหว่างรอ) — ลองเข้าสู่ระบบใหม่' }
|
||||
if (pending.state !== query.state)
|
||||
return { ok: false, error: 'state ไม่ตรงกัน (อาจเป็นการโจมตี CSRF) — ยกเลิกการเข้าสู่ระบบ' }
|
||||
|
||||
try {
|
||||
const tokens = await postTokenEndpoint(
|
||||
new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: query.code,
|
||||
redirect_uri: `${window.location.origin}/auth/callback`,
|
||||
client_id: clientId,
|
||||
code_verifier: pending.verifier,
|
||||
}),
|
||||
)
|
||||
setTokens(tokens)
|
||||
// กัน open redirect — รับเฉพาะ path ภายในแอปเท่านั้น
|
||||
const returnTo = pending.returnTo.startsWith('/') && !pending.returnTo.startsWith('//') ? pending.returnTo : '/'
|
||||
return { ok: true, returnTo }
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : 'แลก token ไม่สำเร็จ' }
|
||||
}
|
||||
}
|
||||
|
||||
/** refresh token — คืน token ชุดใหม่ / throw เมื่อ refresh token ตาย (ต้อง login ใหม่) */
|
||||
async function refresh(): Promise<MemoryTokens> {
|
||||
const currentRefresh = state.value.tokens?.refreshToken ?? readSession()?.refreshToken
|
||||
if (!currentRefresh) {
|
||||
state.value.tokens = null
|
||||
state.value.username = null
|
||||
writeSession(null)
|
||||
throw new AuthError('ไม่มี session ให้ต่ออายุ — กรุณาเข้าสู่ระบบใหม่')
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await postTokenEndpoint(
|
||||
new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: currentRefresh,
|
||||
client_id: clientId,
|
||||
}),
|
||||
)
|
||||
setTokens(tokens)
|
||||
return state.value.tokens
|
||||
} catch (err) {
|
||||
// refresh token ตาย/ถูก revoke — เคลียร์ session แล้วให้ UI กลับไปหน้า login แบบ graceful
|
||||
state.value.tokens = null
|
||||
state.value.username = null
|
||||
writeSession(null)
|
||||
throw new AuthError(err instanceof Error ? `เซสชันหมดอายุ (${err.message}) — กรุณาเข้าสู่ระบบใหม่` : 'เซสชันหมดอายุ — กรุณาเข้าสู่ระบบใหม่')
|
||||
}
|
||||
}
|
||||
|
||||
/** access token ที่ยังใช้ได้ — refresh เงียบๆ ก่อนหมดอายุ (เหลือ < 30 วิ ถือว่าใกล้หมด) */
|
||||
async function getAccessToken(forceRefresh = false): Promise<string> {
|
||||
const t = state.value.tokens
|
||||
if (!forceRefresh && t && t.expiresAt - 30_000 > Date.now()) return t.accessToken
|
||||
if (refreshInFlight) return (await refreshInFlight).accessToken
|
||||
refreshInFlight = refresh().finally(() => (refreshInFlight = null))
|
||||
return (await refreshInFlight).accessToken
|
||||
}
|
||||
|
||||
/** ตอน boot — มี session ใน sessionStorage ก็ต่ออาย่เพื่อกลับมา login อยู่โดยไม่ต้อง redirect */
|
||||
async function restoreSession(): Promise<void> {
|
||||
if (!import.meta.client) {
|
||||
state.value.ready = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (readSession()) await getAccessToken(true)
|
||||
} catch {
|
||||
/* refresh พัง = ไม่ login อยู่ — แค่นั้น */
|
||||
} finally {
|
||||
state.value.ready = true
|
||||
}
|
||||
}
|
||||
|
||||
/** ออกจากระบบ — เคลียร์ของในเครื่องทั้งหมดแล้วปิด session ฝั่ง Keycloak ด้วย */
|
||||
function logout() {
|
||||
if (!import.meta.client) return
|
||||
const idToken = state.value.tokens?.idToken ?? readSession()?.idToken
|
||||
state.value.tokens = null
|
||||
state.value.username = null
|
||||
sessionStorage.removeItem(PENDING_KEY)
|
||||
writeSession(null)
|
||||
|
||||
const qs = new URLSearchParams({ post_logout_redirect_uri: `${window.location.origin}/` })
|
||||
if (idToken) qs.set('id_token_hint', idToken)
|
||||
window.location.assign(`${authority}/protocol/openid-connect/logout?${qs.toString()}`)
|
||||
}
|
||||
|
||||
return { state, isAuthenticated, username: computed(() => state.value.username), ready: computed(() => state.value.ready), login, handleCallback, getAccessToken, restoreSession, logout }
|
||||
}
|
||||
53
web/app/pages/auth/callback.vue
Normal file
53
web/app/pages/auth/callback.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script setup lang="ts">
|
||||
/** ปลายทางของ OIDC redirect — รับ code+state แลก token แล้วพากลับหน้าที่ login จาก (ทำงานฝั่ง client ทั้งหมด) */
|
||||
const route = useRoute()
|
||||
const { handleCallback, login } = useAuth()
|
||||
|
||||
const message = ref('กำลังตรวจสอบผลการเข้าสู่ระบบ…')
|
||||
const failed = ref(false)
|
||||
const busy = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
const result = await handleCallback(route.query)
|
||||
if (result.ok) {
|
||||
// replace เพื่อเช็ด ?code=...&state=... ออกจาก history
|
||||
await navigateTo(result.returnTo ?? '/', { replace: true })
|
||||
return
|
||||
}
|
||||
failed.value = true
|
||||
busy.value = false
|
||||
message.value = result.error ?? 'เข้าสู่ระบบไม่สำเร็จ'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto max-w-xl space-y-4 py-10 text-center">
|
||||
<template v-if="busy">
|
||||
<div class="flex items-center justify-center gap-3 text-sm text-slate-500 dark:text-slate-400">
|
||||
<span
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
|
||||
/>
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<AlertBox type="error">{{ message }}</AlertBox>
|
||||
<div class="flex justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
|
||||
@click="login()"
|
||||
>
|
||||
เข้าสู่ระบบใหม่
|
||||
</button>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
|
||||
>
|
||||
กลับหน้าหลัก
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
222
web/app/pages/documents/create/[templateId].vue
Normal file
222
web/app/pages/documents/create/[templateId].vue
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด */
|
||||
import type { GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const { apiJson, downloadFile } = useApi()
|
||||
|
||||
const templateId = computed(() => String(route.params.templateId))
|
||||
const detail = ref<TemplateDetail | null>(null)
|
||||
const values = ref<Record<string, string>>({})
|
||||
const issues = ref<Record<string, string>>({})
|
||||
|
||||
const loading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const submitting = ref(false)
|
||||
const submitError = ref<string | null>(null)
|
||||
const result = ref<GenerateDocumentResponse | null>(null)
|
||||
const downloading = ref(false)
|
||||
const downloadError = ref<string | null>(null)
|
||||
|
||||
const sortedFields = computed(() => [...(detail.value?.fields ?? [])].sort((a, b) => a.sortOrder - b.sortOrder))
|
||||
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
detail.value
|
||||
? `สร้างเอกสาร: ${detail.value.name} — e-Court Documents Versioning`
|
||||
: 'สร้างเอกสาร — e-Court Documents Versioning',
|
||||
),
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
detail.value = (await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`))!
|
||||
values.value = Object.fromEntries(detail.value.fields.map((f) => [f.key, '']))
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : 'โหลด template ไม่สำเร็จ'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** validate ฝั่ง client (required/type) ก่อนยิง backend — backend ตรวจซ้ำอีกชั้นเสมอ */
|
||||
async function submit() {
|
||||
submitError.value = null
|
||||
downloadError.value = null
|
||||
issues.value = Object.fromEntries(
|
||||
validateFieldValues(sortedFields.value, values.value).map((i) => [i.key, i.message]),
|
||||
)
|
||||
if (Object.keys(issues.value).length > 0) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
result.value = await apiJson<GenerateDocumentResponse>('/api/documents/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ templateId: templateId.value, values: values.value }),
|
||||
})
|
||||
window.scrollTo({ top: 0 })
|
||||
} catch (err) {
|
||||
submitError.value = err instanceof Error ? err.message : 'สร้างเอกสารไม่สำเร็จ'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
if (!result.value) return
|
||||
downloading.value = true
|
||||
downloadError.value = null
|
||||
try {
|
||||
const name = detail.value ? `${detail.value.name}_v${result.value.versionNumber}.docx` : 'document_v1.docx'
|
||||
await downloadFile(result.value.downloadUrl, name)
|
||||
} catch (err) {
|
||||
downloadError.value = err instanceof Error ? err.message : 'ดาวน์โหลดไม่สำเร็จ'
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม */
|
||||
function resetForm() {
|
||||
result.value = null
|
||||
submitError.value = null
|
||||
downloadError.value = null
|
||||
issues.value = {}
|
||||
values.value = Object.fromEntries((detail.value?.fields ?? []).map((f) => [f.key, '']))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthGate>
|
||||
<section class="mx-auto max-w-2xl space-y-6">
|
||||
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
|
||||
<span
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
|
||||
/>
|
||||
กำลังโหลด template…
|
||||
</div>
|
||||
|
||||
<AlertBox v-else-if="loadError" type="error">
|
||||
{{ loadError }}
|
||||
<NuxtLink to="/" class="ml-1 font-medium underline">กลับหน้าหลัก</NuxtLink>
|
||||
</AlertBox>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<!-- ผลลัพธ์: สร้างสำเร็จเป็น v1 แล้ว -->
|
||||
<template v-if="result">
|
||||
<AlertBox type="success">
|
||||
สร้างเอกสารสำเร็จ — บันทึกเป็น <strong>version {{ result.versionNumber }}</strong> แล้ว
|
||||
</AlertBox>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800">
|
||||
<h1 class="text-xl font-bold">{{ detail.name }}</h1>
|
||||
<dl class="mt-4 space-y-2 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<dt class="w-32 shrink-0 text-slate-500 dark:text-slate-400">document id</dt>
|
||||
<dd class="break-all font-mono text-xs leading-6">{{ result.documentId }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<dt class="w-32 shrink-0 text-slate-500 dark:text-slate-400">version</dt>
|
||||
<dd>{{ result.versionNumber }} (ต้นทางของ version history)</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-5 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="downloading"
|
||||
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
|
||||
@click="download"
|
||||
>
|
||||
{{ downloading ? 'กำลังเตรียมไฟล์…' : '⬇ ดาวน์โหลด .docx' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
|
||||
@click="resetForm"
|
||||
>
|
||||
สร้างอีกฉบับ
|
||||
</button>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
|
||||
>
|
||||
กลับหน้าหลัก
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<AlertBox v-if="downloadError" type="error" class="mt-4">{{ downloadError }}</AlertBox>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-sky-200 bg-sky-50 p-4 text-sm dark:border-sky-800 dark:bg-sky-950">
|
||||
<p class="font-medium text-sky-800 dark:text-sky-300">ขั้นต่อไป</p>
|
||||
<ol class="mt-2 list-decimal space-y-1 pl-5 text-sky-800 dark:text-sky-200">
|
||||
<li>เปิดไฟล์ที่ดาวน์โหลดใน Word (Desktop หรือ Online)</li>
|
||||
<li>
|
||||
เปิด task pane ของ add-in <strong>e-Court Documents Versioning</strong> — ระบบจะตรวจพบว่าเป็น
|
||||
tracked document และแสดง <strong>version 1</strong> ทันที โดยไม่ต้องกด Start Versioning
|
||||
</li>
|
||||
<li>แก้ไขเอกสารต่อแล้วกด <strong>Save & Version</strong> เพื่อบันทึก version ถัดไป</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ฟอร์มกรอกค่า -->
|
||||
<form v-else class="space-y-5" @submit.prevent="submit" novalidate>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ detail.name }}</h1>
|
||||
<p v-if="detail.description" class="mt-1 text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ detail.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="f in sortedFields"
|
||||
:key="f.key"
|
||||
>
|
||||
<label :for="`field-${f.key}`" class="mb-1 block text-sm font-medium">
|
||||
{{ f.label }}
|
||||
<span v-if="f.isRequired" class="text-red-500">*</span>
|
||||
<span class="ml-1 font-mono text-xs font-normal text-slate-400">{{ f.key }}</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
:id="`field-${f.key}`"
|
||||
v-model="values[f.key]"
|
||||
:type="f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'"
|
||||
:step="f.type === 'number' ? 'any' : undefined"
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
:class="issues[f.key] ? 'border-red-400 dark:border-red-500' : ''"
|
||||
/>
|
||||
<p v-if="issues[f.key]" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ issues[f.key] }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="sortedFields.length === 0"
|
||||
class="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
|
||||
>
|
||||
template นี้ไม่มี field definitions —
|
||||
<NuxtLink :to="`/templates/${detail.id}/edit`" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
ตรวจสอบที่หน้าจัดการ template
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<AlertBox v-if="submitError" type="error">{{ submitError }}</AlertBox>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting || sortedFields.length === 0"
|
||||
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 1)' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
|
|
@ -1,15 +1,71 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-10 UI — หน้าหลัก: card รายการ template ที่ active ให้เลือกสร้างเอกสาร */
|
||||
import type { TemplateListItem } from '~/types/api'
|
||||
|
||||
const { apiJson } = useApi()
|
||||
|
||||
const templates = ref<TemplateListItem[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
templates.value = (await apiJson<TemplateListItem[]>('/api/templates')) ?? []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'โหลดรายการ template ไม่สำเร็จ'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<AuthGate>
|
||||
<section class="space-y-5">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">สร้างเอกสารจาก template</h1>
|
||||
<p class="text-slate-600 dark:text-slate-300">
|
||||
เลือก template กรอกค่า fields แล้วดาวน์โหลด .docx ที่เป็น version 1 พร้อม metadata
|
||||
ไปเปิดต่อใน Word พร้อม add-in
|
||||
<p class="mt-1 text-sm text-slate-600 dark:text-slate-300">
|
||||
เลือก template กรอกค่า fields แล้วดาวน์โหลด .docx ที่เป็น <strong>version 1</strong> พร้อม metadata
|
||||
ไว้เปิดต่อใน Word พร้อม add-in
|
||||
</p>
|
||||
<!-- Phase 6: รายการ template ที่ active + ลิงก์เข้าหน้า documents/create/[templateId] -->
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
|
||||
<span
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
|
||||
/>
|
||||
กำลังโหลดรายการ template…
|
||||
</div>
|
||||
|
||||
<AlertBox v-else-if="error" type="error">{{ error }}</AlertBox>
|
||||
|
||||
<div
|
||||
v-else-if="templates.length === 0"
|
||||
class="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
|
||||
>
|
||||
รายการ template จะแสดงที่นี่ (Phase 6)
|
||||
ยังไม่มี template — ไปที่
|
||||
<NuxtLink to="/templates/new" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
สร้าง template ตัวแรก
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2">
|
||||
<NuxtLink
|
||||
v-for="t in templates"
|
||||
:key="t.id"
|
||||
:to="`/documents/create/${t.id}`"
|
||||
class="block rounded-lg border border-slate-200 bg-white p-5 transition hover:border-sky-400 hover:shadow-sm dark:border-slate-700 dark:bg-slate-800 dark:hover:border-sky-500"
|
||||
>
|
||||
<h2 class="font-semibold">{{ t.name }}</h2>
|
||||
<p class="mt-1 line-clamp-2 min-h-10 text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ t.description ?? '—' }}
|
||||
</p>
|
||||
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ t.fieldCount }} fields · อัปเดต {{ formatDateTime(t.updatedAt) }}
|
||||
</p>
|
||||
<p class="mt-3 text-sm font-medium text-sky-600 dark:text-sky-400">สร้างเอกสาร →</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
|
|
|
|||
325
web/app/pages/templates/[id]/edit.vue
Normal file
325
web/app/pages/templates/[id]/edit.vue
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-9 UI — แก้ไข template: metadata + field definitions (label/type/required/ลำดับ) + แทนที่ไฟล์ */
|
||||
import type { FieldType, ReplaceTemplateFileResponse, TemplateDetail } from '~/types/api'
|
||||
|
||||
const route = useRoute()
|
||||
const { apiJson, apiUpload } = useApi()
|
||||
useHead({ title: 'แก้ไข template — e-Court Documents Versioning' })
|
||||
|
||||
const templateId = computed(() => String(route.params.id))
|
||||
const FIELD_TYPES: FieldType[] = ['text', 'number', 'date']
|
||||
|
||||
const detail = ref<TemplateDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
// ฟอร์ม metadata
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const isActive = ref(true)
|
||||
|
||||
// ฟอร์ม field defs (แก้ใน local copy แล้วส่งทั้งชุดตอนบันทึก)
|
||||
const fields = ref<(TemplateDetail['fields'][number] & { label: string })[]>([])
|
||||
|
||||
const saving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
const savedAt = ref<string | null>(null)
|
||||
|
||||
const replaceFile = ref<File | null>(null)
|
||||
const replaceInput = ref<HTMLInputElement | null>(null)
|
||||
const replacing = ref(false)
|
||||
const replaceError = ref<string | null>(null)
|
||||
const replaceResult = ref<ReplaceTemplateFileResponse | null>(null)
|
||||
|
||||
function applyDetail(d: TemplateDetail) {
|
||||
detail.value = d
|
||||
name.value = d.name
|
||||
description.value = d.description ?? ''
|
||||
isActive.value = d.isActive
|
||||
fields.value = d.fields.map((f) => ({ ...f }))
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
applyDetail((await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`))!)
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : 'โหลดข้อมูล template ไม่สำเร็จ'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saveError.value = null
|
||||
savedAt.value = null
|
||||
if (!name.value.trim()) {
|
||||
saveError.value = 'ชื่อ template ห้ามว่าง'
|
||||
return
|
||||
}
|
||||
if (fields.value.some((f) => !f.label.trim())) {
|
||||
saveError.value = 'ทุก field ต้องมี label'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const updated = await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim() || null,
|
||||
isActive: isActive.value,
|
||||
fields: fields.value.map((f) => ({
|
||||
key: f.key,
|
||||
label: f.label.trim(),
|
||||
type: f.type,
|
||||
isRequired: f.isRequired,
|
||||
sortOrder: f.sortOrder,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
applyDetail(updated!)
|
||||
savedAt.value = new Date().toLocaleTimeString('th-TH')
|
||||
} catch (err) {
|
||||
saveError.value = err instanceof Error ? err.message : 'บันทึกไม่สำเร็จ'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onReplaceFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
replaceFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
async function submitReplaceFile() {
|
||||
replaceError.value = null
|
||||
replaceResult.value = null
|
||||
if (!replaceFile.value) {
|
||||
replaceError.value = 'เลือกไฟล์ .docx ใหม่ก่อน'
|
||||
return
|
||||
}
|
||||
if (!replaceFile.value.name.toLowerCase().endsWith('.docx')) {
|
||||
replaceError.value = 'รองรับเฉพาะไฟล์ .docx เท่านั้น'
|
||||
return
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
form.append('file', replaceFile.value)
|
||||
|
||||
replacing.value = true
|
||||
try {
|
||||
const result = await apiUpload<ReplaceTemplateFileResponse>(
|
||||
`/api/templates/${templateId.value}/file`,
|
||||
form,
|
||||
'PUT',
|
||||
)
|
||||
replaceResult.value = result
|
||||
await load()
|
||||
replaceFile.value = null
|
||||
if (replaceInput.value) replaceInput.value.value = ''
|
||||
} catch (err) {
|
||||
replaceError.value = err instanceof Error ? err.message : 'แทนที่ไฟล์ไม่สำเร็จ'
|
||||
} finally {
|
||||
replacing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthGate>
|
||||
<section class="mx-auto max-w-4xl space-y-8">
|
||||
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
|
||||
<span
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
|
||||
/>
|
||||
กำลังโหลด…
|
||||
</div>
|
||||
|
||||
<AlertBox v-else-if="loadError" type="error">{{ loadError }}</AlertBox>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ detail.name }}</h1>
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
สร้าง {{ formatDateTime(detail.createdAt) }} · อัปเดต {{ formatDateTime(detail.updatedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink
|
||||
to="/templates"
|
||||
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
|
||||
>
|
||||
← รายการ template
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 1) metadata -->
|
||||
<form class="space-y-4" @submit.prevent="save">
|
||||
<h2 class="text-lg font-semibold">ข้อมูล template</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="edit-name" class="mb-1 block text-sm font-medium">ชื่อ</label>
|
||||
<input
|
||||
id="edit-name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end gap-2 pb-1">
|
||||
<input
|
||||
id="edit-active"
|
||||
v-model="isActive"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-slate-300 dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
<label for="edit-active" class="text-sm">
|
||||
active
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400">(เอาออก = สร้างเอกสารใหม่ไม่ได้ แต่เอกสารเดิมยังใช้ได้)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="edit-desc" class="mb-1 block text-sm font-medium">คำอธิบาย</label>
|
||||
<textarea
|
||||
id="edit-desc"
|
||||
v-model="description"
|
||||
rows="2"
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 2) field definitions -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Field definitions</h2>
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span class="font-mono">key</span> มาจาก content control tag ในไฟล์ — เพิ่ม/ลบ key ต้องแทนที่ไฟล์ด้านล่าง
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
<tr>
|
||||
<th class="px-4 py-3">key</th>
|
||||
<th class="px-4 py-3">label</th>
|
||||
<th class="px-4 py-3">type</th>
|
||||
<th class="px-4 py-3 text-center">required</th>
|
||||
<th class="px-4 py-3">ลำดับ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<tr v-for="(f, i) in fields" :key="f.key">
|
||||
<td class="px-4 py-2 font-mono text-xs">{{ f.key }}</td>
|
||||
<td class="px-4 py-2">
|
||||
<input
|
||||
v-model="f.label"
|
||||
type="text"
|
||||
:aria-label="`label ของ ${f.key}`"
|
||||
class="w-full min-w-40 rounded-md border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<select
|
||||
v-model="f.type"
|
||||
:aria-label="`type ของ ${f.key}`"
|
||||
class="rounded-md border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
>
|
||||
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-center">
|
||||
<input
|
||||
v-model="f.isRequired"
|
||||
type="checkbox"
|
||||
:aria-label="`required ของ ${f.key}`"
|
||||
class="h-4 w-4 rounded border-slate-300 dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<input
|
||||
v-model.number="f.sortOrder"
|
||||
type="number"
|
||||
:aria-label="`ลำดับของ ${f.key}`"
|
||||
class="w-20 rounded-md border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="fields.length === 0">
|
||||
<td colspan="5" class="px-4 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
ไฟล์นี้ไม่มี content control เลย — แทนที่ไฟล์ด้วยไฟล์ที่มี content controls
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? 'กำลังบันทึก…' : 'บันทึกทั้งหมด' }}
|
||||
</button>
|
||||
<span v-if="savedAt && !saveError" class="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
บันทึกแล้วเมื่อ {{ savedAt }}
|
||||
</span>
|
||||
</div>
|
||||
<AlertBox v-if="saveError" type="error">{{ saveError }}</AlertBox>
|
||||
</div>
|
||||
|
||||
<!-- 3) replace file -->
|
||||
<div class="space-y-3 border-t border-slate-200 pt-6 dark:border-slate-700">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">แทนที่ไฟล์ template</h2>
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
ระบบจะ detect tags ใหม่จากไฟล์ — field definition ของ tag ที่หายไปจะถูกลบ, tag ใหม่จะเพิ่มแบบ default
|
||||
(เอกสารที่สร้างไปแล้วไม่กระทบ)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref="replaceInput"
|
||||
type="file"
|
||||
accept=".docx"
|
||||
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-slate-100 file:px-3 file:py-1 file:text-sm dark:border-slate-600 dark:bg-slate-800 dark:file:bg-slate-700"
|
||||
@change="onReplaceFileChange"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="replacing || !replaceFile"
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:hover:bg-slate-800"
|
||||
@click="submitReplaceFile"
|
||||
>
|
||||
{{ replacing ? 'กำลังอัปโหลด…' : 'อัปโหลดไฟล์ใหม่' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AlertBox v-if="replaceError" type="error">{{ replaceError }}</AlertBox>
|
||||
<AlertBox v-if="replaceResult" type="success">
|
||||
แทนที่ไฟล์สำเร็จ —
|
||||
เพิ่ม {{ replaceResult.addedTags.length }} tags
|
||||
<span v-if="replaceResult.addedTags.length" class="font-mono">({{ replaceResult.addedTags.join(', ') }})</span>,
|
||||
ลบ {{ replaceResult.removedTags.length }} tags
|
||||
<span v-if="replaceResult.removedTags.length" class="font-mono">({{ replaceResult.removedTags.join(', ') }})</span>
|
||||
— ตรวจและบันทึก field definitions ด้านบนอีกครั้ง
|
||||
</AlertBox>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
167
web/app/pages/templates/index.vue
Normal file
167
web/app/pages/templates/index.vue
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-9 UI — รายการ template ทั้งหมด (admin): ดู/แก้ไข/activate-deactivate */
|
||||
import type { TemplateListItem } from '~/types/api'
|
||||
|
||||
const { apiJson } = useApi()
|
||||
useHead({ title: 'จัดการ template — e-Court Documents Versioning' })
|
||||
|
||||
const templates = ref<TemplateListItem[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const showInactive = ref(false)
|
||||
const busyId = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const qs = showInactive.value ? '?includeInactive=true' : ''
|
||||
templates.value = (await apiJson<TemplateListItem[]>(`/api/templates${qs}`)) ?? []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'โหลดรายการ template ไม่สำเร็จ'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** deactivate = DELETE (soft-delete), activate กลับ = PUT { isActive: true } */
|
||||
async function toggleActive(t: TemplateListItem) {
|
||||
const verb = t.isActive ? 'deactivate' : 'activate'
|
||||
if (!window.confirm(`ยืนยัน${verb === 'deactivate' ? 'การ deactivate' : 'การ activate'} template “${t.name}”?`)) return
|
||||
|
||||
busyId.value = t.id
|
||||
error.value = null
|
||||
try {
|
||||
if (t.isActive) await apiJson(`/api/templates/${t.id}`, { method: 'DELETE' })
|
||||
else await apiJson(`/api/templates/${t.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: true }),
|
||||
})
|
||||
await load()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : `${verb} ไม่สำเร็จ`
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(showInactive, load)
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthGate>
|
||||
<section class="space-y-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold">จัดการ template</h1>
|
||||
<NuxtLink
|
||||
to="/templates/new"
|
||||
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
|
||||
>
|
||||
+ สร้าง template ใหม่
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<input
|
||||
v-model="showInactive"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-slate-300 dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
แสดง template ที่ deactivate ไว้ด้วย
|
||||
</label>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
|
||||
<span
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
|
||||
/>
|
||||
กำลังโหลด…
|
||||
</div>
|
||||
|
||||
<AlertBox v-else-if="error" type="error">{{ error }}</AlertBox>
|
||||
|
||||
<div
|
||||
v-else-if="templates.length === 0"
|
||||
class="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
|
||||
>
|
||||
ยังไม่มี template{{ showInactive ? ' เลย' : 'ที่ active' }} —
|
||||
<NuxtLink to="/templates/new" class="font-medium text-sky-600 hover:underline dark:text-sky-400">
|
||||
สร้าง template แรก
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
<tr>
|
||||
<th class="px-4 py-3">ชื่อ</th>
|
||||
<th class="px-4 py-3">คำอธิบาย</th>
|
||||
<th class="px-4 py-3 text-center">fields</th>
|
||||
<th class="px-4 py-3">สถานะ</th>
|
||||
<th class="px-4 py-3">อัปเดตล่าสุด</th>
|
||||
<th class="px-4 py-3 text-right">การกระทำ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<tr v-for="t in templates" :key="t.id" class="align-top">
|
||||
<td class="px-4 py-3 font-medium">
|
||||
<NuxtLink
|
||||
:to="`/templates/${t.id}/edit`"
|
||||
class="text-sky-700 hover:underline dark:text-sky-400"
|
||||
>
|
||||
{{ t.name }}
|
||||
</NuxtLink>
|
||||
</td>
|
||||
<td class="max-w-60 px-4 py-3 text-slate-600 dark:text-slate-300">{{ t.description ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-center">{{ t.fieldCount }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
v-if="t.isActive"
|
||||
class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300"
|
||||
>
|
||||
active
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="rounded-full bg-slate-200 px-2 py-0.5 text-xs font-medium text-slate-600 dark:bg-slate-700 dark:text-slate-300"
|
||||
>
|
||||
deactivated
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">{{ formatDateTime(t.updatedAt) }}</td>
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
<NuxtLink
|
||||
:to="`/templates/${t.id}/edit`"
|
||||
class="rounded-md border border-slate-300 px-2.5 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
|
||||
>
|
||||
แก้ไข
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="busyId === t.id"
|
||||
class="ml-2 rounded-md border px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
:class="
|
||||
t.isActive
|
||||
? 'border-red-300 text-red-600 hover:bg-red-50 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-950'
|
||||
: 'border-emerald-300 text-emerald-600 hover:bg-emerald-50 dark:border-emerald-800 dark:text-emerald-400 dark:hover:bg-emerald-950'
|
||||
"
|
||||
@click="toggleActive(t)"
|
||||
>
|
||||
{{ busyId === t.id ? '…' : t.isActive ? 'deactivate' : 'activate' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||
การ deactivate เป็น soft-delete — เอกสารที่สร้างจาก template นั้นไปแล้วยังใช้งานต่อได้ตามปกติ
|
||||
</p>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
164
web/app/pages/templates/new.vue
Normal file
164
web/app/pages/templates/new.vue
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<script setup lang="ts">
|
||||
/** FR-9 UI — upload template ใหม่: ระบบ detect fields (SDT tags) จากไฟล์ให้อัตโนมัติ */
|
||||
import type { CreateTemplateResponse } from '~/types/api'
|
||||
|
||||
const { apiUpload } = useApi()
|
||||
useHead({ title: 'สร้าง template ใหม่ — e-Court Documents Versioning' })
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const file = ref<File | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const submitting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const created = ref<CreateTemplateResponse | null>(null)
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
file.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
if (!name.value.trim()) {
|
||||
error.value = 'กรอกชื่อ template ด้วย'
|
||||
return
|
||||
}
|
||||
if (!file.value) {
|
||||
error.value = 'เลือกไฟล์ .docx ของ template ด้วย'
|
||||
return
|
||||
}
|
||||
if (!file.value.name.toLowerCase().endsWith('.docx')) {
|
||||
error.value = 'รองรับเฉพาะไฟล์ .docx เท่านั้น'
|
||||
return
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
form.append('name', name.value.trim())
|
||||
if (description.value.trim()) form.append('description', description.value.trim())
|
||||
form.append('file', file.value)
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
created.value = await apiUpload<CreateTemplateResponse>('/api/templates', form)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'อัปโหลด template ไม่สำเร็จ'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** เคลียร์ฟอร์มเพื่ออัปโหลดไฟล์ถัดไป */
|
||||
function resetForm() {
|
||||
created.value = null
|
||||
name.value = ''
|
||||
description.value = ''
|
||||
file.value = null
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthGate>
|
||||
<section class="mx-auto max-w-2xl space-y-5">
|
||||
<h1 class="text-2xl font-bold">สร้าง template ใหม่</h1>
|
||||
|
||||
<!-- ผลลัพธ์หลังอัปโหลดสำเร็จ: field list ที่ detect ได้จากไฟล์ -->
|
||||
<template v-if="created">
|
||||
<AlertBox type="success">
|
||||
อัปโหลดสำเร็จ — ตรวจพบ <strong>{{ created.fields.length }}</strong> fields จากไฟล์
|
||||
</AlertBox>
|
||||
|
||||
<div class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
<tr>
|
||||
<th class="px-4 py-3">key (SDT tag)</th>
|
||||
<th class="px-4 py-3">label เริ่มต้น</th>
|
||||
<th class="px-4 py-3">type เริ่มต้น</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<tr v-for="f in created.fields" :key="f.key">
|
||||
<td class="px-4 py-2 font-mono text-xs">{{ f.key }}</td>
|
||||
<td class="px-4 py-2">{{ f.label }}</td>
|
||||
<td class="px-4 py-2">{{ f.type }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<NuxtLink
|
||||
:to="`/templates/${created.id}/edit`"
|
||||
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700"
|
||||
>
|
||||
ตั้งค่า label/type/required ต่อ →
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
|
||||
@click="resetForm"
|
||||
>
|
||||
อัปโหลดอีกไฟล์
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form v-else class="space-y-4" @submit.prevent="submit">
|
||||
<AlertBox v-if="error" type="error">{{ error }}</AlertBox>
|
||||
|
||||
<div>
|
||||
<label for="tpl-name" class="mb-1 block text-sm font-medium">ชื่อ template <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
id="tpl-name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="เช่น สัญญาจ้างงาน"
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tpl-desc" class="mb-1 block text-sm font-medium">คำอธิบาย</label>
|
||||
<textarea
|
||||
id="tpl-desc"
|
||||
v-model="description"
|
||||
rows="2"
|
||||
placeholder="คำอธิบายสั้นๆ ว่าเอกสารนี้ใช้ทำอะไร"
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tpl-file" class="mb-1 block text-sm font-medium">ไฟล์ template (.docx) <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
id="tpl-file"
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".docx"
|
||||
required
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-slate-100 file:px-3 file:py-1 file:text-sm dark:border-slate-600 dark:bg-slate-800 dark:file:bg-slate-700"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
สร้างไฟล์ด้วย Word แล้วใส่
|
||||
<strong>Content Controls</strong> (Developer → Rich Text/Plain Text Control) โดยตั้งค่า
|
||||
<span class="font-mono">Tag</span> ของแต่ละ control เป็น field key — ระบบจะ detect tags
|
||||
ออกมาเป็นรายการ fields ให้อัตโนมัติ
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{{ submitting ? 'กำลังอัปโหลด…' : 'อัปโหลด template' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</AuthGate>
|
||||
</template>
|
||||
8
web/app/plugins/auth.client.ts
Normal file
8
web/app/plugins/auth.client.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* ต่ออายุ session ตอนแอป boot (client เท่านั้น) — ถ้ามี refresh token ใน sessionStorage
|
||||
* ก็ได้ access token กลับมาโดยไม่ต้อง redirect ไป Keycloak ใหม่
|
||||
* เป็น async plugin เพื่อรอผลก่อนหน้าแรก render (กัน UI กระพริบไปหน้า login แป๊บนึง)
|
||||
*/
|
||||
export default defineNuxtPlugin(async () => {
|
||||
await useAuth().restoreSession()
|
||||
})
|
||||
56
web/app/types/api.ts
Normal file
56
web/app/types/api.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/** DTO ตรงกับ JSON ที่ backend คืน (camelCase — ดู Controllers/*.cs + *Dtos.cs) */
|
||||
|
||||
export type FieldType = 'text' | 'number' | 'date'
|
||||
|
||||
export interface TemplateFieldDto {
|
||||
id: string
|
||||
/** = SDT tag ในไฟล์ template — ใช้เป็น key ของ values ตอน generate */
|
||||
key: string
|
||||
label: string
|
||||
type: FieldType | string
|
||||
isRequired: boolean
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface TemplateListItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
isActive: boolean
|
||||
fieldCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface TemplateDetail {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
fields: TemplateFieldDto[]
|
||||
}
|
||||
|
||||
/** response ของ POST /api/templates — fields ที่ detect อัตโนมัติพร้อมค่า default */
|
||||
export interface CreateTemplateResponse {
|
||||
id: string
|
||||
fields: TemplateFieldDto[]
|
||||
}
|
||||
|
||||
/** response ของ PUT /api/templates/{id}/file */
|
||||
export interface ReplaceTemplateFileResponse {
|
||||
id: string
|
||||
fields: TemplateFieldDto[]
|
||||
addedTags: string[]
|
||||
removedTags: string[]
|
||||
}
|
||||
|
||||
/** response ของ POST /api/documents/generate (FR-10) */
|
||||
export interface GenerateDocumentResponse {
|
||||
documentId: string
|
||||
versionId: string
|
||||
versionNumber: number
|
||||
/** path สัมพัทธ์ของ backend — เติม apiBaseUrl ตอนเรียก */
|
||||
downloadUrl: string
|
||||
}
|
||||
41
web/app/utils/fieldValidation.ts
Normal file
41
web/app/utils/fieldValidation.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Validation ฝั่ง client ของ dynamic form สร้างเอกสาร (FR-10) — pure function ให้ unit test ได้
|
||||
* เงื่อนไขตรงกับ backend (DocumentsController.Generate): required ห้ามว่าง,
|
||||
* number ต้อง parse เป็นเลขได้, date ต้องเป็นวันที่ ISO ที่ parse ได้ — client เช็คก่อนเพื่อ feedback ทันที
|
||||
*/
|
||||
|
||||
export interface FieldDefLike {
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
isRequired: boolean
|
||||
}
|
||||
|
||||
export interface FieldIssue {
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/** ตัวเลข: จำนวนเต็ม/ทศนิยม ติดลบได้ มี comma คั่นหลักได้ (ตรง NumberStyles.Number ของ backend) */
|
||||
const NUMBER_RE = /^[+-]?\d+(?:\.\d+)?$/
|
||||
|
||||
export function validateFieldValues(
|
||||
defs: FieldDefLike[],
|
||||
values: Record<string, string>,
|
||||
): FieldIssue[] {
|
||||
const issues: FieldIssue[] = []
|
||||
for (const def of defs) {
|
||||
const raw = (values[def.key] ?? '').trim()
|
||||
if (raw === '') {
|
||||
if (def.isRequired) issues.push({ key: def.key, message: `“${def.label}” เป็นช่องที่ต้องกรอก` })
|
||||
continue
|
||||
}
|
||||
if (def.type === 'number' && !NUMBER_RE.test(raw.replace(/,/g, ''))) {
|
||||
issues.push({ key: def.key, message: `“${def.label}” ต้องเป็นตัวเลข` })
|
||||
}
|
||||
if (def.type === 'date' && Number.isNaN(Date.parse(raw))) {
|
||||
issues.push({ key: def.key, message: `“${def.label}” ต้องเป็นวันที่ (YYYY-MM-DD)` })
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
6
web/app/utils/format.ts
Normal file
6
web/app/utils/format.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** format วันที่เป็นภาษาไทย — เรียกจากฝั่ง client เท่านั้น (ข้อมูล API ดึงหลัง mount จึงไม่มีปัญหา hydration) */
|
||||
export function formatDateTime(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return new Intl.DateTimeFormat('th-TH', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
75
web/app/utils/oidc.ts
Normal file
75
web/app/utils/oidc.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Helpers สำหรับ OIDC + PKCE — เป็น pure functions (แยกจาก useAuth) เพื่อ unit test ได้ง่าย
|
||||
* เว็บเป็น top-level page จึงใช้ browser redirect ปกติได้ (ไม่ต้องมี Office Dialog API แบบ add-in)
|
||||
*/
|
||||
|
||||
/** เข้ารหัส bytes เป็น base64url (ไม่มี padding) ตาม RFC 7636 */
|
||||
export function toBase64Url(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const b of bytes) binary += String.fromCharCode(b)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/** code_verifier ของ PKCE — 48 random bytes → base64url 64 อักขระ (อยู่ในช่วง 43–128 ตาม spec) */
|
||||
export function generateCodeVerifier(): string {
|
||||
const bytes = new Uint8Array(48)
|
||||
crypto.getRandomValues(bytes)
|
||||
return toBase64Url(bytes)
|
||||
}
|
||||
|
||||
/** state สุ่มสำหรับกัน CSRF — เทียบตอนรับ code กลับ */
|
||||
export function generateState(): string {
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
return toBase64Url(bytes)
|
||||
}
|
||||
|
||||
/** code_challenge = base64url(SHA-256(verifier)) วิธี S256 */
|
||||
export async function createCodeChallenge(verifier: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
||||
return toBase64Url(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
export interface AuthorizeUrlParams {
|
||||
authorizeEndpoint: string
|
||||
clientId: string
|
||||
redirectUri: string
|
||||
state: string
|
||||
codeChallenge: string
|
||||
scope?: string
|
||||
}
|
||||
|
||||
/** ประกอบ authorization URL แบบ authorization code + PKCE S256 */
|
||||
export function buildAuthorizeUrl(params: AuthorizeUrlParams): string {
|
||||
const qs = new URLSearchParams({
|
||||
client_id: params.clientId,
|
||||
response_type: 'code',
|
||||
scope: params.scope ?? 'openid',
|
||||
redirect_uri: params.redirectUri,
|
||||
state: params.state,
|
||||
code_challenge: params.codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
})
|
||||
return `${params.authorizeEndpoint}?${qs.toString()}`
|
||||
}
|
||||
|
||||
/** รูป response ของ token endpoint (เฉพาะ field ที่เราใช้) */
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
id_token?: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
/** decode payload ของ JWT เพื่ออ่าน claims (exp / preferred_username) — token ไม่ถูกต้องคืน null ไม่ throw */
|
||||
export function parseJwtPayload(token: string): Record<string, unknown> | null {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
|
||||
return JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,18 @@ export default defineNuxtConfig({
|
|||
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
|
||||
// config ทั้งหมดแยกเป็น env ได้ (กติกา: ห้าม hardcode) — override ด้วย env
|
||||
// NUXT_PUBLIC_API_BASE_URL / NUXT_PUBLIC_KEYCLOAK_AUTHORITY / NUXT_PUBLIC_KEYCLOAK_CLIENT_ID
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
// เว็บเป็น top-level http page → เรียก backend ด้วย http ได้ (ไม่มีปัญหา mixed content แบบ task pane)
|
||||
apiBaseUrl: 'http://localhost:5075',
|
||||
// Keycloak dev ใช้ https:8443 (cert ของ office-addin-dev-certs — เชื่อถือได้ในเครื่อง)
|
||||
keycloakAuthority: 'https://localhost:8443/realms/word-versioning',
|
||||
keycloakClientId: 'word-versioning-web',
|
||||
},
|
||||
},
|
||||
|
||||
// ~/assets/css/tailwind.css ถูก @nuxtjs/tailwindcss pick up อัตโนมัติ
|
||||
|
||||
app: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue