Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## 唯一真相来源

- 全局主题 token 在 [frontend/src/index.css](../frontend/src/index.css)。
- 对话框、提示、卡片、Chip、Drawer、表单等可访问 UI 场景,优先用 `@heroui/react` 的 HeroUI 原语。
- 对话框、提示、卡片、Chip、Drawer、表单等可访问 UI 场景,**强制**用 `@heroui/react` 的 HeroUI 原语。没有对应组件时,再查 shadcn MCP / 官方目录找等价物;最后才允许手写。
- 当前项目直接使用 `@heroui/react` 原语;布局与复用组件位于 `frontend/src/components/`。
- 主题 token 与视觉变量统一维护在 [frontend/src/index.css](../frontend/src/index.css)。

Expand Down Expand Up @@ -57,25 +57,34 @@

## 组件

可访问的组合组件用 HeroUI:
可访问的组合组件**必须**用 HeroUI,按这个顺序选:

- `Modal`、`Alert`、`Card`、`Chip`、`Drawer`、`NumberField`
- 基于 HeroUI 的本地 `Button` 和 `Input` wrapper
1. `@heroui/react` 已有原语(`Modal`、`Alert`、`AlertDialog`、`Form`、`NumberField`、`DateRangePicker`、`Select`、`Chip`、`Drawer`、`Card`)。
2. 没有时,用 shadcn MCP / 官方组件目录找可映射到 HeroUI 组合的模式,不要另开一套视觉语言。
3. 仍没有、或现有行为确实无法覆盖,才手写。手写必须说明缺的是哪个 HeroUI 组件。

账号编辑等设置弹窗:

- 用 `Modal` `size="lg"`(或更宽),不要用 `sm` 把名称、并发、优先级挤进窄卡片。
- 数字用 `NumberField`(步进按钮),不要用原生 `type="number"`。
- 校验失败用 `Alert`,不要手写红框。
- 表单用 `Form` + `Label` / `Description`。
- 页脚操作按钮用默认尺寸,不要再套卡片上的 32px `account-button`。

构建新弹窗时:

- 标准流程优先用 HeroUI `Modal`。
- 标准流程必须用 HeroUI `Modal`。
- 仅在现有行为/布局有特殊要求时才手写对话框。
- 弹窗表面保持视觉扁平:一个背景、浅边框、适度阴影。
- 避免 header/body/footer 各用不同背景色。
- 分割线只在有助于快速扫视时才加,不要默认上下都加边框。

构建表单时:

- 文本输入用 `Input` wrapper。
- 文本输入用 `Input`。
- 标签应小而安静:`text-xs` 或 `text-sm font-medium text-muted-foreground`。
- Select 视觉上应与 Input 一致:`h-10 rounded-md border border-input bg-background px-3 text-sm`。
- 辅助说明用安静文字表达,不要包在灰色卡片里,除非确实是需要强调的 callout。
- 辅助说明用 `Description` 或安静文字,不要包在灰色卡片里,除非确实是需要强调的 callout。

构建按钮时:

Expand Down Expand Up @@ -122,7 +131,7 @@

