fix dockerfile unhealth problem
This commit is contained in:
parent
4cd221af5e
commit
413d5f0b10
|
|
@ -183,7 +183,36 @@ runner.RegisterStepHandler("analyze_8d", func(ctx context.Context, step job.Step
|
||||||
|
|
||||||
## 前端設計規則(`web/`)
|
## 前端設計規則(`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-icon(brand-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/
|
src/
|
||||||
api/ # API client(envelope、JWT refresh)
|
api/ # API client(envelope、JWT refresh)
|
||||||
auth/ # AuthContext
|
auth/ # AuthContext
|
||||||
components/ # Layout、ui、ThemeToggle、AuthShell
|
components/ # Layout、AuthShell、AuthDecor、ui、ThemeToggle、MobileBottomNav、AcIcon
|
||||||
theme/ # ThemeContext(淺色 / 深色)
|
theme/ # ThemeContext(淺色 / 深色)
|
||||||
pages/ # 路由頁面
|
pages/ # 路由頁面
|
||||||
lib/ # jobStatus 等共用工具
|
lib/ # acAssets(導覽 icon key)、jobStatus 等
|
||||||
index.css # 設計 token 唯一來源
|
index.css # 設計 token 與場景樣式唯一來源
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```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 |
|
| 英文 | **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`)。
|
- `ThemeProvider`(`src/theme/ThemeContext.tsx`)包住 App;偏好存 `localStorage` key:`haixun.theme`(`light` | `dark`)。
|
||||||
- `index.html` 內嵌 script 在 React 載入前設定 `data-theme`,避免閃爍。
|
- `index.html` 內嵌 script 在 React 載入前設定 `data-theme`,避免閃爍。
|
||||||
- 所有顏色必須走 CSS 變數 `--hx-*`,再映射到 Tailwind `@theme`(`bg-canvas`、`text-brand` 等)。
|
- 所有顏色必須走 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。
|
- **禁止**在元件裡寫死 `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(語意命名)
|
### 色彩 token(語意命名)
|
||||||
|
|
||||||
|
|
@ -262,7 +307,7 @@ make web-build # tsc + vite build
|
||||||
--radius-pill 9999px Button、Badge、導覽 pill
|
--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 bar(UID + `ThemeToggle`)+ `Outlet`。
|
- 已登入(桌面):`Layout` = `hx-scene` 背景 + `SceneDecor` + `ac-app-header`(品牌 + 角色 chip + `ThemeToggle`)+ 左 `ac-pocket-device` + 右 `auth-ticket` 主內容 `Outlet`。
|
||||||
- 已登入(手機):頂欄品牌 + 主題切換;導覽走 `MobileBottomNav`。
|
- 已登入(手機):同上頂欄;導覽走 `MobileBottomNav`(總覽/任務/排程/更多)。
|
||||||
- 側欄分組:**工作區**(總覽、背景任務、排程、AI)、**管理**(模板、設定、會員、權限)。
|
- 側欄 App 來源:`src/lib/acAssets.ts` 的 `navApps`;圖示 key 對應 `AcIcon`。
|
||||||
- Active 導覽:`bg-brand text-white shadow-soft`;hover:`bg-brand-soft text-brand`。
|
- Active 導覽:`ac-app-tile--active`(brand-soft 底 + brand 字色);hover:`bg-brand-soft text-brand`。
|
||||||
- 未登入:`AuthShell` 置中卡片 + 右上主題切換;背景用柔和 blob,不要花俏插圖牆。
|
- 未登入:`AuthShell` 置中 `auth-ticket` + 右上 `ThemeToggle`;`auth-welcome` 內品牌,表單緊接說明文字。
|
||||||
- 語氣:年輕、直接、短句;Hero 可有一句主標 + brand 色強調詞,避免長篇企業八股。
|
- 語氣:年輕、直接、短句;可帶「島民」「巡樓」等原創文案,避免企業八股與任天堂用語。
|
||||||
|
|
||||||
### API 與狀態
|
### API 與狀態
|
||||||
|
|
||||||
|
|
@ -305,15 +350,17 @@ make web-build # tsc + vite build
|
||||||
|
|
||||||
### 新增頁面流程
|
### 新增頁面流程
|
||||||
|
|
||||||
1. 在 `App.tsx` 掛路由(需登入的放在 `Layout` 底下)。
|
1. 在 `App.tsx` 掛路由(需登入的放在 `Layout` 底下,自動享有 `hx-scene` + 頂欄 + 主內容 `auth-ticket`)。
|
||||||
2. 頁面用 `PageTitle` + `Card` + 既有元件;色票只引用 semantic token。
|
2. 頁面內用 `PageTitle`(含 `.ac-title-bar` 小標)+ `Card` / `ac-bulletin` + `ui.tsx` 元件;色票只引用 semantic token。
|
||||||
3. 若需新語意色,**先**改 `index.css` 的 `--hx-*` 與 `@theme`,再改元件;不要頁面內硬編色碼。
|
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 庫。
|
- 不要引入 MUI / Ant Design / Chakra 等大型 UI 庫。
|
||||||
- 不要為單頁新增第三套配色或漸層彩虹按鈕。
|
- 不要為單頁新增第三套配色、木質頂欄、或漸層彩虹按鈕。
|
||||||
|
- 不要在登入/註冊頁加回獨立大牌 `ac-title-bar` 或咖啡色 header ribbon。
|
||||||
- 不要讓 SSE / AI 直接吃 provider 原始 chunk(後端已 normalize)。
|
- 不要讓 SSE / AI 直接吃 provider 原始 chunk(後端已 normalize)。
|
||||||
- 不要用 `offset/limit` 呼叫列表 API;用 `page` / `pageSize`。
|
- 不要用 `offset/limit` 呼叫列表 API;用 `page` / `pageSize`。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,38 @@
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { AuthTicketIcon, SceneDecor } from './AuthDecor'
|
||||||
import { ThemeToggle } from './ThemeToggle'
|
import { ThemeToggle } from './ThemeToggle'
|
||||||
|
|
||||||
export function AuthShell({ children }: { children: ReactNode }) {
|
export function AuthShell({
|
||||||
|
children,
|
||||||
|
tagline = '準備好巡樓了嗎?',
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
tagline?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-canvas p-6">
|
<div className="hx-scene auth-scene p-4 sm:p-6">
|
||||||
<div className="absolute top-5 right-5 z-20">
|
<SceneDecor />
|
||||||
<ThemeToggle />
|
|
||||||
|
<div className="absolute top-4 right-4 z-20 sm:top-5 sm:right-5">
|
||||||
|
<ThemeToggle compact />
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
aria-hidden
|
<div className="auth-shell relative z-10 w-full max-w-xl">
|
||||||
className="glow-blob pointer-events-none absolute -top-24 -right-16 h-72 w-72 rounded-full opacity-70 blur-3xl"
|
<div className="auth-ticket">
|
||||||
/>
|
<div className="auth-ticket-body ac-dialog-texture">
|
||||||
<div
|
<div className="auth-welcome">
|
||||||
aria-hidden
|
<AuthTicketIcon />
|
||||||
className="glow-blob-alt pointer-events-none absolute -bottom-20 -left-10 h-64 w-64 rounded-full opacity-80 blur-3xl"
|
<div className="min-w-0 flex-1">
|
||||||
/>
|
<p className="display-en text-[11px] font-semibold tracking-[0.16em] text-accent uppercase">
|
||||||
<div className="relative z-10 w-full max-w-md">
|
Haixun Patrol
|
||||||
<div className="mb-8 text-center">
|
</p>
|
||||||
<p className="display-en text-xs font-semibold tracking-[0.2em] text-brand uppercase">Haixun Patrol</p>
|
<p className="text-lg font-bold leading-snug text-ink sm:text-xl">巡樓管理台</p>
|
||||||
<h1 className="mt-2 text-3xl font-black text-ink">巡樓管理系統</h1>
|
<p className="mt-0.5 text-sm font-semibold text-brand">{tagline}</p>
|
||||||
<p className="mt-2 text-sm text-ink-secondary">智慧巡檢 · 任務追蹤 · 即時協作</p>
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 { useAuth } from '../auth/AuthContext'
|
||||||
|
import { AuthTicketIcon, SceneDecor } from './AuthDecor'
|
||||||
|
import { AcIcon } from './AcIcon'
|
||||||
import { MobileBottomNav } from './MobileBottomNav'
|
import { MobileBottomNav } from './MobileBottomNav'
|
||||||
import { ThemeToggle } from './ThemeToggle'
|
import { ThemeToggle } from './ThemeToggle'
|
||||||
|
|
||||||
const navMain = [
|
function AppTile({
|
||||||
{ to: '/', label: '總覽' },
|
to,
|
||||||
{ to: '/jobs', label: '背景任務' },
|
label,
|
||||||
{ to: '/job-schedules', label: '排程' },
|
icon,
|
||||||
{ to: '/ai', label: 'AI' },
|
end,
|
||||||
]
|
matchPrefix,
|
||||||
|
}: {
|
||||||
const navMore = [
|
to: string
|
||||||
{ to: '/job-templates', label: '模板' },
|
label: string
|
||||||
{ to: '/settings', label: '設定' },
|
icon: AcAppKey
|
||||||
{ to: '/profile', label: '會員' },
|
end?: boolean
|
||||||
{ to: '/permissions', label: '權限' },
|
matchPrefix?: string
|
||||||
]
|
}) {
|
||||||
|
const { pathname } = useLocation()
|
||||||
function NavItem({ to, label, end }: { to: string; label: string; end?: boolean }) {
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
end={end}
|
end={end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) => {
|
||||||
`rounded-[var(--radius-pill)] px-4 py-2 text-sm font-semibold transition ${
|
const prefixActive = matchPrefix ? pathname.startsWith(matchPrefix) : false
|
||||||
isActive
|
const active = isActive || prefixActive
|
||||||
? 'bg-brand text-white shadow-soft'
|
return `ac-app-tile ${active ? 'ac-app-tile--active' : ''}`
|
||||||
: 'text-ink hover:bg-brand-soft hover:text-brand'
|
}}
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{label}
|
<AcIcon app={icon} size="md" />
|
||||||
|
<span>{label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -39,72 +41,77 @@ export function Layout() {
|
||||||
const { member, uid, logout } = useAuth()
|
const { member, uid, logout } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-canvas">
|
<div className="hx-scene ac-app-shell flex min-h-screen flex-col">
|
||||||
<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">
|
<SceneDecor />
|
||||||
<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>
|
|
||||||
|
|
||||||
<nav className="mt-6 flex flex-1 flex-col gap-5">
|
<header className="ac-app-header">
|
||||||
<div>
|
<div className="ac-app-header-brand">
|
||||||
<p className="mb-2 px-2 text-xs font-bold tracking-wide text-ink-secondary uppercase">工作區</p>
|
<AuthTicketIcon className="ac-app-header-icon" />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="min-w-0">
|
||||||
{navMain.map((item) => (
|
<p className="display-en text-[10px] font-semibold tracking-[0.16em] text-accent uppercase sm:text-[11px]">
|
||||||
<NavItem key={item.to} to={item.to} label={item.label} end={item.to === '/'} />
|
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>
|
</div>
|
||||||
<div>
|
</aside>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="rounded-[var(--radius-lg)] border border-line bg-surface-muted p-3 text-sm">
|
<main className="auth-ticket ac-app-main-panel layout-main">
|
||||||
<div className="truncate font-semibold text-ink">{member?.email}</div>
|
<div className="ac-app-main-inner ac-dialog-texture">
|
||||||
<div className="mt-1.5">
|
<Outlet />
|
||||||
<BadgePill>{member?.roles?.join(', ') || 'user'}</BadgePill>
|
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileBottomNav />
|
<MobileBottomNav />
|
||||||
</div>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { navApps } from '../lib/acAssets'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
import { AcIcon } from './AcIcon'
|
||||||
import { ThemeToggle } from './ThemeToggle'
|
import { ThemeToggle } from './ThemeToggle'
|
||||||
|
|
||||||
const mobileTabs = [
|
const mobileTabs = [
|
||||||
{ to: '/', label: '總覽', end: true },
|
{ to: '/', label: '總覽', icon: 'home' as const, end: true },
|
||||||
{ to: '/jobs', label: '任務', matchPrefix: '/jobs' },
|
{ to: '/jobs', label: '任務', icon: 'jobs' as const, matchPrefix: '/jobs' },
|
||||||
{ to: '/job-schedules', label: '排程' },
|
{ to: '/job-schedules', label: '排程', icon: 'schedule' as const },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const moreRoutes = [
|
const moreRoutes = navApps.filter((n) => !mobileTabs.some((t) => t.to === n.to))
|
||||||
{ to: '/ai', label: 'AI' },
|
|
||||||
{ to: '/job-templates', label: '模板' },
|
|
||||||
{ to: '/settings', label: '設定' },
|
|
||||||
{ to: '/profile', label: '會員' },
|
|
||||||
{ to: '/permissions', label: '權限' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function isMoreActive(pathname: string) {
|
function isMoreActive(pathname: string) {
|
||||||
return moreRoutes.some((r) => pathname === r.to || pathname.startsWith(`${r.to}/`))
|
return moreRoutes.some((r) => pathname === r.to || pathname.startsWith(`${r.to}/`))
|
||||||
|
|
@ -51,11 +47,8 @@ export function MobileBottomNav() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav
|
<nav className="ac-dock mobile-bottom-nav fixed inset-x-0 bottom-0 z-30 lg:hidden" aria-label="主要導覽">
|
||||||
className="mobile-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-line bg-surface/95 backdrop-blur-md lg:hidden"
|
<div className="mx-auto grid max-w-lg grid-cols-4 px-2 pt-1">
|
||||||
aria-label="主要導覽"
|
|
||||||
>
|
|
||||||
<div className="mx-auto grid max-w-lg grid-cols-4">
|
|
||||||
{mobileTabs.map((tab) => (
|
{mobileTabs.map((tab) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={tab.to}
|
key={tab.to}
|
||||||
|
|
@ -65,25 +58,25 @@ export function MobileBottomNav() {
|
||||||
const prefixActive =
|
const prefixActive =
|
||||||
'matchPrefix' in tab && location.pathname.startsWith(tab.matchPrefix)
|
'matchPrefix' in tab && location.pathname.startsWith(tab.matchPrefix)
|
||||||
const active = isActive || prefixActive
|
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 ${
|
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 ? 'text-brand' : 'text-ink-secondary hover:text-brand'
|
active ? 'ac-dock-btn--active' : ''
|
||||||
}`
|
}`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TabIcon name={tab.label} />
|
<AcIcon app={tab.icon} size="sm" />
|
||||||
<span>{tab.label}</span>
|
<span>{tab.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMoreOpen(true)}
|
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 ${
|
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) ? 'text-brand' : 'text-ink-secondary hover:text-brand'
|
isMoreActive(location.pathname) ? 'ac-dock-btn--active' : ''
|
||||||
}`}
|
}`}
|
||||||
aria-expanded={moreOpen}
|
aria-expanded={moreOpen}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
>
|
>
|
||||||
<MoreIcon />
|
<AcIcon app="more" size="sm" />
|
||||||
<span>更多</span>
|
<span>更多</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -101,20 +94,20 @@ export function MobileBottomNav() {
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="更多功能"
|
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">
|
<div className="ac-title-bar flex items-center justify-between">
|
||||||
<h2 className="text-lg font-bold text-ink">更多</h2>
|
<span>更多</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMoreOpen(false)}
|
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>
|
</button>
|
||||||
</div>
|
</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) => {
|
{moreRoutes.map((item) => {
|
||||||
const active = location.pathname === item.to
|
const active = location.pathname === item.to
|
||||||
return (
|
return (
|
||||||
|
|
@ -122,33 +115,28 @@ export function MobileBottomNav() {
|
||||||
key={item.to}
|
key={item.to}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => goMore(item.to)}
|
onClick={() => goMore(item.to)}
|
||||||
className={`rounded-[var(--radius-lg)] border px-4 py-4 text-left text-sm font-bold transition ${
|
className={`ac-app-tile ${active ? 'ac-app-tile--active' : ''}`}
|
||||||
active
|
|
||||||
? 'border-brand bg-brand-soft text-brand'
|
|
||||||
: 'border-line bg-surface-muted text-ink hover:border-brand/30 hover:text-brand'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{item.label}
|
<AcIcon app={item.icon} size="md" />
|
||||||
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-line p-4">
|
<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="truncate font-semibold text-ink">{member?.email}</div>
|
||||||
<div className="mt-2 text-xs font-semibold text-ink-secondary">
|
<div className="mt-2 text-xs font-semibold text-muted">{member?.roles?.join(', ') || 'member'}</div>
|
||||||
{member?.roles?.join(', ') || 'user'}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
<ThemeToggle />
|
<ThemeToggle compact />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMoreOpen(false)
|
setMoreOpen(false)
|
||||||
logout()
|
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>
|
</button>
|
||||||
|
|
@ -160,28 +148,4 @@ export function MobileBottomNav() {
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,28 +1,51 @@
|
||||||
import { useTheme } from '../theme/ThemeContext'
|
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({
|
export function ThemeToggle({
|
||||||
className = '',
|
className = '',
|
||||||
compact = false,
|
compact = false,
|
||||||
|
wood = false,
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
wood?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
const isDark = theme === 'dark'
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
|
const base = wood
|
||||||
|
? 'border-white/30 bg-white/10 text-white hover:bg-white/20'
|
||||||
|
: 'ac-btn-secondary'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
|
||||||
onClick={toggleTheme}
|
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 ? '切換為淺色模式' : '切換為深色模式'}
|
aria-label={isDark ? '切換為淺色模式' : '切換為深色模式'}
|
||||||
>
|
>
|
||||||
<span aria-hidden className="text-base leading-none">
|
{isDark ? <SunIcon /> : <MoonIcon />}
|
||||||
{isDark ? '☀️' : '🌙'}
|
|
||||||
</span>
|
|
||||||
<span className={compact ? 'hidden sm:inline' : ''}>{isDark ? '淺色' : '深色'}</span>
|
<span className={compact ? 'hidden sm:inline' : ''}>{isDark ? '淺色' : '深色'}</span>
|
||||||
</Button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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 }) {
|
export function PageTitle({ title, subtitle }: { title: string; subtitle?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<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 ? (
|
{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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -14,9 +16,7 @@ export function PageTitle({ title, subtitle }: { title: string; subtitle?: strin
|
||||||
|
|
||||||
export function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
|
export function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-[var(--radius-lg)] border border-line bg-surface p-6 shadow-card ${className}`}>
|
<div className={`ac-dialog rounded-[var(--radius-lg)] p-5 md:p-6 ${className}`}>{children}</div>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function Field({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="block text-sm">
|
<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}
|
{children}
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
|
|
@ -39,7 +39,7 @@ export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
{...props}
|
{...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 (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
{...props}
|
{...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' }) {
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'ghost' | 'danger' | 'soft' }) {
|
||||||
const styles =
|
const styles =
|
||||||
variant === 'primary'
|
variant === 'primary'
|
||||||
? 'bg-brand text-white hover:bg-brand-hover shadow-soft'
|
? 'ac-btn-primary'
|
||||||
: variant === 'danger'
|
: variant === 'danger'
|
||||||
? 'bg-danger text-white hover:opacity-90'
|
? 'ac-btn-danger'
|
||||||
: variant === 'soft'
|
: variant === 'soft'
|
||||||
? 'bg-brand-soft text-brand hover:opacity-90'
|
? 'ac-btn-secondary bg-brand-soft text-brand'
|
||||||
: 'border border-line bg-surface text-ink hover:bg-surface-muted'
|
: 'ac-btn-secondary'
|
||||||
const { className, ...rest } = props
|
const { className, ...rest } = props
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
{...rest}
|
{...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}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -85,16 +85,16 @@ export function Badge({
|
||||||
tone?: 'neutral' | 'brand' | 'sky' | 'success' | 'warning' | 'danger'
|
tone?: 'neutral' | 'brand' | 'sky' | 'success' | 'warning' | 'danger'
|
||||||
}) {
|
}) {
|
||||||
const tones = {
|
const tones = {
|
||||||
neutral: 'bg-accent-soft text-ink',
|
neutral: 'border-wood-dark bg-accent-soft text-ink',
|
||||||
brand: 'bg-brand-soft text-brand',
|
brand: 'border-brand bg-brand-soft text-brand',
|
||||||
sky: 'bg-glow text-brand',
|
sky: 'border-accent bg-accent-soft text-accent',
|
||||||
success: 'bg-success-soft text-success',
|
success: 'border-success bg-success-soft text-success',
|
||||||
warning: 'bg-warning-soft text-warning',
|
warning: 'border-warning bg-warning-soft text-warning',
|
||||||
danger: 'bg-danger-soft text-danger',
|
danger: 'border-danger bg-danger-soft text-danger',
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -103,7 +103,7 @@ export function Badge({
|
||||||
|
|
||||||
export function ErrorText({ message }: { message?: string }) {
|
export function ErrorText({ message }: { message?: string }) {
|
||||||
if (!message) return null
|
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 }) {
|
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)
|
await navigator.clipboard.writeText(value)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[var(--radius-md)] border border-line bg-surface-muted px-4 py-3">
|
<div className="ac-slot px-4 py-3">
|
||||||
<div className="text-xs font-semibold text-ink-secondary">{label}</div>
|
<div className="text-xs font-bold text-ink-secondary">{label}</div>
|
||||||
<div className="mt-1 flex items-start justify-between gap-2">
|
<div className="mt-1 flex items-start justify-between gap-2">
|
||||||
<code className="break-all text-sm text-ink">{value || '—'}</code>
|
<code className="break-all text-sm text-ink">{value || '—'}</code>
|
||||||
{value ? (
|
{value ? (
|
||||||
<button
|
<button type="button" onClick={copy} className="ac-btn-secondary shrink-0 px-3 py-1 text-xs">
|
||||||
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>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -134,24 +130,20 @@ export function QuickLinkCard({
|
||||||
to,
|
to,
|
||||||
title,
|
title,
|
||||||
desc,
|
desc,
|
||||||
tag,
|
icon,
|
||||||
}: {
|
}: {
|
||||||
to: string
|
to: string
|
||||||
title: string
|
title: string
|
||||||
desc: string
|
desc: string
|
||||||
|
icon: AcAppKey
|
||||||
tag?: string
|
tag?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link to={to} className="ac-app-card group block p-5">
|
||||||
to={to}
|
<AcIcon app={icon} size="lg" className="mx-auto" />
|
||||||
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"
|
<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>
|
||||||
{tag ? <Badge tone="brand">{tag}</Badge> : null}
|
<p className="mt-3 text-center text-sm font-bold text-brand">打開 →</p>
|
||||||
<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>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -167,11 +159,12 @@ export function StatCard({
|
||||||
hint?: string
|
hint?: string
|
||||||
tone?: 'default' | 'brand' | 'sky'
|
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 (
|
return (
|
||||||
<div className={`rounded-[var(--radius-xl)] border border-line p-6 shadow-card ${bg}`}>
|
<div className={`${slot} p-5`}>
|
||||||
<p className="text-sm font-semibold text-ink-secondary">{label}</p>
|
<p className="text-sm font-bold text-ink-secondary">{label}</p>
|
||||||
<div className="mt-3 text-3xl font-bold tracking-tight text-ink">{value}</div>
|
<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}
|
{hint ? <p className="mt-2 text-sm text-muted">{hint}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,65 +2,83 @@
|
||||||
@import "taipei-sans-tc/dist/Regular/TaipeiSansTCBeta-Regular.css";
|
@import "taipei-sans-tc/dist/Regular/TaipeiSansTCBeta-Regular.css";
|
||||||
@import "taipei-sans-tc/dist/Bold/TaipeiSansTCBeta-Bold.css";
|
@import "taipei-sans-tc/dist/Bold/TaipeiSansTCBeta-Bold.css";
|
||||||
|
|
||||||
/* ── Light:明亮、對比足夠(小字也清楚) ── */
|
/* ══ 淺色:沉穩田園色(低飽和、無貼圖感) ══ */
|
||||||
:root,
|
:root,
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
--hx-canvas: #f0f4fc;
|
--hx-canvas: #d8e2e8;
|
||||||
--hx-surface: #ffffff;
|
--hx-canvas-grass: #d4dfd4;
|
||||||
--hx-surface-muted: #e8eef8;
|
--hx-surface: #faf7f2;
|
||||||
--hx-ink: #0c1222;
|
--hx-surface-muted: #f1ece4;
|
||||||
--hx-ink-secondary: #334155;
|
--hx-ink: #3a3530;
|
||||||
--hx-muted: #5a6578;
|
--hx-ink-secondary: #5a554e;
|
||||||
--hx-subtle: #6b7589;
|
--hx-muted: #6f6a63;
|
||||||
--hx-line: #d5dde8;
|
--hx-subtle: #8a847c;
|
||||||
--hx-brand: #3b57e8;
|
--hx-wood: #c4a882;
|
||||||
--hx-brand-hover: #2f46d4;
|
--hx-wood-dark: #9a7d5c;
|
||||||
--hx-brand-soft: #dce4ff;
|
--hx-wood-deep: #6f5a44;
|
||||||
--hx-glow: #cfe0ff;
|
--hx-line: #ddd4c8;
|
||||||
--hx-accent: #3b57e8;
|
--hx-brand: #5a8f7b;
|
||||||
--hx-accent-hover: #2f46d4;
|
--hx-brand-hover: #4d7f6c;
|
||||||
--hx-accent-soft: #e8eeff;
|
--hx-brand-shadow: #3f6b59;
|
||||||
--hx-success: #15803d;
|
--hx-brand-soft: #e6f0eb;
|
||||||
--hx-success-soft: #d1fae5;
|
--hx-glow: #dce8ee;
|
||||||
--hx-warning: #b45309;
|
--hx-glow-alt: #ebe4d8;
|
||||||
--hx-warning-soft: #fde68a;
|
--hx-accent: #6a8fa0;
|
||||||
--hx-danger: #dc2626;
|
--hx-accent-hover: #5a7f90;
|
||||||
--hx-danger-soft: #fecaca;
|
--hx-accent-soft: #e8eff3;
|
||||||
--hx-shadow-soft: 0 12px 40px -16px rgb(59 87 232 / 0.2);
|
--hx-device: #6a9488;
|
||||||
--hx-shadow-card: 0 4px 24px -10px rgb(12 18 34 / 0.1);
|
--hx-device-dark: #4f7568;
|
||||||
--hx-hero-gradient: linear-gradient(135deg, #dce4ff 0%, #f0f4fc 55%, #ffffff 100%);
|
--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"] {
|
[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
--hx-canvas: #080d18;
|
--hx-canvas: #1e2a32;
|
||||||
--hx-surface: #111827;
|
--hx-canvas-grass: #243028;
|
||||||
--hx-surface-muted: #1a2438;
|
--hx-surface: #2c363c;
|
||||||
--hx-ink: #f8fafc;
|
--hx-surface-muted: #354048;
|
||||||
--hx-ink-secondary: #e2e8f0;
|
--hx-ink: #ece6dc;
|
||||||
--hx-muted: #b8c4d6;
|
--hx-ink-secondary: #c8c0b4;
|
||||||
--hx-subtle: #9aa8bc;
|
--hx-muted: #a8a094;
|
||||||
--hx-line: #2a3a52;
|
--hx-subtle: #8a8278;
|
||||||
--hx-brand: #8ba3ff;
|
--hx-wood: #6a5a48;
|
||||||
--hx-brand-hover: #a5b8ff;
|
--hx-wood-dark: #524438;
|
||||||
--hx-brand-soft: #243560;
|
--hx-wood-deep: #3a3028;
|
||||||
--hx-glow: #1e3356;
|
--hx-line: #4a544c;
|
||||||
--hx-accent: #8ba3ff;
|
--hx-brand: #7aab96;
|
||||||
--hx-accent-hover: #a5b8ff;
|
--hx-brand-hover: #8abba6;
|
||||||
--hx-accent-soft: #1c2844;
|
--hx-brand-shadow: #4f7568;
|
||||||
--hx-success: #4ade80;
|
--hx-brand-soft: #2a3c34;
|
||||||
--hx-success-soft: #14532d;
|
--hx-glow: #2a3840;
|
||||||
--hx-warning: #fcd34d;
|
--hx-glow-alt: #3a342c;
|
||||||
--hx-warning-soft: #78350f;
|
--hx-accent: #7a9aa8;
|
||||||
--hx-danger: #fca5a5;
|
--hx-accent-hover: #8aaab8;
|
||||||
--hx-danger-soft: #7f1d1d;
|
--hx-accent-soft: #2a3840;
|
||||||
--hx-shadow-soft: 0 12px 40px -16px rgb(0 0 0 / 0.55);
|
--hx-device: #5a8070;
|
||||||
--hx-shadow-card: 0 4px 24px -10px rgb(0 0 0 / 0.35);
|
--hx-device-dark: #3f5a50;
|
||||||
--hx-hero-gradient: linear-gradient(135deg, #1a2848 0%, #111827 50%, #080d18 100%);
|
--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 {
|
@theme {
|
||||||
|
|
@ -75,6 +93,8 @@
|
||||||
--color-ink-secondary: var(--hx-ink-secondary);
|
--color-ink-secondary: var(--hx-ink-secondary);
|
||||||
--color-muted: var(--hx-muted);
|
--color-muted: var(--hx-muted);
|
||||||
--color-subtle: var(--hx-subtle);
|
--color-subtle: var(--hx-subtle);
|
||||||
|
--color-wood: var(--hx-wood);
|
||||||
|
--color-wood-dark: var(--hx-wood-dark);
|
||||||
--color-line: var(--hx-line);
|
--color-line: var(--hx-line);
|
||||||
--color-brand: var(--hx-brand);
|
--color-brand: var(--hx-brand);
|
||||||
--color-brand-hover: var(--hx-brand-hover);
|
--color-brand-hover: var(--hx-brand-hover);
|
||||||
|
|
@ -83,6 +103,7 @@
|
||||||
--color-accent: var(--hx-accent);
|
--color-accent: var(--hx-accent);
|
||||||
--color-accent-hover: var(--hx-accent-hover);
|
--color-accent-hover: var(--hx-accent-hover);
|
||||||
--color-accent-soft: var(--hx-accent-soft);
|
--color-accent-soft: var(--hx-accent-soft);
|
||||||
|
--color-device: var(--hx-device);
|
||||||
--color-success: var(--hx-success);
|
--color-success: var(--hx-success);
|
||||||
--color-success-soft: var(--hx-success-soft);
|
--color-success-soft: var(--hx-success-soft);
|
||||||
--color-warning: var(--hx-warning);
|
--color-warning: var(--hx-warning);
|
||||||
|
|
@ -91,9 +112,9 @@
|
||||||
--color-danger-soft: var(--hx-danger-soft);
|
--color-danger-soft: var(--hx-danger-soft);
|
||||||
|
|
||||||
--radius-sm: 0.75rem;
|
--radius-sm: 0.75rem;
|
||||||
--radius-md: 1.25rem;
|
--radius-md: 1rem;
|
||||||
--radius-lg: 1.75rem;
|
--radius-lg: 1.375rem;
|
||||||
--radius-xl: 2.25rem;
|
--radius-xl: 1.75rem;
|
||||||
--radius-pill: 9999px;
|
--radius-pill: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,13 +125,12 @@ body {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
background: var(--hx-canvas);
|
|
||||||
color: var(--hx-ink);
|
color: var(--hx-ink);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
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 {
|
h3 {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.01em;
|
||||||
color: var(--hx-ink);
|
color: var(--hx-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,12 +155,707 @@ h3 {
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表頭、欄位標籤:小字也要清楚 */
|
|
||||||
th {
|
th {
|
||||||
color: var(--hx-ink-secondary);
|
color: var(--hx-ink-secondary);
|
||||||
font-weight: 600;
|
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 {
|
.shadow-soft {
|
||||||
box-shadow: var(--hx-shadow-soft);
|
box-shadow: var(--hx-shadow-soft);
|
||||||
}
|
}
|
||||||
|
|
@ -149,19 +864,6 @@ th {
|
||||||
box-shadow: var(--hx-shadow-card);
|
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 {
|
.mobile-bottom-nav {
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
|
|
@ -176,6 +878,6 @@ th {
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.layout-main {
|
.layout-main {
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -17,30 +17,23 @@ export function DashboardPage() {
|
||||||
const healthy = health === 'pong'
|
const healthy = health === 'pong'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl">
|
<div>
|
||||||
<section className="hero-panel relative mb-10 overflow-hidden rounded-[var(--radius-xl)] border border-line p-8 shadow-soft md:p-10">
|
<section className="ac-bulletin mb-10">
|
||||||
<div
|
<div className="mb-3 flex items-center gap-2">
|
||||||
aria-hidden
|
<span className="ac-mark" aria-hidden />
|
||||||
className="glow-blob pointer-events-none absolute -top-16 -right-10 h-48 w-48 rounded-full opacity-60 blur-2xl"
|
<p className="display-en text-xs font-semibold tracking-[0.16em] text-accent uppercase">Overview</p>
|
||||||
/>
|
</div>
|
||||||
<div
|
<h1 className="max-w-xl text-balance text-3xl font-bold tracking-tight text-ink md:text-4xl">
|
||||||
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"
|
<span className="text-brand">{member?.email?.split('@')[0] || '使用者'}</span>
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="relative mt-4 max-w-lg text-base text-ink-secondary">
|
<p className="mt-3 max-w-lg text-base leading-relaxed text-ink-secondary">
|
||||||
任務、排程、AI 助手都在這裡。狀態一眼看懂,不用到處找。
|
任務、排程與 AI 助手可從左側導覽或底部選單進入。
|
||||||
</p>
|
</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="brand">巡檢</Badge>
|
||||||
<Badge tone="sky">任務</Badge>
|
<Badge tone="sky">任務</Badge>
|
||||||
<Badge tone="neutral">即時</Badge>
|
<Badge tone={healthy ? 'success' : 'warning'}>{healthy ? 'API 正常' : 'API 異常'}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -48,33 +41,34 @@ export function DashboardPage() {
|
||||||
|
|
||||||
<div className="mb-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="mb-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="API"
|
label="API 連線"
|
||||||
value={healthy ? '連線正常' : health || '…'}
|
value={healthy ? '正常' : health || '…'}
|
||||||
hint="GET /api/v1/health"
|
hint="GET /api/v1/health"
|
||||||
tone={healthy ? 'brand' : 'sky'}
|
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} />
|
<StatCard label="角色" value={member?.roles?.join(', ') || 'user'} hint={member?.email} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PageTitle title="快速開始" subtitle="常用功能" />
|
<PageTitle title="快速入口" subtitle="常用功能" />
|
||||||
|
|
||||||
<div className="mb-10 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="mb-10 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<QuickLinkCard to="/jobs" tag="任務" title="背景任務" desc="建立、追蹤、取消或重試巡檢任務。" />
|
<QuickLinkCard to="/jobs" icon="jobs" title="背景任務" desc="建立、追蹤、取消或重試巡檢任務。" />
|
||||||
<QuickLinkCard to="/job-schedules" tag="排程" title="定時排程" desc="用 cron 自動觸發,不用手動按。" />
|
<QuickLinkCard to="/job-schedules" icon="schedule" title="定時排程" desc="以 cron 自動觸發背景任務。" />
|
||||||
<QuickLinkCard to="/ai" tag="AI" title="AI 助手" desc="串流對話,輔助紀錄與分析。" />
|
<QuickLinkCard to="/ai" icon="ai" title="AI 助手" desc="串流對話,輔助紀錄與分析。" />
|
||||||
<QuickLinkCard to="/settings" tag="設定" title="系統設定" desc="管理 key-value 設定。" />
|
<QuickLinkCard to="/settings" icon="settings" title="系統設定" desc="管理 key-value 設定項目。" />
|
||||||
<QuickLinkCard to="/profile" tag="會員" title="個人資料" desc="更新顯示名稱與偏好。" />
|
<QuickLinkCard to="/profile" icon="profile" title="個人資料" desc="更新顯示名稱與偏好。" />
|
||||||
|
<QuickLinkCard to="/job-templates" icon="template" title="任務模板" desc="查看可用的背景任務模板。" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="text-lg font-bold text-ink">Session</h2>
|
<div className="ac-title-bar -mx-1 -mt-1 mb-4">Session</div>
|
||||||
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
<dl className="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-slot px-4 py-3">
|
||||||
<dt className="text-xs font-semibold text-ink-secondary">UID</dt>
|
<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>
|
<dd className="mt-1 break-all font-mono text-sm text-ink">{member?.uid || uid}</dd>
|
||||||
</div>
|
</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>
|
<dt className="text-xs font-semibold text-ink-secondary">Email</dt>
|
||||||
<dd className="mt-1 text-ink">{member?.email}</dd>
|
<dd className="mt-1 text-ink">{member?.email}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Link, Navigate, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import { AuthShell } from '../components/AuthShell'
|
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() {
|
export function LoginPage() {
|
||||||
const { login, setTenantId, tenantId, isAuthenticated } = useAuth()
|
const { login, setTenantId, tenantId, isAuthenticated } = useAuth()
|
||||||
|
|
@ -30,11 +30,12 @@ export function LoginPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell>
|
<AuthShell tagline="歡迎回來,島民!">
|
||||||
<Card>
|
<div className="auth-shell-body">
|
||||||
<h2 className="text-xl font-bold text-ink">登入系統</h2>
|
<p className="auth-shell-lead text-ink-secondary">
|
||||||
<p className="mt-1 text-sm text-ink-secondary">Email / 密碼驗證</p>
|
輸入帳號密碼,就能開始今天的<span className="font-bold text-brand">巡樓任務</span>。
|
||||||
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
|
</p>
|
||||||
|
<form className="auth-shell-form space-y-5" onSubmit={onSubmit}>
|
||||||
<Field label="Tenant ID">
|
<Field label="Tenant ID">
|
||||||
<Input value={tenantId} onChange={(e) => setTenantId(e.target.value)} required />
|
<Input value={tenantId} onChange={(e) => setTenantId(e.target.value)} required />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -51,16 +52,16 @@ export function LoginPage() {
|
||||||
</Field>
|
</Field>
|
||||||
<ErrorText message={error} />
|
<ErrorText message={error} />
|
||||||
<Button type="submit" disabled={loading} className="w-full">
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
{loading ? '登入中…' : '登入巡樓系統'}
|
{loading ? '登入中…' : '登入'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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 className="font-semibold text-brand hover:underline" to="/register">
|
||||||
前往註冊
|
前往註冊
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { Link, Navigate, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import { AuthShell } from '../components/AuthShell'
|
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() {
|
export function RegisterPage() {
|
||||||
const { register, setTenantId, tenantId, isAuthenticated } = useAuth()
|
const { register, setTenantId, tenantId, isAuthenticated } = useAuth()
|
||||||
|
|
@ -31,11 +31,13 @@ export function RegisterPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell>
|
<AuthShell tagline="新夥伴加入!">
|
||||||
<Card>
|
<div className="auth-shell-body">
|
||||||
<h2 className="text-xl font-bold text-ink">註冊帳號</h2>
|
<div className="ac-title-bar auth-shell-title">註冊</div>
|
||||||
<p className="mt-1 text-sm text-ink-secondary">建立巡樓管理帳號</p>
|
<p className="auth-shell-lead text-ink-secondary">
|
||||||
<form className="mt-6 space-y-4" onSubmit={onSubmit}>
|
建立帳號後,就能一起打理這座<span className="font-bold text-brand">巡樓小島</span>。
|
||||||
|
</p>
|
||||||
|
<form className="auth-shell-form space-y-5" onSubmit={onSubmit}>
|
||||||
<Field label="Tenant ID">
|
<Field label="Tenant ID">
|
||||||
<Input value={tenantId} onChange={(e) => setTenantId(e.target.value)} required />
|
<Input value={tenantId} onChange={(e) => setTenantId(e.target.value)} required />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -59,13 +61,13 @@ export function RegisterPage() {
|
||||||
{loading ? '註冊中…' : '建立帳號並登入'}
|
{loading ? '註冊中…' : '建立帳號並登入'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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 className="font-semibold text-brand hover:underline" to="/login">
|
||||||
前往登入
|
前往登入
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue