+
{value || '—'}
{value ? (
)
+}
+
+export function HoverTip({
+ label,
+ children,
+ placement = 'top',
+ className = '',
+}: {
+ label: string
+ children: ReactNode
+ placement?: 'top' | 'bottom'
+ className?: string
+}) {
+ return (
+
+ {children}
+
+ {label}
+
+
+ )
}
\ No newline at end of file
diff --git a/haixun-backend/web/src/hooks/useIslanderUnlock.ts b/haixun-backend/web/src/hooks/useIslanderUnlock.ts
new file mode 100644
index 0000000..351f702
--- /dev/null
+++ b/haixun-backend/web/src/hooks/useIslanderUnlock.ts
@@ -0,0 +1,60 @@
+import { useEffect, useState } from 'react'
+import {
+ appendIslanderUnlockKey,
+ ISLANDER_UNLOCK_EVENT,
+ readIslanderUnlocked,
+ toggleIslanderUnlocked,
+} from '../lib/islander/unlock'
+
+function shouldIgnoreKeydown(event: KeyboardEvent): boolean {
+ if (event.isComposing) return true
+ if (event.ctrlKey || event.metaKey || event.altKey) return true
+
+ const el = event.target
+ if (!(el instanceof HTMLElement)) return false
+ if (el.isContentEditable) return true
+ if (el instanceof HTMLInputElement && el.type === 'password') return true
+
+ return false
+}
+
+export function useIslanderUnlock() {
+ const [unlocked, setUnlocked] = useState(readIslanderUnlocked)
+
+ useEffect(() => {
+ const onUnlockChange = (event: Event) => {
+ const detail = (event as CustomEvent<{ unlocked?: boolean }>).detail
+ setUnlocked(typeof detail?.unlocked === 'boolean' ? detail.unlocked : readIslanderUnlocked())
+ }
+
+ window.addEventListener(ISLANDER_UNLOCK_EVENT, onUnlockChange)
+
+ return () => window.removeEventListener(ISLANDER_UNLOCK_EVENT, onUnlockChange)
+ }, [])
+
+ useEffect(() => {
+ let buffer = ''
+ let toggling = false
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (shouldIgnoreKeydown(event)) return
+
+ const { buffer: nextBuffer, matched } = appendIslanderUnlockKey(buffer, event.key)
+ buffer = nextBuffer
+ if (!matched || toggling) return
+
+ toggling = true
+ const next = toggleIslanderUnlocked()
+ setUnlocked(next)
+ buffer = ''
+ window.setTimeout(() => {
+ toggling = false
+ }, 200)
+ }
+
+ document.addEventListener('keydown', onKeyDown, true)
+ return () => document.removeEventListener('keydown', onKeyDown, true)
+ }, [])
+
+ return unlocked
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/index.css b/haixun-backend/web/src/index.css
index db5a9cd..9c69e25 100644
--- a/haixun-backend/web/src/index.css
+++ b/haixun-backend/web/src/index.css
@@ -2474,6 +2474,16 @@ th {
}
}
+.ac-brand-link {
+ color: inherit;
+ text-decoration: none;
+ transition: background 0.12s ease, color 0.12s ease;
+}
+
+.ac-brand-link:hover {
+ color: var(--hx-brand);
+}
+
.ac-sidebar-brand {
display: flex;
flex-shrink: 0;
@@ -2483,12 +2493,30 @@ th {
padding: 1rem 1rem 0.9rem;
}
+.ac-brand-link.ac-app-header-brand {
+ border-radius: var(--radius-md);
+ padding: 0.15rem 0.35rem 0.15rem 0;
+}
+
+.ac-brand-link.ac-app-header-brand:hover {
+ background: var(--hx-brand-soft);
+}
+
+.ac-brand-link.ac-sidebar-brand:hover {
+ background: color-mix(in srgb, var(--hx-brand-soft) 55%, transparent 45%);
+}
+
.ac-sidebar-brand-icon.auth-ticket-icon {
height: 2.5rem;
width: 2.5rem;
flex-shrink: 0;
}
+.ac-sidebar-brand-icon.auth-ticket-icon .auth-ticket-icon__svg {
+ height: 1.55rem;
+ width: 1.55rem;
+}
+
.ac-sidebar-nav {
min-height: 0;
flex: 1;
@@ -2863,15 +2891,30 @@ th {
flex-shrink: 0;
align-items: center;
justify-content: center;
- border: 2px solid var(--hx-line);
- border-radius: 1rem;
- background: var(--hx-brand-soft);
+ border: 1px solid color-mix(in srgb, var(--hx-brand) 34%, var(--hx-line) 66%);
+ border-radius: 1.2rem;
+ background:
+ radial-gradient(
+ 120% 90% at 18% 12%,
+ color-mix(in srgb, var(--hx-brand-soft) 88%, var(--hx-surface) 12%),
+ transparent 58%
+ ),
+ linear-gradient(
+ 165deg,
+ color-mix(in srgb, var(--hx-surface) 90%, var(--hx-brand-soft) 10%),
+ color-mix(in srgb, var(--hx-brand-soft) 42%, var(--hx-surface) 58%)
+ );
color: var(--hx-brand);
+ box-shadow:
+ 0 1px 0 color-mix(in srgb, var(--hx-surface) 88%, transparent 12%) inset,
+ 0 0 0 3px color-mix(in srgb, var(--hx-brand) 7%, transparent 93%),
+ 0 8px 20px color-mix(in srgb, var(--hx-brand) 14%, transparent 86%);
}
-.auth-ticket-icon svg {
- height: 1.65rem;
- width: 1.65rem;
+.auth-ticket-icon__svg {
+ height: 2.05rem;
+ width: 2.05rem;
+ filter: drop-shadow(0 1px 1px color-mix(in srgb, var(--hx-brand-shadow) 10%, transparent 90%));
}
.auth-shell-title.ac-title-bar {
@@ -2947,11 +2990,21 @@ th {
width: 2.75rem;
}
+.ac-app-header-icon.auth-ticket-icon .auth-ticket-icon__svg {
+ height: 1.75rem;
+ width: 1.75rem;
+}
+
@media (min-width: 640px) {
.ac-app-header-icon.auth-ticket-icon {
height: 3rem;
width: 3rem;
}
+
+ .ac-app-header-icon.auth-ticket-icon .auth-ticket-icon__svg {
+ height: 1.9rem;
+ width: 1.9rem;
+ }
}
.ac-app-main-panel {
@@ -3171,17 +3224,36 @@ th {
align-items: center;
justify-content: center;
flex-shrink: 0;
- border-radius: 0.75rem;
- border: 2px solid var(--hx-line);
- background: var(--hx-surface);
+ border-radius: 0.9rem;
+ border: 1px solid color-mix(in srgb, var(--hx-line) 82%, var(--hx-brand) 18%);
+ background:
+ radial-gradient(
+ 95% 85% at 22% 14%,
+ color-mix(in srgb, var(--hx-surface) 92%, var(--hx-brand-soft) 8%),
+ var(--hx-surface) 72%
+ );
color: var(--hx-ink-secondary);
+ box-shadow: 0 1px 0 color-mix(in srgb, var(--hx-surface) 90%, transparent 10%) inset;
+ transition:
+ border-color 0.16s ease,
+ color 0.16s ease,
+ background 0.16s ease,
+ box-shadow 0.16s ease;
}
.ac-app-tile--active .ac-app-icon-svg,
.ac-app-tile:hover .ac-app-icon-svg {
- border-color: var(--hx-brand);
+ border-color: color-mix(in srgb, var(--hx-brand) 48%, var(--hx-line) 52%);
color: var(--hx-brand);
- background: var(--hx-surface);
+ background:
+ radial-gradient(
+ 95% 85% at 22% 14%,
+ color-mix(in srgb, var(--hx-brand-soft) 72%, var(--hx-surface) 28%),
+ var(--hx-surface) 70%
+ );
+ box-shadow:
+ 0 1px 0 color-mix(in srgb, var(--hx-surface) 90%, transparent 10%) inset,
+ 0 0 0 2px color-mix(in srgb, var(--hx-brand) 10%, transparent 90%);
}
.ac-btn-primary {
@@ -4950,6 +5022,209 @@ th {
color: var(--hx-muted);
}
+.ac-job-history-row {
+ padding: 1.15rem 1.35rem;
+}
+
+@media (min-width: 640px) {
+ .ac-job-history-row {
+ padding: 1.25rem 1.6rem;
+ }
+}
+
+.ac-schedule-row {
+ padding: 0.7rem 0.9rem;
+}
+
+@media (min-width: 640px) {
+ .ac-schedule-row {
+ padding: 0.75rem 1rem;
+ }
+}
+
+.ac-schedule-row__main {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.65rem 1rem;
+}
+
+.ac-schedule-row__info {
+ min-width: 0;
+ flex: 1 1 12rem;
+}
+
+.ac-schedule-row__controls {
+ flex: 2 1 16rem;
+}
+
+.ac-schedule-row__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-left: auto;
+}
+
+.ac-job-history-grid {
+ display: grid;
+ gap: 0.75rem 1rem;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-areas:
+ 'meta'
+ 'status'
+ 'progress'
+ 'summary';
+}
+
+.ac-job-history-grid__meta {
+ grid-area: meta;
+ min-width: 0;
+}
+
+.ac-job-history-grid__title {
+ font-size: 0.9375rem;
+ font-weight: 800;
+ line-height: 1.45;
+ color: var(--hx-ink);
+}
+
+.ac-job-history-grid__time {
+ margin-top: 0.2rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--hx-muted);
+}
+
+.ac-job-history-grid__status {
+ grid-area: status;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.65rem 1rem;
+}
+
+.ac-job-history-grid__status-badges {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.ac-job-history-grid__percent {
+ font-size: 0.875rem;
+ font-weight: 800;
+ color: var(--hx-ink-secondary);
+ white-space: nowrap;
+}
+
+.ac-job-history-grid__progress {
+ grid-area: progress;
+}
+
+.ac-job-history-grid__summary {
+ grid-area: summary;
+ font-size: 0.875rem;
+ line-height: 1.55;
+ color: var(--hx-ink-secondary);
+}
+
+@media (min-width: 768px) {
+ .ac-job-history-grid {
+ gap: 0.65rem 1.5rem;
+ grid-template-columns: minmax(8.5rem, 30%) minmax(0, 1fr);
+ grid-template-areas:
+ 'meta status'
+ 'meta progress'
+ 'summary summary';
+ align-items: center;
+ }
+
+ .ac-job-history-grid__meta {
+ align-self: start;
+ }
+
+ .ac-job-history-grid__title {
+ font-size: 1rem;
+ }
+}
+
+.ac-hover-tip {
+ position: relative;
+ display: inline-flex;
+ max-width: 100%;
+}
+
+.ac-job-error-btn {
+ display: inline-flex;
+ align-items: center;
+ border: 2px solid color-mix(in srgb, var(--hx-danger) 35%, var(--hx-line) 65%);
+ border-radius: var(--radius-pill);
+ background: var(--hx-danger-soft);
+ padding: 0.2rem 0.7rem;
+ font-size: 0.75rem;
+ font-weight: 800;
+ line-height: 1.4;
+ color: var(--hx-danger);
+ transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
+}
+
+.ac-job-error-btn:hover,
+.ac-job-error-btn:focus-visible {
+ border-color: var(--hx-danger);
+ background: color-mix(in srgb, var(--hx-danger-soft) 70%, var(--hx-surface) 30%);
+ color: var(--hx-danger);
+ outline: none;
+}
+
+.ac-hover-tip__panel {
+ position: absolute;
+ z-index: 40;
+ left: 50%;
+ width: max-content;
+ max-width: min(22rem, calc(100vw - 2rem));
+ transform: translateX(-50%) translateY(0.35rem);
+ border: 2px solid var(--hx-line);
+ border-radius: var(--radius-md);
+ background: var(--hx-surface);
+ padding: 0.65rem 0.85rem;
+ box-shadow: var(--hx-shadow-card);
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.55;
+ color: var(--hx-danger);
+ white-space: pre-wrap;
+ pointer-events: none;
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s ease;
+}
+
+.ac-hover-tip--top .ac-hover-tip__panel {
+ bottom: calc(100% + 0.35rem);
+ transform: translateX(-50%) translateY(-0.35rem);
+}
+
+.ac-hover-tip--bottom .ac-hover-tip__panel {
+ top: calc(100% + 0.35rem);
+}
+
+.ac-hover-tip:hover .ac-hover-tip__panel,
+.ac-hover-tip:focus-visible .ac-hover-tip__panel {
+ opacity: 1;
+ visibility: visible;
+}
+
+.ac-hover-tip--top:hover .ac-hover-tip__panel,
+.ac-hover-tip--top:focus-visible .ac-hover-tip__panel {
+ transform: translateX(-50%) translateY(0);
+}
+
+.ac-hover-tip--bottom:hover .ac-hover-tip__panel,
+.ac-hover-tip--bottom:focus-visible .ac-hover-tip__panel {
+ transform: translateX(-50%) translateY(0);
+}
+
.ac-table-action {
display: inline-flex;
align-items: center;
diff --git a/haixun-backend/web/src/lib/acAssets.ts b/haixun-backend/web/src/lib/acAssets.ts
index b9e2d0c..8b1cdeb 100644
--- a/haixun-backend/web/src/lib/acAssets.ts
+++ b/haixun-backend/web/src/lib/acAssets.ts
@@ -3,20 +3,21 @@ export type AcAppKey =
| 'persona'
| 'jobs'
| 'schedule'
- | 'ai'
| 'template'
| 'settings'
| 'profile'
| 'permissions'
+ | 'easter'
| 'threads'
| 'more'
-type NavItem = {
+export type NavItem = {
to: string
label: string
icon: AcAppKey
end?: boolean
matchPrefix?: string
+ adminOnly?: boolean
}
/** 外層雙軌:流程 A 拷貝忍者 + 流程 B 找 TA(主題列表 + 子步驟) */
@@ -45,11 +46,11 @@ export const navGroups: { label: string; items: NavItem[] }[] = [
{
label: '系統',
items: [
- { to: '/ai', label: 'AI', icon: 'ai' },
- { to: '/job-templates', label: '模板', icon: 'template' },
+ { to: '/job-templates', label: '模板', icon: 'template', adminOnly: true },
{ to: '/settings', label: '設定', icon: 'settings' },
{ to: '/profile', label: '會員', icon: 'profile' },
- { to: '/permissions', label: '權限', icon: 'permissions' },
+ { to: '/permissions', label: '權限', icon: 'permissions', adminOnly: true },
+ { to: '/easter-eggs', label: '彩蛋手冊', icon: 'easter', adminOnly: true },
],
},
]
diff --git a/haixun-backend/web/src/lib/aiCredentials.ts b/haixun-backend/web/src/lib/aiCredentials.ts
index 5f3effc..bf3694b 100644
--- a/haixun-backend/web/src/lib/aiCredentials.ts
+++ b/haixun-backend/web/src/lib/aiCredentials.ts
@@ -1,9 +1,11 @@
-export type ProviderId = 'xai' | 'openai' | 'anthropic' | 'google' | 'opencode-go'
+export type ProviderId = 'xai' | 'opencode-go'
export type ProviderApiKeys = Partial
>
export const AI_CREDENTIALS_KEY = 'ai.credentials'
+export const SUPPORTED_PROVIDERS: ProviderId[] = ['opencode-go', 'xai']
+
export const PROVIDER_KEY_LABELS: Record<
ProviderId,
{ label: string; hint: string; docsUrl?: string }
@@ -14,18 +16,9 @@ export const PROVIDER_KEY_LABELS: Record<
docsUrl: 'https://opencode.ai/docs/go/',
},
xai: { label: 'Grok (xAI)', hint: 'xAI Console API key' },
- openai: { label: 'OpenAI', hint: 'platform.openai.com API key' },
- anthropic: { label: 'Anthropic', hint: 'console.anthropic.com API key' },
- google: { label: 'Google Gemini', hint: 'Google AI Studio API key' },
}
-export const PROVIDER_ORDER: ProviderId[] = [
- 'opencode-go',
- 'xai',
- 'openai',
- 'anthropic',
- 'google',
-]
+export const PROVIDER_ORDER: ProviderId[] = [...SUPPORTED_PROVIDERS]
export const PROVIDER_OPTIONS = [
{
@@ -41,13 +34,6 @@ export const PROVIDER_OPTIONS = [
],
},
{ value: 'xai' as const, label: 'Grok (xAI)', models: ['grok-3', 'grok-3-fast', 'grok-2-1212'] },
- { value: 'openai' as const, label: 'OpenAI', models: ['gpt-4o', 'gpt-4o-mini'] },
- {
- value: 'anthropic' as const,
- label: 'Anthropic',
- models: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022'],
- },
- { value: 'google' as const, label: 'Google', models: ['gemini-2.0-flash', 'gemini-1.5-pro'] },
] as const
export type AccountAiCredentials = {
@@ -66,6 +52,48 @@ export const DEFAULT_AI_CREDENTIALS: AccountAiCredentials = {
api_keys: {},
}
+export function isSupportedProvider(value: string | undefined | null): value is ProviderId {
+ return value === 'xai' || value === 'opencode-go'
+}
+
+export function normalizeSupportedProvider(value: string | undefined | null): ProviderId {
+ return isSupportedProvider(value) ? value : DEFAULT_AI_CREDENTIALS.provider
+}
+
+function modelsForProvider(provider: ProviderId): readonly string[] {
+ return PROVIDER_OPTIONS.find((item) => item.value === provider)?.models ?? []
+}
+
+function normalizeModel(provider: ProviderId, model: string | undefined, fallback: string): string {
+ const models = modelsForProvider(provider)
+ if (model && models.includes(model)) return model
+ return models[0] ?? fallback
+}
+
+export type ProviderSettingsInput = {
+ provider?: string
+ model?: string
+ research_provider?: string
+ research_model?: string
+}
+
+export function normalizeProviderSettings(
+ settings: ProviderSettingsInput,
+): Pick {
+ const provider = normalizeSupportedProvider(settings.provider)
+ const researchProvider = normalizeSupportedProvider(settings.research_provider ?? provider)
+ return {
+ provider,
+ model: normalizeModel(provider, settings.model, DEFAULT_AI_CREDENTIALS.model),
+ research_provider: researchProvider,
+ research_model: normalizeModel(
+ researchProvider,
+ settings.research_model,
+ DEFAULT_AI_CREDENTIALS.research_model ?? DEFAULT_AI_CREDENTIALS.model,
+ ),
+ }
+}
+
export function maskApiKey(key: string | undefined | null): string | null {
if (!key) return null
if (key.length <= 4) return '••••'
@@ -91,7 +119,8 @@ export function mergeProviderApiKeys(
incoming: ProviderApiKeys,
): ProviderApiKeys {
const merged = { ...existing }
- for (const [provider, value] of Object.entries(incoming) as [ProviderId, string][]) {
+ for (const provider of PROVIDER_ORDER) {
+ const value = incoming[provider]
const trimmed = value?.trim()
if (!trimmed || isMaskedKey(trimmed)) continue
merged[provider] = trimmed
@@ -113,29 +142,28 @@ export function parseAccountAiCredentials(raw: Record | undefin
raw.api_keys && typeof raw.api_keys === 'object'
? (raw.api_keys as ProviderApiKeys)
: {}
- const provider = (raw.provider as ProviderId) || DEFAULT_AI_CREDENTIALS.provider
- const researchProvider = (raw.research_provider as ProviderId) || provider
- return {
- provider,
+ const normalized = normalizeProviderSettings({
+ provider: typeof raw.provider === 'string' ? raw.provider : DEFAULT_AI_CREDENTIALS.provider,
model: typeof raw.model === 'string' ? raw.model : DEFAULT_AI_CREDENTIALS.model,
- research_provider: researchProvider,
+ research_provider:
+ typeof raw.research_provider === 'string'
+ ? raw.research_provider
+ : DEFAULT_AI_CREDENTIALS.research_provider,
research_model:
typeof raw.research_model === 'string'
? raw.research_model
: DEFAULT_AI_CREDENTIALS.research_model,
- api_keys: apiKeys,
- }
+ })
+ return { ...normalized, api_keys: apiKeys }
}
export function buildAccountAiCredentialsPayload(
current: AccountAiCredentials,
keyInputs: ProviderApiKeys,
): AccountAiCredentials {
+ const normalized = normalizeProviderSettings(current)
return {
- provider: current.provider,
- model: current.model,
- research_provider: current.research_provider,
- research_model: current.research_model,
+ ...normalized,
api_keys: mergeProviderApiKeys(current.api_keys, keyInputs),
}
}
diff --git a/haixun-backend/web/src/lib/easterEggCatalog.ts b/haixun-backend/web/src/lib/easterEggCatalog.ts
new file mode 100644
index 0000000..c405a4f
--- /dev/null
+++ b/haixun-backend/web/src/lib/easterEggCatalog.ts
@@ -0,0 +1,60 @@
+export type EasterEggKind = 'keyboard' | 'admin-page' | 'hidden-route'
+
+export type EasterEggEntry = {
+ id: string
+ title: string
+ /** 鍵盤彩蛋的連續輸入密碼;頁面類彩蛋可為 null */
+ password: string | null
+ kind: EasterEggKind
+ summary: string
+ howTo: string[]
+ notes?: string
+}
+
+/** 島民鍵盤密碼 — unlock.ts 與本目錄共用 */
+export const ISLANDER_UNLOCK_CODE = 'abababc'
+
+/**
+ * 巡樓彩蛋登錄簿(前端)。
+ * 新增彩蛋時請同步更新此檔,管理員可在「彩蛋手冊」頁查閱。
+ */
+export const easterEggCatalog: EasterEggEntry[] = [
+ {
+ id: 'islander-guide',
+ title: '島民嚮導',
+ password: ISLANDER_UNLOCK_CODE,
+ kind: 'keyboard',
+ summary: '預設隱藏右下角浮動島民。連續輸入密碼可開關,狀態僅存於目前分頁的 session。',
+ howTo: [
+ '登入後,在任意已登入頁面(不需點特定區域)',
+ '鍵盤連續輸入密碼(英文小寫,不需 Enter)',
+ '右下角出現島民浮動按鈕;再輸入一次相同密碼可隱藏',
+ '重新整理分頁後,若 session 仍有效會維持顯示狀態',
+ ],
+ notes: '密碼欄位與 IME 組字中不會觸發;一般搜尋框內輸入亦可解鎖。',
+ },
+ {
+ id: 'easter-eggs-handbook',
+ title: '彩蛋手冊(本頁)',
+ password: null,
+ kind: 'admin-page',
+ summary: '記錄所有已知彩蛋密碼與操作說明,僅管理員可從側欄「系統」區進入。',
+ howTo: [
+ '以 admin 角色登入',
+ '桌面:左側 PATROL PAD → 系統 → 彩蛋手冊',
+ '手機:底部「更多」→ 彩蛋手冊',
+ ],
+ notes: '路由:/easter-eggs。非 admin 會被導回總覽。',
+ },
+]
+
+export function easterEggKindLabel(kind: EasterEggKind): string {
+ switch (kind) {
+ case 'keyboard':
+ return '鍵盤密碼'
+ case 'admin-page':
+ return '管理員頁面'
+ case 'hidden-route':
+ return '隱藏路由'
+ }
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/lib/islander/siteGuide.ts b/haixun-backend/web/src/lib/islander/siteGuide.ts
index 18953da..cfa6c5f 100644
--- a/haixun-backend/web/src/lib/islander/siteGuide.ts
+++ b/haixun-backend/web/src/lib/islander/siteGuide.ts
@@ -11,8 +11,8 @@ export const routeGuides: RouteGuide[] = [
{
pattern: /^\/$/,
title: '總覽',
- purpose: '查看巡樓工作台捷徑與近期狀態',
- hints: ['建立經營帳號與連線請到設定', '背景任務進度可看左下「任」或任務列表'],
+ purpose: '查看工作台概況、核心工作流與快速入口',
+ hints: ['概況區顯示進行中任務與排程', '建立經營帳號與連線請到設定', '背景任務進度可看左下「任」或任務列表'],
suggestions: ['這頁要怎麼用?', '帶我去設定頁', '任務列表在哪?'],
},
{
@@ -111,10 +111,10 @@ export const routeGuides: RouteGuide[] = [
},
{
pattern: /^\/jobs$/,
- title: '任務列表',
- purpose: '查看所有背景任務狀態',
- hints: ['8D 風格分析會出現在這裡', '進行中任務也可看左下「任」'],
- suggestions: ['8D 任務失敗怎麼辦?', '幫我刷新任務列表', '任務進度去哪看?'],
+ title: '背景任務',
+ purpose: '查看歷史背景任務執行紀錄',
+ hints: ['列表直接顯示狀態與進度', '進行中任務也可看左下「任」'],
+ suggestions: ['幫我刷新任務列表', '任務進度去哪看?', '8D 任務失敗怎麼辦?'],
},
{
pattern: /^\/jobs\/[^/]+$/,
@@ -125,10 +125,10 @@ export const routeGuides: RouteGuide[] = [
},
{
pattern: /^\/job-schedules$/,
- title: '排程',
- purpose: '管理定時觸發的背景任務',
- hints: ['需先有任務模板', '注意 cron 時區僅用於解讀排程'],
- suggestions: ['怎麼建立排程?', '這頁要怎麼用?'],
+ title: '定時排程',
+ purpose: '管理找 TA 與拷貝忍者的自動海巡排程',
+ hints: ['選類型與對象後儲存', '勾選節點或標籤請在對應工作流頁完成'],
+ suggestions: ['怎麼建立排程?', '找 TA 排程要怎麼設?'],
},
{
pattern: /^\/job-templates$/,
@@ -151,13 +151,6 @@ export const routeGuides: RouteGuide[] = [
hints: ['開發模式可用 extension 同步 session', '正式發文前確認連線就緒'],
suggestions: ['怎麼同步 Chrome Session?', '開發模式怎麼開?', '連線失敗怎麼辦?'],
},
- {
- pattern: /^\/ai$/,
- title: 'AI 實驗台',
- purpose: '直接測試 provider 與模型(進階)',
- hints: ['日常建議用設定頁的 AI 設定', '島民聊天會自動讀設定頁的 key'],
- suggestions: ['這頁要怎麼用?', 'AI key 要去哪裡設?'],
- },
{
pattern: /^\/profile$/,
title: '會員資料',
diff --git a/haixun-backend/web/src/lib/islander/unlock.ts b/haixun-backend/web/src/lib/islander/unlock.ts
new file mode 100644
index 0000000..a356be4
--- /dev/null
+++ b/haixun-backend/web/src/lib/islander/unlock.ts
@@ -0,0 +1,53 @@
+import { ISLANDER_UNLOCK_CODE } from '../easterEggCatalog'
+
+export { ISLANDER_UNLOCK_CODE }
+export const ISLANDER_UNLOCK_KEY = 'haixun.islander.unlocked'
+export const ISLANDER_UNLOCK_EVENT = 'haixun.islander-unlock-changed'
+
+export function readIslanderUnlocked(): boolean {
+ try {
+ return sessionStorage.getItem(ISLANDER_UNLOCK_KEY) === '1'
+ } catch {
+ return false
+ }
+}
+
+export function writeIslanderUnlocked(unlocked: boolean) {
+ try {
+ sessionStorage.setItem(ISLANDER_UNLOCK_KEY, unlocked ? '1' : '0')
+ } catch {
+ // ignore
+ }
+ window.dispatchEvent(
+ new CustomEvent(ISLANDER_UNLOCK_EVENT, { detail: { unlocked } }),
+ )
+}
+
+export function isTypingTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false
+ if (target.isContentEditable) return true
+ const tag = target.tagName
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
+}
+
+export function toggleIslanderUnlocked(): boolean {
+ const next = !readIslanderUnlocked()
+ writeIslanderUnlocked(next)
+ return next
+}
+
+/** Append one key to the rolling buffer; returns true when the unlock code matches. */
+export function appendIslanderUnlockKey(buffer: string, key: string): { buffer: string; matched: boolean } {
+ const code = ISLANDER_UNLOCK_CODE
+
+ if (key === 'Backspace') {
+ return { buffer: buffer.slice(0, -1), matched: false }
+ }
+
+ if (key.length !== 1) {
+ return { buffer, matched: false }
+ }
+
+ const next = (buffer + key.toLowerCase()).slice(-code.length)
+ return { buffer: next, matched: next === code }
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/lib/jobStatus.ts b/haixun-backend/web/src/lib/jobStatus.ts
index 6ba89fe..0bfb995 100644
--- a/haixun-backend/web/src/lib/jobStatus.ts
+++ b/haixun-backend/web/src/lib/jobStatus.ts
@@ -1,5 +1,19 @@
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired'])
+type JobErrorSource = {
+ error?: string
+ progress?: {
+ steps?: Array<{ status: string; message?: string }>
+ }
+}
+
+/** 任務失敗時的錯誤訊息(優先 failed step,其次 job.error)。 */
+export function jobErrorMessage(job: JobErrorSource): string | null {
+ const failedStep = job.progress?.steps?.find((step) => step.status === 'failed')
+ const message = failedStep?.message?.trim() || job.error?.trim()
+ return message || null
+}
+
export function isTerminalJobStatus(status: string): boolean {
return TERMINAL.has(status)
}
diff --git a/haixun-backend/web/src/lib/jobTemplate.ts b/haixun-backend/web/src/lib/jobTemplate.ts
new file mode 100644
index 0000000..4e570bc
--- /dev/null
+++ b/haixun-backend/web/src/lib/jobTemplate.ts
@@ -0,0 +1,14 @@
+const JOB_TEMPLATE_LABELS: Record = {
+ 'style-8d': '8D 風格分析',
+ 'expand-graph': '知識圖譜擴展',
+ 'placement-scan': '雙軌海巡',
+ 'scan-viral': '爆款掃描',
+ 'analyze-copy-mission': '拷貝研究地圖',
+ 'generate-copy-matrix': '內容矩陣產生',
+ 'generate-copy-draft': '深度仿寫草稿',
+ demo_long_task: 'Demo 任務',
+}
+
+export function jobTemplateLabel(templateType: string): string {
+ return JOB_TEMPLATE_LABELS[templateType] ?? templateType
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/lib/memberRole.ts b/haixun-backend/web/src/lib/memberRole.ts
new file mode 100644
index 0000000..e2dcf99
--- /dev/null
+++ b/haixun-backend/web/src/lib/memberRole.ts
@@ -0,0 +1,68 @@
+import type { NavItem } from './acAssets'
+import type { MemberMeData } from '../types/api'
+
+export type NavGroup = {
+ label: string
+ items: NavItem[]
+}
+
+const ROLE_LABELS: Record = {
+ admin: '管理員',
+ member: '一般會員',
+}
+
+const STATUS_LABELS: Record = {
+ active: '使用中',
+ inactive: '已停用',
+}
+
+const ORIGIN_LABELS: Record = {
+ native: '本站註冊',
+ email: '電子郵件',
+}
+
+export function formatMemberRoles(roles: string[] | undefined): string {
+ if (!roles?.length) return ROLE_LABELS.member
+ return roles.map((role) => ROLE_LABELS[role] ?? role).join('、')
+}
+
+export function formatMemberStatus(status: string | undefined): string {
+ if (!status) return '—'
+ return STATUS_LABELS[status] ?? status
+}
+
+export function formatMemberOrigin(origin: string | undefined): string {
+ if (!origin) return '—'
+ return ORIGIN_LABELS[origin] ?? origin
+}
+
+const CJK_CHAR_RE = /\p{Script=Han}/u
+
+/** 頂欄圓形頭像內文字:優先顯示名稱首字,否則依角色顯示中文。 */
+export function memberAvatarGlyph(
+ member: Pick | null | undefined,
+): string {
+ const displayName = member?.display_name?.trim()
+ if (displayName) {
+ const cjk = displayName.match(CJK_CHAR_RE)
+ if (cjk) return cjk[0]
+ }
+ const roles = member?.roles ?? []
+ if (roles.includes('admin')) return '管'
+ return '員'
+}
+
+export function isAdminMember(member: MemberMeData | null | undefined): boolean {
+ const roles = member?.roles ?? []
+ if (roles.length === 0) return false
+ return roles.includes('admin')
+}
+
+export function filterAdminOnlyNavGroups(groups: NavGroup[], isAdmin: boolean): NavGroup[] {
+ return groups
+ .map((group) => ({
+ ...group,
+ items: group.items.filter((item) => !item.adminOnly || isAdmin),
+ }))
+ .filter((group) => group.items.length > 0)
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/lib/onboarding.ts b/haixun-backend/web/src/lib/onboarding.ts
index ec84448..444aa1c 100644
--- a/haixun-backend/web/src/lib/onboarding.ts
+++ b/haixun-backend/web/src/lib/onboarding.ts
@@ -1,5 +1,6 @@
import type { ThreadsAccountConnectionData } from '../types/api'
-import { navGroups, type AcAppKey } from './acAssets'
+import { navGroups } from './acAssets'
+import { filterAdminOnlyNavGroups, type NavGroup } from './memberRole'
export type OnboardingStep = 'account' | 'connection' | 'persona'
@@ -12,15 +13,7 @@ export type OnboardingSnapshot = {
nextStep: OnboardingStep | null
}
-type NavItem = {
- to: string
- label: string
- icon: AcAppKey
- end?: boolean
- matchPrefix?: string
-}
-
-export const onboardingNavGroups: { label: string; items: NavItem[] }[] = [
+export const onboardingNavGroups: NavGroup[] = [
{
label: '入門設定',
items: [
@@ -67,11 +60,13 @@ export function buildOnboardingSnapshot(input: {
}
}
-export function navGroupsForOnboarding(complete: boolean) {
- return complete ? navGroups : onboardingNavGroups
+export function navGroupsForOnboarding(complete: boolean, isAdmin = false): NavGroup[] {
+ const groups: NavGroup[] = complete ? navGroups : onboardingNavGroups
+ return filterAdminOnlyNavGroups(groups, isAdmin)
}
export function isOnboardingAllowedPath(pathname: string) {
+ if (pathname === '/') return true
if (pathname === '/settings') return true
if (pathname === '/personas' || pathname.startsWith('/personas/')) return true
if (pathname === '/brands' || pathname.startsWith('/brands/')) return true
@@ -88,8 +83,8 @@ export function onboardingRedirectPath(pathname: string, complete: boolean) {
return '/settings'
}
-export function onboardingNavApps(complete: boolean) {
- return navGroupsForOnboarding(complete).flatMap((group) => group.items)
+export function onboardingNavApps(complete: boolean, isAdmin = false) {
+ return navGroupsForOnboarding(complete, isAdmin).flatMap((group) => group.items)
}
export function onboardingGlowClass(active: boolean) {
diff --git a/haixun-backend/web/src/lib/scheduleCatalog.ts b/haixun-backend/web/src/lib/scheduleCatalog.ts
new file mode 100644
index 0000000..cc34c4c
--- /dev/null
+++ b/haixun-backend/web/src/lib/scheduleCatalog.ts
@@ -0,0 +1,133 @@
+import type { JobScheduleData } from '../types/api'
+import type { CopyMissionData } from '../types/copyMission'
+import type { PlacementTopicData } from '../types/placementTopic'
+import { describeCron } from './scheduleCron'
+import { jobTemplateLabel } from './jobTemplate'
+
+export const CRON_PRESETS: Array<{ label: string; value: string }> = [
+ { label: '每天 09:00', value: '0 9 * * *' },
+ { label: '每週一 09:00', value: '0 9 * * 1' },
+ { label: '每 6 小時', value: '0 */6 * * *' },
+ { label: '每 12 小時', value: '0 */12 * * *' },
+]
+
+export const SCHEDULE_TIMEZONE = 'Asia/Taipei'
+
+export type ScheduleKind = 'placement_topic_scan' | 'copy_mission_scan'
+
+export type ScheduleKindMeta = {
+ kind: ScheduleKind
+ label: string
+ description: string
+ templateType: 'placement-scan' | 'scan-viral'
+ configureHint: string
+}
+
+export const SCHEDULE_KINDS: ScheduleKindMeta[] = [
+ {
+ kind: 'placement_topic_scan',
+ label: '找 TA · 定期雙軌海巡',
+ description: '依主題研究地圖勾選的節點,定期重跑海巡找貼文;不會自動發文,留言仍須手動 po。',
+ templateType: 'placement-scan',
+ configureHint: '請先在主題研究地圖勾選節點並儲存,再啟用排程。',
+ },
+ {
+ kind: 'copy_mission_scan',
+ label: '拷貝忍者 · 定期爆款海巡',
+ description: '依拷貝任務內勾選的標籤,定期重跑爆款海巡;不會自動發文,仍須手動 po。',
+ templateType: 'scan-viral',
+ configureHint: '請先在拷貝任務勾選搜尋標籤並儲存,再啟用排程。',
+ },
+]
+
+const KIND_BY_TEMPLATE = new Map(
+ SCHEDULE_KINDS.map((item) => [item.templateType, item] as const),
+)
+
+export function isManagedScheduleTemplate(templateType: string): boolean {
+ return templateType === 'placement-scan' || templateType === 'scan-viral'
+}
+
+export function filterManagedSchedules(schedules: T[]): T[] {
+ return schedules.filter((item) => isManagedScheduleTemplate(item.template_type))
+}
+
+export function scheduleKindMeta(templateType: string): ScheduleKindMeta | null {
+ return KIND_BY_TEMPLATE.get(templateType as ScheduleKindMeta['templateType']) ?? null
+}
+
+export function cronPresetLabel(cron: string): string {
+ return CRON_PRESETS.find((preset) => preset.value === cron)?.label ?? describeCron(cron)
+}
+
+type BrandLike = { id: string; display_name?: string; seed_query?: string }
+
+export function resolvePlacementScanTarget(
+ schedule: JobScheduleData,
+ topics: PlacementTopicData[],
+ brands: BrandLike[] = [],
+): { label: string; href: string; source: string } {
+ const topic = topics.find((item) => item.id === schedule.scope_id)
+ if (topic) {
+ const name = topic.topic_name?.trim() || topic.seed_query?.trim() || '未命名主題'
+ return {
+ label: name,
+ href: `/placement/topics/${encodeURIComponent(topic.id)}/research-map`,
+ source: '找 TA 主題',
+ }
+ }
+ const brand = brands.find((item) => item.id === schedule.scope_id)
+ if (brand) {
+ const name = brand.display_name?.trim() || brand.seed_query?.trim() || '未命名品牌'
+ return {
+ label: name,
+ href: '/research',
+ source: '研究地圖',
+ }
+ }
+ if (schedule.scope === 'brand') {
+ return {
+ label: `品牌 ${schedule.scope_id.slice(0, 8)}…`,
+ href: '/research',
+ source: '研究地圖',
+ }
+ }
+ return {
+ label: `主題 ${schedule.scope_id.slice(0, 8)}…`,
+ href: '/placement/topics',
+ source: '找 TA',
+ }
+}
+
+/** @deprecated 使用 resolvePlacementScanTarget */
+export function resolvePlacementTopicTarget(
+ schedule: JobScheduleData,
+ topics: PlacementTopicData[],
+): { label: string; href: string } {
+ const { label, href } = resolvePlacementScanTarget(schedule, topics)
+ return { label, href }
+}
+
+export function resolveCopyMissionTarget(
+ schedule: JobScheduleData,
+ missions: CopyMissionData[],
+): { label: string; href: string; personaId: string } {
+ const mission = missions.find((item) => item.id === schedule.scope_id)
+ if (!mission) {
+ return {
+ label: `任務 ${schedule.scope_id.slice(0, 8)}…`,
+ href: '/matrix',
+ personaId: '',
+ }
+ }
+ const name = mission.label?.trim() || mission.seed_query?.trim() || '未命名任務'
+ return {
+ label: name,
+ href: `/matrix/missions/${encodeURIComponent(mission.id)}`,
+ personaId: mission.persona_id,
+ }
+}
+
+export function scheduleDisplayTitle(templateType: string): string {
+ return scheduleKindMeta(templateType)?.label ?? jobTemplateLabel(templateType)
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/lib/scheduleCron.ts b/haixun-backend/web/src/lib/scheduleCron.ts
new file mode 100644
index 0000000..a82b6c0
--- /dev/null
+++ b/haixun-backend/web/src/lib/scheduleCron.ts
@@ -0,0 +1,130 @@
+export type ScheduleRepeatMode = 'daily' | 'weekly' | 'interval'
+
+export type ScheduleFrequency = {
+ mode: ScheduleRepeatMode
+ hour: number
+ minute: number
+ weekday: number
+ intervalHours: number
+}
+
+export const WEEKDAY_OPTIONS: Array<{ value: number; label: string }> = [
+ { value: 1, label: '週一' },
+ { value: 2, label: '週二' },
+ { value: 3, label: '週三' },
+ { value: 4, label: '週四' },
+ { value: 5, label: '週五' },
+ { value: 6, label: '週六' },
+ { value: 0, label: '週日' },
+]
+
+export const INTERVAL_HOUR_OPTIONS = [1, 2, 3, 4, 6, 8, 12, 24] as const
+
+export const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => hour)
+export const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, minute) => minute)
+
+export function defaultScheduleFrequency(): ScheduleFrequency {
+ return {
+ mode: 'daily',
+ hour: 9,
+ minute: 0,
+ weekday: 1,
+ intervalHours: 6,
+ }
+}
+
+export function buildCronFromFrequency(freq: ScheduleFrequency): string {
+ switch (freq.mode) {
+ case 'daily':
+ return `${freq.minute} ${freq.hour} * * *`
+ case 'weekly':
+ return `${freq.minute} ${freq.hour} * * ${freq.weekday}`
+ case 'interval':
+ return `0 */${freq.intervalHours} * * *`
+ }
+}
+
+export function parseCronToFrequency(cron: string): ScheduleFrequency | null {
+ const parts = cron.trim().split(/\s+/)
+ if (parts.length !== 5) return null
+
+ const [minute, hour, dom, month, dow] = parts
+
+ if (
+ minute === '0' &&
+ dom === '*' &&
+ month === '*' &&
+ dow === '*' &&
+ /^\*\/(\d+)$/.test(hour)
+ ) {
+ const intervalHours = Number.parseInt(hour.slice(2), 10)
+ if (intervalHours >= 1 && intervalHours <= 24) {
+ return {
+ mode: 'interval',
+ hour: 0,
+ minute: 0,
+ weekday: 1,
+ intervalHours,
+ }
+ }
+ }
+
+ if (
+ dom === '*' &&
+ month === '*' &&
+ dow === '*' &&
+ /^\d+$/.test(minute) &&
+ /^\d+$/.test(hour)
+ ) {
+ return {
+ mode: 'daily',
+ hour: Number.parseInt(hour, 10),
+ minute: Number.parseInt(minute, 10),
+ weekday: 1,
+ intervalHours: 6,
+ }
+ }
+
+ if (
+ dom === '*' &&
+ month === '*' &&
+ /^\d+$/.test(minute) &&
+ /^\d+$/.test(hour) &&
+ /^\d+$/.test(dow)
+ ) {
+ return {
+ mode: 'weekly',
+ hour: Number.parseInt(hour, 10),
+ minute: Number.parseInt(minute, 10),
+ weekday: Number.parseInt(dow, 10),
+ intervalHours: 6,
+ }
+ }
+
+ return null
+}
+
+function padTimePart(value: number): string {
+ return String(value).padStart(2, '0')
+}
+
+export function describeCron(cron: string): string {
+ const freq = parseCronToFrequency(cron)
+ if (!freq) return cron
+
+ const time = `${padTimePart(freq.hour)}:${padTimePart(freq.minute)}`
+ switch (freq.mode) {
+ case 'daily':
+ return `每天 ${time}`
+ case 'weekly': {
+ const weekday = WEEKDAY_OPTIONS.find((item) => item.value === freq.weekday)?.label ?? `週${freq.weekday}`
+ return `每${weekday} ${time}`
+ }
+ case 'interval':
+ return `每 ${freq.intervalHours} 小時`
+ }
+}
+
+export function frequencyFromCron(cron: string): ScheduleFrequency {
+ return parseCronToFrequency(cron) ?? defaultScheduleFrequency()
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/pages/AiPage.tsx b/haixun-backend/web/src/pages/AiPage.tsx
deleted file mode 100644
index 4e8a08a..0000000
--- a/haixun-backend/web/src/pages/AiPage.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import { useEffect, useState } from 'react'
-import { api, streamAIChat } from '../api/client'
-import { ApiError } from '../api/client'
-import { storage } from '../lib/storage'
-import { Button, Card, ErrorText, Field, Input, PageTitle, Textarea } from '../components/ui'
-import type { AIProviderOption } from '../types/api'
-
-export function AiPage() {
- const [providers, setProviders] = useState([])
- const [provider, setProvider] = useState('opencode-go')
- const [model, setModel] = useState('')
- const [models, setModels] = useState([])
- const [providerToken, setProviderToken] = useState(() => storage.getAiProviderToken())
- const [prompt, setPrompt] = useState('用一句話介紹巡樓系統')
- const [reply, setReply] = useState('')
- const [streaming, setStreaming] = useState(false)
- const [error, setError] = useState('')
-
- useEffect(() => {
- api
- .get<{ providers: AIProviderOption[] }>('/api/v1/ai/providers')
- .then((d) => {
- setProviders(d.providers)
- if (d.providers[0]) setProvider(d.providers[0].id)
- })
- .catch((e: ApiError) => setError(e.message))
- }, [])
-
- const loadModels = async () => {
- setError('')
- storage.setAiProviderToken(providerToken)
- try {
- const data = await api.post<{ models: string[]; error?: string }>(
- `/api/v1/ai/providers/${provider}/models`,
- { provider },
- { memberAuth: true, providerToken },
- )
- setModels(data.models)
- if (data.models[0]) setModel(data.models[0])
- if (data.error) setError(data.error)
- } catch (e) {
- setError(e instanceof ApiError ? e.message : '拉 models 失敗')
- }
- }
-
- const chatOnce = async () => {
- setError('')
- setReply('')
- storage.setAiProviderToken(providerToken)
- try {
- const data = await api.post<{ text: string }>(
- '/api/v1/ai/chat',
- {
- provider,
- model,
- messages: [{ role: 'user', content: prompt }],
- },
- { memberAuth: true, providerToken },
- )
- setReply(data.text)
- } catch (e) {
- setError(e instanceof ApiError ? e.message : 'chat 失敗')
- }
- }
-
- const chatStream = async () => {
- setError('')
- setReply('')
- setStreaming(true)
- storage.setAiProviderToken(providerToken)
- await streamAIChat(
- { provider, model, messages: [{ role: 'user', content: prompt }] },
- (t) => setReply((r) => r + t),
- () => setStreaming(false),
- (msg) => {
- setError(msg)
- setStreaming(false)
- },
- )
- setStreaming(false)
- }
-
- return (
-
-
-
-
-
- setProviderToken(e.target.value)}
- placeholder="sk-..."
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 回應
- {reply || '—'}
-
-
-
- )
-}
\ No newline at end of file
diff --git a/haixun-backend/web/src/pages/DashboardPage.tsx b/haixun-backend/web/src/pages/DashboardPage.tsx
index 8e76304..2dfda32 100644
--- a/haixun-backend/web/src/pages/DashboardPage.tsx
+++ b/haixun-backend/web/src/pages/DashboardPage.tsx
@@ -1,98 +1,181 @@
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../api/client'
import { useAuth } from '../auth/AuthContext'
import { CopyFlowPipeline } from '../components/CopyFlowPipeline'
import { PlacementFlowPipeline } from '../components/PlacementFlowPipeline'
-import { Badge, Card, PageTitle, QuickLinkCard, StatCard } from '../components/ui'
+import {
+ Badge,
+ Card,
+ PageTitle,
+ ProgressBar,
+ QuickLinkCard,
+ StatCard,
+ StatusBadge,
+} from '../components/ui'
+import { isActiveJobStatus, isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus'
+import { jobTemplateLabel } from '../lib/jobTemplate'
+import { filterManagedSchedules } from '../lib/scheduleCatalog'
+import type { JobData, JobScheduleData, Pagination } from '../types/api'
+
+type DashboardStats = {
+ activeJobs: number
+ scheduleTotal: number
+ enabledSchedules: number
+}
export function DashboardPage() {
- const { member, tenantId, uid } = useAuth()
- const [health, setHealth] = useState('')
- useEffect(() => {
- api
- .get<{ pong: string }>('/api/v1/health')
- .then((d) => setHealth(d.pong))
- .catch(() => setHealth('unreachable'))
+ const { member } = useAuth()
+ const [jobs, setJobs] = useState([])
+ const [stats, setStats] = useState(null)
+ const [statsLoading, setStatsLoading] = useState(true)
+
+ const loadStats = useCallback(async () => {
+ try {
+ const [jobsRes, schedulesRes] = await Promise.all([
+ api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', {
+ auth: true,
+ query: { page: 1, pageSize: 50 },
+ }),
+ api.get<{ list: JobScheduleData[]; pagination: Pagination }>('/api/v1/job/schedules', {
+ auth: true,
+ query: { page: 1, pageSize: 100 },
+ }),
+ ])
+ const list = jobsRes.list ?? []
+ const activeJobs = list.filter((j) => isActiveJobStatus(j.status))
+ const managedSchedules = filterManagedSchedules(schedulesRes.list ?? [])
+ setJobs(activeJobs)
+ setStats({
+ activeJobs: activeJobs.length,
+ scheduleTotal: managedSchedules.length,
+ enabledSchedules: managedSchedules.filter((s) => s.enabled).length,
+ })
+ } catch {
+ setJobs([])
+ setStats(null)
+ } finally {
+ setStatsLoading(false)
+ }
}, [])
- const healthy = health === 'pong'
+ useEffect(() => {
+ loadStats().catch(() => undefined)
+ }, [loadStats])
+
+ useEffect(() => {
+ const hasActive = jobs.some((j) => !isTerminalJobStatus(j.status))
+ if (!hasActive) return
+ const timer = window.setInterval(() => loadStats().catch(() => undefined), 3000)
+ return () => window.clearInterval(timer)
+ }, [jobs, loadStats])
+
+ const displayName =
+ member?.display_name?.trim() || member?.email?.split('@')[0] || '島民'
+ const activeJobs = useMemo(() => jobs.slice(0, 5), [jobs])
return (
-
Overview
+
+ Overview
+
- 歡迎回來,
- {member?.email?.split('@')[0] || '使用者'}
+ 歡迎回來,{displayName}
-
- 兩條平行工作流:拷貝忍者學風格仿寫爆款;找 TA 找痛點、推產品獲客。
-
-
- 巡檢
- 任務
- {healthy ? 'API 正常' : 'API 異常'}
-
+ {stats && stats.activeJobs > 0 ? (
+
+ {stats.activeJobs} 個任務進行中
+
+ ) : null}
-
+ {stats ? (
+ <>
+
+
+ 0 ? '背景任務執行或排隊中' : '目前沒有進行中的任務'}
+ tone={stats.activeJobs > 0 ? 'brand' : 'default'}
+ />
+ 0 ? `${stats.enabledSchedules} 啟用` : stats.scheduleTotal}
+ hint={
+ stats.scheduleTotal > 0
+ ? stats.enabledSchedules > 0
+ ? `共 ${stats.scheduleTotal} 筆海巡排程`
+ : `共 ${stats.scheduleTotal} 筆,尚未啟用`
+ : '尚未建立海巡排程'
+ }
+ tone={stats.enabledSchedules > 0 ? 'sky' : 'default'}
+ />
+
+ >
+ ) : statsLoading ? (
+
載入概況中…
+ ) : null}
-
-
-
-
-
+ {activeJobs.length > 0 ? (
+
+
+
+
+ {activeJobs.map((job) => {
+ const summary = job.progress?.summary?.trim()
+ return (
+ -
+
+
+
+ {jobTemplateLabel(job.template_type)}
+
+ {summary ? (
+
{summary}
+ ) : null}
+
+
+
+ {jobStatusLabel(job.status)}
+
+
+
+ )
+ })}
+
+
+
+ 查看全部任務 →
+
+
+
+
+ ) : null}
-
+
-
+
-
-
- 品牌庫
-
- {'(找 TA 用)· '}
-
- 人設庫
-
- {'(8D 對標與留言語氣)'}
-
-
-
+
+
-
-
-
-
+
-
-
- Session
-
-
-
- UID
- - {member?.uid || uid}
-
-
-
- Email
- - {member?.email}
-
-
-
)
}
\ No newline at end of file
diff --git a/haixun-backend/web/src/pages/EasterEggsPage.tsx b/haixun-backend/web/src/pages/EasterEggsPage.tsx
new file mode 100644
index 0000000..7661514
--- /dev/null
+++ b/haixun-backend/web/src/pages/EasterEggsPage.tsx
@@ -0,0 +1,71 @@
+import { Badge, Card, CopyableId, Notice, PageTitle } from '../components/ui'
+import {
+ easterEggCatalog,
+ easterEggKindLabel,
+ type EasterEggEntry,
+} from '../lib/easterEggCatalog'
+
+function EasterEggCard({ entry }: { entry: EasterEggEntry }) {
+ const kindTone =
+ entry.kind === 'keyboard' ? 'brand' : entry.kind === 'admin-page' ? 'sky' : 'neutral'
+
+ return (
+
+
+
+
{entry.title}
+
{entry.summary}
+
+
{easterEggKindLabel(entry.kind)}
+
+
+ {entry.password ? (
+
+ ) : (
+
+ )}
+
+
+
操作步驟
+
+ {entry.howTo.map((step) => (
+ - {step}
+ ))}
+
+
+
+ {entry.notes ? (
+
+ 備註:
+ {entry.notes}
+
+ ) : null}
+
+ )
+}
+
+export function EasterEggsPage() {
+ return (
+
+
+
+
+
+
+ {easterEggCatalog.map((entry) => (
+
+ ))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/pages/JobSchedulesPage.tsx b/haixun-backend/web/src/pages/JobSchedulesPage.tsx
index b5f466b..1cc0910 100644
--- a/haixun-backend/web/src/pages/JobSchedulesPage.tsx
+++ b/haixun-backend/web/src/pages/JobSchedulesPage.tsx
@@ -1,146 +1,412 @@
-import { useEffect, useState } from 'react'
-import { api } from '../api/client'
-import { ApiError } from '../api/client'
-import { useAuth } from '../auth/AuthContext'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { api, ApiError } from '../api/client'
+import { ScheduleFrequencyPicker } from '../components/ScheduleFrequencyPicker'
import {
Button,
Card,
ErrorText,
Field,
- Input,
+ Notice,
PageTitle,
+ Select,
StatusBadge,
- Table,
- TableAction,
- TablePanel,
- TableShell,
+ TableEmpty,
} from '../components/ui'
-import type { JobScheduleData, Pagination } from '../types/api'
+import { formatUnixNano } from '../lib/formatDate'
+import {
+ buildCronFromFrequency,
+ defaultScheduleFrequency,
+ describeCron,
+ frequencyFromCron,
+ type ScheduleFrequency,
+} from '../lib/scheduleCron'
+import { jobTemplateLabel } from '../lib/jobTemplate'
+import {
+ SCHEDULE_KINDS,
+ SCHEDULE_TIMEZONE,
+ filterManagedSchedules,
+ isManagedScheduleTemplate,
+ resolveCopyMissionTarget,
+ resolvePlacementScanTarget,
+ scheduleDisplayTitle,
+ type ScheduleKind,
+} from '../lib/scheduleCatalog'
+import type { CopyMissionData } from '../types/copyMission'
+import type { JobScheduleData, Pagination, PersonaData } from '../types/api'
+import type { BrandData } from '../types/brand'
+import type { PlacementTopicData } from '../types/placementTopic'
+
+type CopyMissionOption = CopyMissionData & { persona_id: string }
export function JobSchedulesPage() {
- const { uid } = useAuth()
const [schedules, setSchedules] = useState
([])
+ const [otherSchedules, setOtherSchedules] = useState([])
+ const [topics, setTopics] = useState([])
+ const [brands, setBrands] = useState([])
+ const [missions, setMissions] = useState([])
const [pagination, setPagination] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [deletingId, setDeletingId] = useState('')
const [error, setError] = useState('')
- const [form, setForm] = useState({
- template_type: 'placement-scan',
- scope: 'persona',
- scope_id: '',
- cron: '0 9 * * *',
- timezone: 'Asia/Taipei',
- enabled: false,
- })
- const load = async () => {
+ const [kind, setKind] = useState('placement_topic_scan')
+ const [topicId, setTopicId] = useState('')
+ const [missionKey, setMissionKey] = useState('')
+ const [frequency, setFrequency] = useState(defaultScheduleFrequency())
+ const [enabled, setEnabled] = useState(false)
+
+ const cron = useMemo(() => buildCronFromFrequency(frequency), [frequency])
+
+ const loadCatalog = useCallback(async () => {
+ const [topicsRes, personasRes, brandsRes] = await Promise.all([
+ api.get<{ list: PlacementTopicData[] }>('/api/v1/placement/topics/', { auth: true }),
+ api.get<{ list: PersonaData[] }>('/api/v1/personas', { auth: true }),
+ api.get<{ list: BrandData[] }>('/api/v1/brands', { auth: true }),
+ ])
+ const topicList = topicsRes.list ?? []
+ const personaList = personasRes.list ?? []
+ const missionGroups = await Promise.all(
+ personaList.map(async (persona) => {
+ try {
+ const data = await api.get<{ list: CopyMissionData[] }>(
+ `/api/v1/personas/${encodeURIComponent(persona.id)}/copy-missions`,
+ { auth: true },
+ )
+ return (data.list ?? []).map((mission) => ({ ...mission, persona_id: persona.id }))
+ } catch {
+ return [] as CopyMissionOption[]
+ }
+ }),
+ )
+ const flatMissions = missionGroups.flat()
+ setTopics(topicList)
+ setBrands(brandsRes.list ?? [])
+ setMissions(flatMissions)
+ setTopicId((prev) => prev || topicList[0]?.id || '')
+ setMissionKey((prev) => {
+ if (prev) return prev
+ const first = flatMissions[0]
+ return first ? `${first.persona_id}:${first.id}` : ''
+ })
+ }, [])
+
+ const loadSchedules = useCallback(async () => {
setError('')
+ setLoading(true)
try {
const data = await api.get<{ list: JobScheduleData[]; pagination: Pagination }>(
'/api/v1/job/schedules',
- { auth: true, query: { page: 1, pageSize: 20 } },
+ { auth: true, query: { page: 1, pageSize: 50 } },
)
- setSchedules(data.list)
- setPagination(data.pagination)
+ const all = data.list ?? []
+ setSchedules(filterManagedSchedules(all))
+ setOtherSchedules(all.filter((item) => !isManagedScheduleTemplate(item.template_type)))
+ setPagination(data.pagination ?? null)
} catch (e) {
- setError(e instanceof ApiError ? e.message : '載入失敗')
+ setError(e instanceof ApiError ? e.message : '載入排程失敗')
+ setSchedules([])
+ setOtherSchedules([])
+ setPagination(null)
+ } finally {
+ setLoading(false)
}
- }
+ }, [])
useEffect(() => {
- if (uid) setForm((f) => ({ ...f, scope_id: uid }))
- load()
- }, [uid])
+ Promise.all([loadCatalog(), loadSchedules()]).catch(() => undefined)
+ }, [loadCatalog, loadSchedules])
- const create = async () => {
+ const selectedKind = useMemo(
+ () => SCHEDULE_KINDS.find((item) => item.kind === kind) ?? SCHEDULE_KINDS[0],
+ [kind],
+ )
+
+ const selectedMission = useMemo(() => {
+ const [personaId, missionId] = missionKey.split(':')
+ if (!personaId || !missionId) return null
+ return missions.find((item) => item.persona_id === personaId && item.id === missionId) ?? null
+ }, [missionKey, missions])
+
+ const createSchedule = async () => {
+ setSaving(true)
setError('')
try {
- await api.post('/api/v1/job/schedules', form, { auth: true })
- await load()
+ if (kind === 'placement_topic_scan') {
+ if (!topicId) throw new ApiError(0, '請選擇找 TA 主題')
+ await api.put(
+ `/api/v1/placement/topics/${encodeURIComponent(topicId)}/scan-schedule`,
+ { cron, timezone: SCHEDULE_TIMEZONE, enabled },
+ { auth: true },
+ )
+ } else {
+ if (!selectedMission) throw new ApiError(0, '請選擇拷貝任務')
+ await api.put(
+ `/api/v1/personas/${encodeURIComponent(selectedMission.persona_id)}/copy-missions/${encodeURIComponent(selectedMission.id)}/scan-schedule`,
+ { cron, timezone: SCHEDULE_TIMEZONE, enabled },
+ { auth: true },
+ )
+ }
+ await loadSchedules()
} catch (e) {
- setError(e instanceof ApiError ? e.message : '建立失敗')
+ setError(e instanceof ApiError ? e.message : '儲存排程失敗')
+ } finally {
+ setSaving(false)
}
}
- const toggle = async (id: string, enabled: boolean) => {
- const path = enabled ? 'disable' : 'enable'
- await api.post(`/api/v1/job/schedules/${id}/${path}`, {}, { auth: true })
- await load()
+ const updateCron = async (schedule: JobScheduleData, nextCron: string) => {
+ setError('')
+ try {
+ await api.put(
+ `/api/v1/job/schedules/${encodeURIComponent(schedule.id)}`,
+ { cron: nextCron, timezone: schedule.timezone || SCHEDULE_TIMEZONE },
+ { auth: true },
+ )
+ await loadSchedules()
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '更新頻率失敗')
+ }
+ }
+
+ const toggle = async (schedule: JobScheduleData) => {
+ setError('')
+ const path = schedule.enabled ? 'disable' : 'enable'
+ try {
+ await api.post(`/api/v1/job/schedules/${encodeURIComponent(schedule.id)}/${path}`, {}, { auth: true })
+ await loadSchedules()
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '更新狀態失敗')
+ }
+ }
+
+ const removeSchedule = async (schedule: JobScheduleData) => {
+ const target =
+ schedule.template_type === 'placement-scan'
+ ? resolvePlacementScanTarget(schedule, topics, brands).label
+ : resolveCopyMissionTarget(schedule, missions).label
+ if (!window.confirm(`確定刪除「${target}」的排程?刪除後需重新設定,無法復原。`)) return
+
+ setDeletingId(schedule.id)
+ setError('')
+ try {
+ await api.delete(`/api/v1/job/schedules/${encodeURIComponent(schedule.id)}`, { auth: true })
+ await loadSchedules()
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : '刪除排程失敗')
+ } finally {
+ setDeletingId('')
+ }
}
return (
-
-
-
- setForm((f) => ({ ...f, template_type: e.target.value }))}
- placeholder="placement-scan"
+
+
+
+
+
+
+
+
+
+
新增或更新排程
+
+ 同一主題或拷貝任務重複儲存會更新既有排程,不會建立第二筆。
+
+
+
+
+
+
+
+ {selectedKind.description}
+
+ {kind === 'placement_topic_scan' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ 預覽:{describeCron(cron)}(台北時間)
+
+
-
- setForm((f) => ({ ...f, scope: e.target.value }))}
- placeholder="persona"
- />
-
-
- setForm((f) => ({ ...f, cron: e.target.value }))} />
-
-
- setForm((f) => ({ ...f, scope_id: e.target.value }))}
- />
-
-
- setForm((f) => ({ ...f, timezone: e.target.value }))}
- />
-
-
-
-
)
}
\ No newline at end of file
diff --git a/haixun-backend/web/src/pages/JobsPage.tsx b/haixun-backend/web/src/pages/JobsPage.tsx
index 7d49acb..dd9f901 100644
--- a/haixun-backend/web/src/pages/JobsPage.tsx
+++ b/haixun-backend/web/src/pages/JobsPage.tsx
@@ -1,226 +1,160 @@
import { useCallback, useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
-import { api } from '../api/client'
-import { ApiError } from '../api/client'
-import { useAuth } from '../auth/AuthContext'
+import { api, ApiError } from '../api/client'
import {
Button,
Card,
ErrorText,
- Field,
- Input,
+ HoverTip,
PageTitle,
ProgressBar,
StatusBadge,
- Table,
- TableAction,
- TablePanel,
- TableShell,
- Textarea,
+ TableEmpty,
} from '../components/ui'
-import { isTerminalJobStatus, jobStatusBadgeClass, jobStatusLabel } from '../lib/jobStatus'
+import { formatUnixNano } from '../lib/formatDate'
+import {
+ isTerminalJobStatus,
+ jobErrorMessage,
+ jobStatusBadgeClass,
+ jobStatusLabel,
+} from '../lib/jobStatus'
+import { jobTemplateLabel } from '../lib/jobTemplate'
import type { JobData, Pagination } from '../types/api'
+const PAGE_SIZE = 15
+
export function JobsPage() {
- const { uid } = useAuth()
const [jobs, setJobs] = useState([])
const [pagination, setPagination] = useState(null)
+ const [page, setPage] = useState(1)
const [error, setError] = useState('')
- const [filterScope, setFilterScope] = useState('')
- const [filterScopeId, setFilterScopeId] = useState('')
- const [templateType, setTemplateType] = useState('demo_long_task')
- const [scope, setScope] = useState('user')
- const [scopeId, setScopeId] = useState(uid)
- const [payload, setPayload] = useState('{}')
+ const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setError('')
+ setLoading(true)
try {
const data = await api.get<{ list: JobData[]; pagination: Pagination }>('/api/v1/jobs', {
auth: true,
- query: { page: 1, pageSize: 20, scope: filterScope, scope_id: filterScopeId },
+ query: { page, pageSize: PAGE_SIZE },
})
- setJobs(data.list)
- setPagination(data.pagination)
+ setJobs(data.list ?? [])
+ setPagination(data.pagination ?? null)
} catch (e) {
setError(e instanceof ApiError ? e.message : '載入失敗')
+ setJobs([])
+ setPagination(null)
+ } finally {
+ setLoading(false)
}
- }, [filterScope, filterScopeId])
+ }, [page])
useEffect(() => {
- if (uid) setScopeId(uid)
- }, [uid])
-
- useEffect(() => {
- load()
+ load().catch(() => undefined)
}, [load])
useEffect(() => {
const hasActive = jobs.some((j) => !isTerminalJobStatus(j.status))
if (!hasActive) return
- const timer = window.setInterval(load, 3000)
+ const timer = window.setInterval(() => load().catch(() => undefined), 3000)
return () => window.clearInterval(timer)
}, [jobs, load])
- const create = async () => {
- setError('')
- try {
- await api.post(
- '/api/v1/jobs',
- {
- template_type: templateType,
- scope,
- scope_id: scopeId,
- payload: JSON.parse(payload),
- },
- { auth: true },
- )
- await load()
- } catch (e) {
- setError(e instanceof ApiError ? e.message : '建立失敗')
- }
- }
-
- const cancel = async (id: string) => {
- await api.post(`/api/v1/jobs/${id}/cancel`, { reason: 'ui cancel' }, { auth: true })
- await load()
- }
-
- const retry = async (id: string) => {
- await api.post(`/api/v1/jobs/${id}/retry`, {}, { auth: true })
- await load()
- }
+ const totalPages = pagination?.totalPages ?? 1
return (
-
-
- 怎麼知道任務有沒有在跑?
-
- -
- 啟動 API(
make run)只會建立預設模板{' '}
- demo_long_task,不會自動產生執行紀錄。
-
- - 按下方「建立背景任務」或到「排程」頁建立 cron,才會真正產生一筆 Job。
- -
- 看列表「狀態」:執行中 = 正在跑;已排隊 / 等待中 = 還沒被
- worker 接手(確認 Redis 與 gateway 的
JobWorker.Enabled)。
-
- - 點「查看任務詳情」可看進度與事件;有進行中任務時此頁每 3 秒自動刷新。
-
-
-
-
-
- setFilterScope(e.target.value)}
- />
-
-
- setFilterScopeId(e.target.value)}
- />
-
-
-
-
- 重新載入任務列表
-
- {
- setFilterScope('')
- setFilterScopeId('')
- }}
- >
- 清除篩選
-
-
-
-
-
-
- setTemplateType(e.target.value)} />
-
-
- setScope(e.target.value)} />
-
-
- setScopeId(e.target.value)}
- />
-
-
-
-
-
- 建立背景任務
-
-
-
-
-
-
-
-
- | ID |
- 狀態 |
- 進度 |
- 模板 |
- 操作 |
-
-
-
- {jobs.map((j) => (
-
- |
- {j.id.slice(0, 8)}…
-
- 查看詳情
-
- |
-
-
- {jobStatusLabel(j.status)}
-
- {j.phase}
- |
-
-
-
-
- {j.progress.percentage}%
+
+
+
+
+ {pagination ? `共 ${pagination.total} 筆` : loading ? '載入中…' : '—'}
+
+ load().catch(() => undefined)} disabled={loading}>
+ 重新載入
+
+
+
+
+
+
+ {loading && jobs.length === 0 ? (
+ 載入任務紀錄中…
+ ) : jobs.length === 0 ? (
+ 尚無背景任務紀錄。在工作流中觸發的任務會出現在這裡。
+ ) : (
+
+ {jobs.map((job) => {
+ const summary = job.progress?.summary?.trim()
+ const updatedAt = formatUnixNano(job.update_at || job.create_at)
+ const percentage = job.progress?.percentage ?? 0
+ const errorMessage = jobErrorMessage(job)
+
+ return (
+ -
+
+
+
+ {jobTemplateLabel(job.template_type)}
- {j.progress.summary ? (
- {j.progress.summary}
+ {updatedAt ? (
+ {updatedAt}
) : null}
- |
- {j.template_type} |
-
-
- cancel(j.id)}>
- 取消
-
- retry(j.id)}>重試
+
+
+
+
+ {jobStatusLabel(job.status)}
+
+ {errorMessage ? (
+
+
+ 查看錯誤
+
+
+ ) : null}
+
+ {percentage}%
- |
-
- ))}
-
-
-
-
+
+
+
+ {summary ? (
+
{summary}
+ ) : null}
+
+
+ )
+ })}
+
+ )}
+
+
+ {pagination && totalPages > 1 ? (
+
+
+ 第 {pagination.page} / {totalPages} 頁
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ >
+ 上一頁
+
+ = totalPages || loading}
+ onClick={() => setPage((p) => p + 1)}
+ >
+ 下一頁
+
+
+
+ ) : null}
)
-}
+}
\ No newline at end of file
diff --git a/haixun-backend/web/src/pages/ProfilePage.tsx b/haixun-backend/web/src/pages/ProfilePage.tsx
index b8cfd5f..c9cbeba 100644
--- a/haixun-backend/web/src/pages/ProfilePage.tsx
+++ b/haixun-backend/web/src/pages/ProfilePage.tsx
@@ -2,11 +2,64 @@ import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { useAuth } from '../auth/AuthContext'
import { ApiError } from '../api/client'
-import { Button, Card, CopyableId, ErrorText, Field, Input, PageTitle } from '../components/ui'
+import {
+ Badge,
+ Button,
+ Card,
+ CopyableId,
+ ErrorText,
+ Field,
+ Input,
+ PageTitle,
+ SectionTitle,
+} from '../components/ui'
+import { formatUnixNano } from '../lib/formatDate'
+import {
+ formatMemberOrigin,
+ formatMemberRoles,
+ formatMemberStatus,
+} from '../lib/memberRole'
import type { MemberMeData } from '../types/api'
+const slotClass = 'ac-slot px-5 py-5'
+
+function CopyOnlyRow({ label, value, actionLabel = '複製' }: { label: string; value: string; actionLabel?: string }) {
+ const [copied, setCopied] = useState(false)
+
+ const copy = async () => {
+ if (!value) return
+ await navigator.clipboard.writeText(value)
+ setCopied(true)
+ window.setTimeout(() => setCopied(false), 1800)
+ }
+
+ return (
+
+ {label}
+
+ {copied ? '已複製' : actionLabel}
+
+
+ )
+}
+
+function ProfileMetaItem({ label, value }: { label: string; value: string }) {
+ return (
+
+ )
+}
+
export function ProfilePage() {
const { refreshMember, member, tenantId, uid } = useAuth()
+ const [profile, setProfile] = useState(null)
const [form, setForm] = useState({
display_name: '',
language: '',
@@ -19,14 +72,15 @@ export function ProfilePage() {
useEffect(() => {
api
.get('/api/v1/members/me', { auth: true })
- .then((m) =>
+ .then((m) => {
+ setProfile(m)
setForm({
display_name: m.display_name ?? '',
language: m.language ?? '',
currency: m.currency ?? '',
phone: m.phone ?? '',
- }),
- )
+ })
+ })
.catch((e: ApiError) => setError(e.message))
}, [])
@@ -34,7 +88,7 @@ export function ProfilePage() {
setError('')
setSaved(false)
try {
- await api.patch(
+ const data = await api.patch(
'/api/v1/members/me',
{
display_name: form.display_name || undefined,
@@ -44,6 +98,7 @@ export function ProfilePage() {
},
{ auth: true },
)
+ setProfile(data)
await refreshMember()
setSaved(true)
} catch (e) {
@@ -51,40 +106,87 @@ export function ProfilePage() {
}
}
+ const account = profile ?? member
+ const createdAt = formatUnixNano(account?.create_at)
+ const updatedAt = formatUnixNano(account?.update_at)
+
return (
-
-
-
-
-
-
-
-
-
- setForm((f) => ({ ...f, display_name: e.target.value }))}
- />
-
-
- setForm((f) => ({ ...f, language: e.target.value }))}
- />
-
-
- setForm((f) => ({ ...f, currency: e.target.value }))}
- />
-
-
- setForm((f) => ({ ...f, phone: e.target.value }))} />
-
-
- {saved ? 已儲存
: null}
- 儲存個人資料
-
+
+
+
+
+
+
+
帳號資訊
+
+
+
+
+
+
+
+
+
+
+
角色
+
+ {(account?.roles?.length ? account.roles : ['member']).map((role) => (
+
+ {formatMemberRoles([role])}
+
+ ))}
+
+
+
+ {createdAt ?
: null}
+ {updatedAt ?
: null}
+
+
+
+
+
+
+
個人設定
+
+
+
+ {saved ?
已儲存
: null}
+
儲存個人資料
+
+
+
+
)
}
\ No newline at end of file
diff --git a/haixun-backend/web/src/types/api.ts b/haixun-backend/web/src/types/api.ts
index 0d7c16d..11f2a10 100644
--- a/haixun-backend/web/src/types/api.ts
+++ b/haixun-backend/web/src/types/api.ts
@@ -65,12 +65,6 @@ export interface SettingData {
update_at: number
}
-export interface AIProviderOption {
- id: string
- label: string
- streams: boolean
-}
-
export interface JobData {
id: string
template_type: string