- 圆角 8px 或更小,除非现有组件已用更大值
- 账号/资源列表卡片优先使用浅边框和平面表面,不强制最小高度,不额外叠加强阴影
- 账号卡片保持操作台密度:单行身份(名称 + provider/UID,不展示认证方式),状态 Chip 只出现一次,运行状态用 12 格短柱、额度用 `scaleX` 填充条;名称、并发和优先级通过卡片编辑弹窗修改,重启次数仍是只读辅助数字。控制台刷新时卡片保持挂载,让额度和状态柱用 GSAP 过渡,不要整卡换成骨架屏。色条只用很浅的同色渐变,不要换新强调色。
- 账号卡片保持操作台密度:单行身份(名称 + provider/UID,不展示认证方式),状态 Chip 只出现一次,运行状态用 12 格短柱、额度用 `scaleX` 填充条;名称、并发和优先级通过较宽的 HeroUI 编辑弹窗修改(`Form` + `NumberField` + `Alert`),重启次数仍是只读辅助数字。控制台刷新时卡片保持挂载,让额度和状态柱用 GSAP 过渡,不要整卡换成骨架屏。色条只用很浅的同色渐变,不要换新强调色。
- 添加账号是两步向导:第一步选类型、名称和可选高级选项,第二步再选登录方式。不要把登录方式再摊回第一步。打开弹窗时类型列表先显示骨架屏,等 `/api/providers` 返回后再渲染真实选项,不要先闪默认 Qoder Global。类型 ≤ 6 用两列 tile,超过则用下拉;未知 provider 直接用后端 descriptor 的 label,不要静默丢掉。登录方式按 `capabilities` 隐藏,只有一种时不要再渲染选择器。
- 账号网格用 `lg:grid-cols-2 xl:grid-cols-3`,卡片间距 `gap-2.5`,内边距约 12px
- 主卡片只有在确实需要抬升层级时才用 `shadow-card`
Expand Down Expand Up @@ -187,6 +196,8 @@

完成前端改动前:

