你沒有權限使用這個功能
@@ -22,7 +22,7 @@ export function ForbiddenPage() {
{viewer ? '如需編輯權限,請聯絡管理員。' : '如需調整權限,請聯絡管理員。'}
-
+
回旅程列表
diff --git a/web/src/pages/JourneyListPage.tsx b/web/src/pages/JourneyListPage.tsx
new file mode 100644
index 0000000..396ae6d
--- /dev/null
+++ b/web/src/pages/JourneyListPage.tsx
@@ -0,0 +1,149 @@
+import { useCallback, useEffect, useState } from 'react'
+import { listJourneys } from '../api/client'
+import { AppLink } from '../components/AppLink'
+import { Crumbs } from '../components/Shell'
+import { Badge, ErrorBox, MoreControls, Skeleton, Time } from '../components/ui'
+import { JOURNEY_STATUS } from '../format'
+import { usePagedList } from '../hooks'
+import { navigate, type Loc } from '../router'
+
+const STATUS_CHIPS: [string, string][] = [
+ ['', '全部'],
+ ['DRAFT', '草稿'],
+ ['ACTIVE', '進行中'],
+ ['ABORTED', '已停止'],
+]
+
+/** 旅程列表(唯讀):欄位依 JourneySummary;不顯示人數(D-050) */
+export function JourneyListPage({ loc }: { loc: Loc }) {
+ const params = new URLSearchParams(loc.search)
+ const q = (params.get('q') ?? '').trim()
+ const status = params.get('status') ?? ''
+ const [qInput, setQInput] = useState(q)
+
+ function go(nextQ: string, nextStatus: string) {
+ const sp = new URLSearchParams()
+ if (nextQ.trim()) sp.set('q', nextQ.trim())
+ if (nextStatus) sp.set('status', nextStatus)
+ const search = sp.toString() ? `?${sp.toString()}` : ''
+ if (search !== loc.search) navigate(`/journeys${search}`, { replace: true })
+ }
+
+ // 輸入停頓後才查詢,並同步網址
+ useEffect(() => {
+ if (qInput.trim() === q) return
+ const t = window.setTimeout(() => go(qInput, status), 300)
+ return () => window.clearTimeout(t)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [qInput])
+
+ const load = useCallback((cursor?: string) => listJourneys({ q, status, cursor }), [q, status])
+ const { items, next, error, loading, reload, loadMore, moreBusy, moreError, data } = usePagedList(load)
+ const filtered = q !== '' || status !== ''
+
+ function clear() {
+ setQInput('')
+ navigate('/journeys', { replace: true })
+ }
+
+ return (
+ <>
+
+
+
+
旅程
+
+
最近的 Enrollment
+
+
+
+
+ setQInput(e.target.value)} />
+
+
+
+
+ {loading &&
}
+ {error &&
}
+ {data && items.length === 0 && !filtered && (
+
+
還沒有旅程
+
旅程由團隊協助建立並發佈後會出現在這裡。
+
在網頁上建立與編輯旅程預計 v0.3.0 提供。
+
+ )}
+ {data && items.length === 0 && filtered && (
+
+
沒有符合條件的旅程
+
+ 狀態「{STATUS_CHIPS.find(([v]) => v === status)?.[1] ?? status}」
+ {q && <>、名稱含「{q}」>}。
+
+
+
+
+
+ )}
+ {data && items.length > 0 && (
+ <>
+
+
+ 已顯示 {items.length} 筆,依最後更新新到舊
+
+
+ | 名稱 |
+ 狀態 |
+ 版本 |
+ 最後更新 |
+ 最近發佈 |
+
+
+
+ {items.map((j) => {
+ const st = JOURNEY_STATUS[j.status] ?? { label: j.status, tone: 'neutral' as const }
+ return (
+
+ |
+
+ {j.name}
+
+ |
+
+ {st.label}
+ |
+
+ {j.latest_version === null ? '—' : `v${j.latest_version}`}
+ |
+
+
+ |
+
+ {j.published_at ? : '—'}
+ |
+
+ )
+ })}
+
+
+
+
+ >
+ )}
+ >
+ )
+}
diff --git a/web/src/pages/JourneyPage.tsx b/web/src/pages/JourneyPage.tsx
new file mode 100644
index 0000000..77009c3
--- /dev/null
+++ b/web/src/pages/JourneyPage.tsx
@@ -0,0 +1,147 @@
+import { useCallback, useEffect, useMemo, useRef } from 'react'
+import { getJourney, getJourneyVersion } from '../api/client'
+import { AppLink } from '../components/AppLink'
+import { Crumbs } from '../components/Shell'
+import { Badge, ErrorBox, Skeleton, Time } from '../components/ui'
+import { formatLocal, JOURNEY_STATUS } from '../format'
+import { GraphSection } from '../graph/GraphSection'
+import { navigate, type Loc } from '../router'
+import { useFetch } from '../useFetch'
+
+/** 旅程頁(唯讀,D-062):標頭、版本下拉、節點圖(不疊軌跡) */
+export function JourneyPage({ id, loc }: { id: string; loc: Loc }) {
+ const load = useCallback(() => getJourney(id), [id])
+ const { data: detail, error, reload } = useFetch(load)
+
+ const vp = Number(new URLSearchParams(loc.search).get('version'))
+ const requested = Number.isInteger(vp) && vp > 0 ? vp : null
+ const latest = detail?.latest_version ?? null
+ const version = requested ?? latest
+
+ const loadDef = useMemo(
+ () => (detail && version !== null ? () => getJourneyVersion(id, version) : null),
+ [detail, id, version],
+ )
+ const def = useFetch(loadDef)
+
+ const h1 = useRef
(null)
+ const focused = useRef(null)
+ useEffect(() => {
+ if (detail && focused.current !== detail.journey_id) {
+ focused.current = detail.journey_id
+ h1.current?.focus()
+ }
+ }, [detail])
+
+ const crumbs = (last: string) =>
+
+ if (error?.status === 404) {
+ return (
+ <>
+ {crumbs('找不到')}
+
+
找不到這個旅程
+
它可能不存在,或網址有誤。
+
+
+ 回旅程列表
+
+
+
+ >
+ )
+ }
+ if (error) {
+ return (
+ <>
+ {crumbs('旅程')}
+
+ >
+ )
+ }
+ if (!detail) {
+ return (
+ <>
+ {crumbs('載入中…')}
+ 載入中…
+
+ >
+ )
+ }
+
+ const st = JOURNEY_STATUS[detail.status] ?? { label: detail.status, tone: 'neutral' as const }
+ const old = version !== null && latest !== null && version !== latest
+ const selectVersion = (v: number) =>
+ navigate(`/journeys/${encodeURIComponent(id)}${v === latest ? '' : `?version=${v}`}`, { replace: true })
+
+ return (
+ <>
+ {crumbs(detail.name)}
+
+
+
+ {detail.name} {st.label}
+
+
+ {latest !== null ? `最新版本 v${latest}` : '尚未發佈'}
+ {detail.published_at && (
+ <>
+ ・最近發佈
+ >
+ )}
+ ・最後更新
+
+
+
這個旅程的 Enrollment
+
+ {detail.description && (
+
+
說明
+
{detail.description}
+
+ )}
+ {detail.versions.length === 0 ? (
+ 這個旅程尚未發佈任何版本,沒有流程圖可看。
+ ) : (
+ <>
+
+
+
+ 切換後只更新下方的圖;已發佈的版本不可修改。
+
+ {old && (
+
+
+ 你正在看 v{version},不是最新版本。已發佈的版本不可修改;新進入的客戶使用最新版本 v{latest}。{' '}
+ 回到最新版本
+
+
+ )}
+ {def.loading && }
+ {def.error && (
+
+ )}
+ {def.data && Array.isArray(def.data.nodes) && (
+ <>
+
+
+ v0.2.0 的旅程頁唯讀:沒有編輯、停止、審核按鈕;節點只顯示名稱與類型,設定值 v0.3.0 才提供。
+
+ >
+ )}
+ >
+ )}
+ >
+ )
+}
diff --git a/web/src/pages/SearchPage.tsx b/web/src/pages/SearchPage.tsx
index 0aaa994..50fe098 100644
--- a/web/src/pages/SearchPage.tsx
+++ b/web/src/pages/SearchPage.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState, type FormEvent, type MouseEvent } from 'react'
import { ApiError, listEnrollments } from '../api/client'
import type { EnrollmentSummary, Page } from '../api/types'
+import { AppLink } from '../components/AppLink'
import { Badge, ErrorBox, Loading, Time } from '../components/ui'
import { ENROLLMENT_STATUS } from '../format'
import { lastNavigationWasPop, navigate, type Loc } from '../router'
@@ -81,6 +82,9 @@ export function SearchPage({ loc }: { loc: Loc }) {
客戶旅程軌跡
唯讀。查某位客戶走到哪、為什麼沒收到訊息。
+
+ 最近的 Enrollment
+
diff --git a/web/src/pages/DetailPage.tsx b/web/src/pages/TracePage.tsx
similarity index 58%
rename from web/src/pages/DetailPage.tsx
rename to web/src/pages/TracePage.tsx
index aaa2997..0df3326 100644
--- a/web/src/pages/DetailPage.tsx
+++ b/web/src/pages/TracePage.tsx
@@ -1,13 +1,17 @@
-import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode, type RefObject } from 'react'
-import { ApiError, getEnrollment, listDeliveries } from '../api/client'
-import type { Delivery, EnrollmentDetail, TraceEntry } from '../api/types'
+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react'
+import { ApiError, getEnrollment, getJourneyVersion, listDeliveries } from '../api/client'
+import type { Delivery, EnrollmentDetail, JourneyVersion, TraceEntry } from '../api/types'
+import { AppLink } from '../components/AppLink'
import { KeyValues } from '../components/KeyValues'
-import { Badge, ErrorBox, Loading, RequestId, Time, UtcContext } from '../components/ui'
-import { DELIVERY_STATUS, ENROLLMENT_STATUS, TERMINAL, utcOffsetLabel } from '../format'
-import { navigate } from '../router'
+import { Crumbs } from '../components/Shell'
+import { Badge, ErrorBox, RequestId, Skeleton, Time, UtcContext } from '../components/ui'
+import { DELIVERY_STATUS, ENROLLMENT_STATUS, shortId, TERMINAL, utcOffsetLabel } from '../format'
+import { GraphSection } from '../graph/GraphSection'
+import { deriveOverlay } from '../graph/model'
import { useFetch } from '../useFetch'
-export function DetailPage({ id }: { id: string }) {
+/** 圖形軌跡(直線):頁首+目前狀況+圖+逐步紀錄+發送歷史+觸發快照;定義取不到時退回逐步紀錄 */
+export function TracePage({ id }: { id: string }) {
const load = useCallback(() => getEnrollment(id), [id])
const { data, error, loading, last, reload } = useFetch(load)
const shown = data ?? (last && last.enrollment_id === id ? last : undefined)
@@ -22,21 +26,25 @@ export function DetailPage({ id }: { id: string }) {
}
}, [shown])
- function back(e: MouseEvent) {
- e.preventDefault()
- const state = window.history.state as { fromList?: boolean } | null
- if (state?.fromList) window.history.back()
- else navigate(shown ? `/?q=${encodeURIComponent(shown.profile_id)}` : '/')
- }
+ // 以 Enrollment 綁定的版本取定義(不是最新版);Enrollment 失敗時不取
+ const journeyId = shown?.journey_id
+ const journeyVersion = shown?.journey_version
+ const loadDef = useMemo(
+ () =>
+ journeyId !== undefined && journeyVersion !== undefined ? () => getJourneyVersion(journeyId, journeyVersion) : null,
+ [journeyId, journeyVersion],
+ )
+ const def = useFetch(loadDef)
return (
<>
-
-
- ← 回到列表
-
-
- {!shown && loading && 載入軌跡中…}
+ {!shown && !error && (
+ <>
+
+ 載入中…
+
+ >
+ )}
{error && }
{shown && (
@@ -47,6 +55,10 @@ export function DetailPage({ id }: { id: string }) {
onRefresh={reload}
showUtc={showUtc}
onToggleUtc={setShowUtc}
+ def={def.data}
+ defError={def.error}
+ defLoading={def.loading}
+ onRetryDef={def.reload}
/>
)}
@@ -57,23 +69,18 @@ export function DetailPage({ id }: { id: string }) {
function DetailError({ error, onRetry }: { error: ApiError; onRetry: () => void }) {
if (error.status === 404) {
return (
-
+ <>
+
+
+
找不到這筆 Enrollment
+
+ ID 可能有誤,或這筆紀錄不存在。
+
+
+ 回到查詢
+
+
+ >
)
}
return
@@ -86,6 +93,10 @@ function DetailBody({
onRefresh,
showUtc,
onToggleUtc,
+ def,
+ defError,
+ defLoading,
+ onRetryDef,
}: {
d: EnrollmentDetail
h1Ref: RefObject
@@ -93,53 +104,145 @@ function DetailBody({
onRefresh: () => void
showUtc: boolean
onToggleUtc: (v: boolean) => void
+ def: JourneyVersion | undefined
+ defError: ApiError | undefined
+ defLoading: boolean
+ onRetryDef: () => void
}) {
const masked = new Set(d.masked_paths)
const st = ENROLLMENT_STATUS[d.status]
const terminal = TERMINAL.has(d.status)
+ const usableDef = def !== undefined && Array.isArray(def.nodes) && def.nodes.length > 0 ? def : null
+ const overlay = useMemo(
+ () => (usableDef ? deriveOverlay(usableDef, d.trace, d.status) : null),
+ [usableDef, d.trace, d.status],
+ )
+ const customerTo = `/customers/${encodeURIComponent(d.profile_id)}`
+
+ // 第 N 次經過:前端依 seq 計算(journey-engine §4.5);只有同一節點出現多次才標示
+ const visitOf = useMemo(() => {
+ const total = new Map()
+ for (const t of d.trace) total.set(t.node_id, (total.get(t.node_id) ?? 0) + 1)
+ const seen = new Map()
+ return d.trace.map((t) => {
+ const n = (seen.get(t.node_id) ?? 0) + 1
+ seen.set(t.node_id, n)
+ return (total.get(t.node_id) ?? 0) > 1 ? n : 0
+ })
+ }, [d.trace])
+
+ let graph: ReactNode = null
+ if (usableDef && overlay) {
+ graph = (
+
+ )
+ } else if (defLoading) {
+ graph = 載入流程圖中…
+ } else if (defError || def !== undefined) {
+ const reason = defError
+ ? `取得 v${d.journey_version} 流程定義失敗`
+ : `軌跡與 v${d.journey_version} 流程定義對不上`
+ graph = (
+
+
+ 無法顯示旅程圖({reason})。
+ 下方改顯示逐步紀錄。 {defError && }{' '}
+ {defError && defError.status !== 404 && defError.status !== 403 && (
+
+ )}
+
+
+ )
+ }
+
return (
<>
-
-
- {d.journey_name}(v{d.journey_version}) {st.label}
-
-
- {d.enrollment_id} 客戶 {d.profile_id}
-
- {terminal && d.terminated_at && (
+
+
+
+
+ {d.journey_name} {st.label}
+
- 進入 結束
- {d.termination_reason && (原因:{d.termination_reason})}
+ 客戶{' '}
+
+ {shortId(d.profile_id)}
+
+ ・依 v{d.journey_version} 執行
- )}
-
-
+
+
+
- 資料更新於
-
-
-
- (時間以本機時區 {utcOffsetLabel()} 顯示)
-
-
+
+
+
+ - Enrollment ID
+ - {d.enrollment_id}
+ - 資料更新於
+ -
+
+
+
+ (時間以本機時區 {utcOffsetLabel()} 顯示)
+ {graph}
+
{masked.size > 0 && (
- 標示「已遮罩」的值,系統基於個資保護不顯示完整內容,此頁無法解除。ID、時間與事件名稱不遮罩。
+ 標示「已遮罩」的值,系統基於個資保護不顯示完整內容,遮罩值無法解除,也不提供複製。ID、時間與事件名稱不遮罩。
)}
- 節點軌跡
-
- {d.trace.map((t, i) => (
-
- ))}
-
+
+
逐步紀錄(舊到新)
+
+ {d.trace.map((t, i) => (
+
+ ))}
+
+
+
+ {terminal && d.terminated_at && (
+
+ - 結束時間
+ -
+
+
+ {d.termination_reason && (
+ <>
+ - 結束原因
+ - {d.termination_reason}
+ >
+ )}
+
+ )}
@@ -179,11 +282,14 @@ function Summary({ d }: { d: EnrollmentDetail }) {
function TraceItem({
t,
index,
+ visit,
current,
masked,
}: {
t: TraceEntry
index: number
+ /** 第幾次經過;0 = 只經過一次,不標示 */
+ visit: number
current: boolean
masked: ReadonlySet
}) {
@@ -195,6 +301,7 @@ function TraceItem({
{t.seq}. {t.node_name ?? t.node_type}
{t.node_name === null && (未命名)}
+ {visit > 0 && 第 {visit} 次經過}
{current && 目前在此}
{t.node_type} · {t.node_id}
diff --git a/web/src/styles/app.css b/web/src/styles/app.css
index 2237fbf..38be00d 100644
--- a/web/src/styles/app.css
+++ b/web/src/styles/app.css
@@ -136,3 +136,78 @@ input[aria-invalid="true"] { border-color: var(--color-danger-fg); border-width:
}
@media (prefers-reduced-motion: no-preference) { .skel { animation: pulse 1.2s ease-in-out infinite; } }
@keyframes pulse { 50% { opacity: .5; } }
+
+/* ---------- v0.2.0 旅程列表、旅程頁、客戶、圖形軌跡(對應設計稿 docs/ui/journey-platform/index.html) ---------- */
+.sec { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
+h2.sec, h3 { font-size: var(--text-lg); }
+td.nw, td.nw a { white-space: nowrap; }
+.check { display: inline-flex; align-items: center; gap: var(--space-2); min-height: var(--touch-target); }
+.check input { width: 20px; height: 20px; min-height: 0; }
+.tap { display: inline-flex; align-items: center; min-height: 32px; }
+.toolbar { display: flex; flex-wrap: wrap; gap: var(--space-4); align-items: flex-end; margin-bottom: var(--space-4); }
+.toolbar .field { min-width: 220px; }
+.chips { display: flex; flex-wrap: wrap; gap: var(--space-2); border: 0; padding: 0; margin: 0; }
+.chips legend { font-size: var(--text-sm); font-weight: 600; padding: 0; margin-bottom: var(--space-2); }
+.chip { display: inline-flex; align-items: center; min-height: 36px; padding: 0 var(--space-3); border: 1px solid var(--color-border-strong); border-radius: var(--radius-pill); background: var(--color-surface); color: var(--color-text); font: inherit; font-size: var(--text-sm); cursor: pointer; }
+.chip[aria-pressed="true"] { background: var(--color-primary); border-color: var(--color-primary); color: var(--color-primary-text); font-weight: 600; }
+.cols { display: grid; grid-template-columns: minmax(0, 1fr) var(--panel-width); gap: var(--space-4); align-items: start; }
+.cols.two { grid-template-columns: 340px minmax(0, 1fr); }
+.search-big { display: flex; flex-wrap: wrap; gap: var(--space-3); }
+.search-big .field { flex: 1 1 360px; }
+.search-big input { min-height: 48px; font-size: var(--text-md); }
+.search-big .btn { min-height: 48px; align-self: flex-start; }
+.search-big .btn[aria-disabled="true"] { cursor: progress; opacity: .7; }
+table.plain th, table.plain td { padding: var(--space-1) var(--space-2); }
+
+.legend { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--text-sm); color: var(--color-text-muted); margin: 0 0 var(--space-2); padding: 0; list-style: none; }
+.legend li { display: inline-flex; align-items: center; gap: var(--space-2); }
+.sw { display: inline-block; width: 28px; height: 14px; border-radius: 3px; background: var(--color-surface); border: 2px solid var(--color-border-strong); }
+.sw.v { border-color: var(--color-trace-visited); }
+.sw.c { border-color: var(--color-trace-current); border-width: 3px; }
+.sw.u { border-style: dashed; border-color: var(--color-trace-unvisited); }
+.graph { position: relative; background-color: var(--color-graph-bg); background-image: radial-gradient(var(--color-graph-dot) 1px, transparent 1px); background-size: 16px 16px; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow-x: auto; overflow-y: hidden; margin-bottom: var(--space-2); }
+.graph .inner { position: relative; }
+.graph .react-flow { background: transparent; }
+.graph .react-flow__pane, .graph .react-flow__renderer { cursor: default; }
+.graph .react-flow__edge-path.e { fill: none; stroke: var(--color-edge); stroke-width: 2; }
+.graph .react-flow__edge-path.e.taken { stroke: var(--color-edge-taken); stroke-width: 3.5; }
+.graph .react-flow__edge-path.e.skipped { stroke-dasharray: 6 5; }
+.graph .react-flow__edge-path.e.back { stroke-dasharray: 2 5; stroke-linecap: round; }
+.graph .mk path { fill: var(--color-edge); }
+.graph .mk.taken path { fill: var(--color-edge-taken); }
+.graph .react-flow__handle { opacity: 0; pointer-events: none; }
+.graph .react-flow__node { pointer-events: none; }
+.node { pointer-events: all; width: var(--node-width); height: var(--node-height); text-align: left; font: inherit; padding: var(--space-1) var(--space-2) var(--space-1) var(--space-3); background: var(--color-surface); color: var(--color-text); border: 1px solid var(--color-border-strong); border-left: 6px solid var(--catc, var(--color-edge)); border-radius: var(--radius-sm); cursor: pointer; display: flex; flex-direction: column; justify-content: center; gap: 2px; overflow: hidden; }
+.node.cat-source { --catc: var(--color-node-source); }
+.node.cat-wait { --catc: var(--color-node-wait); }
+.node.cat-branch { --catc: var(--color-node-branch); }
+.node.cat-send { --catc: var(--color-node-send); }
+.node.cat-action { --catc: var(--color-node-action); }
+.node.cat-end { --catc: var(--color-node-end); }
+.node .n-cat { font-size: var(--text-xs); color: var(--color-text-muted); }
+.node .n-name { font-size: var(--text-sm); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.node .n-type { font: var(--text-xs) var(--font-mono); color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.node .n-state { font-size: var(--text-xs); font-weight: 700; }
+.node.visited { border-color: var(--color-trace-visited); border-width: 2px; border-left-width: 6px; }
+.node.visited .n-state { color: var(--color-trace-visited); }
+.node.current { border-color: var(--color-trace-current); border-width: 3px; border-left-width: 6px; background: var(--color-warning-bg); }
+.node.current .n-state { color: var(--color-warning-fg); }
+.node.unvisited { border-style: dashed; border-color: var(--color-trace-unvisited); }
+.node.selected { box-shadow: 0 0 0 3px var(--color-focus); }
+.node:focus-visible, .graph:focus-visible { box-shadow: var(--focus-ring); }
+.graph-note { display: none; }
+.react-flow__attribution { font-size: var(--text-xs); }
+
+/* 窄螢幕補充(<= 640px):連結與篩選鈕達 44px、圖可左右捲動並提示改看文字版 */
+@media (max-width: 640px) {
+ .chip { min-height: 44px; }
+ .tap, .check { min-height: 44px; }
+ .crumbs a { display: inline-flex; align-items: center; min-height: 44px; }
+ .search-big .btn, .search-big input { min-height: 48px; }
+ table.stack td.nw, table.stack td.nw a { white-space: normal; }
+ table.stack td a { min-height: 44px; display: inline-flex; align-items: center; }
+ .graph-note { display: block; }
+ .toolbar .field { min-width: 0; flex: 1 1 100%; }
+ .cols, .cols.two { grid-template-columns: minmax(0, 1fr); }
+}
+@media (max-width: 1100px) { .cols, .cols.two { grid-template-columns: minmax(0, 1fr); } }
diff --git a/web/tests/acceptance/journey-customer-ui/customer-page.test.tsx b/web/tests/acceptance/journey-customer-ui/customer-page.test.tsx
new file mode 100644
index 0000000..20f5bb2
--- /dev/null
+++ b/web/tests/acceptance/journey-customer-ui/customer-page.test.tsx
@@ -0,0 +1,233 @@
+// @vitest-environment jsdom
+/** journey-customer-ui:客戶頁(基本資料、遮罩、參加過的旅程)。假設見 fixtures.tsx。 */
+import { cleanup, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ PID, backend, bodyText, deferred, enrollmentSummary, gotoNav, json, page, params, problem, problemWithId,
+ profileDetail, profileSummary, resetEnv, start, teardown,
+} from './fixtures'
+
+beforeEach(resetEnv)
+afterEach(() => { cleanup(); teardown() })
+
+const ENR = `/api/v1/profiles/${PID}/enrollments`
+const enrollments = () => [
+ enrollmentSummary(),
+ enrollmentSummary({
+ enrollment_id: 'e2', journey_id: 'j2', journey_name: '新會員歡迎', journey_version: 7, status: 'COMPLETED',
+ current_node_name: '結束', current_node_type: 'EndNode', enrolled_at: '2026-09-02T06:30:00Z', terminated_at: '2026-09-04T06:31:00Z',
+ }),
+]
+
+async function openCustomer(routes: Parameters[0] = {}) {
+ const api = backend({
+ 'GET /api/v1/profiles/search': () => json({ items: [profileSummary({ profile_id: PID, matched_by: 'PROFILE_ID' })] }),
+ [`GET /api/v1/profiles/${PID}`]: () => json(profileDetail()),
+ [`GET ${ENR}`]: () => json(page(enrollments())),
+ ...routes,
+ })
+ start('/')
+ await gotoNav('客戶')
+ await userEvent.type(await screen.findByLabelText('搜尋客戶'), PID)
+ await userEvent.click(screen.getByRole('button', { name: '搜尋' }))
+ return api
+}
+const h1 = () => screen.findByRole('heading', { level: 1 })
+const section = async (name: string) => (await screen.findByRole('heading', { name })).closest('section, div.card, div') as HTMLElement
+
+describe('客戶頁:基本資料', () => {
+ it('左:客戶 ID(完整值+複製鈕)、建立/最近更新/資料時間(本地時區)、識別碼表、客戶屬性;標題為客戶 ID(沒有 name)', async () => {
+ await openCustomer()
+ const title = await h1()
+ expect(title.textContent).toMatch(/^客戶/)
+ expect(title.textContent).toContain('…') // 長 ID 只顯示頭尾
+ await screen.findByRole('heading', { name: '基本資料' })
+ expect(bodyText()).toContain(PID) // 完整值在客戶頁
+ expect(screen.getByRole('button', { name: '複製客戶 ID' })).toBeTruthy()
+ const text = bodyText()
+ expect(text).toContain('2026-05-03 11:20') // created_at 2026-05-03T03:20:00Z
+ expect(text).toContain('2026-11-01 16:00') // updated_at
+ expect(text).toContain('2026-11-01 16:05') // as_of
+ const idTable = screen.getByRole('table', { name: '識別碼' })
+ expect(within(idTable).getAllByRole('columnheader').map((h) => h.textContent?.trim())).toEqual(['類型', '值'])
+ expect(idTable.textContent).toContain('email')
+ expect(idTable.textContent).toContain('會員編號')
+ expect(idTable.textContent).toContain('M-20418')
+ expect(screen.getByRole('heading', { name: '客戶屬性' })).toBeTruthy()
+ expect(text).toContain('tier')
+ expect(text).toContain('regular')
+ await waitFor(() => expect(document.activeElement).toBe(title))
+ })
+
+ it('標題規則:name 存在且未遮罩 → 用姓名(客戶 ID 移到副標)', async () => {
+ await openCustomer({
+ [`GET /api/v1/profiles/${PID}`]: () =>
+ json(profileDetail({ attributes: { name: '陳大華', tier: 'regular' }, masked_paths: ['identifiers[0].value'] })),
+ })
+ const title = await h1()
+ await waitFor(() => expect(title.textContent).toContain('陳大華'))
+ expect(title.textContent).not.toContain('…')
+ expect(bodyText()).toContain(PID)
+ })
+
+ it('標題規則:name 在 masked_paths(值為 ***)→ 用客戶 ID,不以 *** 當標題', async () => {
+ await openCustomer({
+ [`GET /api/v1/profiles/${PID}`]: () =>
+ json(profileDetail({ attributes: { name: '***', tier: 'regular' }, masked_paths: ['attributes.name'] })),
+ })
+ const title = await h1()
+ await screen.findByRole('heading', { name: '基本資料' })
+ expect(title.textContent).toMatch(/^客戶/)
+ expect(title.textContent).not.toContain('*')
+ })
+
+ it('遮罩只依後端標記:masked_paths/masked 的值顯示「已遮罩」徽章;未標記的 *** 原樣顯示、無徽章;遮罩值無複製鈕', async () => {
+ await openCustomer({
+ [`GET /api/v1/profiles/${PID}`]: () =>
+ json(profileDetail({
+ attributes: { tier: 'regular', birthday: '***', note: '***' },
+ masked_paths: ['attributes.birthday', 'identifiers[0].value'],
+ })),
+ })
+ await screen.findByRole('heading', { name: '客戶屬性' })
+ const idTable = screen.getByRole('table', { name: '識別碼' })
+ const emailRow = within(idTable).getByText('email').closest('tr')!
+ expect(emailRow.textContent).toContain('a***@example.com')
+ expect(within(emailRow).getByText('已遮罩')).toBeTruthy()
+ const memberRow = within(idTable).getByText('會員編號').closest('tr')!
+ expect(within(memberRow).queryByText('已遮罩')).toBeNull()
+ const dd = (key: string) => screen.getByText(key, { selector: 'dt' }).nextElementSibling as HTMLElement
+ expect(within(dd('birthday')).getByText('已遮罩')).toBeTruthy()
+ expect(dd('note').textContent).toContain('***')
+ expect(within(dd('note')).queryByText('已遮罩')).toBeNull()
+ expect(within(dd('tier')).queryByText('已遮罩')).toBeNull()
+ expect(bodyText()).toContain('遮罩值無法解除')
+ expect(within(emailRow).queryByRole('button')).toBeNull()
+ })
+
+ it('未知 IdentifierType 顯示原值、不報錯(DESIGN-04 §5)', async () => {
+ await openCustomer({
+ [`GET /api/v1/profiles/${PID}`]: () =>
+ json(profileDetail({
+ identifiers: [
+ { type: 'EMAIL', value: 'a***@example.com', masked: true },
+ { type: 'TWITTER_HANDLE', value: '@amy_tw', masked: false },
+ ],
+ })),
+ })
+ const idTable = await screen.findByRole('table', { name: '識別碼' })
+ const row = within(idTable).getByText('TWITTER_HANDLE').closest('tr')!
+ expect(row.textContent).toContain('@amy_tw')
+ expect(screen.queryByRole('alert')).toBeNull()
+ })
+
+ it('「同時顯示 UTC 原值」勾選後時間旁加註 UTC 原值', async () => {
+ await openCustomer()
+ await screen.findByRole('heading', { name: '基本資料' })
+ expect(bodyText()).not.toContain('2026-05-03T03:20:00Z')
+ await userEvent.click(screen.getByLabelText('同時顯示 UTC 原值'))
+ expect(bodyText()).toContain('2026-05-03T03:20:00Z')
+ })
+
+ it('404 PROFILE_NOT_FOUND:「找不到這位客戶」+回搜尋連結(與 5xx 分開)', async () => {
+ await openCustomer({ [`GET /api/v1/profiles/${PID}`]: () => problem(404, 'PROFILE_NOT_FOUND') })
+ await screen.findByRole('heading', { level: 1, name: '找不到這位客戶' })
+ expect(screen.getByRole('link', { name: '回搜尋' })).toBeTruthy()
+ expect(screen.queryByRole('button', { name: '重試' })).toBeNull()
+ })
+
+ it('基本資料 5xx:alert 附 request_id 與重試,重試後顯示', async () => {
+ let fail = true
+ await openCustomer({
+ [`GET /api/v1/profiles/${PID}`]: () => (fail ? problemWithId(500, 'INTERNAL_ERROR', 'req_pf1') : json(profileDetail())),
+ })
+ const alert = await screen.findByRole('alert')
+ expect(alert.textContent).toContain('req_pf1')
+ expect(bodyText()).not.toContain('找不到這位客戶')
+ fail = false
+ await userEvent.click(within(alert).getByRole('button', { name: '重試' }))
+ await screen.findByRole('heading', { name: '基本資料' })
+ })
+
+ it('載入中:h1「載入中…」骨架(role=status)', async () => {
+ const d = deferred()
+ await openCustomer({ [`GET /api/v1/profiles/${PID}`]: () => d.promise })
+ await screen.findByRole('heading', { level: 1, name: '載入中…' })
+ expect(screen.getAllByRole('status').length).toBeGreaterThan(0)
+ d.resolve(json(profileDetail()))
+ await screen.findByRole('heading', { name: '基本資料' })
+ })
+})
+
+describe('客戶頁:參加過的旅程', () => {
+ it('表格:旅程(版本)、狀態、目前/最後節點、進入時間、結束時間、Enrollment ID;新到舊;未結束的結束時間為「—」', async () => {
+ const api = await openCustomer()
+ const table = await screen.findByRole('table', { name: /參加過/ })
+ expect(within(table).getAllByRole('columnheader').map((h) => h.textContent?.trim()))
+ .toEqual(['旅程(版本)', '狀態', '目前/最後節點', '進入時間', '結束時間', 'Enrollment ID'])
+ const rows = within(table).getAllByRole('row').slice(1)
+ expect(rows).toHaveLength(2)
+ expect(within(rows[0]).getByRole('link', { name: '棄單挽回(v3)' })).toBeTruthy()
+ expect(rows[0].textContent).toContain('等待中')
+ expect(rows[0].textContent).toContain('等 3 天')
+ expect(rows[0].textContent).toContain('2026-10-18 10:00')
+ expect(rows[0].textContent).toContain('—')
+ expect(rows[1].textContent).toContain('新會員歡迎(v7)')
+ expect(rows[1].textContent).toContain('已完成')
+ expect(rows[1].textContent).toContain('2026-09-04 14:31')
+ expect(api.calls.some((c) => c.path === ENR)).toBe(true)
+ })
+
+ it('cursor 載入更多', async () => {
+ const api = await openCustomer({
+ [`GET ${ENR}`]: (c) =>
+ params(c).get('cursor') === 'c9'
+ ? json(page([enrollmentSummary({ enrollment_id: 'e3', journey_name: '第二頁旅程' })]))
+ : json(page(enrollments(), 'c9')),
+ })
+ await screen.findByRole('table', { name: /參加過/ })
+ await userEvent.click(await screen.findByRole('button', { name: '載入更多' }))
+ await screen.findByRole('link', { name: /第二頁旅程/ })
+ expect(api.calls.some((c) => c.path === ENR && params(c).get('cursor') === 'c9')).toBe(true)
+ expect(screen.queryByRole('button', { name: '載入更多' })).toBeNull()
+ })
+
+ it('尚無旅程:「還沒有參加任何旅程」並說明何時出現;基本資料照常', async () => {
+ await openCustomer({ [`GET ${ENR}`]: () => json(page([])) })
+ await screen.findByRole('heading', { name: '還沒有參加任何旅程' })
+ expect(bodyText()).toContain('客戶觸發旅程的入口事件後,會顯示在這裡')
+ expect(screen.getByRole('heading', { name: '基本資料' })).toBeTruthy()
+ })
+
+ it('旅程紀錄失敗只影響該區塊:基本資料仍顯示,區塊內 alert 附 request_id 與重試;重試成功', async () => {
+ let fail = true
+ await openCustomer({
+ [`GET ${ENR}`]: () => (fail ? problemWithId(500, 'INTERNAL_ERROR', 'req_91ab') : json(page(enrollments()))),
+ })
+ await screen.findByRole('heading', { name: '基本資料' })
+ const alert = await screen.findByRole('alert')
+ expect(alert.textContent).toContain('無法載入旅程紀錄')
+ expect(alert.textContent).toContain('req_91ab')
+ expect(screen.getByRole('table', { name: '識別碼' })).toBeTruthy()
+ fail = false
+ await userEvent.click(within(alert).getByRole('button', { name: '重試' }))
+ await screen.findByRole('table', { name: /參加過/ })
+ expect(screen.queryByRole('alert')).toBeNull()
+ })
+
+ it('基本資料已顯示時,旅程紀錄仍在載入也不阻擋(兩個 API 分開處理)', async () => {
+ const d = deferred()
+ await openCustomer({ [`GET ${ENR}`]: () => d.promise })
+ await screen.findByRole('heading', { name: '基本資料' })
+ expect(screen.queryByRole('table', { name: /參加過/ })).toBeNull()
+ d.resolve(json(page(enrollments())))
+ await screen.findByRole('table', { name: /參加過/ })
+ })
+
+ it('同一頁以 section 呈現「基本資料」與「參加過的旅程」兩個區塊標題', async () => {
+ await openCustomer()
+ await section('基本資料')
+ await section('參加過的旅程')
+ })
+})
diff --git a/web/tests/acceptance/journey-customer-ui/customer-search.test.tsx b/web/tests/acceptance/journey-customer-ui/customer-search.test.tsx
new file mode 100644
index 0000000..ca5e967
--- /dev/null
+++ b/web/tests/acceptance/journey-customer-ui/customer-search.test.tsx
@@ -0,0 +1,161 @@
+// @vitest-environment jsdom
+/** journey-customer-ui:客戶搜尋(D-048)。假設見 fixtures.tsx。 */
+import { cleanup, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import {
+ PID, backend, bodyText, deferred, gotoNav, json, params, problem, problemWithId, profileDetail, profileSummary,
+ resetEnv, start, teardown, page,
+} from './fixtures'
+
+beforeEach(resetEnv)
+afterEach(() => { cleanup(); teardown() })
+
+const SEARCH = '/api/v1/profiles/search'
+const searches = (api: ReturnType) => api.calls.filter((c) => c.path === SEARCH)
+const items = (list: unknown[]) => () => json({ items: list })
+
+async function openSearch(routes: Parameters[0]) {
+ const api = backend({
+ [`GET /api/v1/profiles/${PID}`]: () => json(profileDetail()),
+ [`GET /api/v1/profiles/${PID}/enrollments`]: () => json(page([])),
+ ...routes,
+ })
+ start('/')
+ await gotoNav('客戶')
+ await screen.findByRole('heading', { level: 1, name: '客戶' })
+ return api
+}
+async function search(q: string) {
+ const box = screen.getByLabelText('搜尋客戶')
+ await userEvent.clear(box)
+ if (q) await userEvent.type(box, q)
+ await userEvent.click(screen.getByRole('button', { name: '搜尋' }))
+}
+const two = () => [
+ profileSummary({ matched_by: 'MEMBER_ID' }),
+ profileSummary({
+ profile_id: '01JCX7K2M4N8P3Q6R9S5T7H9KMN', matched_by: 'PROFILE_ID', updated_at: '2026-10-30T01:12:00Z',
+ identifiers: [{ type: 'PHONE', value: '***', masked: true }],
+ }),
+]
+
+describe('客戶搜尋', () => {
+ it('初始:說明可輸入的內容(email、手機、外部 ID、客戶 ID)與完全比對提示,且不送出搜尋', async () => {
+ const api = await openSearch({ [`GET ${SEARCH}`]: items([]) })
+ const text = bodyText()
+ expect(text).toContain('可以輸入什麼')
+ expect(text).toContain('完全比對')
+ for (const w of ['email', '手機', '外部 ID', '客戶 ID']) expect(text).toContain(w)
+ expect(searches(api)).toHaveLength(0)
+ })
+
+ it('單筆結果直接進客戶頁,並顯示「搜尋『…』比對到這位客戶(比對方式:email)」;請求 q 為輸入值', async () => {
+ const api = await openSearch({ [`GET ${SEARCH}`]: items([profileSummary()]) })
+ await search('amy@example.com')
+ const note = await screen.findByText(/比對到這位客戶/)
+ expect(note.textContent).toContain('amy@example.com')
+ expect(note.textContent).toContain('比對方式:email')
+ expect(params(searches(api)[0]).get('q')).toBe('amy@example.com')
+ // 進到客戶頁:有基本資料區,沒有結果表
+ await screen.findByRole('heading', { name: '基本資料' })
+ expect(screen.queryByRole('table', { name: /找到/ })).toBeNull()
+ await waitFor(() => expect(document.activeElement?.tagName).toBe('H1'))
+ })
+
+ it('多筆結果:表格(比對方式、識別碼、客戶 ID、最近更新)、遮罩徽章、長 ID 只顯示頭尾,連結進客戶頁', async () => {
+ await openSearch({ [`GET ${SEARCH}`]: items(two()) })
+ await search('M-20418')
+ const table = await screen.findByRole('table')
+ expect(table.querySelector('caption')!.textContent).toContain('找到 2 位客戶')
+ expect(table.querySelector('caption')!.textContent).toContain('最多顯示 20 筆')
+ expect(within(table).getAllByRole('columnheader').map((h) => h.textContent?.trim()))
+ .toEqual(['比對方式', '識別碼', '客戶 ID', '最近更新'])
+ const rows = within(table).getAllByRole('row').slice(1)
+ expect(rows).toHaveLength(2)
+ expect(rows[0].textContent).toContain('會員編號') // MEMBER_ID
+ expect(rows[0].textContent).toContain('M-20418')
+ expect(rows[0].textContent).toContain('a***@example.com')
+ expect(within(rows[0]).getByText('已遮罩')).toBeTruthy()
+ expect(rows[1].textContent).toContain('客戶 ID') // PROFILE_ID
+ expect(within(rows[1]).getByText('已遮罩')).toBeTruthy()
+ expect(rows[1].textContent).toContain('2026-10-30 09:12')
+ const link = within(rows[0]).getByRole('link')
+ expect(link.textContent).toContain('…')
+ expect(link.textContent).not.toBe(PID)
+ // 完成後焦點到結果標題
+ await waitFor(() => expect(['H1', 'H2', 'H3']).toContain(document.activeElement?.tagName))
+ await userEvent.click(link)
+ await screen.findByRole('heading', { name: '基本資料' })
+ })
+
+ it('最多 20 筆:後端回 20 筆就全列出', async () => {
+ const many = Array.from({ length: 20 }, (_, i) =>
+ profileSummary({ profile_id: `01JCX7K2M4N8P3Q6R9S5T${String(i).padStart(5, '0')}`, matched_by: 'MEMBER_ID' }))
+ await openSearch({ [`GET ${SEARCH}`]: items(many) })
+ await search('M-1')
+ const table = await screen.findByRole('table')
+ expect(within(table).getAllByRole('row')).toHaveLength(21)
+ })
+
+ it('網址同步 ?q=:送出後網址帶 q;以該網址重新載入會自動搜尋並顯示同樣結果', async () => {
+ const api = await openSearch({ [`GET ${SEARCH}`]: items(two()) })
+ await search('M-20418')
+ await screen.findByRole('table')
+ expect(new URLSearchParams(window.location.search).get('q')).toBe('M-20418')
+ const url = window.location.pathname + window.location.search
+ cleanup()
+ const before = searches(api).length
+ start(url)
+ await screen.findByRole('table')
+ expect(searches(api).length).toBeGreaterThan(before)
+ expect(params(searches(api).at(-1)!).get('q')).toBe('M-20418')
+ expect((screen.getByLabelText('搜尋客戶') as HTMLInputElement).value).toBe('M-20418')
+ })
+
+ it('查無(200、items 為空):「找不到符合的客戶」並列出可能原因', async () => {
+ await openSearch({ [`GET ${SEARCH}`]: items([]) })
+ await search('nobody@example.com')
+ await screen.findByRole('heading', { name: '找不到符合的客戶' })
+ const text = bodyText()
+ expect(text).toContain('可能原因')
+ expect(text).toContain('輸入內容不完整或有錯字')
+ expect(text).toContain('客戶尚未送過任何事件')
+ expect(text).toContain('識別碼尚未綁定到客戶')
+ })
+
+ it('空字串:「請輸入要搜尋的內容。」顯示在欄位旁,aria-invalid 並以 aria-describedby 關聯', async () => {
+ await openSearch({ [`GET ${SEARCH}`]: () => problem(400, 'VALIDATION_FAILED') })
+ await search('')
+ const msg = await screen.findByText('請輸入要搜尋的內容。')
+ const box = screen.getByLabelText('搜尋客戶')
+ expect(box.getAttribute('aria-invalid')).toBe('true')
+ const ids = (box.getAttribute('aria-describedby') ?? '').split(/\s+/)
+ expect(ids).toContain(msg.id)
+ expect(bodyText()).not.toContain('搜尋失敗')
+ })
+
+ it('搜尋中:按鈕「搜尋中…」並有載入狀態(role=status)', async () => {
+ const d = deferred()
+ await openSearch({ [`GET ${SEARCH}`]: () => d.promise })
+ await search('abc')
+ expect(await screen.findByRole('button', { name: '搜尋中…' })).toBeTruthy()
+ expect(screen.getAllByRole('status').some((s) => s.textContent?.includes('載入中'))).toBe(true)
+ d.resolve(json({ items: [] }))
+ await screen.findByRole('heading', { name: '找不到符合的客戶' })
+ })
+
+ it('5xx:role=alert 附 request_id 與重試,重試成功顯示結果', async () => {
+ let fail = true
+ await openSearch({
+ [`GET ${SEARCH}`]: () => (fail ? problemWithId(503, 'SERVICE_UNAVAILABLE', 'req_77d1') : json({ items: two() })),
+ })
+ await search('abc')
+ const alert = await screen.findByRole('alert')
+ expect(alert.textContent).toContain('搜尋失敗')
+ expect(alert.textContent).toContain('req_77d1')
+ fail = false
+ await userEvent.click(within(alert).getByRole('button', { name: '重試' }))
+ await screen.findByRole('table')
+ })
+})
diff --git a/web/tests/acceptance/journey-customer-ui/enrollment-browse.test.tsx b/web/tests/acceptance/journey-customer-ui/enrollment-browse.test.tsx
new file mode 100644
index 0000000..53efa8b
--- /dev/null
+++ b/web/tests/acceptance/journey-customer-ui/enrollment-browse.test.tsx
@@ -0,0 +1,111 @@
+// @vitest-environment jsdom
+/**
+ * journey-customer-ui:最近 Enrollment 瀏覽(D-030;journey-engine §4 `/enrollments`,`q` 省略 = 瀏覽)。
+ * 網址 ROUTES.browse(= `/enrollments`,routes.md §1);「旅程」篩選控制項(label 含「旅程」的