fix dockerfile unhealth problem

This commit is contained in:
王性驊 2026-06-23 18:10:22 +08:00
parent 4cd221af5e
commit 413d5f0b10
13 changed files with 1279 additions and 351 deletions

View File

@ -183,7 +183,36 @@ runner.RegisterStepHandler("analyze_8d", func(ctx context.Context, step job.Step
## 前端設計規則(`web/`
巡樓 Console 前端在 `haixun-backend/web/`,風格參考 [simular.co](https://simular.co/)**明亮、年輕、圓角多、配色克制**。不要把舊 Next.js / `template-monorepo` UI 搬進來,也不要引入重型 UI 框架。
巡樓 Console 前端在 `haixun-backend/web/`,視覺為**沉穩田園巡檢台**(動森感:天空、雲朵、奶油卡片、青綠 brand**不是**任天堂 UI 複製)。
**字體固定** Inter + Taipei Sans TC圖示僅 `AcIcon` / `AuthDecor` 內原創 SVG 線條圖。**禁止** emoji、貼圖 JPG、咖啡色木質頂欄、Nook / 任天堂命名。樣式集中在 `index.css``--hx-*` token + `hx-*` / `ac-*` / `auth-*` class
不要把舊 Next.js / `template-monorepo` UI 搬進來,也不要引入重型 UI 框架。
### 視覺架構(登入前後共用)
```text
全頁背景 .hx-scene 灰藍天空 → 淡草地單一漸層(淺/深各一套,見 index.css
裝飾層 SceneDecor 雲朵(多朵緩動)+ 淡光暈 + 小葉子;登入與 Layout 共用
奶油卡片 .auth-ticket 2px line 邊框、圓角 2rem、surface 底、可選 .ac-dialog-texture 點陣
頂部品牌列 圖示 .auth-ticket-iconbrand-soft 底)+ ink 標題;不用獨立色塊 ribbon
主內容 表單或 Outlet內文頁用 PageTitle / Card綠色 .ac-title-bar 僅內容區小標)
桌面側欄 .ac-pocket-device 掌上終端外框PATROL PAD 狀態列;固定尺寸 + .ac-pocket-scroll 內捲
手機 .ac-dock 底部最多 4 格 +「更多」sheet
```
| 區域 | 元件 | 關鍵 class |
|------|------|------------|
| 未登入 | `AuthShell` + `LoginPage` / `RegisterPage` | `hx-scene` `auth-scene` `auth-ticket` `auth-welcome` `auth-shell-form` |
| 已登入外殼 | `Layout` | `hx-scene` `ac-app-shell` `ac-app-header` `auth-ticket` `ac-app-main-inner` |
| 背景裝飾 | `AuthDecor.tsx``SceneDecor` | `hx-scene-deco` `auth-cloud--*` |
| 品牌小圖 | `AuthTicketIcon` | `auth-ticket-icon`(小屋+樹,原創 SVG |
| 側欄導覽 | `Layout` + `navApps` | `ac-pocket-device` `ac-app-tile` |
| 手機導覽 | `MobileBottomNav` | `ac-dock` |
**登入頁刻意不做的事**:上方不要獨立大色塊 header表單上方**不要**再放 `ac-title-bar`「登入」大牌(品牌已在 `auth-welcome`)。註冊頁同理可省略重複大標。
**已淘汰、勿加回**`ac-island`(改用 `hx-scene`)、`ac-wood-bar` / 咖啡色木質頂欄、`public/ac/` 貼圖、Nook Phone 文案。
### 技術棧與指令
@ -192,11 +221,11 @@ web/
src/
api/ # API clientenvelope、JWT refresh
auth/ # AuthContext
components/ # Layout、ui、ThemeToggle、AuthShell
components/ # Layout、AuthShell、AuthDecor、ui、ThemeToggle、MobileBottomNav、AcIcon
theme/ # ThemeContext淺色 / 深色)
pages/ # 路由頁面
lib/ # jobStatus 等共用工具
index.css # 設計 token 唯一來源
lib/ # acAssets導覽 icon keyjobStatus 等
index.css # 設計 token 與場景樣式唯一來源
```
```bash
@ -208,7 +237,7 @@ make web-build # tsc + vite build
| 語言 | 字型 | 載入方式 |
|------|------|----------|
| 繁體中文 | **台北黑體 Taipei Sans TC** | npm `taipei-sans-tc`,在 `index.css` `@import` Light / Regular / Bold |
| 繁體中文 | **台北黑體 Taipei Sans TC** | npm `taipei-sans-tc`,在 `index.css` `@import` Regular + Bold |
| 英文 | **Inter**(與 simular.co 相同Google Fonts 免費) | `web/index.html` link |
規則:
@ -231,10 +260,26 @@ make web-build # tsc + vite build
- `ThemeProvider``src/theme/ThemeContext.tsx`)包住 App偏好存 `localStorage` key`haixun.theme``light` | `dark`)。
- `index.html` 內嵌 script 在 React 載入前設定 `data-theme`,避免閃爍。
- 所有顏色必須走 CSS 變數 `--hx-*`,再映射到 Tailwind `@theme``bg-canvas`、`text-brand` 等)。
- 切換按鈕用 `ThemeToggle`Layout 頂欄與 `AuthShell` 都要有。
- 切換按鈕用 `ThemeToggle``ac-btn-secondary` 樣式)`Layout` 頂欄與 `AuthShell` 右上角都要有。
- **禁止**在元件裡寫死 `bg-slate-*`、`text-emerald-*`、`bg-amber-*` 等 Tailwind 預設色;語意狀態用 `text-success` / `text-warning` / `text-danger``jobStatus.ts` 的 badge class。
淺色預設明亮藍白底;深色為深藍黑底。兩套都只允許 **一個主色 brand靛藍** + success / warning / danger 語意色,不要再加褐色、墨綠、多種 accent 亂配。
淺色:低飽和灰藍天空 + 灰綠草地 + 奶油 `surface` + **brand 青綠**;深色:黃昏低對比、同一套 token 自動切換。頂欄與卡片內品牌區都用 **surface / ink / brand**,不要再用木色 `#c4a882` 當 header 底。
### 場景與卡片 class維護時對照
| Class | 用途 |
|-------|------|
| `.hx-scene` | 全頁天空→草地漸層(登入 + 已登入根節點) |
| `.hx-scene-deco` / `SceneDecor` | 背景雲、光暈、葉子(`pointer-events: none` |
| `.auth-ticket` | 奶油主卡片外框(登入卡、已登入主內容區) |
| `.auth-welcome` | 卡片內品牌列:圖示 + 標題 + 一句 tagline底部分隔線 |
| `.ac-app-header` | 已登入 sticky 頂欄:半透明 surface + blur**非**木色 |
| `.ac-title-bar` | 內容區綠色小標題(裝置色漸層);用於 `PageTitle` 等,**不**用於登入頁表單上方大牌 |
| `.ac-pocket-device` | 側欄掌上終端;`--pocket-width`28rem、`--pocket-screen-height` 固定,內容在 `.ac-pocket-scroll` 捲動 |
| `.ac-app-tile` / `.ac-dock` | App 格導覽、手機底欄 |
| `.auth-shell-form` | 登入/註冊表單放大字級(僅 auth 頁) |
側欄標示用 **PATROL PAD** 等中性英文裝飾字(`display-en`);圖示僅 `AcIcon` SVG。
### 色彩 token語意命名
@ -262,7 +307,7 @@ make web-build # tsc + vite build
--radius-pill 9999px Button、Badge、導覽 pill
```
陰影用 utility`shadow-card`(一般卡片)、`shadow-soft`主按鈕、Hero。Hero 背景用 class `hero-panel`;裝飾 blob 用 `glow-blob` / `glow-blob-alt`
陰影用 utility`shadow-card`(一般卡片)、`shadow-soft`主按鈕、Hero、`.auth-ticket`)。內容 Hero 可用 `ac-bulletin` + `ac-hero-gradient` token全頁裝飾雲朵走 `SceneDecor`,不要另加會打架的強色 blob
### 共用元件(優先復用)
@ -289,12 +334,12 @@ make web-build # tsc + vite build
### 版面與導覽
- 已登入(桌面):`Layout` = 左側欄 + 頂部 sticky barUID + `ThemeToggle`+ `Outlet`
- 已登入(手機):頂欄品牌 + 主題切換;導覽走 `MobileBottomNav`
- 側欄分組:**工作區**總覽、背景任務、排程、AI、**管理**(模板、設定、會員、權限)
- Active 導覽:`bg-brand text-white shadow-soft`hover`bg-brand-soft text-brand`。
- 未登入:`AuthShell` 置中卡片 + 右上主題切換;背景用柔和 blob不要花俏插圖牆
- 語氣:年輕、直接、短句;Hero 可有一句主標 + brand 色強調詞,避免長篇企業八股
- 已登入(桌面):`Layout` = `hx-scene` 背景 + `SceneDecor` + `ac-app-header`(品牌 + 角色 chip + `ThemeToggle`+ 左 `ac-pocket-device` + 右 `auth-ticket` 主內容 `Outlet`
- 已登入(手機):同上頂欄;導覽走 `MobileBottomNav`(總覽/任務/排程/更多)
- 側欄 App 來源:`src/lib/acAssets.ts` 的 `navApps`;圖示 key 對應 `AcIcon`
- Active 導覽:`ac-app-tile--active`brand-soft 底 + brand 字色)hover`bg-brand-soft text-brand`。
- 未登入:`AuthShell` 置中 `auth-ticket` + 右上 `ThemeToggle``auth-welcome` 內品牌,表單緊接說明文字
- 語氣:年輕、直接、短句;可帶「島民」「巡樓」等原創文案,避免企業八股與任天堂用語
### API 與狀態
@ -305,15 +350,17 @@ make web-build # tsc + vite build
### 新增頁面流程
1. 在 `App.tsx` 掛路由(需登入的放在 `Layout` 底下)。
2. 頁面`PageTitle` + `Card` + 既有元件;色票只引用 semantic token。
1. 在 `App.tsx` 掛路由(需登入的放在 `Layout` 底下,自動享有 `hx-scene` + 頂欄 + 主內容 `auth-ticket`)。
2. 頁面內用 `PageTitle`(含 `.ac-title-bar` 小標)+ `Card` / `ac-bulletin` + `ui.tsx` 元件;色票只引用 semantic token。
3. 若需新語意色,**先**改 `index.css``--hx-*``@theme`,再改元件;不要頁面內硬編色碼。
4. 完成後執行 `make web-build`
4. 新導覽項:改 `acAssets.ts``navApps`,並在 `AcIcon` 補 SVG path。
5. 完成後執行 `make web-build`
### 前端禁忌
- 不要引入 MUI / Ant Design / Chakra 等大型 UI 庫。
- 不要為單頁新增第三套配色或漸層彩虹按鈕。
- 不要為單頁新增第三套配色、木質頂欄、或漸層彩虹按鈕。
- 不要在登入/註冊頁加回獨立大牌 `ac-title-bar` 或咖啡色 header ribbon。
- 不要讓 SSE / AI 直接吃 provider 原始 chunk後端已 normalize
- 不要用 `offset/limit` 呼叫列表 API`page` / `pageSize`

View File

@ -0,0 +1,81 @@
import type { AcAppKey } from '../lib/acAssets'
const stroke = {
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.75,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
}
const paths: Record<AcAppKey, React.ReactNode> = {
home: <path {...stroke} d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1v-9.5Z" />,
jobs: <path {...stroke} d="M6 4h12a1 1 0 0 1 1 1v14l-7-3.5L5 19V5a1 1 0 0 1 1-1Z" />,
schedule: (
<>
<path {...stroke} d="M8 2v4M16 2v4M4 8h16M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" />
<path {...stroke} d="M8 12h4M8 16h8" />
</>
),
ai: (
<>
<rect {...stroke} x="5" y="7" width="14" height="10" rx="2" />
<path {...stroke} d="M9 11h.01M15 11h.01M10 15h4" />
<path {...stroke} d="M12 4v2" />
</>
),
template: (
<>
<path {...stroke} d="M8 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z" />
<path {...stroke} d="M10 9h8M10 13h8M10 17h5" />
</>
),
settings: (
<>
<circle {...stroke} cx="12" cy="12" r="3" />
<path
{...stroke}
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4"
/>
</>
),
profile: (
<>
<circle {...stroke} cx="12" cy="8" r="3.5" />
<path {...stroke} d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6" />
</>
),
permissions: (
<>
<path {...stroke} d="M8 11V8a4 4 0 1 1 8 0v3" />
<rect {...stroke} x="6" y="11" width="12" height="9" rx="2" />
</>
),
more: (
<>
<circle fill="currentColor" cx="7" cy="12" r="1.5" stroke="none" />
<circle fill="currentColor" cx="12" cy="12" r="1.5" stroke="none" />
<circle fill="currentColor" cx="17" cy="12" r="1.5" stroke="none" />
</>
),
}
export function AcIcon({
app,
size = 'md',
className = '',
}: {
app: AcAppKey
size?: 'sm' | 'md' | 'lg'
className?: string
}) {
const px = size === 'sm' ? 'h-9 w-9' : size === 'lg' ? 'h-12 w-12' : 'h-10 w-10'
const iconPx = size === 'sm' ? 'h-4 w-4' : size === 'lg' ? 'h-6 w-6' : 'h-5 w-5'
return (
<span className={`ac-app-icon-svg ${px} ${className}`}>
<svg aria-hidden className={iconPx} viewBox="0 0 24 24">
{paths[app]}
</svg>
</span>
)
}

View File

@ -0,0 +1,82 @@
function CloudShape({ path }: { path: string }) {
return <path d={path} fill="currentColor" />
}
const cloudLg =
'M24 36c-9.941 0-18-6.268-18-14S14.059 8 24 8c2.2 0 4.28.45 6.16 1.26C33.1 4.54 38.78 2 45 2c11.046 0 20 7.82 20 17.5 0 .84-.06 1.66-.17 2.46C68.9 20.62 74.6 18 81 18c13.255 0 24 9.402 24 21S94.255 60 81 60H24Z'
const cloudMd =
'M18 30C8.059 30 0 24.627 0 18S8.059 6 18 6c1.65 0 3.21.34 4.62.95C25.58 3.4 30.22 1 35 1c9.389 0 17 6.82 17 15.25 0 .7-.05 1.38-.14 2.05C55.12 16.35 59.68 14 65 14c11.046 0 20 8.402 20 18.5S76.046 51 65 51H18Z'
const cloudSm =
'M14 22C6.82 22 1 17.523 1 12S6.82 2 14 2c1.28 0 2.5.26 3.6.72C19.98 1.13 23.52 0 27 0c7.18 0 13 5.82 13 13s-5.82 13-13 13H14Z'
const clouds = [
{ id: 1, path: cloudLg, viewBox: '0 0 120 48', className: 'auth-cloud--1' },
{ id: 2, path: cloudMd, viewBox: '0 0 96 40', className: 'auth-cloud--2' },
{ id: 3, path: cloudSm, viewBox: '0 0 54 26', className: 'auth-cloud--3' },
{ id: 4, path: cloudMd, viewBox: '0 0 96 40', className: 'auth-cloud--4' },
{ id: 5, path: cloudLg, viewBox: '0 0 120 48', className: 'auth-cloud--5' },
{ id: 6, path: cloudSm, viewBox: '0 0 54 26', className: 'auth-cloud--6' },
{ id: 7, path: cloudMd, viewBox: '0 0 96 40', className: 'auth-cloud--7' },
] as const
export function SceneDecor() {
return (
<div className="hx-scene-deco" aria-hidden>
<span className="auth-scene-blob auth-scene-blob--sky" />
<span className="auth-scene-blob auth-scene-blob--sky-alt" />
{clouds.map((cloud) => (
<svg key={cloud.id} className={`auth-cloud ${cloud.className}`} viewBox={cloud.viewBox} fill="none">
<CloudShape path={cloud.path} />
</svg>
))}
<svg className="auth-leaf auth-leaf--1" viewBox="0 0 32 32" fill="none">
<path
d="M16 4C10 10 6 18 8 26c6-2 12-8 14-16-2-2-4-4-6-6Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M16 10v12M13 18h6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<svg className="auth-leaf auth-leaf--2" viewBox="0 0 32 32" fill="none">
<path
d="M16 4C10 10 6 18 8 26c6-2 12-8 14-16-2-2-4-4-6-6Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
)
}
/** @deprecated 使用 SceneDecor */
export const AuthSceneDecor = SceneDecor
export function AuthTicketIcon({ className = '' }: { className?: string }) {
return (
<span className={`auth-ticket-icon ${className}`.trim()}>
<svg viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M5 11.5 12 6l7 5.5V19a1.5 1.5 0 0 1-1.5 1.5H14v-5.5h-4V20.5H6.5A1.5 1.5 0 0 1 5 19v-7.5Z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
<path
d="M17 4.5c1.2.8 2 2.2 2 3.8 0 2.8-2.2 4.2-4 5.2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<circle cx="18.5" cy="5.5" r="1.25" fill="currentColor" />
</svg>
</span>
)
}

View File

@ -1,27 +1,38 @@
import type { ReactNode } from 'react'
import { AuthTicketIcon, SceneDecor } from './AuthDecor'
import { ThemeToggle } from './ThemeToggle'
export function AuthShell({ children }: { children: ReactNode }) {
export function AuthShell({
children,
tagline = '準備好巡樓了嗎?',
}: {
children: ReactNode
tagline?: string
}) {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-canvas p-6">
<div className="absolute top-5 right-5 z-20">
<ThemeToggle />
<div className="hx-scene auth-scene p-4 sm:p-6">
<SceneDecor />
<div className="absolute top-4 right-4 z-20 sm:top-5 sm:right-5">
<ThemeToggle compact />
</div>
<div
aria-hidden
className="glow-blob pointer-events-none absolute -top-24 -right-16 h-72 w-72 rounded-full opacity-70 blur-3xl"
/>
<div
aria-hidden
className="glow-blob-alt pointer-events-none absolute -bottom-20 -left-10 h-64 w-64 rounded-full opacity-80 blur-3xl"
/>
<div className="relative z-10 w-full max-w-md">
<div className="mb-8 text-center">
<p className="display-en text-xs font-semibold tracking-[0.2em] text-brand uppercase">Haixun Patrol</p>
<h1 className="mt-2 text-3xl font-black text-ink"></h1>
<p className="mt-2 text-sm text-ink-secondary"> · · </p>
<div className="auth-shell relative z-10 w-full max-w-xl">
<div className="auth-ticket">
<div className="auth-ticket-body ac-dialog-texture">
<div className="auth-welcome">
<AuthTicketIcon />
<div className="min-w-0 flex-1">
<p className="display-en text-[11px] font-semibold tracking-[0.16em] text-accent uppercase">
Haixun Patrol
</p>
<p className="text-lg font-bold leading-snug text-ink sm:text-xl"></p>
<p className="mt-0.5 text-sm font-semibold text-brand">{tagline}</p>
</div>
</div>
{children}
</div>
</div>
{children}
</div>
</div>
)

View File

@ -1,36 +1,38 @@
import { NavLink, Outlet } from 'react-router-dom'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { navApps } from '../lib/acAssets'
import type { AcAppKey } from '../lib/acAssets'
import { useAuth } from '../auth/AuthContext'
import { AuthTicketIcon, SceneDecor } from './AuthDecor'
import { AcIcon } from './AcIcon'
import { MobileBottomNav } from './MobileBottomNav'
import { ThemeToggle } from './ThemeToggle'
const navMain = [
{ to: '/', label: '總覽' },
{ to: '/jobs', label: '背景任務' },
{ to: '/job-schedules', label: '排程' },
{ to: '/ai', label: 'AI' },
]
const navMore = [
{ to: '/job-templates', label: '模板' },
{ to: '/settings', label: '設定' },
{ to: '/profile', label: '會員' },
{ to: '/permissions', label: '權限' },
]
function NavItem({ to, label, end }: { to: string; label: string; end?: boolean }) {
function AppTile({
to,
label,
icon,
end,
matchPrefix,
}: {
to: string
label: string
icon: AcAppKey
end?: boolean
matchPrefix?: string
}) {
const { pathname } = useLocation()
return (
<NavLink
to={to}
end={end}
className={({ isActive }) =>
`rounded-[var(--radius-pill)] px-4 py-2 text-sm font-semibold transition ${
isActive
? 'bg-brand text-white shadow-soft'
: 'text-ink hover:bg-brand-soft hover:text-brand'
}`
}
className={({ isActive }) => {
const prefixActive = matchPrefix ? pathname.startsWith(matchPrefix) : false
const active = isActive || prefixActive
return `ac-app-tile ${active ? 'ac-app-tile--active' : ''}`
}}
>
{label}
<AcIcon app={icon} size="md" />
<span>{label}</span>
</NavLink>
)
}
@ -39,60 +41,73 @@ export function Layout() {
const { member, uid, logout } = useAuth()
return (
<div className="flex min-h-screen bg-canvas">
<aside className="hidden w-60 shrink-0 flex-col border-r border-line bg-surface/90 p-4 backdrop-blur-md lg:flex lg:w-64 lg:p-5">
<div className="rounded-[var(--radius-xl)] border border-line bg-surface-muted px-4 py-4">
<div className="display-en text-[10px] font-bold tracking-[0.22em] text-brand uppercase">Haixun</div>
<div className="mt-1 text-2xl font-black text-brand"></div>
</div>
<div className="hx-scene ac-app-shell flex min-h-screen flex-col">
<SceneDecor />
<nav className="mt-6 flex flex-1 flex-col gap-5">
<div>
<p className="mb-2 px-2 text-xs font-bold tracking-wide text-ink-secondary uppercase"></p>
<div className="flex flex-col gap-1">
{navMain.map((item) => (
<NavItem key={item.to} to={item.to} label={item.label} end={item.to === '/'} />
))}
<header className="ac-app-header">
<div className="ac-app-header-brand">
<AuthTicketIcon className="ac-app-header-icon" />
<div className="min-w-0">
<p className="display-en text-[10px] font-semibold tracking-[0.16em] text-accent uppercase sm:text-[11px]">
Haixun Patrol
</p>
<h1 className="truncate text-lg font-bold leading-snug text-ink sm:text-2xl"></h1>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="ac-role-chip hidden px-3 py-1 text-xs sm:inline">
{member?.roles?.[0] || 'member'}
</span>
<ThemeToggle compact />
</div>
</header>
<div className="relative z-10 mx-auto flex w-full max-w-7xl flex-1 flex-col gap-4 p-3 sm:p-4 lg:flex-row lg:gap-5 lg:p-5">
<aside className="ac-pocket-device hidden self-start lg:sticky lg:top-[5.5rem] lg:block">
<div className="ac-pocket-screen">
<div className="ac-pocket-status">
<span className="display-en tracking-[0.14em]">PATROL PAD</span>
<span className="ac-pocket-status-dots" aria-hidden>
<span />
<span />
<span />
</span>
</div>
<div className="ac-pocket-scroll">
<div className="ac-pocket-body">
<nav className="grid grid-cols-2 gap-2.5" aria-label="側欄導覽">
{navApps.map((item) => (
<AppTile
key={item.to}
to={item.to}
label={item.label}
icon={item.icon}
end={'end' in item ? item.end : undefined}
matchPrefix={'matchPrefix' in item ? item.matchPrefix : undefined}
/>
))}
</nav>
<div className="ac-slot ac-pocket-slot mt-4">
<p className="ac-pocket-slot-label"></p>
<div className="ac-pocket-slot-email truncate">{member?.email}</div>
<p className="ac-pocket-slot-uid truncate">{uid || member?.uid}</p>
<button
type="button"
onClick={() => logout()}
className="ac-btn-secondary mt-3 w-full py-2.5 text-[15px] font-semibold"
>
</button>
</div>
</div>
</div>
</div>
<div>
<p className="mb-2 px-2 text-xs font-bold tracking-wide text-ink-secondary uppercase"></p>
<div className="flex flex-col gap-1">
{navMore.map((item) => (
<NavItem key={item.to} to={item.to} label={item.label} />
))}
</div>
</div>
</nav>
</aside>
<div className="rounded-[var(--radius-lg)] border border-line bg-surface-muted p-3 text-sm">
<div className="truncate font-semibold text-ink">{member?.email}</div>
<div className="mt-1.5">
<BadgePill>{member?.roles?.join(', ') || 'user'}</BadgePill>
<main className="auth-ticket ac-app-main-panel layout-main">
<div className="ac-app-main-inner ac-dialog-texture">
<Outlet />
</div>
<button
type="button"
onClick={() => logout()}
className="mt-3 w-full rounded-[var(--radius-pill)] border border-line bg-surface px-3 py-2 font-semibold text-ink transition hover:border-brand/30 hover:text-brand"
>
</button>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-line bg-canvas/85 px-4 py-3 backdrop-blur-md sm:px-5 lg:px-8">
<div className="min-w-0 lg:hidden">
<p className="text-lg font-black text-brand"></p>
<p className="truncate font-mono text-[11px] text-ink-secondary">{uid || member?.uid}</p>
</div>
<p className="hidden truncate text-sm text-ink-secondary lg:block">
<span className="font-mono text-xs text-ink">{uid || member?.uid}</span>
</p>
<ThemeToggle compact className="shrink-0" />
</header>
<main className="layout-main flex-1 overflow-auto px-4 py-5 sm:px-5 md:px-8 md:py-8">
<Outlet />
</main>
</div>
@ -100,11 +115,3 @@ export function Layout() {
</div>
)
}
function BadgePill({ children }: { children: React.ReactNode }) {
return (
<span className="inline-block rounded-[var(--radius-pill)] bg-brand-soft px-2.5 py-0.5 text-xs font-bold text-brand">
{children}
</span>
)
}

View File

@ -1,21 +1,17 @@
import { useEffect, useState } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { navApps } from '../lib/acAssets'
import { useAuth } from '../auth/AuthContext'
import { AcIcon } from './AcIcon'
import { ThemeToggle } from './ThemeToggle'
const mobileTabs = [
{ to: '/', label: '總覽', end: true },
{ to: '/jobs', label: '任務', matchPrefix: '/jobs' },
{ to: '/job-schedules', label: '排程' },
{ to: '/', label: '總覽', icon: 'home' as const, end: true },
{ to: '/jobs', label: '任務', icon: 'jobs' as const, matchPrefix: '/jobs' },
{ to: '/job-schedules', label: '排程', icon: 'schedule' as const },
] as const
const moreRoutes = [
{ to: '/ai', label: 'AI' },
{ to: '/job-templates', label: '模板' },
{ to: '/settings', label: '設定' },
{ to: '/profile', label: '會員' },
{ to: '/permissions', label: '權限' },
]
const moreRoutes = navApps.filter((n) => !mobileTabs.some((t) => t.to === n.to))
function isMoreActive(pathname: string) {
return moreRoutes.some((r) => pathname === r.to || pathname.startsWith(`${r.to}/`))
@ -51,11 +47,8 @@ export function MobileBottomNav() {
return (
<>
<nav
className="mobile-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-line bg-surface/95 backdrop-blur-md lg:hidden"
aria-label="主要導覽"
>
<div className="mx-auto grid max-w-lg grid-cols-4">
<nav className="ac-dock mobile-bottom-nav fixed inset-x-0 bottom-0 z-30 lg:hidden" aria-label="主要導覽">
<div className="mx-auto grid max-w-lg grid-cols-4 px-2 pt-1">
{mobileTabs.map((tab) => (
<NavLink
key={tab.to}
@ -65,25 +58,25 @@ export function MobileBottomNav() {
const prefixActive =
'matchPrefix' in tab && location.pathname.startsWith(tab.matchPrefix)
const active = isActive || prefixActive
return `flex min-h-[3.25rem] flex-col items-center justify-center gap-0.5 px-1 py-2 text-center text-xs font-bold transition ${
active ? 'text-brand' : 'text-ink-secondary hover:text-brand'
return `ac-dock-btn flex min-h-[3.5rem] flex-col items-center justify-center gap-1 py-2 text-center text-[11px] font-semibold ${
active ? 'ac-dock-btn--active' : ''
}`
}}
>
<TabIcon name={tab.label} />
<AcIcon app={tab.icon} size="sm" />
<span>{tab.label}</span>
</NavLink>
))}
<button
type="button"
onClick={() => setMoreOpen(true)}
className={`flex min-h-[3.25rem] flex-col items-center justify-center gap-0.5 px-1 py-2 text-center text-xs font-bold transition ${
isMoreActive(location.pathname) ? 'text-brand' : 'text-ink-secondary hover:text-brand'
className={`ac-dock-btn flex min-h-[3.5rem] flex-col items-center justify-center gap-1 py-2 text-center text-[11px] font-semibold ${
isMoreActive(location.pathname) ? 'ac-dock-btn--active' : ''
}`}
aria-expanded={moreOpen}
aria-haspopup="dialog"
>
<MoreIcon />
<AcIcon app="more" size="sm" />
<span></span>
</button>
</div>
@ -101,20 +94,20 @@ export function MobileBottomNav() {
role="dialog"
aria-modal="true"
aria-label="更多功能"
className="mobile-more-sheet absolute inset-x-0 bottom-0 max-h-[min(85vh,32rem)] overflow-y-auto rounded-t-[var(--radius-xl)] border border-line bg-surface shadow-card"
className="mobile-more-sheet ac-dialog absolute inset-x-2 bottom-0 max-h-[min(85vh,34rem)] overflow-y-auto rounded-b-none"
>
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-line bg-surface px-5 py-4">
<h2 className="text-lg font-bold text-ink"></h2>
<div className="ac-title-bar flex items-center justify-between">
<span></span>
<button
type="button"
onClick={() => setMoreOpen(false)}
className="rounded-[var(--radius-pill)] border border-line px-4 py-2 text-sm font-semibold text-ink transition hover:bg-surface-muted"
className="ac-btn-secondary rounded-[var(--radius-pill)] px-3 py-1 text-xs"
>
</button>
</div>
<div className="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
<div className="grid grid-cols-3 gap-2 p-4">
{moreRoutes.map((item) => {
const active = location.pathname === item.to
return (
@ -122,33 +115,28 @@ export function MobileBottomNav() {
key={item.to}
type="button"
onClick={() => goMore(item.to)}
className={`rounded-[var(--radius-lg)] border px-4 py-4 text-left text-sm font-bold transition ${
active
? 'border-brand bg-brand-soft text-brand'
: 'border-line bg-surface-muted text-ink hover:border-brand/30 hover:text-brand'
}`}
className={`ac-app-tile ${active ? 'ac-app-tile--active' : ''}`}
>
{item.label}
<AcIcon app={item.icon} size="md" />
<span>{item.label}</span>
</button>
)
})}
</div>
<div className="border-t border-line p-4">
<div className="rounded-[var(--radius-lg)] border border-line bg-surface-muted p-4">
<div className="ac-slot p-4">
<div className="truncate font-semibold text-ink">{member?.email}</div>
<div className="mt-2 text-xs font-semibold text-ink-secondary">
{member?.roles?.join(', ') || 'user'}
</div>
<div className="mt-2 text-xs font-semibold text-muted">{member?.roles?.join(', ') || 'member'}</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<ThemeToggle />
<ThemeToggle compact />
<button
type="button"
onClick={() => {
setMoreOpen(false)
logout()
}}
className="rounded-[var(--radius-pill)] border border-line bg-surface px-4 py-2 text-sm font-semibold text-ink transition hover:border-danger/40 hover:text-danger"
className="ac-btn-secondary px-4 py-2 text-sm"
>
</button>
@ -161,27 +149,3 @@ export function MobileBottomNav() {
</>
)
}
function TabIcon({ name }: { name: string }) {
const paths: Record<string, string> = {
: 'M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1v-9.5Z',
: 'M6 4h12a1 1 0 0 1 1 1v14l-7-3.5L5 19V5a1 1 0 0 1 1-1Z',
: 'M8 2v4M16 2v4M4 8h16M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z',
}
const d = paths[name] ?? paths['總覽']
return (
<svg aria-hidden className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d={d} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function MoreIcon() {
return (
<svg aria-hidden className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
)
}

View File

@ -1,28 +1,51 @@
import { useTheme } from '../theme/ThemeContext'
import { Button } from './ui'
function SunIcon() {
return (
<svg aria-hidden className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<circle cx="12" cy="12" r="4" />
<path strokeLinecap="round" d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4" />
</svg>
)
}
function MoonIcon() {
return (
<svg aria-hidden className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20 14.5A7.5 7.5 0 0 1 9.5 4 6.5 6.5 0 1 0 20 14.5Z"
/>
</svg>
)
}
export function ThemeToggle({
className = '',
compact = false,
wood = false,
}: {
className?: string
compact?: boolean
wood?: boolean
}) {
const { theme, toggleTheme } = useTheme()
const isDark = theme === 'dark'
const base = wood
? 'border-white/30 bg-white/10 text-white hover:bg-white/20'
: 'ac-btn-secondary'
return (
<Button
<button
type="button"
variant="ghost"
onClick={toggleTheme}
className={`gap-2 ${compact ? 'px-3 py-2' : 'px-4'} ${className}`}
className={`${base} inline-flex items-center justify-center gap-1.5 rounded-[var(--radius-pill)] ${compact ? 'px-3 py-2 text-sm' : 'px-4 py-2.5 text-sm'} ${className}`}
aria-label={isDark ? '切換為淺色模式' : '切換為深色模式'}
>
<span aria-hidden className="text-base leading-none">
{isDark ? '☀️' : '🌙'}
</span>
{isDark ? <SunIcon /> : <MoonIcon />}
<span className={compact ? 'hidden sm:inline' : ''}>{isDark ? '淺色' : '深色'}</span>
</Button>
</button>
)
}

View File

@ -1,12 +1,14 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import type { AcAppKey } from '../lib/acAssets'
import { AcIcon } from './AcIcon'
export function PageTitle({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<div className="mb-8">
<h1 className="text-balance text-3xl font-bold tracking-tight text-ink md:text-4xl">{title}</h1>
<div className="ac-title-bar">{title}</div>
{subtitle ? (
<p className="mt-2 max-w-2xl text-base leading-relaxed text-ink-secondary">{subtitle}</p>
<p className="max-w-2xl text-base leading-relaxed text-ink-secondary">{subtitle}</p>
) : null}
</div>
)
@ -14,9 +16,7 @@ export function PageTitle({ title, subtitle }: { title: string; subtitle?: strin
export function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<div className={`rounded-[var(--radius-lg)] border border-line bg-surface p-6 shadow-card ${className}`}>
{children}
</div>
<div className={`ac-dialog rounded-[var(--radius-lg)] p-5 md:p-6 ${className}`}>{children}</div>
)
}
@ -29,7 +29,7 @@ export function Field({
}) {
return (
<label className="block text-sm">
<span className="mb-2 block font-semibold text-ink">{label}</span>
<span className="mb-2 block font-bold text-ink">{label}</span>
{children}
</label>
)
@ -39,7 +39,7 @@ export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...props}
className={`w-full rounded-[var(--radius-md)] border border-line bg-surface px-4 py-3 text-[15px] font-normal text-ink outline-none transition placeholder:text-subtle focus:border-brand focus:ring-4 focus:ring-brand-soft ${props.className ?? ''}`}
className={`ac-field w-full px-4 py-3 text-[15px] text-ink outline-none transition placeholder:text-subtle focus:border-brand focus:ring-4 focus:ring-brand-soft ${props.className ?? ''}`}
/>
)
}
@ -48,7 +48,7 @@ export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement
return (
<textarea
{...props}
className={`w-full rounded-[var(--radius-md)] border border-line bg-surface px-4 py-3 font-mono text-[15px] text-ink outline-none transition placeholder:text-subtle focus:border-brand focus:ring-4 focus:ring-brand-soft ${props.className ?? ''}`}
className={`ac-field w-full px-4 py-3 font-mono text-[15px] text-ink outline-none transition placeholder:text-subtle focus:border-brand focus:ring-4 focus:ring-brand-soft ${props.className ?? ''}`}
/>
)
}
@ -60,17 +60,17 @@ export function Button({
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'ghost' | 'danger' | 'soft' }) {
const styles =
variant === 'primary'
? 'bg-brand text-white hover:bg-brand-hover shadow-soft'
? 'ac-btn-primary'
: variant === 'danger'
? 'bg-danger text-white hover:opacity-90'
? 'ac-btn-danger'
: variant === 'soft'
? 'bg-brand-soft text-brand hover:opacity-90'
: 'border border-line bg-surface text-ink hover:bg-surface-muted'
? 'ac-btn-secondary bg-brand-soft text-brand'
: 'ac-btn-secondary'
const { className, ...rest } = props
return (
<button
{...rest}
className={`inline-flex min-h-11 items-center justify-center rounded-[var(--radius-pill)] px-5 py-2.5 text-sm font-semibold whitespace-nowrap transition active:scale-[0.98] disabled:opacity-50 ${styles} ${className ?? ''}`}
className={`inline-flex min-h-11 items-center justify-center rounded-[var(--radius-pill)] px-5 py-2.5 text-sm whitespace-nowrap transition disabled:opacity-50 ${styles} ${className ?? ''}`}
>
{children}
</button>
@ -85,16 +85,16 @@ export function Badge({
tone?: 'neutral' | 'brand' | 'sky' | 'success' | 'warning' | 'danger'
}) {
const tones = {
neutral: 'bg-accent-soft text-ink',
brand: 'bg-brand-soft text-brand',
sky: 'bg-glow text-brand',
success: 'bg-success-soft text-success',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
neutral: 'border-wood-dark bg-accent-soft text-ink',
brand: 'border-brand bg-brand-soft text-brand',
sky: 'border-accent bg-accent-soft text-accent',
success: 'border-success bg-success-soft text-success',
warning: 'border-warning bg-warning-soft text-warning',
danger: 'border-danger bg-danger-soft text-danger',
}
return (
<span
className={`inline-flex items-center rounded-[var(--radius-pill)] px-3 py-1 text-[13px] font-bold ${tones[tone]}`}
className={`inline-flex items-center rounded-[var(--radius-pill)] border-2 px-3 py-1 text-[13px] font-bold ${tones[tone]}`}
>
{children}
</span>
@ -103,7 +103,7 @@ export function Badge({
export function ErrorText({ message }: { message?: string }) {
if (!message) return null
return <p className="mt-2 text-sm text-danger">{message}</p>
return <p className="mt-2 text-sm font-semibold text-danger">{message}</p>
}
export function CopyableId({ label, value }: { label: string; value: string }) {
@ -112,16 +112,12 @@ export function CopyableId({ label, value }: { label: string; value: string }) {
await navigator.clipboard.writeText(value)
}
return (
<div className="rounded-[var(--radius-md)] border border-line bg-surface-muted px-4 py-3">
<div className="text-xs font-semibold text-ink-secondary">{label}</div>
<div className="ac-slot px-4 py-3">
<div className="text-xs font-bold text-ink-secondary">{label}</div>
<div className="mt-1 flex items-start justify-between gap-2">
<code className="break-all text-sm text-ink">{value || '—'}</code>
{value ? (
<button
type="button"
onClick={copy}
className="shrink-0 rounded-[var(--radius-pill)] border border-line bg-surface px-3 py-1 text-xs font-semibold text-ink transition hover:bg-brand-soft hover:text-brand"
>
<button type="button" onClick={copy} className="ac-btn-secondary shrink-0 px-3 py-1 text-xs">
</button>
) : null}
@ -134,24 +130,20 @@ export function QuickLinkCard({
to,
title,
desc,
tag,
icon,
}: {
to: string
title: string
desc: string
icon: AcAppKey
tag?: string
}) {
return (
<Link
to={to}
className="group block rounded-[var(--radius-xl)] border border-line bg-surface p-6 shadow-card transition hover:-translate-y-1 hover:border-brand/40 hover:shadow-soft"
>
{tag ? <Badge tone="brand">{tag}</Badge> : null}
<h3 className="mt-3 text-lg font-bold text-ink transition group-hover:text-brand">{title}</h3>
<p className="mt-2 text-[15px] leading-relaxed text-ink-secondary">{desc}</p>
<span className="mt-4 inline-flex items-center gap-1 text-sm font-semibold text-brand">
<span className="transition group-hover:translate-x-0.5"></span>
</span>
<Link to={to} className="ac-app-card group block p-5">
<AcIcon app={icon} size="lg" className="mx-auto" />
<h3 className="mt-3 text-center text-lg font-bold text-ink transition group-hover:text-brand">{title}</h3>
<p className="mt-2 text-center text-sm leading-relaxed text-ink-secondary">{desc}</p>
<p className="mt-3 text-center text-sm font-bold text-brand"> </p>
</Link>
)
}
@ -167,11 +159,12 @@ export function StatCard({
hint?: string
tone?: 'default' | 'brand' | 'sky'
}) {
const bg = tone === 'brand' ? 'bg-brand-soft' : tone === 'sky' ? 'bg-glow' : 'bg-surface'
const slot =
tone === 'brand' ? 'ac-slot ac-slot--brand' : tone === 'sky' ? 'ac-slot ac-slot--sky' : 'ac-slot'
return (
<div className={`rounded-[var(--radius-xl)] border border-line p-6 shadow-card ${bg}`}>
<p className="text-sm font-semibold text-ink-secondary">{label}</p>
<div className="mt-3 text-3xl font-bold tracking-tight text-ink">{value}</div>
<div className={`${slot} p-5`}>
<p className="text-sm font-bold text-ink-secondary">{label}</p>
<div className="mt-2 text-2xl font-black tracking-tight text-ink md:text-3xl">{value}</div>
{hint ? <p className="mt-2 text-sm text-muted">{hint}</p> : null}
</div>
)

View File

@ -2,65 +2,83 @@
@import "taipei-sans-tc/dist/Regular/TaipeiSansTCBeta-Regular.css";
@import "taipei-sans-tc/dist/Bold/TaipeiSansTCBeta-Bold.css";
/* ── Light明亮、對比足夠小字也清楚 ── */
/* ══ 淺色:沉穩田園色(低飽和、無貼圖感) ══ */
:root,
[data-theme="light"] {
color-scheme: light;
--hx-canvas: #f0f4fc;
--hx-surface: #ffffff;
--hx-surface-muted: #e8eef8;
--hx-ink: #0c1222;
--hx-ink-secondary: #334155;
--hx-muted: #5a6578;
--hx-subtle: #6b7589;
--hx-line: #d5dde8;
--hx-brand: #3b57e8;
--hx-brand-hover: #2f46d4;
--hx-brand-soft: #dce4ff;
--hx-glow: #cfe0ff;
--hx-accent: #3b57e8;
--hx-accent-hover: #2f46d4;
--hx-accent-soft: #e8eeff;
--hx-success: #15803d;
--hx-success-soft: #d1fae5;
--hx-warning: #b45309;
--hx-warning-soft: #fde68a;
--hx-danger: #dc2626;
--hx-danger-soft: #fecaca;
--hx-shadow-soft: 0 12px 40px -16px rgb(59 87 232 / 0.2);
--hx-shadow-card: 0 4px 24px -10px rgb(12 18 34 / 0.1);
--hx-hero-gradient: linear-gradient(135deg, #dce4ff 0%, #f0f4fc 55%, #ffffff 100%);
--hx-canvas: #d8e2e8;
--hx-canvas-grass: #d4dfd4;
--hx-surface: #faf7f2;
--hx-surface-muted: #f1ece4;
--hx-ink: #3a3530;
--hx-ink-secondary: #5a554e;
--hx-muted: #6f6a63;
--hx-subtle: #8a847c;
--hx-wood: #c4a882;
--hx-wood-dark: #9a7d5c;
--hx-wood-deep: #6f5a44;
--hx-line: #ddd4c8;
--hx-brand: #5a8f7b;
--hx-brand-hover: #4d7f6c;
--hx-brand-shadow: #3f6b59;
--hx-brand-soft: #e6f0eb;
--hx-glow: #dce8ee;
--hx-glow-alt: #ebe4d8;
--hx-accent: #6a8fa0;
--hx-accent-hover: #5a7f90;
--hx-accent-soft: #e8eff3;
--hx-device: #6a9488;
--hx-device-dark: #4f7568;
--hx-success: #4a7f5e;
--hx-success-soft: #e4efe8;
--hx-warning: #9a7340;
--hx-warning-soft: #f2ead8;
--hx-danger: #b05a50;
--hx-danger-soft: #f5e6e4;
--hx-shadow-soft: 0 8px 28px -8px rgb(58 53 48 / 0.14);
--hx-shadow-card: 0 1px 2px rgb(58 53 48 / 0.06), 0 6px 20px -4px rgb(58 53 48 / 0.1);
--hx-hero-gradient: linear-gradient(165deg, #eef3f0 0%, #faf7f2 60%, #f1ece4 100%);
--pocket-width: 28rem;
--pocket-screen-height: min(50rem, calc(100dvh - 6.5rem));
}
/* ── Dark正文與輔助字都拉高亮度 ── */
/* ══ 深色:黃昏低對比 ══ */
[data-theme="dark"] {
color-scheme: dark;
--hx-canvas: #080d18;
--hx-surface: #111827;
--hx-surface-muted: #1a2438;
--hx-ink: #f8fafc;
--hx-ink-secondary: #e2e8f0;
--hx-muted: #b8c4d6;
--hx-subtle: #9aa8bc;
--hx-line: #2a3a52;
--hx-brand: #8ba3ff;
--hx-brand-hover: #a5b8ff;
--hx-brand-soft: #243560;
--hx-glow: #1e3356;
--hx-accent: #8ba3ff;
--hx-accent-hover: #a5b8ff;
--hx-accent-soft: #1c2844;
--hx-success: #4ade80;
--hx-success-soft: #14532d;
--hx-warning: #fcd34d;
--hx-warning-soft: #78350f;
--hx-danger: #fca5a5;
--hx-danger-soft: #7f1d1d;
--hx-shadow-soft: 0 12px 40px -16px rgb(0 0 0 / 0.55);
--hx-shadow-card: 0 4px 24px -10px rgb(0 0 0 / 0.35);
--hx-hero-gradient: linear-gradient(135deg, #1a2848 0%, #111827 50%, #080d18 100%);
--hx-canvas: #1e2a32;
--hx-canvas-grass: #243028;
--hx-surface: #2c363c;
--hx-surface-muted: #354048;
--hx-ink: #ece6dc;
--hx-ink-secondary: #c8c0b4;
--hx-muted: #a8a094;
--hx-subtle: #8a8278;
--hx-wood: #6a5a48;
--hx-wood-dark: #524438;
--hx-wood-deep: #3a3028;
--hx-line: #4a544c;
--hx-brand: #7aab96;
--hx-brand-hover: #8abba6;
--hx-brand-shadow: #4f7568;
--hx-brand-soft: #2a3c34;
--hx-glow: #2a3840;
--hx-glow-alt: #3a342c;
--hx-accent: #7a9aa8;
--hx-accent-hover: #8aaab8;
--hx-accent-soft: #2a3840;
--hx-device: #5a8070;
--hx-device-dark: #3f5a50;
--hx-success: #7aab96;
--hx-success-soft: #2a3c34;
--hx-warning: #c8a060;
--hx-warning-soft: #3a3428;
--hx-danger: #c89088;
--hx-danger-soft: #3c2c2c;
--hx-shadow-soft: 0 8px 28px -8px rgb(0 0 0 / 0.4);
--hx-shadow-card: 0 1px 2px rgb(0 0 0 / 0.2), 0 6px 20px -4px rgb(0 0 0 / 0.32);
--hx-hero-gradient: linear-gradient(165deg, #2a3840 0%, #2c363c 60%, #354048 100%);
}
@theme {
@ -75,6 +93,8 @@
--color-ink-secondary: var(--hx-ink-secondary);
--color-muted: var(--hx-muted);
--color-subtle: var(--hx-subtle);
--color-wood: var(--hx-wood);
--color-wood-dark: var(--hx-wood-dark);
--color-line: var(--hx-line);
--color-brand: var(--hx-brand);
--color-brand-hover: var(--hx-brand-hover);
@ -83,6 +103,7 @@
--color-accent: var(--hx-accent);
--color-accent-hover: var(--hx-accent-hover);
--color-accent-soft: var(--hx-accent-soft);
--color-device: var(--hx-device);
--color-success: var(--hx-success);
--color-success-soft: var(--hx-success-soft);
--color-warning: var(--hx-warning);
@ -91,9 +112,9 @@
--color-danger-soft: var(--hx-danger-soft);
--radius-sm: 0.75rem;
--radius-md: 1.25rem;
--radius-lg: 1.75rem;
--radius-xl: 2.25rem;
--radius-md: 1rem;
--radius-lg: 1.375rem;
--radius-xl: 1.75rem;
--radius-pill: 9999px;
}
@ -104,13 +125,12 @@ body {
font-size: 1rem;
font-weight: 400;
line-height: 1.7;
background: var(--hx-canvas);
color: var(--hx-ink);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
transition: background-color 0.25s ease, color 0.25s ease;
transition: color 0.2s ease;
}
* {
@ -122,7 +142,7 @@ h2,
h3 {
font-family: var(--font-sans);
font-weight: 700;
letter-spacing: -0.02em;
letter-spacing: -0.01em;
color: var(--hx-ink);
}
@ -135,12 +155,707 @@ h3 {
text-wrap: balance;
}
/* 表頭、欄位標籤:小字也要清楚 */
th {
color: var(--hx-ink-secondary);
font-weight: 600;
}
/* 全站天空場景(登入頁與登入後共用) */
.hx-scene {
min-height: 100vh;
background: linear-gradient(
180deg,
var(--hx-canvas) 0%,
color-mix(in srgb, var(--hx-canvas) 55%, var(--hx-glow) 45%) 62%,
color-mix(in srgb, var(--hx-canvas-grass) 75%, var(--hx-canvas) 25%) 100%
);
}
[data-theme="dark"] .hx-scene {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--hx-canvas) 92%, black 8%) 0%,
var(--hx-canvas) 48%,
color-mix(in srgb, var(--hx-canvas-grass) 88%, var(--hx-canvas) 12%) 100%
);
}
.ac-app-shell {
position: relative;
overflow: hidden;
}
.ac-dialog {
background: var(--hx-surface);
border: 2px solid var(--hx-line);
border-radius: var(--radius-xl);
box-shadow: var(--hx-shadow-card);
}
.ac-title-bar {
margin: 0 0 1rem;
border-radius: var(--radius-md);
border: 1px solid var(--hx-brand-shadow);
background: linear-gradient(180deg, var(--hx-device) 0%, var(--hx-device-dark) 100%);
padding: 0.65rem 1rem;
color: #faf7f2;
font-weight: 700;
font-size: 1rem;
}
.auth-scene {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
.auth-scene-deco,
.hx-scene-deco {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.auth-scene-blob {
position: absolute;
border-radius: 9999px;
filter: blur(56px);
pointer-events: none;
}
.auth-scene-blob--sky {
top: -4%;
left: 20%;
height: 14rem;
width: 20rem;
background: color-mix(in srgb, var(--hx-glow) 70%, white 30%);
opacity: 0.55;
}
.auth-scene-blob--sky-alt {
right: 10%;
bottom: 18%;
height: 11rem;
width: 13rem;
background: color-mix(in srgb, var(--hx-brand-soft) 45%, var(--hx-glow) 55%);
opacity: 0.4;
}
[data-theme="dark"] .auth-scene-blob--sky {
background: color-mix(in srgb, var(--hx-glow) 80%, var(--hx-accent) 20%);
opacity: 0.28;
}
[data-theme="dark"] .auth-scene-blob--sky-alt {
background: color-mix(in srgb, var(--hx-canvas-grass) 60%, var(--hx-glow) 40%);
opacity: 0.22;
}
.auth-scene-blob--grass {
display: none;
}
.auth-cloud {
position: absolute;
color: rgb(255 255 255 / 0.82);
}
[data-theme="dark"] .auth-cloud {
color: rgb(236 242 248 / 0.14);
}
.auth-cloud--1 {
top: 8%;
right: 8%;
width: 8.5rem;
opacity: 0.95;
animation: auth-drift 9s ease-in-out infinite;
}
.auth-cloud--2 {
top: 18%;
left: 6%;
width: 6rem;
opacity: 0.85;
animation: auth-drift 11s ease-in-out infinite reverse;
}
.auth-cloud--3 {
top: 6%;
left: 28%;
width: 4.25rem;
opacity: 0.7;
animation: auth-drift 13s ease-in-out infinite 1s;
}
.auth-cloud--4 {
top: 32%;
right: 18%;
width: 5.75rem;
opacity: 0.75;
animation: auth-drift 10s ease-in-out infinite 0.5s reverse;
}
.auth-cloud--5 {
top: 38%;
left: 4%;
width: 7rem;
opacity: 0.65;
animation: auth-drift 12s ease-in-out infinite 1.5s;
}
.auth-cloud--6 {
top: 14%;
right: 32%;
width: 3.5rem;
opacity: 0.6;
animation: auth-drift 8s ease-in-out infinite 0.3s;
}
.auth-cloud--7 {
top: 26%;
left: 42%;
width: 5rem;
opacity: 0.55;
animation: auth-drift 14s ease-in-out infinite 2s reverse;
}
.auth-leaf {
position: absolute;
color: var(--hx-brand);
opacity: 0.32;
}
[data-theme="dark"] .auth-leaf {
opacity: 0.22;
}
.auth-leaf--1 {
top: 18%;
left: 6%;
width: 2.5rem;
animation: auth-float 5s ease-in-out infinite;
}
.auth-leaf--2 {
right: 7%;
bottom: 22%;
width: 2rem;
rotate: 28deg;
animation: auth-float 6.5s ease-in-out infinite 0.8s;
}
.auth-grass {
display: none;
}
@keyframes auth-float {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-8px) rotate(4deg);
}
}
@keyframes auth-drift {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(12px);
}
}
.auth-ticket {
overflow: hidden;
border: 2px solid var(--hx-line);
border-radius: 2rem;
background: var(--hx-surface);
box-shadow: var(--hx-shadow-soft);
}
.auth-ticket-body {
background-color: var(--hx-surface);
padding: 1.35rem 1.35rem 1.5rem;
}
@media (min-width: 640px) {
.auth-ticket-body {
padding: 1.6rem 1.75rem 1.85rem;
}
}
.auth-welcome {
display: flex;
align-items: center;
gap: 0.9rem;
margin-bottom: 1.35rem;
padding-bottom: 1.25rem;
border-bottom: 2px solid var(--hx-line);
}
.auth-ticket-icon {
display: flex;
height: 3.25rem;
width: 3.25rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 2px solid var(--hx-line);
border-radius: 1rem;
background: var(--hx-brand-soft);
color: var(--hx-brand);
}
.auth-ticket-icon svg {
height: 1.65rem;
width: 1.65rem;
}
.auth-shell-title.ac-title-bar {
border-radius: var(--radius-pill);
text-align: center;
}
/* 登入/註冊頁:表單放大 */
.auth-shell .ac-title-bar {
margin-bottom: 1.25rem;
padding: 0.85rem 1.25rem;
font-size: 1.25rem;
}
.auth-shell-lead {
margin-bottom: 1.25rem;
font-size: 1rem;
line-height: 1.6;
}
.auth-shell-form label > span {
margin-bottom: 0.5rem;
font-size: 0.9375rem;
}
.auth-shell-form .ac-field {
padding: 0.9rem 1.15rem;
font-size: 1rem;
}
.auth-shell-form button[type="submit"] {
min-height: 3.25rem;
font-size: 1rem;
font-weight: 700;
}
.auth-shell-footer {
margin-top: 1.5rem;
font-size: 0.9375rem;
line-height: 1.6;
}
.ac-app-header {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-bottom: 2px solid var(--hx-line);
background: color-mix(in srgb, var(--hx-surface) 84%, transparent 16%);
padding: 0.75rem 1rem;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
@media (min-width: 640px) {
.ac-app-header {
padding: 0.85rem 1.25rem;
}
}
.ac-app-header-brand {
display: flex;
min-width: 0;
align-items: center;
gap: 0.75rem;
}
.ac-app-header-icon.auth-ticket-icon {
height: 2.75rem;
width: 2.75rem;
}
@media (min-width: 640px) {
.ac-app-header-icon.auth-ticket-icon {
height: 3rem;
width: 3rem;
}
}
.ac-app-main-panel {
min-width: 0;
flex: 1;
}
.ac-app-main-inner {
padding: 1rem 1rem 1.25rem;
}
@media (min-width: 640px) {
.ac-app-main-inner {
padding: 1.5rem 1.75rem 2rem;
}
}
@media (min-width: 768px) {
.ac-app-main-inner {
padding: 2rem;
}
}
/* 側欄掌上終端(固定尺寸,內容超出時捲動) */
.ac-pocket-device {
position: relative;
width: var(--pocket-width);
flex-shrink: 0;
padding: 1.15rem 1rem 1.05rem;
border-radius: 2.15rem;
background: linear-gradient(
155deg,
color-mix(in srgb, var(--hx-device) 88%, white 12%) 0%,
var(--hx-device) 38%,
var(--hx-device-dark) 100%
);
border: 1px solid color-mix(in srgb, var(--hx-device-dark) 75%, black 25%);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.22),
inset 0 -2px 4px rgb(0 0 0 / 0.12),
0 14px 36px -10px rgb(58 53 48 / 0.28),
0 4px 14px rgb(58 53 48 / 0.12);
}
.ac-pocket-device::before {
content: "";
position: absolute;
top: 0.6rem;
left: 50%;
width: 3.75rem;
height: 0.35rem;
transform: translateX(-50%);
border-radius: 9999px;
background: linear-gradient(180deg, rgb(0 0 0 / 0.28) 0%, rgb(0 0 0 / 0.14) 100%);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.08);
}
.ac-pocket-device::after {
content: "";
position: absolute;
right: -2px;
top: 38%;
width: 3px;
height: 2.75rem;
border-radius: 2px 0 0 2px;
background: linear-gradient(180deg, var(--hx-device-dark) 0%, color-mix(in srgb, var(--hx-device-dark) 70%, black 30%) 100%);
box-shadow: -1px 0 2px rgb(0 0 0 / 0.15);
}
.ac-pocket-screen {
position: relative;
display: flex;
height: var(--pocket-screen-height);
flex-direction: column;
overflow: hidden;
border-radius: 1.55rem;
border: 1px solid color-mix(in srgb, var(--hx-line) 85%, var(--hx-device-dark) 15%);
background: var(--hx-surface);
box-shadow:
inset 0 2px 10px rgb(58 53 48 / 0.07),
inset 0 0 0 1px rgb(255 255 255 / 0.55);
}
.ac-pocket-scroll {
min-height: 0;
flex: 1;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--hx-brand) 35%, var(--hx-line) 65%) transparent;
}
.ac-pocket-scroll::-webkit-scrollbar {
width: 6px;
}
.ac-pocket-scroll::-webkit-scrollbar-thumb {
border-radius: 9999px;
background: color-mix(in srgb, var(--hx-brand) 35%, var(--hx-line) 65%);
}
.ac-pocket-status {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-bottom: 1px solid var(--hx-line);
background: linear-gradient(180deg, var(--hx-surface-muted) 0%, var(--hx-surface) 100%);
padding: 0.65rem 1.15rem 0.6rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--hx-ink-secondary);
letter-spacing: 0.06em;
}
.ac-pocket-status-dots {
display: flex;
align-items: center;
gap: 0.2rem;
}
.ac-pocket-status-dots span {
display: block;
height: 0.3rem;
width: 0.3rem;
border-radius: 9999px;
background: var(--hx-brand);
opacity: 0.55;
}
.ac-pocket-status-dots span:first-child {
opacity: 0.9;
}
.ac-pocket-body {
padding: 1.15rem 1.05rem 1.15rem;
}
.ac-pocket-body .ac-app-icon-svg {
height: 4rem;
width: 4rem;
}
.ac-pocket-body .ac-app-icon-svg svg {
height: 2rem;
width: 2rem;
}
.ac-pocket-slot {
padding: 1rem 1.1rem;
}
.ac-pocket-slot-label {
font-size: 0.8125rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--hx-ink-secondary);
}
.ac-pocket-slot-email {
margin-top: 0.35rem;
font-size: 1rem;
font-weight: 700;
line-height: 1.5;
color: var(--hx-ink);
}
.ac-pocket-slot-uid {
margin-top: 0.25rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--hx-ink-secondary);
}
.ac-pocket-body .ac-app-tile {
gap: 0.5rem;
padding: 0.75rem 0.5rem;
font-size: 0.875rem;
font-weight: 700;
line-height: 1.45;
color: var(--hx-ink);
}
.ac-app-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
border-radius: var(--radius-md);
border: 1px solid transparent;
padding: 0.55rem 0.4rem;
text-align: center;
font-size: 0.68rem;
font-weight: 600;
color: var(--hx-ink-secondary);
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.ac-app-tile:hover {
background: var(--hx-brand-soft);
color: var(--hx-brand);
}
.ac-app-tile--active {
border-color: color-mix(in srgb, var(--hx-brand) 35%, var(--hx-line) 65%);
background: var(--hx-brand-soft);
color: var(--hx-brand);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.45);
}
.ac-app-icon-svg {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 0.75rem;
border: 2px solid var(--hx-line);
background: var(--hx-surface);
color: var(--hx-ink-secondary);
}
.ac-app-tile--active .ac-app-icon-svg,
.ac-app-tile:hover .ac-app-icon-svg {
border-color: var(--hx-brand);
color: var(--hx-brand);
background: var(--hx-surface);
}
.ac-btn-primary {
border: 1px solid var(--hx-brand-shadow);
background: var(--hx-brand);
color: #faf7f2;
font-weight: 600;
box-shadow: 0 1px 2px rgb(58 53 48 / 0.12);
}
.ac-btn-primary:hover {
background: var(--hx-brand-hover);
}
.ac-btn-primary:active {
transform: translateY(1px);
}
.ac-btn-secondary {
border: 2px solid var(--hx-line);
background: var(--hx-surface);
color: var(--hx-ink);
font-weight: 600;
}
.ac-btn-secondary:hover {
border-color: var(--hx-brand);
background: var(--hx-brand-soft);
color: var(--hx-brand);
}
.ac-btn-danger {
border: 1px solid #8a4840;
background: var(--hx-danger);
color: #faf7f2;
font-weight: 600;
}
.ac-slot {
border: 2px solid var(--hx-line);
border-radius: var(--radius-lg);
background: var(--hx-surface);
box-shadow: var(--hx-shadow-card);
}
.ac-slot--brand {
background: linear-gradient(180deg, var(--hx-brand-soft) 0%, var(--hx-surface) 100%);
}
.ac-slot--sky {
background: linear-gradient(180deg, var(--hx-accent-soft) 0%, var(--hx-surface) 100%);
}
.ac-app-card {
border: 2px solid var(--hx-line);
border-radius: var(--radius-lg);
background: var(--hx-surface);
box-shadow: var(--hx-shadow-card);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.ac-app-card:hover {
border-color: var(--hx-brand);
box-shadow: var(--hx-shadow-soft);
}
.ac-field {
border: 2px solid var(--hx-line);
border-radius: var(--radius-md);
background: var(--hx-surface);
}
.ac-field:focus {
border-color: var(--hx-brand);
}
.ac-dock {
background: var(--hx-surface);
border-top: 2px solid var(--hx-line);
box-shadow: 0 -4px 16px rgb(58 53 48 / 0.08);
}
[data-theme="dark"] .ac-dock {
background: var(--hx-surface-muted);
}
.ac-dock-btn {
color: var(--hx-muted);
}
.ac-dock-btn--active {
color: var(--hx-brand);
}
.ac-dock-btn--active .ac-app-icon-svg {
border-color: var(--hx-brand);
color: var(--hx-brand);
background: var(--hx-brand-soft);
}
.ac-bulletin {
border: 2px solid var(--hx-line);
border-radius: var(--radius-xl);
background: var(--hx-hero-gradient);
box-shadow: var(--hx-shadow-card);
padding: 1.5rem 1.75rem;
}
.ac-dialog-texture {
background-image: radial-gradient(rgb(90 85 78 / 0.03) 1px, transparent 1px);
background-size: 14px 14px;
}
.ac-role-chip {
border: 2px solid var(--hx-line);
border-radius: var(--radius-pill);
background: var(--hx-brand-soft);
color: var(--hx-brand);
font-weight: 600;
}
.ac-mark {
display: inline-block;
height: 0.5rem;
width: 0.5rem;
border-radius: 9999px;
background: var(--hx-brand);
}
.shadow-soft {
box-shadow: var(--hx-shadow-soft);
}
@ -149,19 +864,6 @@ th {
box-shadow: var(--hx-shadow-card);
}
.hero-panel {
background: var(--hx-hero-gradient);
}
.glow-blob {
background: var(--hx-brand-soft);
}
.glow-blob-alt {
background: var(--hx-glow);
}
/* 手機底部導覽:預留安全區 + 主內容底部間距 */
.mobile-bottom-nav {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
@ -176,6 +878,6 @@ th {
@media (min-width: 1024px) {
.layout-main {
padding-bottom: 2rem;
padding-bottom: 1.25rem;
}
}

View File

@ -0,0 +1,21 @@
export type AcAppKey =
| 'home'
| 'jobs'
| 'schedule'
| 'ai'
| 'template'
| 'settings'
| 'profile'
| 'permissions'
| 'more'
export const navApps = [
{ to: '/', label: '總覽', icon: 'home' as const, end: true },
{ to: '/jobs', label: '任務', icon: 'jobs' as const, matchPrefix: '/jobs' },
{ to: '/job-schedules', label: '排程', icon: 'schedule' as const },
{ to: '/ai', label: 'AI', icon: 'ai' as const },
{ to: '/job-templates', label: '模板', icon: 'template' as const },
{ to: '/settings', label: '設定', icon: 'settings' as const },
{ to: '/profile', label: '會員', icon: 'profile' as const },
{ to: '/permissions', label: '權限', icon: 'permissions' as const },
] as const

View File

@ -17,30 +17,23 @@ export function DashboardPage() {
const healthy = health === 'pong'
return (
<div className="max-w-6xl">
<section className="hero-panel relative mb-10 overflow-hidden rounded-[var(--radius-xl)] border border-line p-8 shadow-soft md:p-10">
<div
aria-hidden
className="glow-blob pointer-events-none absolute -top-16 -right-10 h-48 w-48 rounded-full opacity-60 blur-2xl"
/>
<div
aria-hidden
className="glow-blob-alt pointer-events-none absolute bottom-4 left-1/3 h-28 w-28 rounded-full opacity-50 blur-xl"
/>
<p className="display-en relative text-xs font-bold tracking-[0.24em] text-brand uppercase">
Patrol Hub
</p>
<h1 className="relative mt-3 max-w-2xl text-balance text-4xl font-black tracking-tight text-ink md:text-[2.75rem] md:leading-tight">
<span className="text-brand"></span>
<div>
<section className="ac-bulletin mb-10">
<div className="mb-3 flex items-center gap-2">
<span className="ac-mark" aria-hidden />
<p className="display-en text-xs font-semibold tracking-[0.16em] text-accent uppercase">Overview</p>
</div>
<h1 className="max-w-xl text-balance text-3xl font-bold tracking-tight text-ink md:text-4xl">
<span className="text-brand">{member?.email?.split('@')[0] || '使用者'}</span>
</h1>
<p className="relative mt-4 max-w-lg text-base text-ink-secondary">
AI
<p className="mt-3 max-w-lg text-base leading-relaxed text-ink-secondary">
AI
</p>
<div className="relative mt-6 flex flex-wrap gap-2">
<div className="mt-5 flex flex-wrap gap-2">
<Badge tone="brand"></Badge>
<Badge tone="sky"></Badge>
<Badge tone="neutral"></Badge>
<Badge tone={healthy ? 'success' : 'warning'}>{healthy ? 'API 正常' : 'API 異常'}</Badge>
</div>
</section>
@ -48,33 +41,34 @@ export function DashboardPage() {
<div className="mb-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label="API"
value={healthy ? '連線正常' : health || '…'}
label="API 連線"
value={healthy ? '正常' : health || '…'}
hint="GET /api/v1/health"
tone={healthy ? 'brand' : 'sky'}
/>
<StatCard label="Tenant" value={tenantId} hint="租戶 ID" />
<StatCard label="租戶" value={tenantId} hint="Tenant ID" />
<StatCard label="角色" value={member?.roles?.join(', ') || 'user'} hint={member?.email} />
</div>
<PageTitle title="快速開始" subtitle="常用功能" />
<PageTitle title="快速入口" subtitle="常用功能" />
<div className="mb-10 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<QuickLinkCard to="/jobs" tag="任務" title="背景任務" desc="建立、追蹤、取消或重試巡檢任務。" />
<QuickLinkCard to="/job-schedules" tag="排程" title="定時排程" desc="用 cron 自動觸發,不用手動按。" />
<QuickLinkCard to="/ai" tag="AI" title="AI 助手" desc="串流對話,輔助紀錄與分析。" />
<QuickLinkCard to="/settings" tag="設定" title="系統設定" desc="管理 key-value 設定。" />
<QuickLinkCard to="/profile" tag="會員" title="個人資料" desc="更新顯示名稱與偏好。" />
<QuickLinkCard to="/jobs" icon="jobs" title="背景任務" desc="建立、追蹤、取消或重試巡檢任務。" />
<QuickLinkCard to="/job-schedules" icon="schedule" title="定時排程" desc="以 cron 自動觸發背景任務。" />
<QuickLinkCard to="/ai" icon="ai" title="AI 助手" desc="串流對話,輔助紀錄與分析。" />
<QuickLinkCard to="/settings" icon="settings" title="系統設定" desc="管理 key-value 設定項目。" />
<QuickLinkCard to="/profile" icon="profile" title="個人資料" desc="更新顯示名稱與偏好。" />
<QuickLinkCard to="/job-templates" icon="template" title="任務模板" desc="查看可用的背景任務模板。" />
</div>
<Card>
<h2 className="text-lg font-bold text-ink">Session</h2>
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div className="rounded-[var(--radius-md)] bg-surface-muted px-4 py-3">
<div className="ac-title-bar -mx-1 -mt-1 mb-4">Session</div>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div className="ac-slot px-4 py-3">
<dt className="text-xs font-semibold text-ink-secondary">UID</dt>
<dd className="mt-1 break-all font-mono text-sm text-ink">{member?.uid || uid}</dd>
</div>
<div className="rounded-[var(--radius-md)] bg-surface-muted px-4 py-3">
<div className="ac-slot px-4 py-3">
<dt className="text-xs font-semibold text-ink-secondary">Email</dt>
<dd className="mt-1 text-ink">{member?.email}</dd>
</div>

View File

@ -3,7 +3,7 @@ import { Link, Navigate, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { ApiError } from '../api/client'
import { AuthShell } from '../components/AuthShell'
import { Button, Card, ErrorText, Field, Input } from '../components/ui'
import { Button, ErrorText, Field, Input } from '../components/ui'
export function LoginPage() {
const { login, setTenantId, tenantId, isAuthenticated } = useAuth()
@ -30,11 +30,12 @@ export function LoginPage() {
}
return (
<AuthShell>
<Card>
<h2 className="text-xl font-bold text-ink"></h2>
<p className="mt-1 text-sm text-ink-secondary">Email / </p>
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
<AuthShell tagline="歡迎回來,島民!">
<div className="auth-shell-body">
<p className="auth-shell-lead text-ink-secondary">
<span className="font-bold text-brand"></span>
</p>
<form className="auth-shell-form space-y-5" onSubmit={onSubmit}>
<Field label="Tenant ID">
<Input value={tenantId} onChange={(e) => setTenantId(e.target.value)} required />
</Field>
@ -51,16 +52,16 @@ export function LoginPage() {
</Field>
<ErrorText message={error} />
<Button type="submit" disabled={loading} className="w-full">
{loading ? '登入中…' : '登入巡樓系統'}
{loading ? '登入中…' : '登入'}
</Button>
</form>
<p className="mt-5 text-center text-sm text-muted">
<p className="auth-shell-footer text-center text-ink-secondary">
{' '}
<Link className="font-semibold text-brand hover:underline" to="/register">
</Link>
</p>
</Card>
</div>
</AuthShell>
)
}

View File

@ -3,7 +3,7 @@ import { Link, Navigate, useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { ApiError } from '../api/client'
import { AuthShell } from '../components/AuthShell'
import { Button, Card, ErrorText, Field, Input } from '../components/ui'
import { Button, ErrorText, Field, Input } from '../components/ui'
export function RegisterPage() {
const { register, setTenantId, tenantId, isAuthenticated } = useAuth()
@ -31,11 +31,13 @@ export function RegisterPage() {
}
return (
<AuthShell>
<Card>
<h2 className="text-xl font-bold text-ink"></h2>
<p className="mt-1 text-sm text-ink-secondary"></p>
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
<AuthShell tagline="新夥伴加入!">
<div className="auth-shell-body">
<div className="ac-title-bar auth-shell-title"></div>
<p className="auth-shell-lead text-ink-secondary">
<span className="font-bold text-brand"></span>
</p>
<form className="auth-shell-form space-y-5" onSubmit={onSubmit}>
<Field label="Tenant ID">
<Input value={tenantId} onChange={(e) => setTenantId(e.target.value)} required />
</Field>
@ -59,13 +61,13 @@ export function RegisterPage() {
{loading ? '註冊中…' : '建立帳號並登入'}
</Button>
</form>
<p className="mt-5 text-center text-sm text-muted">
<p className="auth-shell-footer text-center text-ink-secondary">
{' '}
<Link className="font-semibold text-brand hover:underline" to="/login">
</Link>
</p>
</Card>
</div>
</AuthShell>
)
}