- 是否先用了 HeroUI 原语?没有对应组件时是否查过 shadcn / 官方目录,而不是直接手写?
- 设置弹窗是否用了 `Modal` + `Form` + `NumberField` / `Alert`,而不是窄 `sm` 弹窗和原生 number input?
- 该用 HeroUI 或现有本地 wrapper 的地方是否用上了?
- 是否复用了全局语义 token?
- 边框/分割线是否必要,还是用间距和排版就能解决?
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/account/AccountModelsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function AccountModelsModal({ account, t, onClose }: Props) {
) : models.length ? (
<ul className="divide-y divide-[var(--app-line)] overflow-hidden rounded-lg border border-[var(--app-line)]">
{models.map((model) => {
const ownedBy = model.owned_by || model.provider || account?.provider || 'qoder'
const ownedBy = model.provider || model.owned_by || account?.provider || 'qoder'
const routed = routedModelName(model)
return (
<li key={model.id} className="flex items-start gap-3 px-3 py-2.5">
Expand Down
140 changes: 73 additions & 67 deletions frontend/src/components/account/EditAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { useState } from 'react'
import { Button, Input, Modal } from '@heroui/react'
import { WarningCircle, X } from '@phosphor-icons/react'
import { Alert, Button, Chip, Description, Form, Input, Label, Modal, NumberField } from '@heroui/react'
import { X } from '@phosphor-icons/react'
import { ProviderMark } from '@/components/ProviderMark'
import type { AccountRow } from '@/lib/account'
import { accountProviderLabel } from '@/lib/provider'

const ACCOUNT_BUTTON_CLASS = 'account-button'

type Translate = (key: string, vars?: Record<string, string | number>) => string

type Props = {
Expand All @@ -19,31 +17,30 @@ type Props = {

export function EditAccountModal({ account, busy, t, onClose, onSave }: Props) {
const [name, setName] = useState(account?.name || '')
const [maxInFlight, setMaxInFlight] = useState(String(account?.max_inflight ?? 4))
const [priority, setPriority] = useState(String(account?.priority ?? 50))
const [maxInFlight, setMaxInFlight] = useState<number>(account?.max_inflight ?? 4)
const [priority, setPriority] = useState<number>(account?.priority ?? 50)
const [error, setError] = useState('')
const title = t('editAccountTitle', { name: account?.name || account?.id || '' })
const provider = account ? accountProviderLabel(account.provider, account.region, t) : ''

async function submit() {
async function submit(event?: { preventDefault(): void }) {
event?.preventDefault()
const trimmed = name.trim()
if (!trimmed) {
setError(t('accountNameRequired'))
return
}
const nextInFlight = Number(maxInFlight)
if (!Number.isInteger(nextInFlight) || nextInFlight < 1 || nextInFlight > 32) {
if (!Number.isInteger(maxInFlight) || maxInFlight < 1 || maxInFlight > 32) {
setError(t('maxInflightInvalid'))
return
}
const nextPriority = Number(priority)
if (!Number.isInteger(nextPriority) || nextPriority < 1 || nextPriority > 100) {
if (!Number.isInteger(priority) || priority < 1 || priority > 100) {
setError(t('priorityInvalid'))
return
}
setError('')
try {
await onSave({ name: trimmed, max_inflight: nextInFlight, priority: nextPriority })
await onSave({ name: trimmed, max_inflight: maxInFlight, priority })
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
Expand All @@ -53,79 +50,88 @@ export function EditAccountModal({ account, busy, t, onClose, onSave }: Props) {
return (
<Modal.Root isOpen={Boolean(account)} onOpenChange={(next: boolean) => { if (!next && !busy) onClose() }}>
<Modal.Backdrop variant="blur">
<Modal.Container placement="center" size="sm">
<Modal.Dialog>
<Modal.Header className="items-start justify-between gap-4">
<Modal.Container placement="center" size="lg" scroll="inside">
<Modal.Dialog className="sm:min-w-[32rem]">
<Modal.Header className="items-start justify-between gap-4 px-6 pt-6">
<div className="min-w-0">
<Modal.Heading className="text-base font-semibold">{title}</Modal.Heading>
<p className="mt-1 text-xs font-normal leading-5 text-[var(--app-faint)]">{t('editAccountHint')}</p>
<Modal.Heading className="text-lg font-semibold tracking-[-0.02em]">{title}</Modal.Heading>
<p className="mt-1.5 text-sm font-normal leading-6 text-[var(--app-muted)]">{t('editAccountHint')}</p>
{account ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-[var(--app-muted)]">
<ProviderMark provider={account.provider} size={14} />
<span>{provider}</span>
<span className="text-[var(--app-line-strong)]">·</span>
<span className="mono">{account.id}</span>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Chip size="sm" variant="soft">
<span className="flex items-center gap-1.5">
<ProviderMark provider={account.provider} size={14} />
<span>{provider}</span>
</span>
</Chip>
<span className="mono text-xs text-[var(--app-faint)]">{account.id}</span>
</div>
) : null}
</div>
<Modal.CloseTrigger isDisabled={busy} aria-label={t('close')} className="grid size-8 shrink-0 place-items-center rounded-lg text-[var(--app-muted)] hover:bg-[var(--app-surface-muted)]">
<X size={16} />
<Modal.CloseTrigger isDisabled={busy} aria-label={t('close')} className="grid size-9 shrink-0 place-items-center rounded-lg text-[var(--app-muted)] hover:bg-[var(--app-surface-muted)]">
<X size={18} />
</Modal.CloseTrigger>
</Modal.Header>
<Modal.Body className="pt-0">
<Modal.Body className="px-6 pb-2 pt-1">
{error ? (
<div className="mb-3 flex gap-2 rounded-lg border border-[color-mix(in_srgb,var(--app-danger)_24%,transparent)] bg-[color-mix(in_srgb,var(--app-danger)_7%,transparent)] px-3 py-2.5 text-xs leading-5 text-[var(--app-danger)]">
<WarningCircle size={14} className="mt-0.5 shrink-0" />
<span>{error}</span>
</div>
<Alert status="danger" className="mb-4">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>{error}</Alert.Title>
</Alert.Content>
</Alert>
) : null}
<div className="space-y-3">
<label className="block space-y-1.5">
<span className="text-xs font-medium text-[var(--app-muted)]">{t('accountName')}</span>
<Form className="space-y-5" onSubmit={(event) => void submit(event)}>
<div className="space-y-1.5">
<Label className="text-sm font-medium text-[var(--app-muted)]">{t('accountName')}</Label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t('wizardNamePh')}
aria-label={t('accountName')}
disabled={busy}
onKeyDown={(event) => {
if (event.key === 'Enter') void submit()
}}
autoFocus
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block space-y-1.5">
<span className="text-xs font-medium text-[var(--app-muted)]">{t('maxInflight')}</span>
<Input
type="number"
min={1}
max={32}
value={maxInFlight}
onChange={(event) => setMaxInFlight(event.target.value)}
aria-label={t('maxInflight')}
disabled={busy}
/>
<p className="text-[11px] leading-4 text-[var(--app-faint)]">{t('maxInflightHint')}</p>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-medium text-[var(--app-muted)]">{t('priority')}</span>
<Input
type="number"
min={1}
max={100}
value={priority}
onChange={(event) => setPriority(event.target.value)}
aria-label={t('priority')}
disabled={busy}
/>
<p className="text-[11px] leading-4 text-[var(--app-faint)]">{t('priorityHint')}</p>
</label>
</div>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<NumberField
value={maxInFlight}
onChange={(value) => setMaxInFlight(value ?? 4)}
minValue={1}
maxValue={32}
isDisabled={busy}
isRequired
>
<Label className="text-sm font-medium text-[var(--app-muted)]">{t('maxInflight')}</Label>
<NumberField.Group>
<NumberField.DecrementButton />
<NumberField.Input />
<NumberField.IncrementButton />
</NumberField.Group>
<Description className="text-xs leading-5 text-[var(--app-faint)]">{t('maxInflightHint')}</Description>
</NumberField>
<NumberField
value={priority}
onChange={(value) => setPriority(value ?? 50)}
minValue={1}
maxValue={100}
isDisabled={busy}
isRequired
>
<Label className="text-sm font-medium text-[var(--app-muted)]">{t('priority')}</Label>
<NumberField.Group>
<NumberField.DecrementButton />
<NumberField.Input />
<NumberField.IncrementButton />
</NumberField.Group>
<Description className="text-xs leading-5 text-[var(--app-faint)]">{t('priorityHint')}</Description>
</NumberField>
</div>
</Form>
</Modal.Body>
<Modal.Footer className="justify-end">
<Button className={ACCOUNT_BUTTON_CLASS} size="sm" variant="ghost" isDisabled={busy} onPress={onClose}>{t('cancel')}</Button>
<Button className={ACCOUNT_BUTTON_CLASS} size="sm" isPending={busy} onPress={() => void submit()}>{t('save')}</Button>
<Modal.Footer className="justify-end gap-2 px-6 pb-6">
<Button variant="ghost" isDisabled={busy} onPress={onClose}>{t('cancel')}</Button>
<Button isPending={busy} onPress={() => void submit()}>{t('save')}</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const messages: Record<Lang, Dict> = {
updaterReady: 'Host updater connected',
updaterUnavailable: 'Host updater unavailable',
updateNow: 'Update now',
updateReloadingIn: 'Updating… refresh in {seconds}s',
sqliteProtection: 'SQLite protection',
sqliteProtectionHint: 'The named volume remains attached and a verified snapshot is created before the update.',
sqliteBackupBeforeUpdate: 'Verified SQLite snapshot before replacement',
Expand Down Expand Up @@ -477,6 +478,7 @@ export const messages: Record<Lang, Dict> = {
updaterReady: '宿主机更新器已连接',
updaterUnavailable: '宿主机更新器不可用',
updateNow: '立即更新',
updateReloadingIn: '更新中… {seconds}s 后刷新',
sqliteProtection: 'SQLite 保护',
sqliteProtectionHint: '保留原数据卷,并在更新前创建经过完整性校验的快照。',
sqliteBackupBeforeUpdate: '替换前创建并校验 SQLite 快照',
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/AccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ export function AccessPage() {
id: item.id,
textValue: `${item.display_name || item.id} ${item.id} ${item.owned_by || item.provider || ''}`,
label: item.display_name || item.id,
hint: item.owned_by || item.provider ? `${item.id} · ${item.owned_by || item.provider}` : item.id,
hint: item.provider || item.owned_by ? `${item.id} · ${item.provider || item.owned_by}` : item.id,
}))}
/>
) : (
Expand Down
Loading
Loading