From dffac42c81677e1e23ab6819bb4bc9ade6c49fd7 Mon Sep 17 00:00:00 2001 From: Marcus Date: Thu, 22 Oct 2026 01:48:41 +0800 Subject: [PATCH 1/3] =?UTF-8?q?test(journey-customer-ui):=20=E6=97=85?= =?UTF-8?q?=E7=A8=8B=E5=88=97=E8=A1=A8=E3=80=81=E6=97=85=E7=A8=8B=E9=A0=81?= =?UTF-8?q?=E3=80=81=E5=AE=A2=E6=88=B6=E6=90=9C=E5=B0=8B=E8=88=87=E5=AE=A2?= =?UTF-8?q?=E6=88=B6=E9=A0=81=E3=80=81=E5=9C=96=E5=BD=A2=E8=BB=8C=E8=B7=A1?= =?UTF-8?q?=E3=80=81Enrollment=20=E7=80=8F=E8=A6=BD=E3=80=81=E7=AA=84?= =?UTF-8?q?=E8=9E=A2=E5=B9=95=E8=88=87=E7=84=A1=E9=9A=9C=E7=A4=99=E9=A9=97?= =?UTF-8?q?=E6=94=B6=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../customer-page.test.tsx | 233 ++++++++++++++++++ .../customer-search.test.tsx | 161 ++++++++++++ .../enrollment-browse.test.tsx | 111 +++++++++ .../journey-customer-ui/fixtures.tsx | 172 +++++++++++++ .../journey-customer-ui/journeys.test.tsx | 220 +++++++++++++++++ .../journey-customer-ui/narrow-a11y.test.tsx | 115 +++++++++ .../journey-customer-ui/trace-graph.test.tsx | 207 ++++++++++++++++ 7 files changed, 1219 insertions(+) create mode 100644 web/tests/acceptance/journey-customer-ui/customer-page.test.tsx create mode 100644 web/tests/acceptance/journey-customer-ui/customer-search.test.tsx create mode 100644 web/tests/acceptance/journey-customer-ui/enrollment-browse.test.tsx create mode 100644 web/tests/acceptance/journey-customer-ui/fixtures.tsx create mode 100644 web/tests/acceptance/journey-customer-ui/journeys.test.tsx create mode 100644 web/tests/acceptance/journey-customer-ui/narrow-a11y.test.tsx create mode 100644 web/tests/acceptance/journey-customer-ui/trace-graph.test.tsx 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..a859348 --- /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 與「旅程」篩選控制項(label 含「旅程」的 ,選項來自 GET /api/v1/journeys)是假設,見 fixtures.tsx。 + * 網址 ROUTES.browse(= `/enrollments`,routes.md §1);「旅程」篩選控制項(label 含「旅程」的 setShowUtc(e.target.checked)} /> + 同時顯示 UTC 原值 + + + )} + + {!data && } + {data && ( + <> + {direct && ( +

+ 搜尋「{direct.q}」比對到這位客戶(比對方式:{identifierLabel(direct.matched_by!)})。{' '} + 重新搜尋 +

+ )} +
+ + +
+ + )} + + ) +} + +function Basic({ p }: { p: ProfileDetail }) { + const masked = new Set(p.masked_paths) + const attrs = Object.keys(p.attributes).length + const anyMask = masked.size > 0 || p.identifiers.some((i) => i.masked) + return ( +
+

基本資料

+
+
客戶 ID
+
+ {p.profile_id} +
+
建立時間
+
+
+
最近更新
+
+
+
資料時間
+
+
+
+
+ + + + + + + + + + {p.identifiers.map((i, k) => { + const m = i.masked || masked.has(`identifiers[${k}].value`) + return ( + + + + + ) + })} + +
識別碼
類型值
{identifierLabel(i.type)} + {i.value} {m && 已遮罩} +
+
+

客戶屬性

+ {attrs > 0 ? ( + + ) : ( +

沒有客戶屬性。

+ )} + {anyMask && ( +

+ 只有白名單的欄位顯示明文;遮罩值無法解除,也不提供複製。屬性 name 未遮罩時當頁面標題,否則以客戶 ID 為標題。 +

+ )} +
+ ) +} + +function Enrollments({ id }: { id: string }) { + const load = useCallback((cursor?: string) => listProfileEnrollments(id, cursor), [id]) + const { items, next, error, loading, reload, loadMore, moreBusy, moreError, data } = usePagedList(load) + return ( +
+

參加過的旅程

+ {loading && } + {error && } + {data && items.length === 0 && ( +
+

還沒有參加任何旅程

+

客戶觸發旅程的入口事件後,會顯示在這裡。

+
+ )} + {data && items.length > 0 && ( + <> +
+ + + + + + + + + + + + + + {items.map((r) => { + const st = ENROLLMENT_STATUS[r.status] + return ( + + + + + + + + + ) + })} + +
{next ? `參加過的旅程,已載入 ${items.length} 筆` : `參加過 ${items.length} 個旅程`},新到舊
旅程(版本)狀態目前/最後節點進入時間結束時間Enrollment ID
+ + {r.journey_name}(v{r.journey_version}) + + + {st.label} + {r.current_node_name ?? r.current_node_type} + + {r.terminated_at ? + + {shortId(r.enrollment_id)} + +
+
+ +

時間為瀏覽器時區(D-016)。點旅程名稱看圖形軌跡。

+ + )} +
+ ) +} diff --git a/web/src/pages/CustomerSearchPage.tsx b/web/src/pages/CustomerSearchPage.tsx new file mode 100644 index 0000000..e478b8a --- /dev/null +++ b/web/src/pages/CustomerSearchPage.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type FormEvent } from 'react' +import { searchProfiles } from '../api/client' +import type { ProfileSummary } from '../api/types' +import { AppLink } from '../components/AppLink' +import { Crumbs } from '../components/Shell' +import { Badge, ErrorBox, Skeleton, Time } from '../components/ui' +import { identifierLabel, shortId } from '../format' +import { navigate, type Loc } from '../router' +import { useFetch } from '../useFetch' + +/** 客戶搜尋(D-048):完全比對;單筆直進客戶頁,多筆列表最多 20 */ +export function CustomerSearchPage({ loc }: { loc: Loc }) { + const q = (new URLSearchParams(loc.search).get('q') ?? '').trim() + const load = useCallback(() => searchProfiles(q), [q]) + const { data, error, loading, reload } = useFetch(q ? load : null) + + const focusResults = useRef(false) + const headingRef = useRef(null) + const [emptyInput, setEmptyInput] = useState(false) + + // 只有一位客戶:直接進客戶頁(取代這一筆歷史,上一頁不會再被彈回去) + const only = data && data.items.length === 1 ? data.items[0] : null + useEffect(() => { + if (only) { + navigate(`/customers/${encodeURIComponent(only.profile_id)}`, { + replace: true, + state: { profileId: only.profile_id, q, matched_by: only.matched_by }, + }) + } + }, [only, q]) + + // 結果出現後焦點到結果標題(layout effect:與 DOM 更新同一時間點) + const shown = data && data.items.length > 1 + useLayoutEffect(() => { + if (shown && focusResults.current) { + focusResults.current = false + headingRef.current?.focus() + } + }, [shown, data]) + + function submit(value: string) { + const v = value.trim() + if (!v) { + setEmptyInput(true) + return + } + setEmptyInput(false) + focusResults.current = true + if (v === q) reload() + else navigate(`/customers?q=${encodeURIComponent(v)}`) + } + + const invalid = emptyInput || error?.status === 400 + + return ( + <> + +

客戶

+ +
+ {!q && !emptyInput && } + {q && loading && } + {error && error.status !== 400 && } + {data && data.items.length === 0 && ( +
+

找不到符合的客戶

+

可能原因:輸入內容不完整或有錯字、客戶尚未送過任何事件、識別碼尚未綁定到客戶。

+
+ )} + {shown && } +
+ + ) +} + +function SearchForm({ + initial, + busy, + invalid, + onSubmit, +}: { + initial: string + busy: boolean + invalid: boolean + onSubmit: (v: string) => void +}) { + const [value, setValue] = useState(initial) + const id = useId() + function handle(e: FormEvent) { + e.preventDefault() + if (!busy) onSubmit(value) + } + return ( +
+
+ + setValue(e.target.value)} + /> + + 輸入完整內容;手機可含空格與破折號。只做完全比對,不支援輸入一部分(D-048)。 + + {invalid && ( + + 請輸入要搜尋的內容。 + + )} +
+ +
+ ) +} + +function Hints() { + return ( +
+

可以輸入什麼

+
+
email
+
+ name@example.com(不分大小寫) +
+
手機
+
+ 0912-345-678 或 +886912345678 +
+
外部 ID
+
會員編號、LINE 使用者 ID、裝置 ID
+
客戶 ID
+
平台產生的 26 碼 ID
+
+

只做完全比對,不支援輸入一部分。

+
+ ) +} + +function Results({ + items, + headingRef, +}: { + items: ProfileSummary[] + headingRef: React.RefObject +}) { + return ( +
+

+ 搜尋結果 +

+
+ + + + + + + + + + + + {items.map((p) => ( + + + + + + + ))} + +
找到 {items.length} 位客戶(最多顯示 20 筆)
比對方式識別碼客戶 ID最近更新
{identifierLabel(p.matched_by)} + {p.identifiers.map((i, k) => ( +
+ {identifierLabel(i.type)}:{i.value}{' '} + {i.masked && 已遮罩} +
+ ))} +
+ + {shortId(p.profile_id)} + + +
+
+

+ 同一位客戶只列一次;「比對方式」是命中的識別碼類型。長 ID 只顯示頭尾,完整值在客戶頁。個資依白名單遮罩。 +

+
+ ) +} diff --git a/web/src/pages/EnrollmentBrowsePage.tsx b/web/src/pages/EnrollmentBrowsePage.tsx new file mode 100644 index 0000000..9b14931 --- /dev/null +++ b/web/src/pages/EnrollmentBrowsePage.tsx @@ -0,0 +1,160 @@ +import { useCallback, useState, type FormEvent } from 'react' +import { listEnrollments, listJourneys } from '../api/client' +import { AppLink } from '../components/AppLink' +import { Crumbs } from '../components/Shell' +import { Badge, ErrorBox, MoreControls, Skeleton, Time } from '../components/ui' +import { ENROLLMENT_STATUS } from '../format' +import { usePagedList } from '../hooks' +import { navigate, type Loc } from '../router' +import { useFetch } from '../useFetch' + +const STATUSES = Object.keys(ENROLLMENT_STATUS) as (keyof typeof ENROLLMENT_STATUS)[] + +/** 最近 Enrollment 瀏覽(D-030):不收 q,可依狀態、旅程篩選;精確查詢走 `/` */ +export function EnrollmentBrowsePage({ loc }: { loc: Loc }) { + const params = new URLSearchParams(loc.search) + const status = params.get('status') ?? '' + const journeyId = params.get('journey_id') ?? '' + + const load = useCallback( + (cursor?: string) => listEnrollments({ status, journey_id: journeyId, cursor }), + [status, journeyId], + ) + const { items, next, error, loading, reload, loadMore, moreBusy, moreError, data } = usePagedList(load) + + const loadJourneys = useCallback(() => listJourneys({ limit: 100 }), []) + const journeys = useFetch(loadJourneys) + + function submit(nextStatus: string, nextJourney: string) { + const sp = new URLSearchParams() + if (nextStatus) sp.set('status', nextStatus) + if (nextJourney) sp.set('journey_id', nextJourney) + const search = sp.toString() ? `?${sp.toString()}` : '' + if (search === loc.search) reload() + else navigate(`/enrollments${search}`) + } + + return ( + <> + +
+
+

最近的 Enrollment

+

依進入時間新到舊。要查特定客戶或 Enrollment,請用精確查詢。

+
+ 依 ID 查詢 +
+ [j.journey_id, j.name] as const) ?? []} + busy={loading} + onSubmit={submit} + /> + {loading && } + {error && } + {data && items.length === 0 && ( +
+

找不到符合條件的 Enrollment

+

試著放寬狀態或旅程條件。

+
+ )} + {data && items.length > 0 && ( + <> +
+ + + + + + + + + + + + + {items.map((r) => { + const st = ENROLLMENT_STATUS[r.status] + return ( + + + + + + + + ) + })} + +
已載入 {items.length} 筆,依進入時間新到舊
旅程(版本)狀態目前節點進入時間最後更新
+ + {r.journey_name}(v{r.journey_version}) + + + {st.label} + {r.current_node_name ?? r.current_node_type} + +
+
+ + + )} + + ) +} + +function FilterForm({ + status, + journeyId, + journeys, + busy, + onSubmit, +}: { + status: string + journeyId: string + journeys: (readonly [string, string])[] + busy: boolean + onSubmit: (status: string, journeyId: string) => void +}) { + const [s, setS] = useState(status) + const [j, setJ] = useState(journeyId) + function handle(e: FormEvent) { + e.preventDefault() + if (!busy) onSubmit(s, j) + } + // 網址指定了旅程但清單還沒載到(或不在清單內)時,仍保留該選項 + const options = journeys.some(([id]) => id === j) || !j ? journeys : [...journeys, [j, j] as const] + return ( +
+
+ + +
+
+ + +
+ +
+ ) +} diff --git a/web/src/pages/ForbiddenPage.tsx b/web/src/pages/ForbiddenPage.tsx index e679f6b..9e2d062 100644 --- a/web/src/pages/ForbiddenPage.tsx +++ b/web/src/pages/ForbiddenPage.tsx @@ -11,7 +11,7 @@ export function ForbiddenPage() { const viewer = user.role === 'VIEWER' return ( <> - +

你沒有權限使用這個功能 @@ -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)} /> +
+
+ 狀態 + {STATUS_CHIPS.map(([v, label]) => ( + + ))} +
+
+ + {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.map((j) => { + const st = JOURNEY_STATUS[j.status] ?? { label: j.status, tone: 'neutral' as const } + return ( + + + + + + + + ) + })} + +
已顯示 {items.length} 筆,依最後更新新到舊
名稱狀態版本最後更新最近發佈
+ + {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 可能有誤,或這筆紀錄不存在。 -

- { - e.preventDefault() - navigate('/') - }} - > - 回到查詢 - -
+ <> + +
+

找不到這筆 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} +

- 進入

- )} -

-

+
+ + - 資料更新於
+

+ +
+
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); } }