build(frontend): pnpm workspace + @bisheng/ui shared component library

- Convert src/frontend to a pnpm workspace (catalog-pinned shared deps,
  only-allow pnpm, npm lockfiles removed, both Dockerfiles on corepack/pnpm)
- New packages/ui (@bisheng/ui): source-shipped presentation-only library.
  First component Button (moved from client, which keeps a re-export shim so
  all call sites work unchanged), cn util, two-layer design tokens
  (tokens.css + tailwind-preset incl. dark ramp), design-token.cjs SSOT
  moved here (client re-exports it)
- Component docs move into packages/ui/docs (git-tracked); rspress site stays
  hosted in client, root/outDir repointed, button demos import @bisheng/ui;
  doc_build + playground artifacts gitignored
- pnpm-migration fixes: pin vite-plugin-node-polyfills to 0.23.0 (0.23.1
  unenv rewrite breaks CJS named-export detection), rollup/vite overrides
  matching the old npm resolutions, fs/promises + node:fs/promises stubs
- Harness rules: new packages/ui/AGENTS.md (library contract, token SSOT
  discipline, interaction rules, component definition-of-done); client
  AGENTS.md gains design-system hard rules; commands/docs updated to pnpm
This commit is contained in:
dolphin
2026-07-22 18:52:21 +08:00
parent d2ca23524e
commit 3e96e963d7
60 changed files with 25938 additions and 44031 deletions
+1 -1
View File
@@ -103,5 +103,5 @@ Backend runtime pitfalls (tenant-filter SELECT-only gap, ruff hook import trap,
- **Architecture docs** → `docs/architecture/` (overview, permission, gateway, multi-tenant, data-models, …)
- **Skills**: `/sdd-review`, `/task-review`, `/code-review`, `/e2e-test`, `/i18n-localizer`, `/react-component-refactor`
**Instruction files (AGENTS.md map).** Root = this file, loaded every session. Auto-loaded on top when editing the matching directory: `src/backend/`, `src/frontend/platform/`, `src/frontend/client/`, plus deep-dir specials `src/backend/bisheng/core/database/alembic/` (migrations) and `src/backend/scripts/` (one-off scripts). Every `CLAUDE.md` is a symlink to its sibling `AGENTS.md` — edit `AGENTS.md` only. Put a new rule in the deepest file covering its scope (cross-app / cross-module → this file; app- or dir-specific → the nearest file); never duplicate a rule across levels — it *will* drift.
**Instruction files (AGENTS.md map).** Root = this file, loaded every session. Auto-loaded on top when editing the matching directory: `src/backend/`, `src/frontend/platform/`, `src/frontend/client/`, `src/frontend/packages/ui/` (shared component library + design-token SSOT), plus deep-dir specials `src/backend/bisheng/core/database/alembic/` (migrations) and `src/backend/scripts/` (one-off scripts). Every `CLAUDE.md` is a symlink to its sibling `AGENTS.md` — edit `AGENTS.md` only. Put a new rule in the deepest file covering its scope (cross-app / cross-module → this file; app- or dir-specific → the nearest file); never duplicate a rule across levels — it *will* drift.
+5 -8
View File
@@ -21,13 +21,10 @@ uv sync --frozen --python $(which python)
### 前端环境
```bash
# Platform 前端(主应用)
cd src/frontend/platform
npm install
# Client 前端(客户端嵌入应用)
cd src/frontend/client
npm install
# 前端为 pnpm workspace(platform + client + packages/ui),在 workspace 根一次安装。
# 已禁用 npm(only-allow pnpm);pnpm 通过 corepack 提供:corepack enable
cd src/frontend
pnpm install
```
### 存储服务
@@ -77,7 +74,7 @@ cd src/backend
```bash
cd src/frontend/platform
npm start -- --host 0.0.0.0
pnpm start -- --host 0.0.0.0
```
Vite 开发服务器运行在 3001 端口,自动将 `/api/``/health` 请求代理到后端 `localhost:7860`。文件服务路由(`/bisheng``/tmp-dir`)代理到 MinIO。
-6
View File
@@ -1,6 +0,0 @@
{
"name": "bisheng",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+7
View File
@@ -24,3 +24,10 @@ yarn.lock
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# pnpm workspace
node_modules/
packages/ui/doc_build/
package-lock.json
yarn.lock
.obsidian/
+4
View File
@@ -0,0 +1,4 @@
registry=https://registry.npmmirror.com
# Workspace packages always link locally, never fetch from registry.
link-workspace-packages=true
prefer-workspace-packages=true
+4 -2
View File
@@ -2,8 +2,10 @@ FROM node:20-alpine as frontend_build
ARG BACKEND
WORKDIR /app
COPY . /app
RUN cd /app/platform && npm install --registry=https://registry.npmmirror.com && npm run build
RUN cd /app/client && npm install --registry=https://registry.npmmirror.com && npm run build
# pnpm workspace build (registry comes from .npmrc; packageManager pins the version).
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
RUN cd /app && pnpm install --frozen-lockfile
RUN cd /app && pnpm --filter bisheng build && pnpm --filter bishengchat build
FROM nginx
COPY --from=frontend_build /app/platform/build/ /usr/share/nginx/html/platform
+10 -4
View File
@@ -6,8 +6,8 @@ Cross-app boundary + hard rules common to both apps: root `AGENTS.md §4` (singl
## Commands (cwd: `src/frontend/client/`)
```bash
npm install
npm run dev # dev server on :4001
pnpm install # run at src/frontend/ (pnpm workspace root; npm is disabled)
pnpm dev # dev server on :4001 (or `pnpm dev:client` from the workspace root)
```
## Tech Stack
@@ -17,9 +17,9 @@ Vite 6 + React 18 + TypeScript + TailwindCSS 3 + Radix UI (shadcn/ui) + **Recoil
- **Path Aliases**: `~/` (or `@/`) → `src/`.
- **HTTP Requests**: wrapper is `~/api/request.ts`.
- **State Management**: Recoil (`~/store/`). Context or other solutions prohibited for new state.
- **UI Components**: `~/components/ui/` (shadcn / Radix-based).
- **UI Components**: shared library `@bisheng/ui` (`src/frontend/packages/ui/`) first — components migrated there (Button, …) keep re-export shims at `~/components/ui/<Name>` so both import paths work; everything else still lives in `~/components/ui/` (shadcn / Radix-based).
- **Icons**: prefer `bisheng-icons``import { Outlined } from 'bisheng-icons'``<Outlined.Delete />` (variants `Outlined` / `Filled` / `Colored`). Use `lucide-react` ONLY as a fallback when `bisheng-icons` has no matching-semantic icon.
- **⚠️ After upgrading `bisheng-icons`**, clear the Vite pre-bundle cache or new icons crash the page (`Element type is invalid`): `npm run dev -- --force` (or `rm -rf node_modules/.vite && npm run dev`). Its git-source `exports` field defeats Vite's dep-change detection, so the stale pre-bundled snapshot is served unless forced.
- **⚠️ After upgrading `bisheng-icons`**, clear the Vite pre-bundle cache or new icons crash the page (`Element type is invalid`): `pnpm dev -- --force` (or `rm -rf node_modules/.vite && pnpm dev`). Its git-source `exports` field defeats Vite's dep-change detection, so the stale pre-bundled snapshot is served unless forced.
- **Toast**: `const { showToast } = useToastContext(); showToast?.({ message, severity: 'error' | 'success' })`.
- **i18n**: `useLocalize()` from `~/hooks``localize()`. Locale files at `src/locales/{en,zh-Hans,ja}/translation.json` (single file). New keys use nested namespace format (see `/i18n-localizer` skill).
- **Brand theme (blue⇄green)**: brand-colored UI MUST follow the theme — **never hardcode brand hex** (`#165DFF`/`#024DE3`/`#19B476`/`#187C54`…).
@@ -29,3 +29,9 @@ Vite 6 + React 18 + TypeScript + TailwindCSS 3 + Radix UI (shadcn/ui) + **Recoil
- **Illustrations**: inline SVG, `fill`/`stroke` = `rgb(var(--illus-NNN))` (separate brighter palette, in `src/components/illustrations/`). SVG presentation attrs ignore `var()` → use inline `style`/className/CSS-mask, and `useId()` to dedupe gradient/clip ids.
- **Do NOT theme**: semantic colors (success `#00b42a` / danger `#f53f3f` / warning `#ff7d00`), type colors (skill-purple, assistant-orange), third-party logos. Need a muted-but-themed brand color → `rgb(var(--brand-muted))`.
- Full guide: `BRAND-THEME-HANDOFF.md`.
- **Design system (hard rules — full specs in `packages/ui/docs/`, site: `pnpm dev:ui`)**:
- Where a `@bisheng/ui` component exists, USE it — no hand-rolled equivalents. Buttons: `<Button>` dual-axis API (`color` × `variant` × `size`); never hand-write button heights/padding/radius; adjacent buttons same size; one primary-solid per action area.
- Button `loading` prop only — never inject your own Spinner. `iconOnly` requires `aria-label` + Tooltip.
- Typography (new code): semantic classes `text-caption/body-sm/body/h4…h1` (auto-remap ≤768px) — not raw `text-sm/base` (基础-字体规范.md).
- Neutral colors (new code): semantic tokens `text-text-1…4` / `bg-fill-1…4` / `border-border-base|-deep` / `success|warning|danger` — never `text-gray-*` or hex (基础-色彩规范.md).
- Hover/touch: plain `hover:` classes ONLY (`hoverOnlyWhenSupported` disables them on touch app-wide) — **never invent hover variant prefixes**; touch press via `coarse-pointer:active:`; hover/active shade stays within the base color's own ramp (no cross-palette graying).
+3 -1
View File
@@ -38,7 +38,9 @@ module.exports = {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'jest-file-loader',
},
transformIgnorePatterns: ['node_modules/?!@zattoo/use-double-click'],
// @bisheng/ui is source-shipped TS from the workspace — jest must transform it
// (default ignore would skip everything under node_modules).
transformIgnorePatterns: ['node_modules/(?!(@bisheng/ui|@zattoo/use-double-click)/)'],
preset: 'ts-jest',
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect', '<rootDir>/test/setupTests.js'],
clearMocks: true,
-30835
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -26,6 +26,7 @@
"dependencies": {
"@ariakit/react": "^0.4.15",
"@ariakit/react-core": "^0.4.15",
"@bisheng/ui": "workspace:*",
"@codesandbox/sandpack-react": "^2.19.10",
"@dicebear/collection": "^7.0.4",
"@dicebear/core": "^7.0.4",
@@ -94,7 +95,7 @@
"react-flip-toolkit": "^7.1.0",
"react-gtm-module": "^2.0.11",
"react-hook-form": "^7.43.9",
"react-i18next": "^15.4.0",
"react-i18next": "^15.5.3",
"react-lazy-load-image-component": "^1.6.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.7",
+18 -4
View File
@@ -17,8 +17,12 @@ import autoprefixer from 'autoprefixer';
const clientSrc = path.join(__dirname, 'src');
export default defineConfig({
// req 2: docs root = bisheng/docs-ui-refactor
root: path.join(__dirname, '../../../docs-ui-refactor'),
// Docs live with the component library: src/frontend/packages/ui/docs
// (git-tracked; the site stays hosted in client until the app-coupled demos
// finish migrating onto @bisheng/ui).
root: path.join(__dirname, '../packages/ui/docs'),
// Build output next to the docs source (gitignored; CI artifact only).
outDir: path.join(__dirname, '../packages/ui/doc_build'),
title: 'BISHENG 组件库',
description: 'BISHENG client 设计规范 + 组件库',
lang: 'zh', // single language — i18n intentionally not enabled (req 5)
@@ -177,7 +181,17 @@ export default defineConfig({
filter: (url: string) => !url.startsWith('/') && !url.startsWith('$fonts'),
},
},
rspack: (config) => {
rspack: (config, { rspack }) => {
// node:-scheme imports reached only on node-only paths (e.g.
// @dicebear/core toFile → import('node:fs/promises') via the ~/hooks
// barrel) — replace with an empty stub; rspack has no node: handling.
config.plugins = config.plugins || [];
config.plugins.push(
new rspack.NormalModuleReplacementPlugin(
/^node:fs\/promises$/,
path.join(__dirname, 'stubs/empty-module.ts'),
),
);
config.resolve = config.resolve || {};
// filenamify (ESM, imports node:path — an unbundlable scheme) comes
// in via the ~/hooks barrel (usePresets); no demo executes it. The
@@ -209,7 +223,7 @@ export default defineConfig({
// run, and MDX rejects the `<!-- site-hide -->` HTML comments this
// loader relies on. Spec .mdx pages are authored reader-clean instead.
test: /\.md$/,
include: [path.join(__dirname, '../../../docs-ui-refactor')],
include: [path.join(__dirname, '../packages/ui/docs')],
enforce: 'pre',
use: [path.join(__dirname, 'plugins/strip-internal-loader.cjs')],
});
@@ -1,350 +1,8 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva } from 'class-variance-authority';
import { Outlined } from 'bisheng-icons';
import { cn } from '~/utils';
/**
* Button — design-system base component (docs-ui-refactor/组件-Button按钮.md).
*
* New API is the antd-style dual axis: `color` (primary/default/danger) ×
* `variant` (solid/outlined/filled/text/link) × `size` (small/medium/large),
* plus `iconOnly` for icon buttons and `shape` (square/circle, circle being
* icon-only). All colors go through semantic
* tokens (`btn-*` in tailwind.config / style.css, brand via `blue-*`); hover
* states are disabled on touch (§5.5) and disabled/loading are uniform (§5.2).
*
* The legacy shadcn API (`variant="outline" | "ghost" | ...`, `size="sm" |
* "icon" | ...`) still works through an automatic mapping (§6.3) so existing
* call sites keep rendering; they will be migrated batch-by-batch and the
* mapping removed afterwards.
* Button has moved to the shared component library (@bisheng/ui) — the first
* component managed there and consumed by both apps. This re-export keeps every
* existing `~/components/ui/Button` call site working unchanged.
* New code may import from '@bisheng/ui' directly.
*/
type ButtonColor = 'primary' | 'default' | 'danger';
type ButtonVariant = 'solid' | 'outlined' | 'filled' | 'text' | 'link';
type ButtonSize = 'small' | 'medium' | 'large';
type ButtonShape = 'square' | 'circle';
/** @deprecated Legacy single-axis variants — auto-mapped to color×variant (§6.3). */
type LegacyVariant =
| 'default'
| 'destructive'
| 'outline'
| 'secondary'
| 'secondaryBrand'
| 'ghost'
| 'submit';
/** @deprecated Legacy sizes — auto-mapped (`icon` → medium + iconOnly). */
type LegacySize = 'default' | 'sm' | 'lg' | 'icon';
const buttonStyles = cva(
// Disabled is uniform across every combo (§5.2) and must beat both the
// combo colors and legacy className overrides, hence the `!` importants.
// `relative` anchors the .btn-touch-hit ::after hot zone (style.css).
// Weight 400 across all sizes/types (§3.1) — heavier weights are not a knob.
'relative inline-flex items-center justify-center whitespace-nowrap font-normal transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:!border-btn-disabled-border disabled:!bg-btn-disabled-bg disabled:!text-btn-disabled-text [&_svg]:shrink-0',
{
variants: {
// Color axis only carries what is combo-independent (focus ring, §5.2).
color: {
primary: 'focus-visible:ring-blue-500/40',
default: 'focus-visible:ring-blue-500/40',
danger: 'focus-visible:ring-btn-danger/40',
},
variant: {
solid: '',
// bg-bg-page (not bg-white) so the opaque fill follows the theme:
// white in light, #121212 in dark — else outlined = a white chip on
// the dark page and its neutral text drops to unreadable.
outlined: 'border bg-bg-page',
filled: '',
text: '',
link: 'underline-offset-4',
},
// Heights/radii per §2 (24/32/40, 4/6/8px). Font sizes reference the
// PRIMITIVE type scale vars on purpose: the semantic --text-body remaps
// 14→16 under 768px, but control text must not follow (§5.5 / 适配原则 §3).
// Icon size is one 14/16/18 ladder for BOTH icon-only and text+icon (§3.2/3.3).
// Horizontal padding here is the borderless value (8/16/16); bordered
// variants override to 7/15/15 in compoundVariants (visual-width parity).
size: {
small:
'h-6 gap-1 rounded px-2 text-[length:var(--font-size-3)] leading-[var(--line-height-3)] [&_svg]:size-3.5',
medium:
'btn-touch-hit h-8 gap-2 rounded-md px-4 text-[length:var(--font-size-3)] leading-[var(--line-height-3)] [&_svg]:size-4',
large:
'h-10 gap-2 rounded-lg px-4 text-[length:var(--font-size-4)] leading-[var(--line-height-4)] [&_svg]:size-[18px]',
},
// `circle` is declared AFTER `size` so its rounded-full wins the merge
// over the per-size radius; resolveVariants restricts it to icon-only (§1).
shape: {
square: '',
circle: 'rounded-full',
},
iconOnly: {
true: '',
false: '',
},
},
compoundVariants: [
/* ---- color × variant matrix (§5.2; combos the spec leaves implicit
follow the same ramp logic: hover one step, active one deeper).
Active steps are TOUCH-ONLY (coarse-pointer): on hover-capable
devices a pressed button keeps its hover color — no click flash;
on touch, where hover is disabled, active is the only feedback. ---- */
{
color: 'primary',
variant: 'solid',
// btn-brand-primary = green-theme !important override (style.css) —
// kept as agreed tech debt until the theme mechanism is reworked (§6.2).
class:
'btn-brand-primary bg-blue-500 text-white hover:bg-blue-400 coarse-pointer:active:bg-blue-600',
},
{
color: 'primary',
variant: 'outlined',
// Outlined hover is a faint tint of the button's own palette, border/
// text unchanged (§5.2) — same shape as default outlined's gray tint.
// Dark: the solid blue-50 tint would be a light chip on #121212, so
// switch to a brand-alpha tint (same technique as danger filled).
class:
'border-blue-500 text-blue-500 hover:bg-blue-50 coarse-pointer:active:bg-blue-100 dark:hover:bg-blue-500/15 dark:coarse-pointer:active:bg-blue-500/25',
},
{
color: 'primary',
variant: 'filled',
class:
'bg-blue-50 text-blue-600 hover:bg-blue-100 coarse-pointer:active:bg-blue-200 dark:bg-blue-500/15 dark:text-blue-400 dark:hover:bg-blue-500/25 dark:coarse-pointer:active:bg-blue-500/[0.35]',
},
{
color: 'primary',
variant: 'text',
class:
'text-blue-500 hover:bg-blue-50 coarse-pointer:active:bg-blue-100 dark:hover:bg-blue-500/15 dark:coarse-pointer:active:bg-blue-500/25',
},
{
color: 'primary',
variant: 'link',
class:
'text-blue-500 hover:text-blue-400 hover:underline coarse-pointer:active:text-blue-600',
},
{
color: 'default',
variant: 'solid',
// Fill uses its own token (not gray-text) so dark flips the text role
// light while the solid fill stays a mid-gray that keeps white legible.
class:
'bg-btn-gray-solid-bg text-white hover:bg-btn-gray-solid-bg/90 coarse-pointer:active:bg-btn-gray-solid-bg/80',
},
{
color: 'default',
variant: 'outlined',
class:
'border-btn-gray-border text-btn-gray-text hover:bg-btn-fill-1 coarse-pointer:active:bg-btn-fill-2',
},
{
color: 'default',
variant: 'filled',
class:
'bg-btn-fill-2 text-btn-gray-text hover:bg-btn-fill-3 coarse-pointer:active:bg-btn-fill-4',
},
{
color: 'default',
variant: 'text',
class:
'text-btn-gray-text hover:bg-btn-fill-1 coarse-pointer:active:bg-btn-fill-2',
},
{
color: 'default',
variant: 'link',
class:
'text-btn-gray-text hover:text-btn-gray-text/80 hover:underline coarse-pointer:active:text-btn-gray-text',
},
{
color: 'danger',
variant: 'solid',
class:
'bg-btn-danger text-white hover:bg-btn-danger-hover coarse-pointer:active:bg-btn-danger-active',
},
{
color: 'danger',
variant: 'outlined',
// Same faint-tint hover as primary outlined, on the red alpha ladder.
class:
'border-btn-danger text-btn-danger hover:bg-btn-danger/10 coarse-pointer:active:bg-btn-danger/[0.15]',
},
{
color: 'danger',
variant: 'filled',
class:
'bg-btn-danger/10 text-btn-danger hover:bg-btn-danger/[0.15] coarse-pointer:active:bg-btn-danger/20',
},
{
color: 'danger',
variant: 'text',
class:
'text-btn-danger hover:bg-btn-danger/10 coarse-pointer:active:bg-btn-danger/[0.15]',
},
{
color: 'danger',
variant: 'link',
class:
'text-btn-danger hover:text-btn-danger-hover hover:underline coarse-pointer:active:text-btn-danger-active',
},
/* ---- bordered padding 7/15/15 incl. 1px border (§2 visual parity) ---- */
{ variant: 'outlined', size: 'small', class: 'px-[7px]' },
{ variant: 'outlined', size: ['medium', 'large'], class: 'px-[15px]' },
/* ---- icon-only squares 24/32/40 (§3.2, icon ladder shared with the
size axis); every size gets the ≥44px touch hot zone (§5.5) ---- */
{ iconOnly: true, size: 'small', class: 'btn-touch-hit w-6 px-0' },
{ iconOnly: true, size: 'medium', class: 'w-8 px-0' },
{ iconOnly: true, size: 'large', class: 'btn-touch-hit w-10 px-0' },
],
// Bare <Button> keeps its historical primary-solid look (§6.3).
defaultVariants: {
color: 'primary',
variant: 'solid',
size: 'medium',
shape: 'square',
iconOnly: false,
},
},
);
export interface ButtonStyleProps {
// `(string & {})` keeps the three literals in autocomplete while still
// accepting `{...props}` spreads that carry the native HTML `color` attr
// (e.g. TooltipAnchor render props); non-axis strings are ignored at runtime.
color?: ButtonColor | (string & {});
variant?: ButtonVariant | LegacyVariant;
size?: ButtonSize | LegacySize;
/** `circle` renders a full circle — icon-only buttons ONLY (§1); ignored otherwise. */
shape?: ButtonShape;
/** Square icon-only button (§3.2) — must ship an `aria-label` + Tooltip. */
iconOnly?: boolean;
}
function isButtonColor(value: unknown): value is ButtonColor {
return value === 'primary' || value === 'default' || value === 'danger';
}
const LEGACY_VARIANT_MAP: Record<string, { color: ButtonColor; variant: ButtonVariant }> = {
default: { color: 'primary', variant: 'solid' },
submit: { color: 'primary', variant: 'solid' },
destructive: { color: 'danger', variant: 'solid' },
outline: { color: 'default', variant: 'outlined' },
secondary: { color: 'default', variant: 'filled' },
secondaryBrand: { color: 'primary', variant: 'filled' },
ghost: { color: 'default', variant: 'text' },
// Bare `variant="link"` predates the color axis — keep its primary look.
link: { color: 'primary', variant: 'link' },
};
function resolveVariants({ color: rawColor, variant, size, shape, iconOnly }: ButtonStyleProps) {
const color = isButtonColor(rawColor) ? rawColor : undefined;
let resolvedColor = color;
let resolvedVariant = variant as ButtonVariant | undefined;
// Legacy values only kick in while the new `color` axis is absent — any
// explicit `color` means the caller is on the new dual-axis API.
if (color === undefined && variant !== undefined && variant in LEGACY_VARIANT_MAP) {
({ color: resolvedColor, variant: resolvedVariant } = LEGACY_VARIANT_MAP[variant]);
} else if (color !== undefined && variant === undefined) {
// New-API ergonomics matching the §1 named types: <Button color="default">
// is THE default button (outlined), primary/danger default to solid.
resolvedVariant = color === 'default' ? 'outlined' : 'solid';
}
let resolvedSize: ButtonSize | undefined;
let resolvedIconOnly = iconOnly;
switch (size) {
case 'default':
case 'sm':
resolvedSize = 'medium';
break;
case 'lg':
resolvedSize = 'large';
break;
case 'icon':
resolvedSize = 'medium';
resolvedIconOnly = iconOnly ?? true;
break;
default:
resolvedSize = size;
}
return {
color: resolvedColor,
variant: resolvedVariant,
size: resolvedSize,
// Circle is an icon-only privilege (§1) — text buttons fall back to square.
shape: shape === 'circle' && resolvedIconOnly ? ('circle' as const) : ('square' as const),
iconOnly: resolvedIconOnly,
};
}
/** Class-only entry point (for <a>/Slot call sites); accepts both APIs. */
export function buttonVariants(props: ButtonStyleProps & { className?: string } = {}) {
const { className, ...styleProps } = props;
return buttonStyles({ ...resolveVariants(styleProps), className });
}
export interface ButtonProps
// Native `color` attr is shadowed by the color axis.
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'color'>,
ButtonStyleProps {
asChild?: boolean;
/** Single leading icon (§3.3, one icon max); replaced by the spinner while loading. */
icon?: React.ReactNode;
/**
* Built-in loading state (§5.2): spinner takes the icon slot, whole button
* at opacity .65 and not clickable. Do NOT pass your own Spinner.
*/
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
color,
variant,
size,
shape,
iconOnly,
icon,
loading = false,
asChild = false,
children,
...props
},
ref,
) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
aria-busy={loading || undefined}
className={cn(
buttonVariants({ color, variant, size, shape, iconOnly }),
loading && 'pointer-events-none opacity-65',
className,
)}
{...props}
>
{asChild ? (
// Slot requires a single element child — icon/spinner injection is
// skipped; asChild callers render their own content.
children
) : (
<>
{loading ? <Outlined.Loading className="animate-spin" /> : icon}
{children}
</>
)}
</Comp>
);
},
);
Button.displayName = 'Button';
export { Button };
export { Button, buttonVariants } from '@bisheng/ui';
export type { ButtonProps, ButtonStyleProps } from '@bisheng/ui';
+7 -261
View File
@@ -1,264 +1,10 @@
/**
* design-token.js — Single Source of Truth (SSOT) for BiSheng client design tokens.
* design-token SSOT has moved to the shared component library —
* packages/ui/design-token.cjs (`@bisheng/ui/design-token`).
*
* ONE catalog, consumed by two audiences:
* • components — via Tailwind (docs build spreads `tailwindTheme` into theme.extend;
* the live app config can adopt the same import when the team migrates).
* • docs / md — the spec .mdx pages and component demo pages import the catalogs
* below and render every table / swatch straight from this file, so
* the written spec can never drift from the values components ship.
*
* CommonJS (`module.exports`) on purpose: a `.cjs` Tailwind config can `require()` it,
* and rspack/vite ESM-interop lets an .mdx page `import tokens from '~/design-token'`.
*
* ── Naming (req: no unclear numbered names) ──────────────────────────────────────────
* Semantic tokens carry ROLE names (text-title / fill-hover …). The old numeric names
* (text-1, fill-2 …) are kept as DEPRECATED `legacy` aliases so nothing breaks; the app
* migrates to the role names gradually. See MIGRATION at the bottom for the full map.
* Primitive ramps that are genuinely a scale (brand-50…900, gray-1…10) stay numbered —
* that IS their semantic (lightness step), same convention as Tailwind's own palettes.
*
* ── Runtime behaviour ────────────────────────────────────────────────────────────────
* Themeable / responsive tokens still resolve through CSS custom properties defined in
* src/style.css (brand blue⇄green switch; ≤768px type remap). This file owns the token
* NAMES + documented values and drives the Tailwind theme keys; the CSS vars remain the
* runtime carrier. Regenerating :root from this file is a later step (app migration).
* This shim keeps every existing consumer working unchanged
* (tailwind.docs.config.cjs `require('./src/design-token.cjs')`,
* mdx pages `import tokens from '~/design-token'`).
* New code should import '@bisheng/ui/design-token' directly.
*/
/* ------------------------------------------------------------------ *
* Typography — 基础-字体规范.md
* ------------------------------------------------------------------ */
const FONT_FAMILY = {
base: {
token: 'font-family-base',
cls: 'font-sans',
usage: '全局默认(已写入 body/html,无需显式加类)',
stack: [
'-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto',
'"PingFang SC"', '"Hiragino Sans GB"', '"Microsoft YaHei"',
'"Noto Sans CJK SC"', 'sans-serif',
],
},
mono: {
token: 'font-family-mono',
cls: 'font-mono',
usage: 'ID、代码、日志',
stack: ['ui-monospace', '"SF Mono"', '"Cascadia Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
},
};
/** Semantic type scale — each entry is a Tailwind fontSize key AND its own weight. */
const TYPE_SCALE = [
{ name: 'caption', desktop: [12, 20], mobile: [12, 20], weight: 400, cssVar: '--text-caption', leadingVar: '--leading-caption', usage: '时间戳、标签、水印' },
{ name: 'body-sm', desktop: [13, 21], mobile: [14, 22], weight: 400, cssVar: '--text-body-sm', leadingVar: '--leading-body-sm', usage: '密集表格、侧栏次要项' },
{ name: 'body', desktop: [14, 22], mobile: [16, 24], weight: 400, cssVar: '--text-body', leadingVar: '--leading-body', usage: '正文基准,表单、表格默认' },
{ name: 'h4', desktop: [16, 24], mobile: [16, 24], weight: 500, cssVar: '--text-h4', leadingVar: '--leading-h4', usage: '强调正文、四级标题' },
{ name: 'h3', desktop: [18, 26], mobile: [17, 25], weight: 500, cssVar: '--text-h3', leadingVar: '--leading-h3', usage: '卡片标题' },
{ name: 'h2', desktop: [20, 28], mobile: [18, 26], weight: 500, cssVar: '--text-h2', leadingVar: '--leading-h2', usage: '区块标题' },
{ name: 'h1', desktop: [24, 32], mobile: [22, 30], weight: 500, cssVar: '--text-h1', leadingVar: '--leading-h1', usage: '页面标题' },
{ name: 'display', desktop: [30, 38], mobile: [26, 34], weight: 500, cssVar: '--text-display', leadingVar: '--leading-display', usage: '大标题、营销场景' },
{ name: 'metric', desktop: [36, 44], mobile: [30, 38], weight: 500, cssVar: '--text-metric', leadingVar: '--leading-metric', usage: 'Dashboard 核心指标数字' },
];
const FONT_WEIGHT = [
{ name: 'regular', token: 'font-weight-regular', cls: 'font-normal', value: 400, usage: '正文、说明' },
{ name: 'medium', token: 'font-weight-medium', cls: 'font-medium', value: 500, usage: '标题、强调、按钮' },
];
/* ------------------------------------------------------------------ *
* Brand ramp — dual theme (基础-色彩规范.md §1). Documented hex per theme;
* Tailwind resolves these through --brand-N so they switch blue⇄green at
* runtime. Numbered because it is a lightness scale (that is the semantic).
* ------------------------------------------------------------------ */
const BRAND_STEPS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
const BRAND = {
main: '500',
accentStep: '700', // darker shade used as the primary-marker accent bar
role: {
'50': '选中背景', '100': 'filled hover', '200': '触屏 active', '300': '过渡档',
'400': 'hover 态', '500': '主色', '600': '按下 active', '700': '深色档',
'800': '深色档', '900': '深色档', muted: '低饱和点缀',
},
blue: { '50': '#E8F3FF', '100': '#BEDAFF', '200': '#94BFFF', '300': '#6AA1FF', '400': '#4080FF', '500': '#165DFF', '600': '#024DE3', '700': '#0239AB', '800': '#042B80', '900': '#051D52', muted: '#5773B4' },
green: { '50': '#E4F1E7', '100': '#CCE4D2', '200': '#A3D2B0', '300': '#6FBA85', '400': '#3D9B5C', '500': '#169C47', '600': '#098B35', '700': '#076929', '800': '#074E20', '900': '#063216', muted: '#5C8A77' },
};
/* ------------------------------------------------------------------ *
* Neutral primitive — Arco gray 110 (§2.1). Numbered = the lightness
* scale itself; components consume the semantic layer below, not this.
* `channels` = "r g b" for rgb(var(--arco-gray-N)/α).
* `darkHex` / `darkChannels` = official @arco-design/color gray.dark ramp
* (lightness inverts). Runtime carrier: `.dark` override in src/style.css.
* ------------------------------------------------------------------ */
const GRAY = [
{ n: 1, hex: '#F7F8FA', channels: '247 248 250', darkHex: '#17171A', darkChannels: '23 23 26', role: 'hover 底' },
{ n: 2, hex: '#F2F3F5', channels: '242 243 245', darkHex: '#2E2E30', darkChannels: '46 46 48', role: 'filled 底' },
{ n: 3, hex: '#E5E6EB', channels: '229 230 235', darkHex: '#484849', darkChannels: '72 72 73', role: '边框' },
{ n: 4, hex: '#C9CDD4', channels: '201 205 212', darkHex: '#5F5F60', darkChannels: '95 95 96', role: '禁用 / 占位' },
{ n: 5, hex: '#A9AEB8', channels: '169 174 184', darkHex: '#78787A', darkChannels: '120 120 122', role: '过渡' },
{ n: 6, hex: '#86909C', channels: '134 144 156', darkHex: '#929293', darkChannels: '146 146 147', role: '辅助文字' },
{ n: 7, hex: '#6B7785', channels: '107 119 133', darkHex: '#ABABAC', darkChannels: '171 171 172', role: '过渡' },
{ n: 8, hex: '#4E5969', channels: '78 89 105', darkHex: '#C5C5C5', darkChannels: '197 197 197', role: '次文字' },
{ n: 9, hex: '#272E3B', channels: '39 46 59', darkHex: '#DFDFDF', darkChannels: '223 223 223', role: '过渡' },
{ n: 10, hex: '#1D2129', channels: '29 33 41', darkHex: '#F6F6F6', darkChannels: '246 246 246', role: '主文字' },
];
/* ------------------------------------------------------------------ *
* Semantic layer (§2.2) — ROLE names (canonical) + numeric `legacy` alias.
* `cssVar` is the existing runtime carrier in src/style.css.
* ------------------------------------------------------------------ */
// Role names avoid the taken shadcn keys (text-primary/secondary/tertiary);
// intensity ramp strong → muted → hint → disabled maps gray-10 → 8 → 6 → 4.
// `hex` = light value; `darkHex` = same gray ref resolved on the dark ramp.
const TEXT = [
{ name: 'strong', legacy: '1', cssVar: '--text-1', ref: 'gray-10', hex: '#1D2129', darkHex: '#F6F6F6', usage: '主文字:标题、正文主体' },
{ name: 'muted', legacy: '2', cssVar: '--text-2', ref: 'gray-8', hex: '#4E5969', darkHex: '#C5C5C5', usage: '次要文字:次要说明、默认按钮文字' },
{ name: 'hint', legacy: '3', cssVar: '--text-3', ref: 'gray-6', hex: '#86909C', darkHex: '#929293', usage: '辅助文字:弱提示、时间戳、占位符' },
{ name: 'disabled', legacy: '4', cssVar: '--text-4', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '禁用文字' },
];
const FILL = [
{ name: 'subtle', legacy: '1', cssVar: '--fill-1', ref: 'gray-1', hex: '#F7F8FA', darkHex: '#17171A', usage: '浅填充:hover 底、页面浅灰背景' },
{ name: 'default', legacy: '2', cssVar: '--fill-2', ref: 'gray-2', hex: '#F2F3F5', darkHex: '#2E2E30', usage: '填充:active 底、filled 控件底' },
{ name: 'hover', legacy: '3', cssVar: '--fill-3', ref: 'gray-3', hex: '#E5E6EB', darkHex: '#484849', usage: '深填充:filled hover' },
{ name: 'active', legacy: '4', cssVar: '--fill-4', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '重填充:filled active' },
];
const BORDER = [
{ name: 'base', cssVar: '--border-base', ref: 'gray-3', hex: '#E5E6EB', darkHex: '#484849', usage: '常规边框:输入框、卡片、分割线' },
{ name: 'deep', cssVar: '--border-deep', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '深边框:强调分割、hover 边框' },
];
/* Background surfaces — the "white that darkens" family. The gray ramp starts
* at gray-1 (#F7F8FA), so the pure page surface needs its own semantic token
* (the fixed `--white: #fff` legacy var never theme-flips — different job).
* Carrier: --bg-page in src/style.css (:root + .dark override). */
const BG = [
{ name: 'page', cssVar: '--bg-page', hex: '#FFFFFF', darkHex: '#121212', usage: '页面底色:内容区、顶栏等最底层表面' },
];
/* ------------------------------------------------------------------ *
* Functional colors (§3) — fixed hex, never theme-switched.
* ------------------------------------------------------------------ */
const FUNCTIONAL = [
{ name: 'success', label: '成功 Success', cssVar: '--success', main: '#00B42A', hover: '#23C343', active: '#009A29', tint: '#E8FFEA' },
{ name: 'warning', label: '警告 Warning', cssVar: '--warning', main: '#FF7D00', hover: '#FF9A2E', active: '#D25F00', tint: '#FFF7E8' },
{ name: 'danger', label: '危险 Danger', cssVar: '--danger', main: '#F53F3F', hover: '#D6373A', active: '#D02F33', tint: '#FFECE8' },
];
/* ------------------------------------------------------------------ *
* Tag pairs (§4) — light bg + strong text. Purple / approving-blue are
* intentional fixed exceptions (not tokenized, never theme-switched).
* ------------------------------------------------------------------ */
const TAG = [
{ label: '技能(紫 · 未 token 化)', bg: '#F5E8FF', fg: '#722ED1', note: '固定例外色' },
{ label: '助手(橙 = warning 同值)', bg: '#FFF7E8', fg: '#FF7D00', note: 'warning tint' },
{ label: '已完成', bg: '#E8FFEA', fg: '#00B42A', note: 'success tint' },
{ label: '已驳回', bg: '#FFECE8', fg: '#F53F3F', note: 'danger tint' },
{ label: '审批中(例外:永远蓝)', bg: '#E8F3FF', fg: '#165DFF', note: '固定蓝,不换肤' },
];
/* ------------------------------------------------------------------ *
* Radius (01-设计规范.md §1) & icon sizes (基础-图标规范.md §3.2)
* ------------------------------------------------------------------ */
/* Radius ladder aligns 1:1 with Tailwind's own scale (sm…4xl + full) so the
* class name IS the token name. sm/md/lg derive from --radius (8px base) in
* tailwind.config; xl/2xl/3xl are Tailwind defaults; 4xl (32px) is our extend.
* Off-scale legacy values (rounded-[5px]/[10px]/[20px]…) fold into the
* nearest step when touched. */
const RADIUS = [
{ name: 'sm', px: 4, usage: 'small 控件 / 标签 / 表格行内按钮' },
{ name: 'md', px: 6, usage: 'medium 控件(默认):按钮、输入框、菜单项' },
{ name: 'lg', px: 8, usage: 'large 控件 / --radius 基准 / 消息气泡' },
{ name: 'xl', px: 12, usage: '卡片、下拉面板 Popover' },
{ name: '2xl', px: 16, usage: '弹窗 / 大卡片容器' },
{ name: '3xl', px: 24, usage: '抽屉、超大容器' },
{ name: '4xl', px: 32, usage: '特大容器 / hero 区块' },
{ name: 'full', px: 9999, usage: '胶囊 / 圆形:头像、圆形图标按钮、pill 标签' },
];
const ICON_SIZE = [
{ name: 'xs', px: 12, strokeWidth: 2.5, usage: '极小标记(badge、密集表格角标),仅纯展示' },
{ name: 'sm', px: 14, usage: 'small / medium 按钮的文字+icon' },
{ name: 'md', px: 16, usage: '默认:图标按钮、菜单项、输入框内、表格操作' },
{ name: 'lg', px: 20, usage: '导航栏、侧边栏入口、页头操作' },
{ name: 'xl', px: 24, usage: '独立展示、弹窗标题图标(原始画布尺寸)' },
{ name: 'xl2', px: 32, strokeWidth: 1.5, usage: '超大展示:空状态、引导页' },
];
/* ================================================================== *
* Derived: Tailwind theme fragment — spread into theme.extend.
* Channel-triplet form keeps `/<alpha>` opacity modifiers working.
* ================================================================== */
const withAlpha = (cssVar) => `rgb(var(${cssVar}) / <alpha-value>)`;
/** Color keys embed their category (text-/fill-/border-) so a single utility reads right. */
const colors = {};
TEXT.forEach((t) => {
colors[`text-${t.name}`] = withAlpha(t.cssVar); // canonical role name
colors[`text-${t.legacy}`] = withAlpha(t.cssVar); // DEPRECATED alias (kept for migration)
});
FILL.forEach((f) => {
colors[`fill-${f.name}`] = withAlpha(f.cssVar);
colors[`fill-${f.legacy}`] = withAlpha(f.cssVar); // DEPRECATED alias
});
BORDER.forEach((b) => {
colors[`border-${b.name}`] = withAlpha(b.cssVar);
});
BG.forEach((b) => {
colors[`bg-${b.name}`] = withAlpha(b.cssVar); // class: bg-bg-page
});
FUNCTIONAL.forEach((fn) => {
colors[fn.name] = {
DEFAULT: withAlpha(fn.cssVar),
hover: withAlpha(`${fn.cssVar}-hover`),
active: withAlpha(`${fn.cssVar}-active`),
tint: withAlpha(`${fn.cssVar}-tint`),
};
});
const fontSize = {};
TYPE_SCALE.forEach((s) => {
fontSize[s.name] = [`var(${s.cssVar})`, { lineHeight: `var(${s.leadingVar})`, fontWeight: String(s.weight) }];
});
const tailwindTheme = { colors, fontSize };
/* ================================================================== *
* Migration map — old numeric class → new role class, for the app's
* gradual adoption (both resolve identically until the old alias is
* removed). Emitted as data so tooling / codemods can read it.
* ================================================================== */
const MIGRATION = [
...TEXT.map((t) => ({ from: `text-text-${t.legacy}`, to: `text-text-${t.name}`, cssVar: t.cssVar })),
...FILL.map((f) => ({ from: `bg-fill-${f.legacy}`, to: `bg-fill-${f.name}`, cssVar: f.cssVar })),
];
module.exports = {
FONT_FAMILY,
TYPE_SCALE,
FONT_WEIGHT,
BRAND,
BRAND_STEPS,
GRAY,
TEXT,
FILL,
BORDER,
BG,
FUNCTIONAL,
TAG,
RADIUS,
ICON_SIZE,
tailwindTheme,
MIGRATION,
};
module.exports = require('@bisheng/ui/design-token');
@@ -0,0 +1,6 @@
// Empty stub for node-only dynamic imports reached from browser-safe code paths
// (e.g. @dicebear/core `toFile()` → import('node:fs/promises'), never called in
// the browser). vite-plugin-node-polyfills maps bare `fs` but mangles the
// `fs/promises` subpath under the pnpm workspace layout, so we alias the
// subpath here explicitly.
export default {};
+3 -1
View File
@@ -11,7 +11,9 @@ module.exports = {
future: {
hoverOnlyWhenSupported: true,
},
content: ['./src/**/*.{js,jsx,ts,tsx}'],
// packages/ui is source-shipped: its classes must be scanned here too,
// or shared components (e.g. @bisheng/ui Button) lose their styles.
content: ['./src/**/*.{js,jsx,ts,tsx}', '../packages/ui/src/**/*.{ts,tsx}'],
// darkMode: 'class',
darkMode: ['class'],
theme: {
+14 -2
View File
@@ -103,8 +103,8 @@ export default defineConfig(({ command, mode }) => {
// VITE_DEV_MINIO_TARGET, whose host MUST match the backend `sharepoint` config —
// SigV4 presigned URLs sign the Host header, so a mismatch yields 403
// SignatureDoesNotMatch (e.g. set http://localhost:9000 when sharepoint=localhost:9000).
const minioTarget = env.VITE_DEV_MINIO_TARGET || 'http://127.0.0.1:9000';
const apiTarget = env.VITE_DEV_API_TARGET || 'http://127.0.0.1:7860';
const minioTarget = env.VITE_DEV_MINIO_TARGET || 'http://192.168.106.120:3002/';
const apiTarget = env.VITE_DEV_API_TARGET || 'http://192.168.106.120:3002/';
return {
base: app_env.BASE_URL || '/',
@@ -246,6 +246,14 @@ export default defineConfig(({ command, mode }) => {
build: {
sourcemap: process.env.NODE_ENV === 'development',
outDir: './build',
// pnpm workspace: deps live in ../node_modules (outside this vite root),
// which defeats vite-plugin-node-polyfills' node_modules exemption — its
// injected ESM imports land inside CJS deps (react, react-dom) and break
// rollup's named-export detection. Let the commonjs plugin transform
// mixed ESM/CJS modules so named exports survive the injection.
commonjsOptions: {
transformMixedEsModules: true,
},
minify: 'terser',
// Strip all console.* / debugger from production bundles so no debug data
// (API payloads, tokens, filenames) leaks to the browser console. The
@@ -424,6 +432,10 @@ export default defineConfig(({ command, mode }) => {
$fonts: path.resolve(__dirname, 'public/fonts'),
// SUL-licensed nodebox is unused (only static/react-ts templates); stub it out of the bundle.
'@codesandbox/nodebox': path.resolve(__dirname, 'stubs/nodebox-stub.ts'),
// node-only dynamic imports (see stubs/empty-module.ts) — the polyfill
// plugin's `fs` alias mangles the /promises subpath under pnpm layout.
'node:fs/promises': path.resolve(__dirname, 'stubs/empty-module.ts'),
'fs/promises': path.resolve(__dirname, 'stubs/empty-module.ts'),
},
},
};
-6
View File
@@ -1,6 +0,0 @@
{
"name": "frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "bisheng-frontend",
"private": true,
"packageManager": "pnpm@9.15.9",
"scripts": {
"dev:client": "pnpm --filter bishengchat dev",
"dev:platform": "pnpm --filter bisheng dev",
"dev:ui": "pnpm --filter bishengchat dev:docs",
"build:client": "pnpm --filter bishengchat build",
"build:platform": "pnpm --filter bisheng build",
"build": "pnpm --filter bisheng build && pnpm --filter bishengchat build",
"build:docs": "pnpm --filter bishengchat build:docs",
"preinstall": "npx only-allow pnpm"
},
"pnpm": {
"overrides": {
"rollup@>=4.45.0": "4.44.1",
"vite@>=6.4.2": "6.4.1"
}
}
}
+29
View File
@@ -0,0 +1,29 @@
# Component Library Rules — @bisheng/ui
Auto-loaded when editing files in `src/frontend/packages/ui/`.
Full design specs live in `docs/` (site: `pnpm dev:ui`); this file is the enforcement layer — hard rules only.
## Library Contract (permanent — violating components do not belong here)
- **Presentation-only.** Never import: state managers (Recoil/Zustand/jotai), HTTP/SSE/WS clients, react-router, or i18n (`useTranslation`/`useLocalize`). Data in via props, events out via callbacks, ALL text via props.
- **Source-shipped**: `exports` points at TS source; consumers compile it. No build step, no npm publish; apps consume via `workspace:*`.
- **Strict TS** (`strict: true` here, unlike the apps). No `any`; run `pnpm typecheck` before handing off.
- Icons from the `bisheng-icons` package only — never add icon components here.
## Design Tokens (SSOT discipline)
- `design-token.cjs` is the single source of truth for token NAMES + documented values (client re-exports it). `src/styles/tokens.css` + `tailwind-preset.cjs` are its runtime carriers and MUST stay value-identical with `client/src/style.css` until client fully migrates onto the preset.
- Components consume the **semantic layer only** (`text-text-1…4`, `bg-fill-1…4`, `border-border-base`, `blue-*` = brand, `btn-*`, `success/warning/danger`). Primitives (`--arco-gray-N`) are intentionally not wired — never hardcode hex or reach around the semantic names.
## Interaction Rules (from 多端适配原则 / 组件-Button按钮 §5.5)
- Write plain `hover:` classes ONLY — `future.hoverOnlyWhenSupported` disables them on touch globally. **Never invent custom hover variant prefixes** (tailwind-merge can't dedupe them; business-page `hover:` overrides silently lose).
- Touch press feedback via `coarse-pointer:active:`; hover/active shade stays **within the current base color's ramp** (brand bg → brand ramp, red → red ramp; never gray out cross-palette).
- Small touch targets get the invisible ≥44px hot zone via `btn-touch-hit` (component-internal; never hand-rolled per page).
## Definition of Done for a new/migrated component
1. Component under `src/components/<Name>/` + export in `src/index.ts`.
2. Docs page `docs/components/<name>.mdx` — demos import from `'@bisheng/ui'` (never `~/…` app paths); scenario-per-demo, simplest first.
3. Consuming app keeps a re-export shim at its old path (e.g. client `~/components/ui/Button.tsx`) so call sites stay unchanged.
4. `docs/组件-*.md` spec updated if behavior/API changed (spec and code must not drift).
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+43
View File
@@ -0,0 +1,43 @@
# @bisheng/ui
BiSheng 前端共享组件库,同时服务 `client``platform` 两个应用。
## 铁律(permanent contract)
组件库是**纯展示层**。任何组件 **严禁** 引入:
1. **状态管理** — 不 import Recoil / Zustand / jotai;状态经 props/回调进出。
2. **请求层** — 不发 HTTP / SSE / WS;数据经 props 进,事件经回调出。
3. **i18n 业务 key** — 不调 `useTranslation`;所有文案通过 props 传入。
4. **路由** — 不 import react-router;跳转交给调用方回调。
违反任何一条的组件不属于这里,放回各自 app。
## 形态
- **源码直出**:`exports` 指向 TS 源码,由消费方(两个 app 各自的 Vite)编译。
不预编译、不发 npm;workspace 内 `"@bisheng/ui": "workspace:*"` 直连,改动即时热更新。
- **token 双层契约**:`src/styles/tokens.css`(原始层 + 语义层 CSS 变量)+
`tailwind-preset.cjs`(把语义 token 映射成 Tailwind 类)。组件只允许消费语义层
(`text-text-1``bg-fill-2``bg-blue-500``btn-*`),禁止硬编码色值。
- 图标一律用 `bisheng-icons` npm 包,不在本包内新增图标组件。
## 开发
```bash
pnpm dev:ui # 在 workspace 根运行 — 启动 rspress 文档站(菜单 + 实时示例),
# 即组件开发/预览环境;改本包源码即时热更新
pnpm typecheck # 本包内运行 — 严格 TS 检查(strict: true)
```
文档源码在本包 `docs/`(git 跟踪);站点暂由 client 托管构建
(rspress 配置与依赖在 client,待 app 耦合的 demo 迁完后整体搬入本包)。
`doc_build/` 是构建产物,已 gitignore,正式发布由 CI 构建。
## 消费方接入
1. `"@bisheng/ui": "workspace:*"` 加入 dependencies。
2. tailwind config `content` 加入 `'../packages/ui/src/**/*.{ts,tsx}'`
(否则组件里的 class 不会被生成)。
3. app 全局样式确保 token 变量可用:client 的 `style.css` 目前已内含同一份
token(历史原因,保持同步);platform 接入时直接 `@import '@bisheng/ui/tokens.css'`
+264
View File
@@ -0,0 +1,264 @@
/**
* design-token.js — Single Source of Truth (SSOT) for BiSheng client design tokens.
*
* ONE catalog, consumed by two audiences:
* • components — via Tailwind (docs build spreads `tailwindTheme` into theme.extend;
* the live app config can adopt the same import when the team migrates).
* • docs / md — the spec .mdx pages and component demo pages import the catalogs
* below and render every table / swatch straight from this file, so
* the written spec can never drift from the values components ship.
*
* CommonJS (`module.exports`) on purpose: a `.cjs` Tailwind config can `require()` it,
* and rspack/vite ESM-interop lets an .mdx page `import tokens from '~/design-token'`.
*
* ── Naming (req: no unclear numbered names) ──────────────────────────────────────────
* Semantic tokens carry ROLE names (text-title / fill-hover …). The old numeric names
* (text-1, fill-2 …) are kept as DEPRECATED `legacy` aliases so nothing breaks; the app
* migrates to the role names gradually. See MIGRATION at the bottom for the full map.
* Primitive ramps that are genuinely a scale (brand-50…900, gray-1…10) stay numbered —
* that IS their semantic (lightness step), same convention as Tailwind's own palettes.
*
* ── Runtime behaviour ────────────────────────────────────────────────────────────────
* Themeable / responsive tokens still resolve through CSS custom properties defined in
* src/style.css (brand blue⇄green switch; ≤768px type remap). This file owns the token
* NAMES + documented values and drives the Tailwind theme keys; the CSS vars remain the
* runtime carrier. Regenerating :root from this file is a later step (app migration).
*/
/* ------------------------------------------------------------------ *
* Typography — 基础-字体规范.md
* ------------------------------------------------------------------ */
const FONT_FAMILY = {
base: {
token: 'font-family-base',
cls: 'font-sans',
usage: '全局默认(已写入 body/html,无需显式加类)',
stack: [
'-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto',
'"PingFang SC"', '"Hiragino Sans GB"', '"Microsoft YaHei"',
'"Noto Sans CJK SC"', 'sans-serif',
],
},
mono: {
token: 'font-family-mono',
cls: 'font-mono',
usage: 'ID、代码、日志',
stack: ['ui-monospace', '"SF Mono"', '"Cascadia Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
},
};
/** Semantic type scale — each entry is a Tailwind fontSize key AND its own weight. */
const TYPE_SCALE = [
{ name: 'caption', desktop: [12, 20], mobile: [12, 20], weight: 400, cssVar: '--text-caption', leadingVar: '--leading-caption', usage: '时间戳、标签、水印' },
{ name: 'body-sm', desktop: [13, 21], mobile: [14, 22], weight: 400, cssVar: '--text-body-sm', leadingVar: '--leading-body-sm', usage: '密集表格、侧栏次要项' },
{ name: 'body', desktop: [14, 22], mobile: [16, 24], weight: 400, cssVar: '--text-body', leadingVar: '--leading-body', usage: '正文基准,表单、表格默认' },
{ name: 'h4', desktop: [16, 24], mobile: [16, 24], weight: 500, cssVar: '--text-h4', leadingVar: '--leading-h4', usage: '强调正文、四级标题' },
{ name: 'h3', desktop: [18, 26], mobile: [17, 25], weight: 500, cssVar: '--text-h3', leadingVar: '--leading-h3', usage: '卡片标题' },
{ name: 'h2', desktop: [20, 28], mobile: [18, 26], weight: 500, cssVar: '--text-h2', leadingVar: '--leading-h2', usage: '区块标题' },
{ name: 'h1', desktop: [24, 32], mobile: [22, 30], weight: 500, cssVar: '--text-h1', leadingVar: '--leading-h1', usage: '页面标题' },
{ name: 'display', desktop: [30, 38], mobile: [26, 34], weight: 500, cssVar: '--text-display', leadingVar: '--leading-display', usage: '大标题、营销场景' },
{ name: 'metric', desktop: [36, 44], mobile: [30, 38], weight: 500, cssVar: '--text-metric', leadingVar: '--leading-metric', usage: 'Dashboard 核心指标数字' },
];
const FONT_WEIGHT = [
{ name: 'regular', token: 'font-weight-regular', cls: 'font-normal', value: 400, usage: '正文、说明' },
{ name: 'medium', token: 'font-weight-medium', cls: 'font-medium', value: 500, usage: '标题、强调、按钮' },
];
/* ------------------------------------------------------------------ *
* Brand ramp — dual theme (基础-色彩规范.md §1). Documented hex per theme;
* Tailwind resolves these through --brand-N so they switch blue⇄green at
* runtime. Numbered because it is a lightness scale (that is the semantic).
* ------------------------------------------------------------------ */
const BRAND_STEPS = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
const BRAND = {
main: '500',
accentStep: '700', // darker shade used as the primary-marker accent bar
role: {
'50': '选中背景', '100': 'filled hover', '200': '触屏 active', '300': '过渡档',
'400': 'hover 态', '500': '主色', '600': '按下 active', '700': '深色档',
'800': '深色档', '900': '深色档', muted: '低饱和点缀',
},
blue: { '50': '#E8F3FF', '100': '#BEDAFF', '200': '#94BFFF', '300': '#6AA1FF', '400': '#4080FF', '500': '#165DFF', '600': '#024DE3', '700': '#0239AB', '800': '#042B80', '900': '#051D52', muted: '#5773B4' },
green: { '50': '#E4F1E7', '100': '#CCE4D2', '200': '#A3D2B0', '300': '#6FBA85', '400': '#3D9B5C', '500': '#169C47', '600': '#098B35', '700': '#076929', '800': '#074E20', '900': '#063216', muted: '#5C8A77' },
};
/* ------------------------------------------------------------------ *
* Neutral primitive — Arco gray 110 (§2.1). Numbered = the lightness
* scale itself; components consume the semantic layer below, not this.
* `channels` = "r g b" for rgb(var(--arco-gray-N)/α).
* `darkHex` / `darkChannels` = official @arco-design/color gray.dark ramp
* (lightness inverts). Runtime carrier: `.dark` override in src/style.css.
* ------------------------------------------------------------------ */
const GRAY = [
{ n: 1, hex: '#F7F8FA', channels: '247 248 250', darkHex: '#17171A', darkChannels: '23 23 26', role: 'hover 底' },
{ n: 2, hex: '#F2F3F5', channels: '242 243 245', darkHex: '#2E2E30', darkChannels: '46 46 48', role: 'filled 底' },
{ n: 3, hex: '#E5E6EB', channels: '229 230 235', darkHex: '#484849', darkChannels: '72 72 73', role: '边框' },
{ n: 4, hex: '#C9CDD4', channels: '201 205 212', darkHex: '#5F5F60', darkChannels: '95 95 96', role: '禁用 / 占位' },
{ n: 5, hex: '#A9AEB8', channels: '169 174 184', darkHex: '#78787A', darkChannels: '120 120 122', role: '过渡' },
{ n: 6, hex: '#86909C', channels: '134 144 156', darkHex: '#929293', darkChannels: '146 146 147', role: '辅助文字' },
{ n: 7, hex: '#6B7785', channels: '107 119 133', darkHex: '#ABABAC', darkChannels: '171 171 172', role: '过渡' },
{ n: 8, hex: '#4E5969', channels: '78 89 105', darkHex: '#C5C5C5', darkChannels: '197 197 197', role: '次文字' },
{ n: 9, hex: '#272E3B', channels: '39 46 59', darkHex: '#DFDFDF', darkChannels: '223 223 223', role: '过渡' },
{ n: 10, hex: '#1D2129', channels: '29 33 41', darkHex: '#F6F6F6', darkChannels: '246 246 246', role: '主文字' },
];
/* ------------------------------------------------------------------ *
* Semantic layer (§2.2) — ROLE names (canonical) + numeric `legacy` alias.
* `cssVar` is the existing runtime carrier in src/style.css.
* ------------------------------------------------------------------ */
// Role names avoid the taken shadcn keys (text-primary/secondary/tertiary);
// intensity ramp strong → muted → hint → disabled maps gray-10 → 8 → 6 → 4.
// `hex` = light value; `darkHex` = same gray ref resolved on the dark ramp.
const TEXT = [
{ name: 'strong', legacy: '1', cssVar: '--text-1', ref: 'gray-10', hex: '#1D2129', darkHex: '#F6F6F6', usage: '主文字:标题、正文主体' },
{ name: 'muted', legacy: '2', cssVar: '--text-2', ref: 'gray-8', hex: '#4E5969', darkHex: '#C5C5C5', usage: '次要文字:次要说明、默认按钮文字' },
{ name: 'hint', legacy: '3', cssVar: '--text-3', ref: 'gray-6', hex: '#86909C', darkHex: '#929293', usage: '辅助文字:弱提示、时间戳、占位符' },
{ name: 'disabled', legacy: '4', cssVar: '--text-4', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '禁用文字' },
];
const FILL = [
{ name: 'subtle', legacy: '1', cssVar: '--fill-1', ref: 'gray-1', hex: '#F7F8FA', darkHex: '#17171A', usage: '浅填充:hover 底、页面浅灰背景' },
{ name: 'default', legacy: '2', cssVar: '--fill-2', ref: 'gray-2', hex: '#F2F3F5', darkHex: '#2E2E30', usage: '填充:active 底、filled 控件底' },
{ name: 'hover', legacy: '3', cssVar: '--fill-3', ref: 'gray-3', hex: '#E5E6EB', darkHex: '#484849', usage: '深填充:filled hover' },
{ name: 'active', legacy: '4', cssVar: '--fill-4', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '重填充:filled active' },
];
const BORDER = [
{ name: 'base', cssVar: '--border-base', ref: 'gray-3', hex: '#E5E6EB', darkHex: '#484849', usage: '常规边框:输入框、卡片、分割线' },
{ name: 'deep', cssVar: '--border-deep', ref: 'gray-4', hex: '#C9CDD4', darkHex: '#5F5F60', usage: '深边框:强调分割、hover 边框' },
];
/* Background surfaces — the "white that darkens" family. The gray ramp starts
* at gray-1 (#F7F8FA), so the pure page surface needs its own semantic token
* (the fixed `--white: #fff` legacy var never theme-flips — different job).
* Carrier: --bg-page in src/style.css (:root + .dark override). */
const BG = [
{ name: 'page', cssVar: '--bg-page', hex: '#FFFFFF', darkHex: '#121212', usage: '页面底色:内容区、顶栏等最底层表面' },
];
/* ------------------------------------------------------------------ *
* Functional colors (§3) — fixed hex, never theme-switched.
* ------------------------------------------------------------------ */
const FUNCTIONAL = [
{ name: 'success', label: '成功 Success', cssVar: '--success', main: '#00B42A', hover: '#23C343', active: '#009A29', tint: '#E8FFEA' },
{ name: 'warning', label: '警告 Warning', cssVar: '--warning', main: '#FF7D00', hover: '#FF9A2E', active: '#D25F00', tint: '#FFF7E8' },
{ name: 'danger', label: '危险 Danger', cssVar: '--danger', main: '#F53F3F', hover: '#D6373A', active: '#D02F33', tint: '#FFECE8' },
];
/* ------------------------------------------------------------------ *
* Tag pairs (§4) — light bg + strong text. Purple / approving-blue are
* intentional fixed exceptions (not tokenized, never theme-switched).
* ------------------------------------------------------------------ */
const TAG = [
{ label: '技能(紫 · 未 token 化)', bg: '#F5E8FF', fg: '#722ED1', note: '固定例外色' },
{ label: '助手(橙 = warning 同值)', bg: '#FFF7E8', fg: '#FF7D00', note: 'warning tint' },
{ label: '已完成', bg: '#E8FFEA', fg: '#00B42A', note: 'success tint' },
{ label: '已驳回', bg: '#FFECE8', fg: '#F53F3F', note: 'danger tint' },
{ label: '审批中(例外:永远蓝)', bg: '#E8F3FF', fg: '#165DFF', note: '固定蓝,不换肤' },
];
/* ------------------------------------------------------------------ *
* Radius (01-设计规范.md §1) & icon sizes (基础-图标规范.md §3.2)
* ------------------------------------------------------------------ */
/* Radius ladder aligns 1:1 with Tailwind's own scale (sm…4xl + full) so the
* class name IS the token name. sm/md/lg derive from --radius (8px base) in
* tailwind.config; xl/2xl/3xl are Tailwind defaults; 4xl (32px) is our extend.
* Off-scale legacy values (rounded-[5px]/[10px]/[20px]…) fold into the
* nearest step when touched. */
const RADIUS = [
{ name: 'sm', px: 4, usage: 'small 控件 / 标签 / 表格行内按钮' },
{ name: 'md', px: 6, usage: 'medium 控件(默认):按钮、输入框、菜单项' },
{ name: 'lg', px: 8, usage: 'large 控件 / --radius 基准 / 消息气泡' },
{ name: 'xl', px: 12, usage: '卡片、下拉面板 Popover' },
{ name: '2xl', px: 16, usage: '弹窗 / 大卡片容器' },
{ name: '3xl', px: 24, usage: '抽屉、超大容器' },
{ name: '4xl', px: 32, usage: '特大容器 / hero 区块' },
{ name: 'full', px: 9999, usage: '胶囊 / 圆形:头像、圆形图标按钮、pill 标签' },
];
const ICON_SIZE = [
{ name: 'xs', px: 12, strokeWidth: 2.5, usage: '极小标记(badge、密集表格角标),仅纯展示' },
{ name: 'sm', px: 14, usage: 'small / medium 按钮的文字+icon' },
{ name: 'md', px: 16, usage: '默认:图标按钮、菜单项、输入框内、表格操作' },
{ name: 'lg', px: 20, usage: '导航栏、侧边栏入口、页头操作' },
{ name: 'xl', px: 24, usage: '独立展示、弹窗标题图标(原始画布尺寸)' },
{ name: 'xl2', px: 32, strokeWidth: 1.5, usage: '超大展示:空状态、引导页' },
];
/* ================================================================== *
* Derived: Tailwind theme fragment — spread into theme.extend.
* Channel-triplet form keeps `/<alpha>` opacity modifiers working.
* ================================================================== */
const withAlpha = (cssVar) => `rgb(var(${cssVar}) / <alpha-value>)`;
/** Color keys embed their category (text-/fill-/border-) so a single utility reads right. */
const colors = {};
TEXT.forEach((t) => {
colors[`text-${t.name}`] = withAlpha(t.cssVar); // canonical role name
colors[`text-${t.legacy}`] = withAlpha(t.cssVar); // DEPRECATED alias (kept for migration)
});
FILL.forEach((f) => {
colors[`fill-${f.name}`] = withAlpha(f.cssVar);
colors[`fill-${f.legacy}`] = withAlpha(f.cssVar); // DEPRECATED alias
});
BORDER.forEach((b) => {
colors[`border-${b.name}`] = withAlpha(b.cssVar);
});
BG.forEach((b) => {
colors[`bg-${b.name}`] = withAlpha(b.cssVar); // class: bg-bg-page
});
FUNCTIONAL.forEach((fn) => {
colors[fn.name] = {
DEFAULT: withAlpha(fn.cssVar),
hover: withAlpha(`${fn.cssVar}-hover`),
active: withAlpha(`${fn.cssVar}-active`),
tint: withAlpha(`${fn.cssVar}-tint`),
};
});
const fontSize = {};
TYPE_SCALE.forEach((s) => {
fontSize[s.name] = [`var(${s.cssVar})`, { lineHeight: `var(${s.leadingVar})`, fontWeight: String(s.weight) }];
});
const tailwindTheme = { colors, fontSize };
/* ================================================================== *
* Migration map — old numeric class → new role class, for the app's
* gradual adoption (both resolve identically until the old alias is
* removed). Emitted as data so tooling / codemods can read it.
* ================================================================== */
const MIGRATION = [
...TEXT.map((t) => ({ from: `text-text-${t.legacy}`, to: `text-text-${t.name}`, cssVar: t.cssVar })),
...FILL.map((f) => ({ from: `bg-fill-${f.legacy}`, to: `bg-fill-${f.name}`, cssVar: f.cssVar })),
];
module.exports = {
FONT_FAMILY,
TYPE_SCALE,
FONT_WEIGHT,
BRAND,
BRAND_STEPS,
GRAY,
TEXT,
FILL,
BORDER,
BG,
FUNCTIONAL,
TAG,
RADIUS,
ICON_SIZE,
tailwindTheme,
MIGRATION,
};
+173
View File
@@ -0,0 +1,173 @@
# BISHENG Client 组件统一化改造 · 总纲
> 这份文档是整个改造工程的"说明书 + 交接书"。
> 每开一个新会话(新窗口),**先让 AI 读这份 00-总纲 + 对应的组件文档**,就能无缝接上。
> 本文件夹 `docs-ui-refactor/` 已加入根 `.gitignore`,**不会提交、不会发版**,只存在于本地。
---
## 一、这件事在干嘛(一句话)
BISHENG 前台(client)有很多历史遗留的重复组件——同一种东西(弹窗 / 下拉 / 按钮…)存在好几个版本,样式间距阴影交互都不一致。
本工程把它们**逐个统一**,最终目标是抽成一套**可复用的设计组件库**。
- **终极目标**:统一的、可复用的设计组件库。
- **现阶段目标**:把高频组件的间距 / 样式 / 交互统一,全平台同一组件表现一致。
- **负责人**:UI 设计师(无技术背景),由 AI 落地成代码。
---
<!-- site-hide -->
## 二、核心工作模型(重要,别改错方向)
### 关键事实:组件是"共享"的,改一次全场生效
所有页面(画廊页 + 真实业务页)用的是**同一个组件文件**(如 `src/components/ui/Dialog.tsx`)。
改这一个文件 → 画廊里变 + 所有业务页同时变 + 跟 beta4 正常发版上线。
所以**不搞"永不合并的隔离分支"**——那样组件就不在真实场景生效了。
### 三样东西放哪
| 东西 | 位置 | 说明 |
|---|---|---|
| **组件代码改动** | 直接在日常分支(当前 `feat/2.6.0-beta4`),一个组件一次干净提交 | 跟日常 UI 工作同流、快速上线、真实场景生效 |
| **画廊 / Demo 页** | 也在同分支,但**只在开发环境显示**(`import.meta.env.DEV` 门控),生产打包剔除 | 随时能看,用户看不到,不进产品体积 |
| **交接文档(本文件夹)** | `docs-ui-refactor/`(已 gitignore) | 不提交、不发版、不冲突 |
### "干净隔离"怎么保证(不靠分支靠这个)
- 每个组件一次**独立的小提交**——改坏了单独回退那一笔,不牵连别处。
- 画廊页靠"开发环境专属"隔离,永远漏不进产品。
- 要不要为某次改动单开小分支做审查再并回,由设计师临时决定;默认直接在日常分支小步提交,最快。
### "留改动余地"怎么做(应对特殊页面)
不为特殊情况改源码,而是用 **cva 变体(档位)** + `className` 覆盖:
- 组件预设几个档位(大/中/小、主色/描边/幽灵…),页面按需选档位。
- 极特殊的一次性情况,用 `className` 单独覆盖。
- 按钮组件 `Button.tsx` 已经是这套写法,作为范本参考。
### 双窗口协作 & 提交规则(重要)
设计师同时开多个会话窗口:**组件窗口**(做组件改造)与**日常 UI 窗口**(做日常 UI 工单)。
它们**共用同一个文件夹、同一条分支、同一个 Git 工作区**——未提交改动是混在一起的。规则:
1. **只由一个"committer 窗口"执行 git 提交**(当前约定:日常 UI 窗口)。其它窗口只改文件、不提交,避免两个窗口抢提交 / 用 `git add -A` 把对方的活儿一起提交走。
2. **组件改动与日常 UI 改动必须分成不同的提交**(设计师选定 B 方案)。committer 窗口提交时**按文件路径分开 add**:
- **组件一笔**:只 `git add``src/frontend/client/src/components/ui/**``src/frontend/client/src/pages/_gallery/**``docs-ui-refactor/**``src/frontend/client/AGENTS.md`(规则更新)、`.gitignore`(相关行)。
- **日常 UI 一笔**:其它业务页面/功能文件。
- **绝不用 `git add -A` / `git add .`**(会混提)。
3. committer 窗口提交组件那一笔前,**先读 `docs-ui-refactor/` 进度看板**补齐上下文,好写提交说明。
4. 别在同一秒让两个窗口同时提交(暂存区共用,有极小串味风险);错开即可。
5. 组件迁移会碰到业务页面文件时(把某页旧组件换成基准组件),**换一个页面就尽快提交一笔并知会另一窗口**,避免该文件长期停在工作区与日常窗口打架。
---
<!-- site-hide -->
## 三、标准工作流(每个组件一个会话窗口)
以"弹窗"为例,一个窗口里这么走:
1. **看**:画廊页把现有版本并排列出来。
2. **定**:设计师指定统一标准(间距/阴影/圆角…)→ AI 改基准组件。
3. **留余地**:把常见特殊情况做成档位,特例保留 `className` 口子。
4. **指定替换**:设计师点名"先把 A/B/C 页面换成基准组件" → AI 只改这几个。
5. **记账**:更新对应组件文档 + 本总纲的进度看板。
6. **提交**:一个干净的小提交。
### 画廊卫生规则(每个组件都执行,设计师 2026-07-03 定)
迁移过程中,画廊必须始终反映**当前真实现状**,不留"名存实亡"的展品:
- **某个旧写法/旧变体清零**(业务里一处不剩)→ 删掉它的 demo 卡片,清单表里保留一行 `0(原 N)✅ 已迁基准` 作为迁移台账。
- **demo 引用的例子文件被删/被迁** → 换成还活着的代表文件,计数同步更新(如 red-700 系 8→6)。
- **旧组件整体退役**(0 业务引用、源码删除)→ 该组件在画廊的所有旧版对比卡片一并删除,版块从"现状病历"瘦身为"基准组件的标准用法文档",状态标 ✅。
- 每完成一批迁移,随手做上面的核对——不要攒到最后。
(先例:二次确认弹窗版块,bg-destructive 系清零删卡、red-700 系换例并改计数,见组件-Modal弹窗.md 改动记录。)
---
## 四、进度看板
状态:⬜ 待办 / 🟨 进行中 / ✅ 完成
| 组件 | 状态 | 现有版本数 | 基准(收敛目标) | 文档 |
|---|---|---|---|---|
| 字体规范(Typography | 🟨 进行中 | Tailwind 默认字号,无自定义 scale | 九档 semantic token + 系统字体栈已落地(2026-07-14,见文档 §8 落地记录);剩:删死字体 / typography.css / 逐步迁移 | [基础-字体规范.md](基础-字体规范.md) |
| 多端适配原则(基础) | 🟨 v1 已建 | 移动端处理散落各组件 | 双判定口径 + 4 原则;细则写进各组件文档的"移动端适配"节 | [基础-多端适配原则.md](基础-多端适配原则.md) |
| 文案规范(基础) | 🟨 v1 已建 | 无统一文案规则 | 6 条:混排空格 / 全半角标点 / 数字 / 按钮文案 / 用户视角 / 命名一致 | [基础-文案规范.md](基础-文案规范.md) |
| 滚动条规范(基础) | ✅ 完成 | 原全局强制细滚动条已移除(2026-07-15) | 显隐跟随系统设置;默认不自定义,例外只许减显 utility;剩 `.scrollbar-os` 空类 ~33 处随手清 + 灵思深色面板例外待定夺 | [基础-滚动条规范.md](基础-滚动条规范.md) |
| 图标规范(Icon | 🟨 进行中 | lucide 159 文件 / bisheng-icons 39 文件双轨并行(2026-07-16 扫描) | 规范 **v1 已定稿**bisheng-icons 单一来源(24×24 / 2px),六档尺寸阶梯 + 线宽对照 + 着色/可达性规则;剩:lucide 高频逐批迁移(设计师点名) | [基础-图标规范.mdx](基础-图标规范.mdx) |
| 色彩规范(Color) | 🟨 进行中 | 9 套体系并行、裸 hex 2469 处/215 值(2026-07-14 扫描) | Arco 色板规范 **v1 已定稿****token 两层 + Tailwind 接线 + 画廊「色彩 Colors」页已落地**(2026-07-15,实际命名见文档 §7.1 落地注:`border` 避让为 `border-base`);剩:逐批迁移(第一优先 = LibreChat 语义 token 重指向 Arco 值) | [基础-色彩规范.md](基础-色彩规范.md) |
| Modal 弹窗 | 🟨 进行中 | 4(含1个死代码) | 待定(见组件文档) | [组件-Modal弹窗.md](组件-Modal弹窗.md) |
| Select / 下拉菜单 | ⬜ 待办 | 多个 | 待定 | 待建 |
| Button 按钮 | 🟨 进行中 | 5 路并行(详见文档附录 A) | color×variant 双轴规范 **v1 已定稿**;**基准组件已重构落地 + 旧 API 兼容映射 + 画廊已重做**(2026-07-14,见组件文档改动记录);剩:设计师验收推导值 → 逐批迁移业务页 → 清退 btn 系全局类与 Generations/Button | [组件-Button按钮.md](组件-Button按钮.md) |
| Dropdown 菜单 | ⬜ 待办 | 2Dropdown / DropdownMenu | 待定 | 待建 |
| Input 输入框 | ⬜ 待办 | 待扫描 | 待定 | 待建 |
| Tabs 标签页 | ⬜ 待办 | 待扫描 | 待定 | 待建 |
> 优先级:先 **Modal**,再 **Select menu**,后续由设计师逐个指定。
---
## 五、基础设施清单(已搭好的东西)
| 项 | 位置 | 状态 |
|---|---|---|
| gitignore 忽略文档夹 | 根 `.gitignore` 末尾 `docs-ui-refactor/` | ✅ |
| 交接文档 | `docs-ui-refactor/` | ✅ |
| 设计规范草稿 | `docs-ui-refactor/01-设计规范.md` | ✅ 草稿待设计师定稿 |
| 组件画廊页 | `src/frontend/client/src/pages/_gallery/`,路由 `/gallery`(仅 DEV | 见 Task |
| **rspress 组件库文档站**2026-07-16 | 配置 `src/frontend/client/rspress.config.ts`;文档 root = 本文件夹;demo 在 `components/`(**必须 ASCII 目录名**,中文子目录路由会空白)| ✅ `npm run dev:docs`client 下,端口 3000|
### rspress 文档站(2026-07-16 搭建)
- **跑法**`cd src/frontend/client && npm run dev:docs``http://localhost:3000`。构建:`npm run build:docs`
- **结构**:导航「文档」= 本文件夹的规范 md(根级中文文件名没问题);导航「组件」= `components/*.mdx` 实时 demo`@rspress/plugin-preview`internal 模式),demo 里直接 `import { Button } from '~/components/ui/Button'` 渲染真实组件。
- **进度**2026-07-20):组件区已有 6 页(button / modal / confirm / feedback / icon / illustration),`components/index.md` 是实际总览(含 demo 书写约定)。其余组件页待各自规范定稿后补。
- **内部章节过滤**2026-07-20Kinyoo 拍板"单源 + 构建期过滤"):文档站构建时自动剔除内部工作区章节,规范 md 继续当工作文档随便写,**不要为站点另开一份文档**。实现是 rspack pre-loader `src/frontend/client/plugins/strip-internal-loader.cjs`(在 MDX 编译前做文本级裁剪——remark 层做不行,rspress 的 TOC/搜索提取先于用户 remark 插件,会留幽灵目录项)。规则:
- 章节名含 **改动记录 / 关键结论 / 附录(附:)/ 落地记录 / 给实现窗口 / 待决策清单 / 代码锚点 / 扫描存档** 的整节自动隐藏——新写内部内容时**沿用这些章节名**即可,站点自动干净;
- **首个 H1 正下方的引用块自动隐藏**(约定它是工作元信息:版本戳、与总纲配套、给下个窗口的话)——面向读者的正文别写在这个位置;
- **`---` 分节线全部剔除**(rspress 主题本来就在每个 h2 前画分隔线,源文件里的 `---` 会叠成双线)——md 里照常写没关系,站点不渲染;
- 个别章节要藏,在标题上一行加 `<!-- site-hide -->`(藏整节);任意范围用 `<!-- site-hide:start -->` / `<!-- site-hide:end -->`。已标记:本文 §二§三、Modal 文档 §二§二点五§二点六。
- **接线要点**(都在 rspress.config.ts,动它前先懂):别名 `~`/`@`/$fonts 镜像 vite`globalStyles` 直指 `src/style.css`(带 Tailwind + 全部 token**别用 @import 包一层**rspack cssExtract 会崩);`source.define` 补了 vite 的 `__APP_ENV__` / `import.meta.env`(组件经 `~/utils` barrel 在模块顶层读它们,缺了整页白屏且只在 console 报);`url` 模块 alias 到 `stubs/url-stub.ts`Node 内置模块 fallback false。
- **坑**:① demo 目录**只能 ASCII**`components/`),中文子目录 rspress v1 路由匹配失败 → 页面空白;② rspress 与 @rspress/plugin-preview **大版本必须一致**(v1 配 v1);③ npm 装完若报 `rspress: command not found`,跑 `npm rebuild rspress` 重建 bin 链接。
### 画廊双模式结构(2026-07-15 重构)
画廊按"读者"分成两个模式,侧栏顶部 segment control 切换:
- **设计规范**(默认,给设计师/工程师/PM 看):`_gallery/sections/*` —— 每页 = 使用规则 + 档位/token 表 + 实时演示,无迁移噪音;总览页含六条设计原则 + 规范索引;未定稿组件(Modal)带「未定稿」标。
- **迁移进度**(给改造负责人看):`_gallery/progress/*` —— 总览看板(同本文件 §四)+ 每组件的迁移账本(用量盘点 / 旧写法清单 / 旧 API 台账 / 待设计师定夺)。状态点只在此模式显示。
记账规则更新:迁移类内容(盘点表、selectClasses 清单、迁移速查、待定夺)写进 `progress/` 页;规范类内容(规则、档位、anatomy、标准 demo)写进 `sections/` 页,别再混排。
### 怎么打开画廊页
```bash
cd src/frontend/client
npm run dev # 开发服务器,端口 4001
# 浏览器打开:http://localhost:4001/workspace/gallery
```
basename 是 `/workspace`,所以路径带 `/workspace` 前缀。生产构建里这个路由不存在。)
---
## 六、相关约束(client 项目铁律,改代码时遵守)
- 只用 TypeScript;函数式组件;单文件 ≤ 600 行。
- UI 组件在 `~/components/ui/`**不引入新 UI 库**。
- 品牌色蓝⇄绿主题:**不硬编码品牌 hex**,用 `blue-*` 类(已重指向 `--brand-*`)或 `rgb(var(--brand-NNN))`
- 语义色(成功/危险/警告)不参与换肤。
- 详见 `src/frontend/client/CLAUDE.md`
## 七、图标规则(设计师偏好,优先级高于项目历史习惯)
> 项目规则文件 `src/frontend/client/AGENTS.md`(即 `CLAUDE.md` 软链接)**已更新为"bisheng-icons 优先、lucide 兜底"**。
> 历史现状是 lucide 用在 260 文件、bisheng-icons 用在 75 文件(早期习惯用 lucide),本工程按下述规则逐步收敛:
1. **优先用 `bisheng-icons`**。若某语义在 bisheng-icons 里已有,**必须**用它,不用 lucide 同义图标。
- 用法:`import { Outlined } from 'bisheng-icons';``<Outlined.Delete />``<Outlined.Edit />`
- 三种风格:`Outlined`(描边,最常用,116 个)/ `Filled`(实心)/ `Colored`(彩色)。
2. **lucide-react 只作兜底**:仅当 bisheng-icons 没有对应语义的图标时才用。
3. **升级替换**bisheng-icons 会持续升级新增图标。库升级后,可把原来 lucide 兜底的图标**换回 bisheng-icons**——**换哪些由设计师逐个指定**,不自动批量替换。
4. 结论:新代码 / 改造组件时,图标默认从 bisheng-icons 找;找不到再用 lucide 并留记号(便于日后替换)。
### ⚠️ 升级 bisheng-icons 后必须清 Vite 预构建缓存 + 重启
- **现象**:升级 bisheng-icons 后直接用新图标,页面白屏/崩溃(`Element type is invalid`)。
- **原因**bisheng-icons 被 Vite 预构建缓存在 `node_modules/.vite/deps/bisheng-icons.js`,是旧版快照;且该包 `package.json``exports` 是 git 源写法,Vite 依赖哈希检测漏检 → 缓存不失效 → 新图标解析成 `undefined` → React 崩。
- **正确操作**(二选一):
```bash
cd src/frontend/client
npm run dev -- --force # 推荐:强制重新预构建并重启
# 或
rm -rf node_modules/.vite && npm run dev
```
@@ -0,0 +1,113 @@
# 设计规范(通用 Design Tokens)· 草稿
> ⚠️ **草稿**,等设计师定稿。与 [00-总纲.md](00-总纲.md) 配套。
> 本文只管**尚未拆出独立规范的通用 token**:圆角、间距、阴影/投影、层级 z-index。
> 色彩、字体、按钮、多端适配已各自成文并 v1 定稿(见 §0 索引);遮罩按设计师批注(2026-07-14)**归 Modal 文档定夺**,本文不重复。
---
## 0. 规范体系索引(先看这个,别翻旧账)
| 领域 | 文档 | 状态 |
|---|---|---|
| 色彩(品牌/中性/语义/标签/插画) | [基础-色彩规范.md](基础-色彩规范.md) | ✅ v1 定稿,token 已落地 |
| 字体(字体栈/九档字号/字重) | [基础-字体规范.md](基础-字体规范.md) | ✅ v1token 已落地 |
| 多端适配(双判定口径 + 4 原则) | [基础-多端适配原则.md](基础-多端适配原则.md) | ✅ v1 |
| 按钮(双轴类型/尺寸/状态) | [组件-Button按钮.md](组件-Button按钮.md) | ✅ v1 定稿,基准组件已重构 |
| 弹窗(遮罩/壳解剖/收敛方向) | [组件-Modal弹窗.md](组件-Modal弹窗.md) | 🟨 进行中,待定夺 |
| 圆角/间距/阴影/层级(本文) | 01-设计规范.md | 🟨 部分已随组件定稿,其余待定 |
---
## 1. 圆角 Radius
### 已定(随组件规范定稿,不再讨论)
| 场景 | 值 | 出处 |
|---|---|---|
| 按钮 small / medium / large | 4 / 6 / 8px(随尺寸档) | 按钮规范 §2 |
| 圆形 icon 按钮 | `border-radius: 50%` | 按钮规范 §1 shape=circle |
| 二次确认弹窗(C 套,已落地) | 16px(`rounded-2xl` | Modal 文档改动记录 2026-07-02 |
### 待定
| 场景 | 现状 | 待拍板 |
|---|---|---|
| Modal 弹窗 | A 套 8px`sm:rounded-lg`vs B/C 套 16px`rounded-2xl`)并存 | 8 vs 16 二选一(Modal 文档待决策清单) |
| 输入框 / 卡片 / 下拉面板 | 散落 `rounded`(4) / `rounded-md`(6) / `rounded-lg`(8) 等 | 随 Input / Select 组件期定;倾向对齐按钮 6px(medium 控件档) |
> 方向建议:控件圆角跟按钮三档(4/6/8)走,容器类(弹窗/大卡片)单独一档(16),不再保留中间杂档。
## 2. 间距 Spacing
### 已定
| 场景 | 值 | 出处 |
|---|---|---|
| 同组相邻按钮(紧凑/工具栏) | 8px | 按钮规范 §4 |
| 弹窗 footer 按钮间距 | 12px`gap-3`) | 按钮规范 §4(对齐本文) |
| 弹窗 footer 按钮 min-width | 60px;移动端等宽平铺 | 按钮规范 §3.1 / §5.5 |
| 二次确认弹窗内边距(C 套,已落地) | 20px(`p-5` | Modal 文档改动记录 2026-07-02 |
### 待定
| 场景 | 现状 | 待拍板 |
|---|---|---|
| Modal 弹窗内边距 | A 套 `p-5`(20) vs 手拼 AlertDialog `p-6`(24) 并存 | 20 vs 24 二选一(Modal 文档待决策清单);C 套先例是 20 |
| 表单项垂直间距 | 未统计 | 建议 16px(`space-y-4`),随 Input 组件期定 |
## 3. 阴影 / 投影 Shadow(归口本文,2026-07-14 设计师批注)
### 现状(三种并存)
| 载体 | 写法 |
|---|---|
| A 套 Dialog | `shadow-lg`+ border |
| B/C 套弹窗(已对齐) | border `#ebebeb` + 淡投影 |
| AlertDialog 底座 | 无边框无阴影 |
### 建议(待定稿)
只留 2 档:
| 档位 | 用于 |
|---|---|
| 中 | 下拉 / 气泡 / 卡片悬浮 |
| 大 | 弹窗 / 抽屉 |
> 待设计师给出两档具体值(offset / blur / 颜色透明度),落成 token 后各组件引用,禁止散写 `shadow-*`。
> 注意与「是否保留 border」一起定:B/C 套现在是「淡投影 + 边框」组合。
## 4. 遮罩 Overlay → 归 Modal 文档
设计师批注(2026-07-14):遮罩不放本文,**归 [组件-Modal弹窗.md](组件-Modal弹窗.md) 定夺**。
当前之争已演进(旧的 `bg-black/80` 纯深色在二次确认期淘汰):现为 **浅黑毛玻璃**A 套 `bg-black/40`+blurvs **灰白毛玻璃**B/C 套 `bg-gray-500/90`+blur)二选一,见 Modal 文档 §二点六。
## 5. 层级 z-index
### 现状(三档并存,不一致)
| 层 | z-index |
|---|---|
| B 套弹窗 | `z-50` |
| A 套弹窗 | `z-[100]` |
| AlertDialogC 套底座) | `z-[110]` |
### 待定稿
- 建议统一一套层级阶梯:遮罩 + 弹窗一档,Drawer / Sheet / Popover / Tooltip 各归其位,写成 token 表。
- 统一时需**先盘 Drawer / Sheet / Popover 与弹窗的叠放关系**(Modal 文档 §二点六 已提示),避免改完出现弹窗被抽屉盖住类回归。
---
## 附:已迁出本文的内容
- ~~按钮变体标准~~ → 按钮规范 v1 已定稿(color×variant 双轴 + 3 档尺寸),旧的 8 变体清单作废,见 [组件-Button按钮.md](组件-Button按钮.md)。
- ~~色彩 / 字号建议~~ → 见 §0 索引对应文档。
## 改动记录
| 日期 | 改了什么 | 提交 |
|---|---|---|
| (早期) | 建档:圆角/间距/阴影/遮罩/z-index 建议值 + 按钮现状 | 不提交(文档夹已 gitignore |
| 2026-07-15 | 全文重写对齐各规范最新事实:加 §0 规范体系索引;圆角/间距拆「已定(引按钮规范/C 套落地值)vs 待定」;阴影承接归口批注(色彩规范 2026-07-14)并列三种现状;遮罩改为指向 Modal 文档(之争已演进为浅黑 vs 灰白毛玻璃);z-index 更新为三档现状(z-50/100/110)并提示盘 Drawer/Sheet 关系;删除过时的按钮变体章节 | 不提交 |
@@ -0,0 +1,234 @@
# 按钮 Button
组件库 `@bisheng/ui` Button 的实时预览(client 经 `~/components/ui/Button` re-export 同一实现)。**color × variant 双轴**color 管颜色(primary / default / danger),variant 管画法(solid / outlined / filled / text / link),3×5 组合自动成立。规范全文见「文档 → 组件规范 → Button 按钮」。
## 常用类型
六个常用别名,其余组合按双轴自然推导。一个操作区域只放一个 `primary solid` 主按钮;最常用的次级按钮是 `default outlined`(白底灰描边)。
```tsx
import { Button } from '@bisheng/ui';
const rows = [
['Primary 主按钮 · primary × solid', <Button color="primary" variant="solid">主按钮</Button>],
['Secondary 次强调 · primary × filled', <Button color="primary" variant="filled">次强调</Button>],
['Default 默认按钮 · default × outlined', <Button color="default" variant="outlined">取消</Button>],
['Text 文字按钮 · default × text', <Button color="default" variant="text">文字按钮</Button>],
['Link 链接按钮 · primary × link', <Button color="primary" variant="link">链接按钮</Button>],
[
'Danger 危险按钮 · danger × solid / outlined / text',
<>
<Button color="danger" variant="solid">删除</Button>
<Button color="danger" variant="outlined">删除</Button>
<Button color="danger" variant="text">删除</Button>
</>,
],
];
export default () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{rows.map(([label, node]) => (
<div key={label as string} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ width: 300, flexShrink: 0, fontSize: 13, color: 'rgb(var(--text-2))' }}>
{label}
</span>
{node}
</div>
))}
</div>
);
```
## color × variant 全矩阵
3 色 × 5 画法共 15 种组合全部成立,不必逐个定义;不设 dashed 与 ghost,「无描边无背景」的诉求由 text 承接。
```tsx
import { Button } from '@bisheng/ui';
const colors = ['primary', 'default', 'danger'] as const;
const variants = ['solid', 'outlined', 'filled', 'text', 'link'] as const;
export default () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ width: 80, flexShrink: 0 }} />
{colors.map((c) => (
<span key={c} style={{ width: 96, fontSize: 13, color: 'rgb(var(--text-2))' }}>{c}</span>
))}
</div>
{variants.map((v) => (
<div key={v} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<code style={{ width: 80, flexShrink: 0, fontSize: 13 }}>{v}</code>
{colors.map((c) => (
<span key={c} style={{ width: 96 }}>
<Button color={c} variant={v}>按钮</Button>
</span>
))}
</div>
))}
</div>
);
```
## 尺寸 size
三档:small 24px / medium 32px(默认)/ large 40px,圆角 4 / 6 / 8px。不要手写高度、内边距、圆角;同一视图内相邻按钮必须同尺寸。
```tsx
import { Outlined } from 'bisheng-icons';
import { Button } from '@bisheng/ui';
const sizes = [
['small', '高 24px · 字号 14/22 · 圆角 4px — 表格行内、紧凑工具条'],
['medium', '高 32px · 字号 14/22 · 圆角 6px — 默认,绝大多数场景'],
['large', '高 40px · 字号 16/24 · 圆角 8px — 登录页、大表单提交'],
] as const;
export default () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{sizes.map(([s, desc]) => (
<div key={s} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 300, flexShrink: 0, fontSize: 13, color: 'rgb(var(--text-2))' }}>
<code>size="{s}"</code>
<div style={{ fontSize: 12, color: 'rgb(var(--text-3))' }}>{desc}</div>
</div>
<Button color="primary" size={s}>按钮</Button>
<Button color="default" size={s}>按钮</Button>
<Button color="default" size={s} iconOnly aria-label="搜索">
<Outlined.Search />
</Button>
</div>
))}
</div>
);
```
## 内容形态
一个按钮最多一个 iconicon 尺寸三档 14 / 16 / 18px,与文字间距 8pxsmall 档可收紧至 4px)。
### 纯文字
不换行不省略;两个汉字**不加**中间空格;字重 400(全尺寸全类型一致)。
```tsx
import { Button } from '@bisheng/ui';
export default () => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button>确定</Button>
<Button color="default">取消</Button>
</div>
);
```
### 纯 iconshape square / circle
`circle` 正圆仅限纯 icon 按钮;必须带 Tooltip 与 `aria-label`;触屏热区 ≥44px(组件内置,视觉尺寸不变)。
```tsx
import { Outlined } from 'bisheng-icons';
import { Button } from '@bisheng/ui';
export default () => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button color="default" variant="outlined" size="small" iconOnly aria-label="编辑">
<Outlined.Edit />
</Button>
<Button color="default" variant="outlined" size="medium" iconOnly aria-label="编辑">
<Outlined.Edit />
</Button>
<Button color="default" variant="outlined" size="large" iconOnly aria-label="编辑">
<Outlined.Edit />
</Button>
<Button color="default" variant="outlined" shape="circle" iconOnly aria-label="搜索">
<Outlined.Search />
</Button>
<Button shape="circle" iconOnly aria-label="发送">
<Outlined.Send />
</Button>
<Button color="default" variant="text" iconOnly aria-label="删除">
<Outlined.Delete />
</Button>
<Button color="danger" variant="text" iconOnly aria-label="删除">
<Outlined.Delete />
</Button>
</div>
);
```
### 文字 + icon
用 `icon` 属性,默认在文字左侧;loading 时 spinner 顶替 icon 位。
```tsx
import { Outlined } from 'bisheng-icons';
import { Button } from '@bisheng/ui';
export default () => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button icon={<Outlined.Plus />}>新建</Button>
<Button color="default" icon={<Outlined.Download />}>下载</Button>
<Button color="danger" variant="outlined" icon={<Outlined.Delete />}>删除</Button>
</div>
);
```
### icon 在右(方向语义)
「下一步 →」这类方向语义可放右侧:icon 走 children 尾部。
```tsx
import { Outlined } from 'bisheng-icons';
import { Button } from '@bisheng/ui';
export default () => (
<Button>
下一步
<Outlined.ArrowRight />
</Button>
);
```
## 状态 state
disabled / loading 全类型统一;focus 环仅键盘(`focus-visible`)出现。
### disabled
灰底、不可点,全类型一个样。
```tsx
import { Button } from '@bisheng/ui';
export default () => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button disabled>主按钮</Button>
<Button color="primary" variant="filled" disabled>次强调</Button>
<Button color="default" disabled>默认</Button>
<Button color="default" variant="text" disabled>文字</Button>
<Button color="danger" disabled>删除</Button>
</div>
);
```
### loading
用组件内置的 `loading` 属性,期间不可点;**禁止业务页自塞 Spinner**。
```tsx
import { Outlined } from 'bisheng-icons';
import { Button } from '@bisheng/ui';
export default () => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button loading>提交中</Button>
<Button color="default" loading>提交中</Button>
<Button color="danger" loading>删除中</Button>
<Button loading icon={<Outlined.Plus />}>新建</Button>
</div>
);
```
> 文档站为默认蓝主题;主按钮/品牌色显示蓝色是正常表现,绿主题仅存在于产品端换肤。
@@ -0,0 +1,257 @@
# 色彩 Color
两层 token 结构:primitive`--arco-gray-1~10`)→ semantic(文字 / 填充 / 边框 / 语义色),**业务代码只接触 semantic 层,禁止裸 hex**。品牌色永远走 `blue-*` 类(自动蓝⇄绿换肤)。规范全文见「文档 → 设计规范 → 色彩 Color」。
> 本页所有色块 / 表格均从设计 token 单一源 `~/design-token.cjs` 渲染,与组件消费的是同一份数据——改 token 即改此页。
## 品牌色 Brand
主色 = 500 档,跟随蓝⇄绿主题;hover 用 400、active 用 600,浅底走 50–200。悬停色块可看每档用途。
```tsx
import { BRAND, BRAND_STEPS } from '~/design-token.cjs';
const overlay = (hex) => {
const h = hex.slice(1);
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6
? 'rgba(0,0,0,0.85)'
: 'rgba(255,255,255,0.95)';
};
const Ramp = ({ title, theme }) => {
const steps = [...BRAND_STEPS, 'muted'];
const accent = BRAND[theme][BRAND.accentStep];
return (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 10, color: 'rgb(var(--text-1))' }}>{title}</div>
<div style={{ display: 'flex' }}>
{steps.map((step) => {
const hex = BRAND[theme][step];
const isMain = step === BRAND.main;
return (
<div
key={step}
title={BRAND.role[step]}
style={{
flex: 1,
minWidth: 0,
height: 132,
padding: 12,
background: hex,
color: overlay(hex),
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
position: 'relative',
}}
>
<div style={{ fontSize: 13, fontWeight: isMain ? 500 : 400 }}>{step}</div>
<div style={{ fontSize: 11, fontFamily: 'monospace', opacity: 0.8 }}>{hex}</div>
{isMain && (
<span style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 3, background: accent }} />
)}
</div>
);
})}
</div>
</div>
);
};
export default () => (
<div>
<Ramp title="蓝(默认)" theme="blue" />
<Ramp title="绿(theme-green" theme="green" />
<p style={{ fontSize: 13, color: 'rgb(var(--text-3))', margin: 0 }}>
例外(固定不换肤):审批 tag 永远蓝、第三方 logo 原色;muted 档为低饱和品牌点缀。
</p>
</div>
);
```
## 中性色 · primitive(灰阶 110
中性色数值源;组件不直接用这层,走下方 semantic。色块背景实时取自 `rgb(var(--arco-gray-N))`hex 为规范文档值。
```tsx
import { GRAY } from '~/design-token.cjs';
export default () => (
<div style={{ display: 'flex' }}>
{GRAY.map((g) => (
<div
key={g.n}
title={g.role}
style={{
flex: 1,
minWidth: 0,
height: 116,
padding: 12,
background: `rgb(var(--arco-gray-${g.n}))`,
// On-swatch text uses the opposite end of the ramp, which co-flips
// with the bg in dark mode → contrast holds in both themes.
color: g.n <= 5 ? 'rgb(var(--arco-gray-10))' : 'rgb(var(--arco-gray-1))',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}
>
<div style={{ fontSize: 13 }}>gray-{g.n}</div>
<div style={{ fontSize: 11, fontFamily: 'monospace', opacity: 0.8 }}>{g.hex} / {g.darkHex}</div>
</div>
))}
</div>
);
```
## 中性色 · semantic(组件用这层)
文字 / 填充 / 边框,**同一语义只有一个值**。token 名称已语义化(`text-title` / `fill-hover`…);旧数字名(`text-1`…)作为 deprecated 别名保留,方便渐进迁移。右侧为实时渲染示例。
```tsx
import { TEXT, FILL, BORDER, BG } from '~/design-token.cjs';
const Row = ({ token, cls, legacy, value, usage, demo }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '10px 0', borderBottom: '1px solid rgb(var(--border-base))' }}>
<code style={{ width: 150, flexShrink: 0, fontSize: 13 }}>{cls}</code>
<code style={{ width: 90, flexShrink: 0, fontSize: 12, color: 'rgb(var(--text-4))' }}>{legacy || ''}</code>
<span style={{ width: 130, flexShrink: 0, fontSize: 12, color: 'rgb(var(--text-3))' }}>{value}</span>
<span style={{ flex: 1, fontSize: 13, color: 'rgb(var(--text-2))' }}>{usage}</span>
<span style={{ width: 150, flexShrink: 0 }}>{demo}</span>
</div>
);
export default () => (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{TEXT.map((t) => (
<Row
key={t.name}
cls={`text-text-${t.name}`}
legacy={`text-text-${t.legacy}`}
value={`${t.ref} ${t.hex}`}
usage={t.usage}
demo={<span className={`text-body text-text-${t.name}`}>示例文字 Abc</span>}
/>
))}
{FILL.map((f) => (
<Row
key={f.name}
cls={`bg-fill-${f.name}`}
legacy={`bg-fill-${f.legacy}`}
value={`${f.ref} ${f.hex}`}
usage={f.usage}
demo={<span className={`bg-fill-${f.name}`} style={{ display: 'inline-block', width: 64, height: 24, borderRadius: 4, border: '1px solid rgb(var(--border-deep))' }} />}
/>
))}
{BORDER.map((b) => (
<Row
key={b.name}
cls={`border-border-${b.name}`}
legacy=""
value={`${b.ref} ${b.hex}`}
usage={b.usage}
demo={<span className={`border-border-${b.name}`} style={{ display: 'inline-block', width: 64, height: 24, borderRadius: 6, borderWidth: 1, borderStyle: 'solid', background: 'transparent' }} />}
/>
))}
{BG.map((b) => (
<Row
key={b.name}
cls={`bg-bg-${b.name}`}
legacy=""
value={`${b.hex} · 暗 ${b.darkHex}`}
usage={b.usage}
demo={<span style={{ display: 'inline-block', width: 64, height: 24, borderRadius: 6, background: `rgb(var(${b.cssVar}))`, border: '1px solid rgb(var(--border-base))' }} />}
/>
))}
</div>
);
```
## 语义色 Functional(不换肤)
成功 / 警告 / 危险固定 hex,全主题恒定;链接 / 信息 = 品牌色,跟随主题。危险浅底分两种:按钮用主色透明阶 `bg-danger/10~20`tag 用实色 `bg-danger-tint`。
```tsx
import { FUNCTIONAL } from '~/design-token.cjs';
const overlay = (hex) => {
const h = hex.slice(1);
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6
? 'rgba(0,0,0,0.85)'
: 'rgba(255,255,255,0.95)';
};
const STOPS = [['主色', 'main'], ['hover', 'hover'], ['active', 'active'], ['tint 浅底', 'tint']];
export default () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{FUNCTIONAL.map((fn) => (
<div key={fn.name}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'rgb(var(--text-1))' }}>{fn.label}</div>
<div style={{ display: 'flex' }}>
{STOPS.map(([label, key]) => (
<div
key={key}
style={{
flex: 1,
minWidth: 0,
height: 96,
padding: 12,
background: fn[key],
color: overlay(fn[key]),
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}
>
<div style={{ fontSize: 13 }}>{label}</div>
<div style={{ fontSize: 11, fontFamily: 'monospace', opacity: 0.8 }}>{fn[key]}</div>
</div>
))}
</div>
</div>
))}
</div>
);
```
## 标签色 Tag
成对使用:浅底 + 深字。橙 / 绿 / 红三对走 token;技能紫、审批蓝为固定例外色。
```tsx
import { TAG } from '~/design-token.cjs';
export default () => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
{TAG.map((t) => (
<span
key={t.label}
title={t.note}
style={{
display: 'inline-flex',
alignItems: 'center',
borderRadius: 4,
padding: '2px 8px',
fontSize: 13,
background: t.bg,
color: t.fg,
}}
>
{t.label}
</span>
))}
</div>
);
```
## 使用规则
- 只用 semantic 层(`text-text-strong` / `bg-fill-subtle` / `border-border-base` / `bg-success`…),**禁止裸 hex**。
- 品牌色永远走 `blue-*` 类(自动蓝⇄绿换肤),禁止写品牌 hex。列表/菜单选中浅底统一 `bg-blue-500/[0.07]`。
- 语义色(成功/警告/危险)**不参与换肤**。
- 同一语义只允许一个值;新颜色先在色板找替代。
> 色块的 hex 为规范文档值(展示用途);业务代码一律走 token / 类名,不抄这些 hex。
@@ -0,0 +1,81 @@
# 二次确认弹窗 Confirm
删除 / 危险操作时的「确认 / 取消」小弹窗。标准实现是全局服务 `useConfirm()``ConfirmContext`,底层 AlertDialog),分 destructive / default 两档,标准**已定稿**、全站已收敛。迁移台账见「文档 → 组件规范 → Modal 弹窗」§二点五。
## 两个变体
- **destructive(危险态)**:红图标 + 红标题 + 暂不 / 确认删除——删除等不可逆操作。
- **default(普通态)**:橙色警示图标 + 主色确认——可逆但需用户知情的操作。
```tsx
import { RecoilRoot } from 'recoil';
import '~/locales/i18n';
import store from '~/store';
import { ConfirmProvider, useConfirm } from '~/Providers/ConfirmContext';
import { Button } from '~/components/ui/Button';
function Demos() {
const confirm = useConfirm();
const rows = [
[
'危险态 variant: destructive — 删除等不可逆操作',
() =>
confirm({
variant: 'destructive',
description:
'确认删除知识空间 "默认组织的知识空间" 吗?此操作不可逆,请谨慎删除!',
}),
],
[
'普通态 variant: default — 可逆但需用户知情',
() =>
confirm({
variant: 'default',
description: '切换频道后未保存的编辑将丢失,是否继续?',
}),
],
] as const;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{rows.map(([label, onClick]) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ width: 340, flexShrink: 0, fontSize: 13, color: 'rgb(var(--text-2))' }}>
{label}
</span>
<Button color="default" variant="outlined" onClick={onClick}>
打开
</Button>
</div>
))}
</div>
);
}
export default () => (
<RecoilRoot initializeState={({ set }) => set(store.lang, 'zh-Hans')}>
<ConfirmProvider>
<Demos />
</ConfirmProvider>
</RecoilRoot>
);
```
业务用法:`const ok = await confirm({ variant: 'destructive', title, description, confirmText }); if (!ok) return; mutate(...)` —— Esc / 点遮罩关闭也会 resolve `false`,不会挂起调用方。
## 规格 Anatomy
| 部位 | 值 | 备注 |
|---|---|---|
| 弹窗容器 | `rounded-2xl p-5 gap-4` + border `#ebebeb` + 淡投影 | 圆角 16 / padding 20 |
| 遮罩 | `bg-gray-500/90` + `backdrop-blur-md` | 灰底毛玻璃 |
| 标题 | `text-base font-medium leading-6` | |
| 确认按钮 | 两档:danger `#f53f3f` / primary 品牌主色 | 语义红不换肤;特例可自定义 |
| 取消按钮 | 白底描边 `hover:bg-[#f7f8fa]``focus-visible` 焦点环 | 默认文案「暂不」 |
## 使用规则
- 危险操作(删除等)用 `variant: 'destructive'`;可逆但需确认的用 `default`。**不要自拼确认弹窗**。
- Loading 统一走服务内置的 pending 态,禁止业务页自塞 Spinner。
- 带表单输入的弹窗不是二次确认——属于普通 Modal(见「Modal 弹窗」)。
> demo 里的 `RecoilRoot` + `ConfirmProvider` 只是文档站的挂载脚手架;业务页在 App 全局已具备,直接 `useConfirm()` 即可。
@@ -0,0 +1,52 @@
# 点赞 / 点踩反馈 Feedback
`MessageFeedbackButtons` —— 全部 6 类 AI 回答界面共用的反馈控件(首页对话 / 知源 / 订阅 3 面板 / 灵思 / appChat)。点踩为**延迟提交**:弹窗(共享壳 `CommentDialog`)点「提交」才落库并高亮,原因选填;「取消」= 彻底放弃点踩。
## 三种初始状态
demo 只把回调打到旁边的文本上,不落库,可任意点。
```tsx
import { useState } from 'react';
import { RecoilRoot } from 'recoil';
import '~/locales/i18n';
import store from '~/store';
import { MessageFeedbackButtons } from '~/components/Chat/MessageFeedbackButtons';
function Demo({ label, hint, liked }: { label: string; hint: string; liked?: number }) {
const [last, setLast] = useState('—');
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 300, flexShrink: 0, fontSize: 13, color: 'rgb(var(--text-2))' }}>
{label}
<div style={{ fontSize: 12, color: 'rgb(var(--text-3))' }}>{hint}</div>
</div>
<MessageFeedbackButtons
liked={liked}
onLike={(l) => setLast(`onLike(${l})`)}
onDislikeComment={(c) => setLast(`onDislikeComment("${c}")`)}
/>
<span style={{ fontSize: 12, color: 'rgb(var(--text-3))' }}>最近调用:{last}</span>
</div>
);
}
export default () => (
<RecoilRoot initializeState={({ set }) => set(store.lang, 'zh-Hans')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<Demo label="初始未评价" hint="点踩先弹窗,提交后才高亮;取消不留痕" />
<Demo label="已点赞态(liked=1" hint="点踩弹窗取消后应保持点赞高亮" liked={1} />
<Demo label="已点踩态(liked=2" hint="再点踩 = 直接取消,不弹窗" liked={2} />
</div>
</RecoilRoot>
);
```
## 规则
- 点赞与取消评价**立即落库**;点踩只有弹窗提交后才落库并高亮。
- 已点踩态再点踩 = 直接取消,不再弹窗。
- 按钮视觉与 AI 消息操作行一致:`size-6` 热区、14px bisheng-icons Outlined 字形、`#818181` 常态 / 品牌色激活。
- 弹窗规格(CommentDialog):圆角 12 / 边距 20 / 按钮 32 高 · 14px · 字重 400 · 圆角 6。
> demo 里的 `RecoilRoot` + `import '~/locales/i18n'` 只是文档站的挂载脚手架;业务页在 App 全局已具备。
@@ -0,0 +1,159 @@
# 图标 Icon
图标单一来源是 `bisheng-icons`Figma 插件管理设计源 → 自动生成 React 组件 → semver 发版)。**Outlined 为主力**`lucide-react` 只在库里没有对应语义时兜底(留 `// TODO(bisheng-icons)` 记号);禁止新增手写 SVG 图标组件。规范全文见「文档 → 设计规范 → 图标 Icon」。
## Outlined 全量(主力)
线性风格,116 个,`import { Outlined } from 'bisheng-icons'` → `<Outlined.Delete />`。命名 PascalCase、按语义不按形状(`Delete` 而非 `Trash2`)。下方清单直接遍历库导出,升级版本后自动同步。
```tsx
import { Outlined } from 'bisheng-icons';
export default () => (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(108px, 1fr))',
gap: 4,
}}
>
{Object.entries(Outlined).map(([name, Icon]: [string, any]) => (
<div
key={name}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
padding: '12px 4px',
borderRadius: 6,
color: 'rgb(var(--text-2))',
}}
>
<Icon size={16} />
<span style={{ fontSize: 11, color: 'rgb(var(--text-3))', wordBreak: 'break-all', textAlign: 'center' }}>
{name}
</span>
</div>
))}
</div>
);
```
## Filled 与 Colored
Filled(实心)与 Colored(多色,文件类型/文件夹)按图标自身语义选用;**Colored 自带固有色,不参与着色**。
```tsx
import { Filled, Colored } from 'bisheng-icons';
const Row = ({ title, set }: { title: string; set: Record<string, any> }) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<span style={{ width: 80, flexShrink: 0, fontSize: 13, color: 'rgb(var(--text-2))', paddingTop: 14 }}>
{title}
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{Object.entries(set).map(([name, Icon]) => (
<div
key={name}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
width: 96,
padding: '12px 4px',
color: 'rgb(var(--text-2))',
}}
>
<Icon size={20} />
<span style={{ fontSize: 11, color: 'rgb(var(--text-3))', textAlign: 'center' }}>{name}</span>
</div>
))}
</div>
</div>
);
export default () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Row title="Filled" set={Filled} />
<Row title="Colored" set={Colored} />
</div>
);
```
## 尺寸阶梯
六档 12 / 14 / 16 / 20 / 24 / 32**不自定义任意尺寸**。`strokeWidth` 只在阶梯两端补偿(12 → 2.5、32 → 1.5),14–24px 一律默认 2 不覆盖;补偿值写在场景组件一处,禁止业务页逐个自调。
```tsx
import { Outlined } from 'bisheng-icons';
const STEPS = [
['xs · 12px', 12, 2.5, '极小标记(badge 内、密集表格角标),仅纯展示;strokeWidth 补偿 2.5'],
['sm · 14px', 14, undefined, 'small/medium 按钮的文字+icon'],
['md · 16px(默认)', 16, undefined, '绝大多数场景:图标按钮、菜单项、输入框内、表格操作'],
['lg · 20px', 20, undefined, '导航栏、侧边栏入口、页头操作'],
['xl · 24px', 24, undefined, '独立展示、弹窗标题图标(原始画布尺寸)'],
['2xl · 32px', 32, 1.5, '超大展示:空状态、引导页;strokeWidth 补偿 1.5'],
] as const;
export default () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{STEPS.map(([label, size, sw, desc]) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 300, flexShrink: 0, fontSize: 13, color: 'rgb(var(--text-2))' }}>
<code>{label}</code>
<div style={{ fontSize: 12, color: 'rgb(var(--text-3))' }}>{desc}</div>
</div>
<span style={{ color: 'rgb(var(--text-2))', display: 'flex', alignItems: 'center', gap: 12 }}>
<Outlined.Search size={size} strokeWidth={sw} />
<Outlined.Edit size={size} strokeWidth={sw} />
<Outlined.Delete size={size} strokeWidth={sw} />
</span>
</div>
))}
</div>
);
```
## 着色
默认 `currentColor` **继承文字颜色**——优先用中性语义档;品牌色用 `text-blue-*` 类(自动换肤);语义色用语义 token;**不写裸 hex**。SVG 展示属性里 `var()` 不生效,内联着色用 `style={{ color: 'rgb(var(--brand-500))' }}`。
```tsx
import { Outlined } from 'bisheng-icons';
const rows = [
['继承文字颜色(currentColor 默认行为)', <span style={{ color: 'rgb(var(--text-2))' }}><Outlined.Info size={16} /> 跟随所在容器的 text 色</span>],
['品牌色 · text-blue-500(自动换肤)', <span className="text-blue-500"><Outlined.Pin size={16} /> 品牌色</span>],
['品牌色 · 内联 var 写法', <span style={{ color: 'rgb(var(--brand-500))' }}><Outlined.Pin size={16} /> rgb(var(--brand-500))</span>],
['语义色(成功/警告/危险,不换肤)', (
<span style={{ display: 'inline-flex', gap: 16 }}>
<span style={{ color: '#00b42a' }}><Outlined.CheckCircle size={16} /> 成功</span>
<span style={{ color: '#ff7d00' }}><Outlined.Attention size={16} /> 警告</span>
<span style={{ color: '#f53f3f' }}><Outlined.CloseCircle size={16} /> 危险</span>
</span>
)],
['加载态 · Outlined.Loading + animate-spin', <span style={{ color: 'rgb(var(--text-2))' }}><Outlined.Loading size={16} className="animate-spin" /> 加载中(不混用 lucide Loader2</span>],
];
export default () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{rows.map(([label, node]) => (
<div key={label as string} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ width: 300, flexShrink: 0, fontSize: 13, color: 'rgb(var(--text-2))' }}>{label}</span>
{node}
</div>
))}
</div>
);
```
## 使用规则
- 纯图标按钮必须带 Tooltip + `aria-label`;触屏热区 ≥44×44 由 Button 基座统一处理。
- 方向语义用库内方向图标,**不用 CSS 旋转拼方向**;例外:展开/收起 chevron 的 `rotate-180` 过渡。
- 图标与文字间距 8px`gap-2`),small 档可收紧 4px。
- 同一视图内同类图标同档;与文字混排时 icon 不大于行高。
- **升级 bisheng-icons 后必须清 Vite 预构建缓存**`npm run dev -- --force`,否则新图标白屏。
@@ -0,0 +1,109 @@
# 插画 Illustration
空状态 / 状态反馈用的主题化插画(`src/components/illustrations/`,内联 SVG),颜色走独立的插画调色板(比 UI 品牌色更亮),随蓝⇄绿主题自动切换,另有主题无关的灰稿模式。规范见「文档 → 设计规范 → 插画 Illustration」与「色彩 Color」§5。
## 调色板三态
| 档 | 蓝主题(默认) | 绿主题 | 灰稿 |
|---|---|---|---|
| 浅底 `--illus-100` | `#BEDAFF` | `#DDF0E8` | `#E5E5E5` |
| 中间调 `--illus-300` | `#6AA1FF` | `#A2D7B5` | `#FFFFFF` |
| 主体 `--illus-500` | `#165DFF` | `#169C47` | `#BCBCBC` |
| 整图不透明度 | 100% | 100% | 80% |
## 全部插画
切换查看三种颜色模式(绿主题 = 外层加 `theme-green` 类的效果;灰稿 = 传 `grey` 属性)。
```tsx
import { CSSProperties, useState } from 'react';
import {
ArticleQAIllustration,
CrawlingIllustration,
EmptyStateIllustration,
ListWebLinkIllustration,
NoPermissionIllustration,
SuccessIllustration,
SystemMaintenanceIllustration,
} from '~/components/illustrations';
const MODES = [
{ id: 'blue', label: '蓝主题', hint: '默认主题。' },
{ id: 'green', label: '绿主题', hint: '全局切换绿主题时,插画自动变绿。' },
{ id: 'grey', label: '灰稿', hint: '主题无关的灰稿,整图降至 80% 不透明度。' },
] as const;
/* Display-only pin: keeps the blue tab deterministic whatever global theme is on. */
const BLUE_ILLUS_VARS = {
'--illus-100': '190 218 255',
'--illus-300': '106 161 255',
'--illus-500': '22 93 255',
} as CSSProperties;
const ILLUSTRATIONS = [
['EmptyStateIllustration', '通用空状态 — 列表 / 成员 / 频道等无数据', EmptyStateIllustration],
['NoPermissionIllustration', '无权限访问 / 内容待审核不可见', NoPermissionIllustration],
['ListWebLinkIllustration', '列表网页链接 — 按名称搜索无收录空态', ListWebLinkIllustration],
['CrawlingIllustration', '爬取中 — 网页爬取等待态', CrawlingIllustration],
['SuccessIllustration', '成功态 — 跟随品牌主题', SuccessIllustration],
['ArticleQAIllustration', '文章问答场景', ArticleQAIllustration],
['SystemMaintenanceIllustration', '系统维护 — 后端 500 全屏维护弹层', SystemMaintenanceIllustration],
] as const;
export default () => {
const [mode, setMode] = useState<'blue' | 'green' | 'grey'>('blue');
const active = MODES.find((m) => m.id === mode)!;
return (
<div>
<div style={{ display: 'inline-flex', gap: 2, padding: 2, border: '1px solid rgb(var(--border-base))', borderRadius: 8, marginBottom: 8 }}>
{MODES.map((m) => (
<button
key={m.id}
onClick={() => setMode(m.id)}
style={{
height: 30,
padding: '0 16px',
borderRadius: 6,
border: 'none',
cursor: 'pointer',
fontSize: 13,
background: mode === m.id ? 'rgb(var(--brand-500) / 0.08)' : 'transparent',
color: mode === m.id ? 'rgb(var(--brand-500))' : 'rgb(var(--text-3))',
fontWeight: mode === m.id ? 500 : 400,
}}
>
{m.label}
</button>
))}
</div>
<p style={{ fontSize: 13, color: 'rgb(var(--text-3))', marginBottom: 16 }}>{active.hint}</p>
<div
className={mode === 'green' ? 'theme-green' : undefined}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
gap: 12,
...(mode === 'blue' ? BLUE_ILLUS_VARS : {}),
}}
>
{ILLUSTRATIONS.map(([name, usage, Comp]) => (
<div key={name} style={{ border: '1px solid rgb(var(--border-base))', borderRadius: 8, padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 12 }}>
<Comp style={{ width: 120, height: 120 }} grey={mode === 'grey'} />
</div>
<div style={{ fontSize: 12, fontWeight: 500, color: 'rgb(var(--text-2))', wordBreak: 'break-all' }}>{name}</div>
<div style={{ fontSize: 12, color: 'rgb(var(--text-3))' }}>{usage}</div>
</div>
))}
</div>
</div>
);
};
```
## 使用规则
- 空状态 / 加载 / 无权限 / 成功等场景一律用这批插画组件,**不再用静态 PNG**。
- 灰稿:给组件传 `grey` 属性,主题无关、整图降 80% 不透明度。
- 插画调色板(`--illus-*`)只给插画用;UI 图标仍走品牌色。
- 业务代码**禁止写死插画颜色**——一律 `rgb(var(--illus-NNN))`SVG 展示属性里 `var()` 不生效,用内联 `style` / className / CSS mask,并用 `useId()` 去重渐变 / clip 的 id。
@@ -0,0 +1,28 @@
# 组件总览
组件库的实时 demo 页。每个组件页用 `@rspress/plugin-preview` 直接渲染 `src/frontend/client/src/components/ui/` 下的**真实业务组件**——所见即业务页所得,组件代码一改,这里同步变。
规范正文(定义、取值、状态矩阵、迁移台账)在顶部导航「文档」;组件页以演示为主,只穿插最要紧的使用规则。
## 组件清单
| 组件 | 状态 | 内容 |
|---|---|---|
| [Typography 字体](/components/typography) | ✅ 规范 v1 | 字体栈实时示例 / 九档字号阶梯(实时渲染 + 移动端重映射)/ 两档字重 / 使用规则 |
| [Color 色彩](/components/color) | ✅ 规范 v1 | 品牌色蓝绿双 ramp 色块 / 灰阶 110(实时取 var/ semantic 层对照 + 实时示例 / 语义色四组 / 标签色 |
| [Button 按钮](/components/button) | ✅ demo 齐全 | 常用类型 6 别名 / color×variant 3×5 全矩阵 / 尺寸三档 + iconOnly / 内容形态(纯文字、纯 icon square+circle、文字+icon、icon 在右)/ disabled + loading |
| [Modal 弹窗](/components/modal) | 🟨 标准未定稿 | 基准候选壳(OGDialogTemplatedemo / 壳规格已定项 / 过渡期规则 / CommentDialog 共享壳 |
| [Confirm 二次确认](/components/confirm) | ✅ 已定稿 | useConfirm() destructive / default 两档可打开 demo / 规格 Anatomy / 使用规则 |
| [Feedback 点赞点踩](/components/feedback) | ✅ 已收敛 | MessageFeedbackButtons 三种初始状态(回调可视化)/ 延迟点踩规则 |
| [Icon 图标](/components/icon) | ✅ 规范 v1 | bisheng-icons 全量清单(遍历库导出,升版自动同步)/ 尺寸六档 + strokeWidth 补偿 / 着色 / 使用规则 |
| [Illustration 插画](/components/illustration) | ✅ | 7 个主题化插画 × 蓝 / 绿 / 灰稿三态切换 / 调色板 |
| Select / Dropdown / Input / Tabs | ⬜ 待补 | 规范待建 |
## demo 书写约定(给补页面的窗口)
- 每个 `tsx` 围栏代码块必须是**自包含完整组件**(有 `export default`),plugin-previewinternal 模式)才会渲染成 demo。
- 组件从 `~/components/ui/*` 导入,图标从 `bisheng-icons` 导入(`Outlined.*` 优先)——与画廊同款写法。
- 用到 `useLocalize` / `useConfirm` 的组件,demo 里要自带脚手架:`<RecoilRoot initializeState={({ set }) => set(store.lang, 'zh-Hans')}>`+ `ConfirmProvider`)包一层,并 `import '~/locales/i18n'`(不固定 lang 会跟浏览器语言走成英文);业务页无需这样做。
- demo 内布局用内联 style,别用 app CSS 里不存在的 Tailwind 任意值类(文档站的样式来自 app 构建产物,docs 目录不在 tailwind content 扫描范围)。
- 本目录只能用 **ASCII 文件名**(中文子目录路由会空白)。
- 文档站为默认蓝主题(没有 theme-green),品牌色显示蓝色是正常的。
@@ -0,0 +1,105 @@
# 弹窗 Modal
承载业务内容(表单、列表、预览)的普通弹窗。**统一标准尚未定稿**——本页只演示「已定」的基准候选壳与过渡期写法;现状盘点(5 套壳 64 文件)、待决策清单见「文档 → 组件规范 → Modal 弹窗」。
## 基准候选壳
当前推荐的新弹窗写法:`OGDialogTemplate`(壳已对齐 C 套视觉——圆角 16 / p-5 / 灰底毛玻璃,取消/确认按钮已统一两档)。
```tsx
import { RecoilRoot } from 'recoil';
import '~/locales/i18n';
import store from '~/store';
import { Button } from '~/components/ui/Button';
import { Input } from '~/components/ui/Input';
import { OGDialog, OGDialogTrigger } from '~/components/ui/OriginalDialog';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
export default () => (
<RecoilRoot initializeState={({ set }) => set(store.lang, 'zh-Hans')}>
<OGDialog>
<OGDialogTrigger asChild>
<Button color="default" variant="outlined">打开基准候选壳</Button>
</OGDialogTrigger>
<OGDialogTemplate
title="弹窗标题"
description="标题下的说明文字。"
className="max-w-md"
main={
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<p>这里是弹窗正文示例。观察内边距、行距与正文和标题/按钮的间距。</p>
<Input placeholder="示例输入框" />
</div>
}
selection={{
selectHandler: () => null,
selectVariant: 'primary',
selectText: '确定',
}}
/>
</OGDialog>
</RecoilRoot>
);
```
## 壳规格(已定部分)
与二次确认弹窗同一套壳:
| 部位 | 值 | 备注 |
|---|---|---|
| 圆角 | `rounded-2xl`16px | 移动端是否保留直角/贴底待定 |
| 内边距 | `p-5`20px)· 区块间 `gap-4` | |
| 遮罩 | `bg-gray-500/90` + `backdrop-blur-md` | 灰底毛玻璃 |
| 边框 / 阴影 | border `#ebebeb` + 淡投影 | |
| 标题 | `text-base font-medium leading-6` | |
| 取消 / 确认按钮 | 白底描边 + danger / primary 两档 | 与二次确认一致 |
仍待定:遮罩、圆角 8 vs 16、内边距 20 vs 24、标题字重、z-index 三档(50 / 100 / 110)收敛、footer 按钮间距、原语收敛方向(A 套 22 处直拼是最大人群)。
## 过渡期规则
- 新增业务弹窗优先 `OGDialogTemplate`**不要再新增手拼 `AlertDialog` 弹窗**。
- 纯「确认 / 取消」的二次确认不属于本页——用 `useConfirm()`(见「Confirm 二次确认」)。
- 窄屏下弹层贴边距、footer 按钮等宽平铺(见「文档 → 设计规范 → 多端适配」)。
## 反馈评论弹窗 CommentDialog(共享壳)
「标题 + textarea + 取消/提交」的轻量共享壳(容器零 padding、header/body/footer 各 `px-5`、移动端 `calc(100%-48px)` 宽 + 标题居中 + 按钮等宽、每次打开重置草稿、可选 `submitting` 支持异步提交)。点踩反馈、权限申请等场景都用它,不要再手拼。
```tsx
import { useState } from 'react';
import { RecoilRoot } from 'recoil';
import '~/locales/i18n';
import store from '~/store';
import { Button } from '~/components/ui/Button';
import { CommentDialog } from '~/components/ui/CommentDialog';
function Demo() {
const [open, setOpen] = useState(false);
const [last, setLast] = useState('—');
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button color="default" variant="outlined" onClick={() => setOpen(true)}>
打开 CommentDialog
</Button>
<span style={{ fontSize: 12, color: 'rgb(var(--text-3))' }}>最近提交:{last}</span>
<CommentDialog
open={open}
onOpenChange={setOpen}
title="接入申请"
placeholder="请填写申请原因(选填)"
onSubmit={(c) => setLast(c || '(空)')}
/>
</div>
);
}
export default () => (
<RecoilRoot initializeState={({ set }) => set(store.lang, 'zh-Hans')}>
<Demo />
</RecoilRoot>
);
```
> demo 里的 `RecoilRoot` + `import '~/locales/i18n'` 只是文档站的挂载脚手架(组件内部用了 `useLocalize`);业务页在 App 全局已具备,无需自己包。
@@ -0,0 +1,107 @@
# 字体 Typography
九档 semantic 字号(自带字重)+ 两档字重,纯系统字体栈(零 webfont 加载)。**组件与业务代码只用 semantic 类**`text-body` / `text-h1`…),不写裸 `text-sm` 或数值。规范全文见「文档 → 设计规范 → 字体 Typography」。
> 本页表格均从设计 token 单一源 `~/design-token.cjs` 渲染,与 Tailwind 主题、组件消费的是同一份数据。
## 字体栈 Font Family
```tsx
import { FONT_FAMILY } from '~/design-token.cjs';
const SAMPLES = {
base: ['毕昇平台让大模型应用触手可及', 'The quick brown fox jumps over the lazy dog 0123456789'],
mono: ['wf_a1b2c3 · 0123456789', 'const answer = 42; // code sample'],
};
export default () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{Object.entries(FONT_FAMILY).map(([key, f]) => (
<div key={key} style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
<div style={{ width: 190, flexShrink: 0 }}>
<code style={{ fontSize: 13 }}>{f.token}</code>
<div style={{ fontSize: 12, color: 'rgb(var(--text-3))', marginTop: 2 }}>
类名 <code>{f.cls}</code>
</div>
<div style={{ fontSize: 12, color: 'rgb(var(--text-3))', marginTop: 2 }}>{f.usage}</div>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{SAMPLES[key].map((s) => (
<div key={s} className={f.cls} style={{ fontSize: 16, lineHeight: '24px', color: 'rgb(var(--text-1))', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{s}
</div>
))}
<div style={{ fontSize: 11, fontFamily: 'monospace', color: 'rgb(var(--text-3))', marginTop: 6, wordBreak: 'break-all' }}>
{f.stack.join(', ')}
</div>
</div>
</div>
))}
</div>
);
```
同一份字体栈覆盖所有设备:macOS/iOS 命中 San Francisco + 苹方,Windows 命中 Segoe UI + 微软雅黑,Android 命中 Roboto + 思源黑体,无需为移动端单独配置。
## 字号阶梯 Semantic
九档,token 即 Tailwind 类名,**自带字重与行高**。示例为该档实时渲染;窗口缩到 ≤768px 可看移动端重映射(同一类名自动换档)。
```tsx
import { TYPE_SCALE } from '~/design-token.cjs';
export default () => (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', gap: 16, padding: '6px 0', fontSize: 12, color: 'rgb(var(--text-3))' }}>
<span style={{ width: 110, flexShrink: 0 }}>Token</span>
<span style={{ width: 80, flexShrink: 0 }}>桌面</span>
<span style={{ width: 80, flexShrink: 0 }}>移动</span>
<span style={{ width: 40, flexShrink: 0 }}>字重</span>
<span>实时示例</span>
</div>
{TYPE_SCALE.map((s) => (
<div
key={s.name}
style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '10px 0', borderBottom: '1px solid rgb(var(--border-base))' }}
>
<code style={{ width: 110, flexShrink: 0, fontSize: 13 }}>text-{s.name}</code>
<span style={{ width: 80, flexShrink: 0, fontSize: 12, color: 'rgb(var(--text-3))', fontVariantNumeric: 'tabular-nums' }}>{s.desktop[0]} / {s.desktop[1]}</span>
<span style={{ width: 80, flexShrink: 0, fontSize: 12, color: 'rgb(var(--text-3))', fontVariantNumeric: 'tabular-nums' }}>{s.mobile[0]} / {s.mobile[1]}</span>
<span style={{ width: 40, flexShrink: 0, fontSize: 12, color: 'rgb(var(--text-3))' }}>{s.weight}</span>
<div className={`text-${s.name}`} style={{ color: 'rgb(var(--text-1))', minWidth: 0 }}>{s.usage}</div>
</div>
))}
</div>
);
```
## 字重 Font Weight
只用 400 / 500 两档,**禁用 600 / 700**。
```tsx
import { FONT_WEIGHT } from '~/design-token.cjs';
export default () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{FONT_WEIGHT.map((w) => (
<div key={w.name} style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
<div style={{ width: 190, flexShrink: 0 }}>
<code style={{ fontSize: 13 }}>{w.token}</code>
<div style={{ fontSize: 12, color: 'rgb(var(--text-3))', marginTop: 2 }}>
类名 <code>{w.cls}</code> · {w.value}
</div>
</div>
<div style={{ fontSize: 18, lineHeight: '26px', fontWeight: w.value, color: 'rgb(var(--text-1))' }}>{w.usage}</div>
</div>
))}
</div>
);
```
## 使用规则
- 组件与业务代码只用 semantic 类(`text-body` / `text-h1`…),不写裸 `text-sm` 或数值字号。
- 字重只用 `font-normal`(400) / `font-medium`(500) 两档。
- 标题层级连续使用(h1→h2→h3),不跳级取字号。
- 数字/金额加 `tabular-nums`ID、代码用 `font-mono`。
@@ -0,0 +1,328 @@
import { useState } from 'react';
import {
BRAND, BRAND_STEPS, GRAY, TEXT, FILL, BORDER, BG, FUNCTIONAL, TAG,
FONT_FAMILY, TYPE_SCALE, FONT_WEIGHT, RADIUS, ICON_SIZE,
} from '~/design-token.cjs';
export const mono = 'ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono", monospace';
{/* Neutral/semantic tables follow the SITE theme (navbar sun/moon toggle):
swatches read the live CSS vars (`.dark` flips them), and the documented
hex pair is shown/hidden via the bs-hex-light / bs-hex-dark helpers in
rspress-overrides.css. */}
export const Seg = ({ value, options, onChange }) => (
<div style={{ display: 'inline-flex', gap: 2, padding: 2, border: '1px solid rgb(var(--border-base))', borderRadius: 8 }}>
{options.map((o) => (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
style={{
height: 30, padding: '0 16px', borderRadius: 6, border: 'none', cursor: 'pointer', fontSize: 13,
background: value === o.value ? 'rgb(var(--brand-500) / 0.08)' : 'transparent',
color: value === o.value ? 'rgb(var(--brand-500))' : 'rgb(var(--text-3))',
fontWeight: value === o.value ? 500 : 400,
}}
>
{o.label}
</button>
))}
</div>
);
export const Dot = ({ color }) => (
<span style={{ display: 'inline-block', width: 14, height: 14, borderRadius: 3, background: color, marginRight: 8, verticalAlign: '-2px', boxShadow: 'inset 0 0 0 1px rgba(128,128,128,0.25)', flexShrink: 0 }} />
);
export const Name = ({ color, children }) => (
<span style={{ whiteSpace: 'nowrap', color: 'rgb(var(--text-1))' }}>
{color != null && <Dot color={color} />}
<span style={{ fontFamily: mono, fontSize: 13 }}>{children}</span>
</span>
);
export const Code = ({ children, dim }) => (
<span style={{ fontFamily: mono, fontSize: 12.5, color: dim ? 'rgb(var(--text-3))' : 'rgb(var(--text-2))' }}>{children}</span>
);
export const HexPair = ({ hex, darkHex }) => (
<>
<span className="bs-hex-light">{hex}</span>
<span className="bs-hex-dark">{darkHex}</span>
</>
);
export const TokenTable = ({ cols, rows }) => (
<div style={{ overflowX: 'auto', margin: '16px 0 8px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, lineHeight: '20px' }}>
<thead>
<tr>
{cols.map((c) => (
<th key={c.t} style={{ padding: '10px 16px', textAlign: 'left', fontWeight: 500, whiteSpace: 'nowrap', color: 'rgb(var(--text-1))', background: 'rgb(var(--fill-1))', borderBottom: '1px solid rgb(var(--border-base))' }}>{c.t}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
{cols.map((c) => (
<td key={c.t} style={{ padding: '10px 16px', color: 'rgb(var(--text-2))', borderBottom: '1px solid rgb(var(--border-base))', ...(c.style || {}) }}>{c.r(r)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
export const BrandTable = () => {
const [theme, setTheme] = useState('blue');
const rows = [...BRAND_STEPS, 'muted'].map((step) => ({ step, hex: BRAND[theme][step] }));
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-start', margin: '16px 0 0' }}>
<Seg value={theme} onChange={setTheme} options={[{ value: 'blue', label: '蓝' }, { value: 'green', label: '绿' }]} />
</div>
<TokenTable
cols={[
{ t: '变量名', r: (r) => <Name color={r.hex}>brand-{r.step}</Name> },
{ t: '变量值', r: (r) => <Code>{r.hex}</Code> },
{ t: 'CSS 变量', r: (r) => <Code>--brand-{r.step}</Code> },
{ t: 'Tailwind 类', r: (r) => (r.step === 'muted' ? <Code dim>—(rgb(var(--brand-muted))</Code> : <Code>blue-{r.step}</Code>) },
{ t: '描述', r: (r) => <>{BRAND.role[r.step]}{r.step === BRAND.main ? 'brand main' : ''}</> },
]}
rows={rows}
/>
</div>
);
};
export const GrayTable = () => (
<TokenTable
cols={[
{ t: '变量名', r: (g) => <Name color={`rgb(var(--arco-gray-${g.n}))`}>gray-{g.n}</Name> },
{ t: '变量值', r: (g) => <Code><HexPair hex={g.hex} darkHex={g.darkHex} /></Code> },
{ t: 'CSS 变量', r: (g) => <Code>--arco-gray-{g.n}</Code> },
{ t: '描述', r: (g) => g.role },
]}
rows={GRAY}
/>
);
export const RoleTable = ({ data, prefix, cls, legacy }) => {
return (
<TokenTable
cols={[
{ t: '变量名', r: (x) => <Name color={`rgb(var(${x.cssVar}))`}>{prefix}-{x.name}</Name> },
{ t: '变量值', r: (x) => <Code>{x.ref ? `${x.ref} · ` : ''}<HexPair hex={x.hex} darkHex={x.darkHex} /></Code>, style: { whiteSpace: 'nowrap' } },
{ t: 'CSS 变量', r: (x) => <Code>{x.cssVar}</Code> },
{
t: 'Tailwind 类',
r: (x) => (
<span style={{ whiteSpace: 'nowrap' }}>
<Code>{cls(x)}</Code>
{legacy && x.legacy && <><br /><Code dim>旧 {legacy(x)}deprecated</Code></>}
</span>
),
},
{ t: '描述', r: (x) => x.usage },
]}
rows={data}
/>
);
};
export const FunctionalTable = () => {
const stops = [['', 'main', '常规'], ['-hover', 'hover', '悬浮(hover'], ['-active', 'active', '点击(active'], ['-tint', 'tint', '浅底(tag / 提示条)']];
const rows = FUNCTIONAL.flatMap((fn) => stops.map(([suffix, key, desc]) => ({
name: `${fn.name}${suffix}`,
hex: fn[key],
cssVar: `${fn.cssVar}${suffix}`,
desc: `${fn.label} · ${desc}`,
})));
return (
<TokenTable
cols={[
{ t: '变量名', r: (r) => <Name color={r.hex}>{r.name}</Name> },
{ t: '变量值', r: (r) => <Code>{r.hex}</Code> },
{ t: 'CSS 变量', r: (r) => <Code>{r.cssVar}</Code> },
{ t: 'Tailwind 类', r: (r) => <Code>bg-{r.name} / text-{r.name}</Code>, style: { whiteSpace: 'nowrap' } },
{ t: '描述', r: (r) => r.desc },
]}
rows={rows}
/>
);
};
export const TagTable = () => (
<TokenTable
cols={[
{ t: '场景', r: (t) => <span style={{ color: 'rgb(var(--text-1))' }}>{t.label}</span> },
{ t: '背景 / 文字', r: (t) => <span style={{ whiteSpace: 'nowrap' }}><Dot color={t.bg} /><Code>{t.bg}</Code>&emsp;<Dot color={t.fg} /><Code>{t.fg}</Code></span> },
{ t: '预览', r: (t) => <span style={{ display: 'inline-flex', alignItems: 'center', borderRadius: 4, padding: '2px 8px', fontSize: 12, background: t.bg, color: t.fg }}>标签</span> },
{ t: '说明', r: (t) => t.note },
]}
rows={TAG}
/>
);
export const FontFamilyTable = () => (
<TokenTable
cols={[
{ t: '变量名', r: (f) => <Name>{f.token}</Name> },
{ t: 'Tailwind 类', r: (f) => <Code>{f.cls}</Code> },
{ t: '字体栈', r: (f) => <Code dim>{f.stack.join(', ')}</Code> },
{ t: '描述', r: (f) => f.usage, style: { whiteSpace: 'nowrap' } },
]}
rows={[FONT_FAMILY.base, FONT_FAMILY.mono]}
/>
);
export const TypeScaleTable = () => (
<TokenTable
cols={[
{ t: '变量名', r: (s) => <Name>text-{s.name}</Name> },
{ t: '桌面(字号 / 行高)', r: (s) => <Code>{s.desktop[0]}px / {s.desktop[1]}px</Code> },
{ t: '移动 ≤768px', r: (s) => <Code dim={s.mobile[0] === s.desktop[0] && s.mobile[1] === s.desktop[1]}>{s.mobile[0]}px / {s.mobile[1]}px</Code> },
{ t: '字重', r: (s) => <Code>{s.weight}</Code> },
{ t: 'CSS 变量', r: (s) => <span style={{ whiteSpace: 'nowrap' }}><Code>{s.cssVar}</Code><br /><Code dim>{s.leadingVar}</Code></span> },
{ t: '描述', r: (s) => s.usage },
]}
rows={TYPE_SCALE}
/>
);
export const WeightTable = () => (
<TokenTable
cols={[
{ t: '变量名', r: (w) => <Name>{w.token}</Name> },
{ t: '变量值', r: (w) => <Code>{w.value}</Code> },
{ t: 'Tailwind 类', r: (w) => <Code>{w.cls}</Code> },
{ t: '示例', r: (w) => <span style={{ fontWeight: w.value, color: 'rgb(var(--text-1))' }}>字体示例 Aa 123</span> },
{ t: '描述', r: (w) => w.usage },
]}
rows={FONT_WEIGHT}
/>
);
export const RadiusTable = () => (
<TokenTable
cols={[
{ t: '变量名', r: (x) => <Name>radius-{x.name}</Name> },
{ t: '变量值', r: (x) => <Code>{x.px >= 999 ? '9999px(胶囊)' : `${x.px}px`}</Code> },
{ t: 'Tailwind 类', r: (x) => <Code>{x.cls || `rounded-${x.name}`}</Code> },
{
t: '预览',
r: (x) => (x.px >= 999
? <span style={{ display: 'inline-block', width: 44, height: 44, borderRadius: 9999, border: '1px solid rgb(var(--brand-500))', background: 'rgb(var(--brand-50))', verticalAlign: 'middle' }} />
: <span style={{ display: 'inline-block', width: 44, height: 44, borderTopLeftRadius: x.px, border: '1px solid rgb(var(--brand-500))', background: 'rgb(var(--brand-50))', verticalAlign: 'middle' }} />),
},
{ t: '描述', r: (x) => x.usage },
]}
rows={RADIUS}
/>
);
export const IconSizeTable = () => (
<TokenTable
cols={[
{ t: '变量名', r: (x) => <Name>icon-{x.name}</Name> },
{ t: '变量值', r: (x) => <Code>{x.px}px</Code> },
{ t: '线宽 stroke', r: (x) => <Code dim={!x.strokeWidth}>{x.strokeWidth || '2(默认)'}</Code> },
{
t: '预览',
r: (x) => (
<svg width={x.px} height={x.px} viewBox="0 0 24 24" fill="none" stroke="rgb(var(--text-2))" strokeWidth={x.strokeWidth || 2} strokeLinecap="round" strokeLinejoin="round" style={{ verticalAlign: 'middle' }}>
<circle cx="12" cy="12" r="9" />
<path d="M8 12h8M12 8v8" />
</svg>
),
},
{ t: '描述', r: (x) => x.usage },
]}
rows={ICON_SIZE}
/>
);
# 设计变量 Design Token
组件库的全局 Token——单一源为 <code>src/design-token.cjs</code>SSOT),本页所有表格与色块直接从该文件渲染,**改 token 即改此页**,规范不会与组件实际取值漂移。业务代码只消费 Tailwind 类 / CSS 变量(semantic 层),**禁止裸 hex**。色块与「变量值」随右上角站点主题切换,展示对应的亮 / 暗色取值。
## 品牌色 Brand
主色 = 500 档,随蓝⇄绿主题整体切换(运行时载体 `--brand-*``.theme-green` 覆写)。业务代码一律走 `blue-*` 类——它已重定向到 `--brand-*`,语义是「品牌色」而非字面蓝色;**禁止写品牌 hex**。
<BrandTable />
> 选中 / 激活浅底统一 `bg-blue-500/[0.07]`muted 档无 Tailwind 类,用 `rgb(var(--brand-muted))`。
## 中性色 Neutralprimitive
中性灰阶 1–10,中性色数值源;暗色模式下明度整体翻转(`.dark` 覆写 `--arco-gray-*`)。**组件不直接消费这层**,走下方 semantic 层。
<GrayTable />
## 文字颜色 Text
强度阶 strong → muted → hint → disabled,对应 gray-10 → 8 → 6 → 4。旧数字名(`text-1`…)为 deprecated 别名,仅供渐进迁移。
<RoleTable data={TEXT} prefix="text" cls={(x) => `text-text-${x.name}`} legacy={(x) => `text-text-${x.legacy}`} />
## 填充颜色 Fill
<RoleTable data={FILL} prefix="fill" cls={(x) => `bg-fill-${x.name}`} legacy={(x) => `bg-fill-${x.legacy}`} />
## 边框颜色 Border
<RoleTable data={BORDER} prefix="border" cls={(x) => `border-border-${x.name}`} />
## 背景颜色 Background
「会变暗的白」——页面最底层表面。固定不换肤的 `--white: #fff` 是另一职责,勿混用。
<RoleTable data={BG} prefix="bg" cls={(x) => `bg-bg-${x.name}`} />
## 功能色 Functional
成功 / 警告 / 危险为固定 hex,**全主题恒定、不参与换肤**;链接 / 信息色 = 品牌色,随主题切换。
<FunctionalTable />
## 标签色 Tag
成对使用:浅底 + 深字。橙 / 绿 / 红三对走 token;技能紫、审批蓝为固定例外色(不 token 化、不换肤)。
<TagTable />
## 字体家族 Font Family
<FontFamilyTable />
## 字号与行高 Font Size
语义化字阶(Tailwind fontSize key 即变量名),≤768px 自动重映射(运行时载体为 CSS 变量,媒体查询切换)。字号与字重绑定:类名自带对应字重。
<TypeScaleTable />
## 字重 Font Weight
只用 400 / 500 两档,不使用 600+。
<WeightTable />
## 圆角 Radius
阶梯与 Tailwind 圆角命名 1:1 对齐——**类名即 token 名**。控件档 sm / md / lg4 / 6 / 8`--radius` 基准 = 8px),容器档 xl / 2xl12 / 16),特大容器档 3xl / 4xl24 / 32),胶囊 / 圆形用 full。
<RadiusTable />
> - 只允许表内档位;历史遗留的离散值(`rounded-[5px]` / `[10px]` / `[20px]` / `[2px]`…)迭代到时归并至最近档,不再新增。
> - `4xl`32px)为项目扩展档(Tailwind 官方阶梯止于 3xl),已在 `tailwind.config.cjs` 注册。
> - Dialog 默认样式含 `sm:rounded-lg`,覆写圆角需连同 `sm:` 变体(`rounded-2xl sm:rounded-2xl`)。
## 图标尺寸 Icon Size
原始画布 24px、线宽 2;缩放只改容器尺寸,线宽随缩放视觉等比(xs / xl2 例外档单独标注)。
<IconSizeTable />
+9
View File
@@ -0,0 +1,9 @@
# BISHENG 组件库
BISHENG client 前台的**设计规范 + 组件库**文档站(基于 rspress)。
- **文档** —— 设计规范:字体、色彩、多端适配、图标、插画、滚动条,以及各组件规范。
- **组件** —— 组件的实时可预览 demo(`@rspress/plugin-preview`),直接渲染真实业务组件 `src/components/ui/`。demo 源文件在 `docs-ui-refactor/components/`(ASCII 目录名,中文目录会让路由失效)。
> 本站为内部开发工具,文档源在仓库根 `docs-ui-refactor/`(已 gitignore、不发版)。
> 本地预览:在 `src/frontend/client/` 下运行 `npm run dev:docs`。
@@ -0,0 +1,47 @@
# 文案规范化脚本
把《文案规范》《文档撰写规范》里能机械化的排版规则,一键刷到文档的**正文**上——代码、JSX、链接、URL、文件名一律不动。
> 前置:本机装了 Node。脚本是纯 JS(`.cjs`),无依赖。
> 这个目录不进站点(在 `rspress.config.ts` 的 `route.exclude` 里排除了)。
## 两个脚本
| 脚本 | 干什么 |
|---|---|
| `normalize.cjs` | 落地:中西文空格、半角标点→全角、ASCII 引号→`「」`、中文语境括号→`()` |
| `detect.cjs` | 检查:报告还残留在中文语境里的半角标点(人工复查用),不改文件 |
## 用法
```bash
# 在 docs-ui-refactor/ 下
node scripts/normalize.cjs 基础-多端适配原则.md 组件-Button按钮.md # 刷指定文件
node scripts/normalize.cjs *.md components/*.mdx # 刷一批
node scripts/detect.cjs *.md components/*.mdx # 刷完后查残留
```
`normalize.cjs` 做四件事:
1. 中文↔英文/数字之间补一个半角空格;
2. 半角 `, ; : ? .` 在中文语境里换成全角(`**` `` ` `` `)` 这类标记会看穿,最多跳 2 步);
3. ASCII `"…"` 里包中文的 → `「…」`
4. 中文语境的半角 `( )` → 全角 ` `,成对转换。
### `--curly`:只给《文案规范》用
《文案规范》**规定的是产品文案标准**,产品用全角双引号 `“”`。要刷它时(一般不需要,已定稿)加 `--curly`,把引号换成 `“”` 而不是 `「」`
```bash
node scripts/normalize.cjs --curly --force 基础-文案规范.md
```
## ⚠️ 三条铁律
1. **`基础-文案规范.md` 默认被跳过**,因为它有「✅ 推荐 / ❌ 不推荐」对照表——`❌` 单元格是**故意写错的**,一刷就废。要动它只能手改,或明知风险加 `--force`。**任何带 ❌ 反例的文档都要当心这一条。**
2. **刷完一定看 `git diff` / 文件 diff**。脚本是启发式的,边界情况(标点夹在英文 token 之间、句末英文后的句号等)它可能漏或误判,靠你复查兜底。
3. **别对代码动手**。脚本已屏蔽代码;`--arco-gray-*` 这类是真实 CSS 变量名,不是「引用 Arco」,本就不该改。
## 规则出处
完整规则见《文档撰写规范》(`元-文档撰写规范.md`)§1、§6、§7 与《文案规范》(`基础-文案规范.md`)。脚本只覆盖其中机械的部分;措辞、命名、引用来源这些要人来把关。
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Residual checker for docs-ui-refactor: after running normalize.cjs and doing
* a manual pass, this flags half-width , ; : ? that still sit in a Chinese
* context (including cases where markdown markup separates the punctuation from
* the nearest CJK char). Reports file:line so a human can eyeball each hit.
* Never edits.
*
* Usage: node scripts/detect.cjs <file...>
* Note: 基础-文案规范.md legitimately keeps a few half-width marks (the ❌
* counter-examples and the 西文半角 illustration) — those hits are expected.
*/
const fs = require('fs');
const CJK = /[㐀-䶿一-鿿豈-﫿]/;
const S = String.fromCharCode(1);
const isCJK = (ch) => ch != null && CJK.test(ch);
function mask(src) {
let s = src;
const put = () => S;
s = s.replace(/```[\s\S]*?```/g, put);
s = s.replace(/~~~[\s\S]*?~~~/g, put);
s = s.replace(/<!--[\s\S]*?-->/g, put);
s = s.replace(/^[ \t]*(?:import|export)[ \t][^\n]*$/gm, () => '');
s = s.replace(/`[^`\n]*`/g, put);
s = s.replace(/\[[^\]]*\]\([^)]*\)/g, put); // whole [display](url)
s = s.replace(/https?:\/\/[^\s)]+/g, put);
s = s.replace(/[0-9A-Za-z一-鿿_./-]+\.(?:mdx?|tsx?|jsx?|cjs|mjs|css|json|svg|png|ya?ml|html?)\b/g, put);
s = s.replace(/<\/?[A-Za-z][^>]*?>/g, put);
return s;
}
// markup chars we "see through" when hunting for a CJK neighbour
const SKIP = new Set([' ', '\t', '*', '_', '~', '`', ')', ']', '(', '[',
'', '', '【', '】', '「', '」', '『', '』', '"', '“', '”', "'", '《', '》', S, '#', '>', '-']);
const PUNCT = new Set([',', ';', ':', '?']);
function nearCJK(chars, i, dir) {
let steps = 0;
for (let j = i + dir; j >= 0 && j < chars.length && steps < 6; j += dir) {
const c = chars[j];
if (c === '\n' || c === '\r') return false;
if (SKIP.has(c)) { steps++; continue; }
return CJK.test(c);
}
return false;
}
let total = 0;
for (const f of process.argv.slice(2)) {
mask(fs.readFileSync(f, 'utf8')).split('\n').forEach((line, idx) => {
if (/^\s*\|?[\s:|-]+\|?\s*$/.test(line)) return; // table delimiter row
const chars = Array.from(line);
for (let i = 0; i < chars.length; i++) {
if (!PUNCT.has(chars[i])) continue;
if (chars[i] === ':' && /\d/.test(chars[i + 1] || '')) continue; // ratio/time
if (nearCJK(chars, i, -1) || nearCJK(chars, i, +1)) {
console.log(`${f}:${idx + 1} [${chars[i]}] ${line.trim().slice(0, 90)}`);
total++;
break; // one report per line is enough to eyeball
}
}
});
}
console.log(`\n${total} line(s) flagged.`);
@@ -0,0 +1,154 @@
#!/usr/bin/env node
/**
* docs-ui-refactor punctuation normalizer (all passes in one).
*
* Applies the 文案规范 / 文档撰写规范 rules to a doc's PROSE, leaving code,
* JSX, links and URLs untouched:
* 1. add a half-width space at CJK<->[A-Za-z0-9] boundaries
* 2. half-width , ; : ? . -> full-width when in Chinese context
* (markup like ** ` ) is seen through, up to 2 hops)
* 3. ASCII "..." pairs containing CJK -> 「」 (or “” with --curly)
* 4. half-width ( ) in Chinese context -> , pair-aware
*
* SAFETY: 基础-文案规范.md is skipped by default — it contains deliberate
* ❌ counter-examples (wrong-on-purpose text) that must NOT be normalized.
* Edit that file by hand. Pass --force to override (you almost never should).
*
* Usage:
* node scripts/normalize.cjs [--curly] [--force] <file...>
* --curly use full-width double quotes “” instead of 「」 (only 文案规范,
* which states the product quote standard, wants this)
*
* ALWAYS review the diff afterwards, then run detect.cjs to catch stragglers.
*/
const fs = require('fs');
const path = require('path');
const CJK = /[㐀-䶿一-鿿豈-﫿]/;
const S = String.fromCharCode(1);
const isCJK = (ch) => ch != null && CJK.test(ch);
function mask(src) {
const store = [];
const put = (m) => S + (store.push(m) - 1) + S;
let s = src;
s = s.replace(/```[\s\S]*?```/g, put);
s = s.replace(/~~~[\s\S]*?~~~/g, put);
s = s.replace(/<!--[\s\S]*?-->/g, put);
s = s.replace(/^[ \t]*(?:import|export)[ \t][^\n]*$/gm, put);
s = s.replace(/`[^`\n]*`/g, put);
s = s.replace(/\[[^\]]*\]\([^)]*\)/g, put); // whole [display](url) — filenames in link text must stay verbatim
s = s.replace(/https?:\/\/[^\s)]+/g, put);
// bare filename / path tokens (e.g. 组件-Button按钮.md, tailwind.config.cjs) —
// identifiers, must stay verbatim (no CJK<->Latin spacing inside them)
s = s.replace(/[0-9A-Za-z一-鿿_./-]+\.(?:mdx?|tsx?|jsx?|cjs|mjs|css|json|svg|png|ya?ml|html?)\b/g, put);
s = s.replace(/<\/?[A-Za-z][^>]*?>/g, put); // real tags only ("< 576px" is safe)
return { s, store };
}
function unmask(s, store) {
const re = new RegExp(S + '(\\d+)' + S, 'g');
let prev;
do { prev = s; s = s.replace(re, (_, i) => store[+i]); } while (s !== prev);
return s;
}
function spacing(s) {
s = s.replace(/([㐀-䶿一-鿿豈-﫿])([A-Za-z0-9])/g, '$1 $2');
s = s.replace(/([A-Za-z0-9])([㐀-䶿一-鿿豈-﫿])/g, '$1 $2');
return s;
}
const FULL = { ',': '', ';': '', ':': '', '?': '' };
const SKIP = new Set([' ', '\t', '*', '_', '~', ')', '(', '', '', '[', ']',
'「', '」', '『', '』', '"', '“', '”', '《', '》', S]);
function nearCJK(chars, i, dir) {
let skips = 0;
for (let j = i + dir; j >= 0 && j < chars.length; j += dir) {
const c = chars[j];
if (c === '\n' || c === '\r') return false;
if (SKIP.has(c)) { if (++skips > 2) return false; continue; }
return isCJK(c);
}
return false;
}
function punct(line) {
if (/^\s*\|?[\s:|-]+\|?\s*$/.test(line)) return line; // table delimiter row
const chars = Array.from(line);
const sideNS = (i, dir) => {
for (let j = i + dir; j >= 0 && j < chars.length; j += dir) {
if (chars[j] === ' ' || chars[j] === '\t') continue;
return chars[j];
}
return null;
};
for (let i = 0; i < chars.length; i++) {
const c = chars[i];
if (c === '.') {
const prev = sideNS(i, -1), next = sideNS(i, +1);
if (isCJK(prev) && (next === null || isCJK(next))) chars[i] = '。';
} else if (c in FULL) {
if (c === ':' && /\d/.test(chars[i + 1] || '')) continue; // ratio/time 1:1
if (nearCJK(chars, i, -1) || nearCJK(chars, i, +1)) chars[i] = FULL[c];
}
}
return chars.join('');
}
function parens(line) {
const chars = Array.from(line);
const stack = [];
const mark = new Set();
for (let i = 0; i < chars.length; i++) {
if (chars[i] === '(') stack.push(i);
else if (chars[i] === ')' && stack.length) {
const o = stack.pop();
const inner = chars.slice(o + 1, i).join('');
if (CJK.test(inner) || isCJK(chars[o - 1]) || isCJK(chars[i + 1])) { mark.add(o); mark.add(i); }
}
}
if (!mark.size) return line;
return chars.map((c, i) => (mark.has(i) ? (c === '(' ? '' : '') : c)).join('');
}
function quotes(line, open, close) {
const chars = Array.from(line);
const pos = [];
for (let i = 0; i < chars.length; i++) if (chars[i] === '"') pos.push(i);
for (let p = 0; p + 1 < pos.length; p += 2) {
const a = pos[p], b = pos[p + 1];
if (CJK.test(chars.slice(a + 1, b).join(''))) { chars[a] = open; chars[b] = close; }
}
return chars.join('');
}
function normalize(src, curly) {
const { s, store } = mask(src);
const [oq, cq] = curly ? ['“', '”'] : ['「', '」'];
const out = spacing(s)
.split('\n')
.map((line) => punct(parens(quotes(line, oq, cq))))
.join('\n');
return unmask(out, store);
}
let curly = false, force = false;
const files = [];
for (const a of process.argv.slice(2)) {
if (a === '--curly') curly = true;
else if (a === '--force') force = true;
else files.push(a);
}
let changed = 0;
for (const f of files) {
if (path.basename(f) === '基础-文案规范.md' && !force) {
console.log(' SKIP ', f, '(has ❌ counter-examples — edit by hand, or --force)');
continue;
}
const src = fs.readFileSync(f, 'utf8');
const out = normalize(src, curly);
if (out !== src) { fs.writeFileSync(f, out); changed++; console.log(' fixed', f); }
else console.log(' same ', f);
}
console.log('\n' + changed + ' file(s) changed. Review the diff, then run detect.cjs.');
@@ -0,0 +1,75 @@
# 文档撰写规范
> 设计系统 · v1 · 2026-07-22
> 适用:本站(docs-ui-refactor)里所有规范文档的撰写与维护。
> 参考:[文案规范](基础-文案规范.md)、rspress 站点配置 `src/frontend/client/rspress.config.ts`。
---
本站是内部的「设计规范 + 组件库」文档站。为了让每篇读起来像一个人写的、也别踩站点机制的坑,写文档前先过一遍下面几条约定。
---
## 1. 文案跟着《文案规范》走
正文一律遵循 [文案规范](基础-文案规范.md):中西文之间留一个空格、标点跟着主体语言走、数字用半角、说人话。
有一个例外必须记牢:**「✅ 推荐 / ❌ 不推荐」对照表里的 ❌ 单元格是「故意写错的」**。批量格式化、脚本规范化、顺手一改——都容易把反例「修好」,让正反例变得一样,例子就废了。处理文案时,**反例一律跳过,保持原样**。
机械的部分(空格、全半角标点、引号、括号)可以用 `scripts/normalize.cjs` 批量刷、`scripts/detect.cjs` 查残留(用法见 `scripts/README.md`)。但**刷完务必看 diff**,且脚本默认跳过《文案规范》本身——就是因为上面这条反例铁律。
## 2. 先选对格式:md 还是 mdx
| 场景 | 用 | 原因 |
|---|---|---|
| 纯文字规范 | `.md` | 支持内部区块自动隐藏(见 §4),写起来最省心 |
| 需要实时组件 demo / JSX 图示 | `.mdx` | 能内嵌真实组件预览 |
一个关键差别:**`.mdx` 不能用 `<!-- site-hide -->` 隐藏内部内容**。MDX 会先一步预编译、拒绝 HTML 注释,隐藏标记在 mdx 里不生效。所以 mdx 页要写成「读者干净版」,别把改动记录、给实现窗口这类内部段落留在里面。
## 3. 文件命名与落位
- 规范页放仓库根 `docs-ui-refactor/`**可以用中文名**(如 `基础-色彩规范.mdx`),会被解析成顶层路由。
- **组件 demo 目录 `components/` 必须全用 ASCII 文件名**。rspress v1 的客户端路由匹配不了中文的嵌套目录,中文名会让 demo 页打不开。
- 引用另一篇文档时,链接文字用**文件名原样**:写 `[组件-Button按钮.md](组件-Button按钮.md)`,不要写成 `组件-Button 按钮.md`。文件名是一个整体标识,即便中西文之间本该留空格,这里也不加。
## 4. 页面结构约定
- **开头**`# 标题` 紧跟一段 `>` 引用块,放元信息(版本 · 日期 / 适用范围 / 参考)。这段引用块**会被站点自动隐藏**——它是给维护者看的,不进正文。
- **分节**:章节用 `##`。**不要手写 `---` 分隔线**:主题会在每个 `##` 前自动画一条分隔线,手写的会渲染成双线。
- **内部内容**:改动记录、附录、给实现窗口、待决策清单、代码锚点……照常写在文末。站点会按标题关键字**自动隐藏**这些段落(命中关键字:改动记录 / 附录 / 给实现窗口 / 待决策清单 / 落地记录 / 代码锚点 / 关键结论 / 扫描存档),md 仍是唯一事实源。
- **精确隐藏**(仅 `.md` 生效):
- `<!-- site-hide -->` 隐藏紧跟的那个标题及其整节
- `<!-- site-hide:start -->``<!-- site-hide:end -->` 隐藏中间区间
- **文末**:留一张「改动记录」表,登记每次改动,注明「不提交」(文档夹已 gitignore)。
## 5. 新页面上线清单
1. 选 md / mdx,放对目录(demo 进 ASCII 命名的 `components/`)。
2. 写好开头的元信息引用块,正文遵循 §1 的文案规范。
3.`src/frontend/client/rspress.config.ts``themeConfig.sidebar` 里注册入口:文档进 `/` 分组,demo 进 `/components/` 分组。**改完配置要重启 dev 服务**——rspress 不热重载 config。
4. 本地在 `src/frontend/client/` 下跑 `npm run dev:docs` 预览,检查左侧栏、正文、右侧 TOC 是否都正常。
5. 文末补一条「改动记录」。
## 6. 引号与括号
- **引号**:所有文档(**包括《文案规范》**)自己的正文,中文引号一律用**直角引号 `「」`**(需要嵌套时用 `『』`)——比双引号利落,也不跟代码里的 `"` 抢眼。
- 别和「产品文案」搞混:《文案规范》**规定的对象**是 BISHENG 产品界面的文字,产品面向普通用户,规则里定的是**全角双引号 `“”`**(这是写给产品的标准)。但《文案规范》**这篇文档本身**,跟其它文档一样,正文与示例都用 `「」`
- **括号**:跟着主体语言走。中文句子里用**全角 `()`**;纯代码 / 英文 token(如 `rgb(var(--x))``(hover: none)``(14/22)`)保持**半角 `()`**。一句之内不混用。
## 7. 命名与措辞
- **产品名写全大写 `BISHENG`**,不是 BiSheng / Bisheng。代码标识符例外——`bisheng-icons`、路径里的 `bisheng` 照原样。
- **中英混排的词组,中文在前、英文在后**:写「设计变量 Design Token」「字体 Typography」,不写「Design Token 设计变量」。侧栏、标题、标签一律照此(变体名 / 层名等技术专名如 `Outlined``Primitive` 例外,本身就没有中文名)。
- **展示层不写「参考 / 对齐 antd、Arco、Apple 设计系统」这类同行致意**。规范就是规范——用对我们自己更贴切的描述来陈述规则,别在正文里说「我们对齐了谁的做法」。灵感来源、外部链接留在**隐藏区**(开头的 meta 引用块、附录、改动记录、给实现窗口)即可,不进展示层。
- 例外:拿**权威标准**给某个数值背书是可以的,比如「44px 是 WCAG / Apple HIG 无障碍标准的推荐值」——那是在给规则找依据,不是在说抄了谁的设计。
---
## 改动记录
| 日期 | 改了什么 | 提交 |
|---|---|---|
| 2026-07-22 | 建档:文案例外(反例不改)、md/mdx 选择、命名落位、页面结构与 site-hide、上线清单,共 6 节 | 不提交(文档夹已 gitignore |
| 2026-07-22 | §6 定稿:引号(所有文档含《文案规范》正文/示例用「」;产品标准“”只是《文案规范》规则里写给产品的对象)+ 括号跟语境(中文全角、代码半角);全站落地一遍 | 不提交 |
| 2026-07-22 | §7 新增命名与措辞:产品名 BISHENG 全大写、中英词组中文在前、展示层不写引用来源(出处留隐藏区);全站落地 | 不提交 |
@@ -0,0 +1,319 @@
{/* 工作档头(站点不渲染):
设计系统 · 图标部分 v1 定稿 · 2026-07-16
参考 antd 图标设计原则(准确 / 简单 / 节奏 / 愉悦),基于 bisheng-icons 库现状制定。
与 00-总纲.md(§七 图标规则)、组件-Button按钮.md(§3.2/3.3 按钮内 icon)、基础-色彩规范.mdx 配套。现状扫描见文末附录 A(仅迁移参考)。
本页 2026-07-21 由 .md 转 .mdx(为内嵌 SVG 示意图);.md 时代靠 strip-internal-loader 隐藏的内部段落,现改用 MDX 注释包裹,源文件仍是单一事实源。 */}
import { Outlined } from 'bisheng-icons';
export const IconFile = Outlined.File;
export const IconFileImage = Outlined.FileImage;
export const IconFileWord = Outlined.FileWord;
export const figRow = { display: 'flex', gap: 40, flexWrap: 'wrap', alignItems: 'stretch', margin: '12px 0 20px' };
export const half = { flex: 1, minWidth: 240, margin: 0, display: 'flex', flexDirection: 'column', alignItems: 'center' };
export const colHalf = { flex: 1, minWidth: 240, display: 'flex', justifyContent: 'center' };
export const figCap = { marginTop: 'auto', paddingTop: 8, fontSize: 12, color: 'rgb(var(--text-3))', textAlign: 'center' };
export const Pair = ({ a, b, cap }) => (
<figure style={{ margin: '12px 0 20px' }}>
<div style={{ display: 'flex', gap: 40, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={colHalf}>{a}</div>
<div style={colHalf}>{b}</div>
</div>
<figcaption style={{ ...figCap, marginTop: 0, paddingTop: 12 }}>{cap}</figcaption>
</figure>
);
export const dimS = { stroke: 'rgb(var(--text-3))', strokeWidth: 1 };
export const dimT = { fill: 'rgb(var(--text-3))', fontSize: 12 };
export const keyline = { fill: 'none', stroke: 'rgb(var(--brand-500)/0.4)', strokeWidth: 1 };
export const gridS = { stroke: 'rgb(var(--text-3)/0.35)', strokeWidth: 1 };
export const canvasBox = { fill: 'none', stroke: 'rgb(var(--text-3)/0.55)', strokeWidth: 1, strokeDasharray: '4 3' };
export const liveBox = { fill: 'none', stroke: 'rgb(var(--brand-500)/0.45)', strokeWidth: 1 };
export const inkStroke = { fill: 'none', stroke: 'rgb(var(--text-2))', strokeWidth: 6, strokeLinecap: 'round', strokeLinejoin: 'round' };
export const okMark = { fill: '#00b42a', fontSize: 16 };
export const badMark = { fill: '#f53f3f', fontSize: 16 };
export const FigCanvas = () => (
<div style={figRow}>
<figure style={half}>
<svg width="290" height="266" viewBox="0 0 340 312" role="img" aria-label="画布、绘制区与安全边距示意">
<rect x="40" y="40" width="240" height="240" style={{ fill: 'rgb(var(--text-3)/0.07)', stroke: 'rgb(var(--text-3)/0.55)', strokeWidth: 1, strokeDasharray: '4 3' }} />
<rect x="60" y="60" width="200" height="200" style={{ fill: 'rgb(var(--brand-500)/0.07)', stroke: 'rgb(var(--brand-500)/0.55)', strokeWidth: 1 }} />
<line x1="40" y1="40" x2="280" y2="280" style={{ stroke: 'rgb(var(--text-3)/0.25)', strokeWidth: 1 }} />
<line x1="280" y1="40" x2="40" y2="280" style={{ stroke: 'rgb(var(--text-3)/0.25)', strokeWidth: 1 }} />
<line x1="160" y1="40" x2="160" y2="280" style={{ stroke: 'rgb(var(--text-3)/0.25)', strokeWidth: 1 }} />
<line x1="40" y1="160" x2="280" y2="160" style={{ stroke: 'rgb(var(--text-3)/0.25)', strokeWidth: 1 }} />
<circle cx="160" cy="160" r="100" style={keyline} />
<rect x="70" y="70" width="180" height="180" rx="16" style={keyline} />
<rect x="80" y="60" width="160" height="200" rx="16" style={keyline} />
<rect x="60" y="80" width="200" height="160" rx="16" style={keyline} />
<line x1="40" y1="24" x2="280" y2="24" style={dimS} />
<line x1="40" y1="19" x2="40" y2="29" style={dimS} />
<line x1="280" y1="19" x2="280" y2="29" style={dimS} />
<text x="160" y="16" textAnchor="middle" style={dimT}>画布 24</text>
<line x1="60" y1="296" x2="260" y2="296" style={dimS} />
<line x1="60" y1="291" x2="60" y2="301" style={dimS} />
<line x1="260" y1="291" x2="260" y2="301" style={dimS} />
<text x="160" y="311" textAnchor="middle" style={dimT}>绘制区 20</text>
<line x1="26" y1="40" x2="26" y2="60" style={dimS} />
<line x1="21" y1="40" x2="31" y2="40" style={dimS} />
<line x1="21" y1="60" x2="31" y2="60" style={dimS} />
<text x="26" y="76" textAnchor="middle" style={{ fill: 'rgb(var(--text-3))', fontSize: 11 }}>2</text>
<text x="290" y="54" style={{ fill: 'rgb(var(--text-3))', fontSize: 11 }}>安全边距</text>
</svg>
<figcaption style={figCap}>24×24 画布 · 中央 20×20 绘制区 · 圆 / 方 / 横竖矩形为基础图形参考线</figcaption>
</figure>
<figure style={half}>
<div style={{ display: 'flex', gap: 48, flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<svg width="120" height="150" viewBox="0 0 130 162" role="img" aria-label="主体撑满绘制区(正确)">
<rect x="5" y="5" width="120" height="120" style={canvasBox} />
<rect x="15" y="15" width="100" height="100" style={liveBox} />
<circle cx="65" cy="65" r="50" style={{ fill: 'none', stroke: 'rgb(var(--text-2))', strokeWidth: 10 }} />
<line x1="44" y1="65" x2="86" y2="65" style={{ stroke: 'rgb(var(--text-2))', strokeWidth: 10, strokeLinecap: 'round' }} />
<line x1="65" y1="44" x2="65" y2="86" style={{ stroke: 'rgb(var(--text-2))', strokeWidth: 10, strokeLinecap: 'round' }} />
<text x="65" y="152" textAnchor="middle" style={okMark}>✓</text>
</svg>
<svg width="120" height="150" viewBox="0 0 130 162" role="img" aria-label="主体过小(错误)">
<rect x="5" y="5" width="120" height="120" style={canvasBox} />
<rect x="15" y="15" width="100" height="100" style={liveBox} />
<circle cx="65" cy="65" r="30" style={{ fill: 'none', stroke: 'rgb(var(--text-3))', strokeWidth: 10 }} />
<line x1="52" y1="65" x2="78" y2="65" style={{ stroke: 'rgb(var(--text-3))', strokeWidth: 10, strokeLinecap: 'round' }} />
<line x1="65" y1="52" x2="65" y2="78" style={{ stroke: 'rgb(var(--text-3))', strokeWidth: 10, strokeLinecap: 'round' }} />
<text x="65" y="152" textAnchor="middle" style={badMark}>✗</text>
</svg>
</div>
<figcaption style={figCap}>主体尽量撑满 20×20 绘制区,整套视觉等大;明显偏小不合格</figcaption>
</figure>
</div>
);
export const FigStroke = () => (
<Pair cap="描边沿路径(虚线)居中;端点圆头 round cap、拐角圆角 round join"
a={
<svg width="150" height="160" viewBox="0 0 160 170" role="img" aria-label="圆头圆角(正确)">
<path d="M45 130 L45 55 L130 55" style={{ fill: 'none', stroke: 'rgb(var(--brand-500)/0.7)', strokeWidth: 28, strokeLinecap: 'round', strokeLinejoin: 'round' }} />
<path d="M45 130 L45 55 L130 55" style={{ fill: 'none', stroke: 'rgb(var(--text-2)/0.8)', strokeWidth: 1.5, strokeDasharray: '5 4' }} />
<text x="87" y="164" textAnchor="middle" style={okMark}>✓</text>
</svg>
}
b={
<svg width="150" height="160" viewBox="0 0 160 170" role="img" aria-label="平头直角(错误)">
<path d="M45 130 L45 55 L130 55" style={{ fill: 'none', stroke: 'rgb(var(--text-3)/0.6)', strokeWidth: 28, strokeLinecap: 'butt', strokeLinejoin: 'miter' }} />
<path d="M45 130 L45 55 L130 55" style={{ fill: 'none', stroke: 'rgb(var(--text-2)/0.8)', strokeWidth: 1.5, strokeDasharray: '5 4' }} />
<text x="87" y="164" textAnchor="middle" style={badMark}>✗</text>
</svg>
}
/>
);
export const PixelGrid = () => (
<g style={gridS}>
<line x1="4" y1="10" x2="196" y2="10" /><line x1="4" y1="34" x2="196" y2="34" /><line x1="4" y1="58" x2="196" y2="58" /><line x1="4" y1="82" x2="196" y2="82" /><line x1="4" y1="106" x2="196" y2="106" /><line x1="4" y1="130" x2="196" y2="130" />
<line x1="4" y1="10" x2="4" y2="130" /><line x1="28" y1="10" x2="28" y2="130" /><line x1="52" y1="10" x2="52" y2="130" /><line x1="76" y1="10" x2="76" y2="130" /><line x1="100" y1="10" x2="100" y2="130" /><line x1="124" y1="10" x2="124" y2="130" /><line x1="148" y1="10" x2="148" y2="130" /><line x1="172" y1="10" x2="172" y2="130" /><line x1="196" y1="10" x2="196" y2="130" />
</g>
);
export const FigPixel = () => (
<Pair cap="同一根 2px 描边:路径坐标落在规范网格上渲染为实边,偏离半像素则两侧发虚"
a={
<svg width="190" height="160" viewBox="0 0 200 170" role="img" aria-label="描边对齐像素网格(实边)">
<rect x="52" y="10" width="48" height="120" style={{ fill: 'rgb(var(--brand-500)/0.85)' }} />
<PixelGrid />
<text x="100" y="152" textAnchor="middle" style={dimT}>边缘对齐像素格 → 实边 <tspan style={okMark}>✓</tspan></text>
</svg>
}
b={
<svg width="190" height="160" viewBox="0 0 200 170" role="img" aria-label="描边骑在像素格上(虚边)">
<rect x="64" y="10" width="12" height="120" style={{ fill: 'rgb(var(--brand-500)/0.4)' }} />
<rect x="76" y="10" width="24" height="120" style={{ fill: 'rgb(var(--brand-500)/0.85)' }} />
<rect x="100" y="10" width="12" height="120" style={{ fill: 'rgb(var(--brand-500)/0.4)' }} />
<PixelGrid />
<text x="100" y="152" textAnchor="middle" style={dimT}>边缘骑在像素格上 → 虚边 <tspan style={badMark}>✗</tspan></text>
</svg>
}
/>
);
export const iconLabel = { fontSize: 12, color: 'rgb(var(--text-3))' };
export const FigRhythm = () => (
<div style={figRow}>
<figure style={half}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<svg width="230" height="52" viewBox="0 0 230 52" role="img" aria-label="圆方三角基本形 · 应用视觉差">
<line x1="10" y1="4" x2="220" y2="4" style={{ stroke: 'rgb(var(--text-3)/0.3)', strokeWidth: 1, strokeDasharray: '4 3' }} />
<line x1="10" y1="48" x2="220" y2="48" style={{ stroke: 'rgb(var(--text-3)/0.3)', strokeWidth: 1, strokeDasharray: '4 3' }} />
<circle cx="38" cy="26" r="22.5" style={{ fill: 'rgb(var(--brand-500)/0.08)', stroke: 'rgb(var(--brand-500)/0.7)', strokeWidth: 4 }} />
<rect x="93" y="4" width="44" height="44" rx="4" style={{ fill: 'rgb(var(--brand-500)/0.08)', stroke: 'rgb(var(--brand-500)/0.7)', strokeWidth: 4, strokeLinejoin: 'round' }} />
<path d="M192 3.5 L218 48.5 L166 48.5 Z" style={{ fill: 'rgb(var(--brand-500)/0.08)', stroke: 'rgb(var(--brand-500)/0.7)', strokeWidth: 4, strokeLinejoin: 'round' }} />
</svg>
<span style={iconLabel}>圆 / 方 / 三角</span>
</div>
<figcaption style={figCap}>圆、三角比方形略放大做视觉补偿(虚线为方形基准),三者观感等大</figcaption>
</figure>
<figure style={half}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 12, color: 'rgb(var(--text-2))' }} role="img" aria-label="文件族图标共用同一外框">
<IconFile size={52} />
<IconFileImage size={52} />
<IconFileWord size={52} />
</div>
<span style={iconLabel}>文件 · 图片 · 文档</span>
</div>
<figcaption style={figCap}>同类图标构图一致:共用同一外框,仅内部元素表达差异</figcaption>
</figure>
</div>
);
# 图标规范 Icon
{/* 图标库(单一来源)——重要背景信息,2026-07-21 定:不在页面展示,仅留档:
| 项 | 内容 |
|---|---|
| 库 | `bisheng-icons`npm 包,源自 bisheng-design-system → github.com/dataelement/bisheng-icon |
| 管理方式 | Figma 插件管理设计源 → 自动生成 React 组件 → semver 发版。图标的增改只发生在 Figma,代码侧只升版本 |
| 规模(v0.2.22 | 131 个:Outlined 116(线性,主力)/ Filled 8(实心)/ Colored 7(多色,文件类型/文件夹) |
| 三风格 | Outlined 为主力;Filled、Colored 按图标自身语义选用(Colored 自带固有色,不参与着色) |
*/}
## 1. 绘制规范
- **画布 24×24**;四周留 **2px 安全边距**,主体图形画在中央 **20×20** 内。个别视觉重量特小/特大的图形可微调侵入安全边距,整套保持视觉等大。
<FigCanvas />
- **线宽统一 2px**,描边居中;端点/拐角一律圆头圆角(`round cap / round join`)。
<FigStroke />
- **像素对齐**:2px 居中描边的路径坐标落在 `x.5` 网格上(如 `2.5 → 21.5`),保证 1:1 渲染不虚边;尺寸取偶数、坐标去小数。
<FigPixel />
- **形式与韵律**:图形从圆 / 方 / 三角基本形推导;同类图标构图一致(如一组文件图标共用同一外框);圆角、点、线的规格在整套内克制复用,不逐个自造。
<FigRhythm />
- **输出卫生**:合并路径、删多余节点、拉平图层;`fill="none"` + `stroke="currentColor"`Outlined),不留写死颜色(Colored 除外)。
- **命名**:如下规范
- 英文用 **大驼峰(PascalCase)命名法**`Square`、`Circle` 等区分字段作后缀。
- **按语义不按形状**命名(`Delete` 而非 `Trash2`),同语义不造近义重复图标。
- **不允许**出现 `-`、空格等特殊符号。
- 原则上命名中**不出现数字**(icon 本身语义含数字的除外);禁用 `CaretLeft1`、`CaretLeft2` 这类顺序编号命名。
- **同一分类下不允许重复命名**。
## 2. 使用规范
### 2.1 选用优先级
1. **bisheng-icons 优先**:语义在库里已有就必须用它。`import { Outlined } from 'bisheng-icons'` → `<Outlined.Delete />`。
2. **lucide-react 只作兜底**:库里没有对应语义才用,并留 `// TODO(bisheng-icons)` 记号;库补齐后由设计师点名换回。
3. **禁止新增手写 SVG 图标组件**;存量 `components/icons/` 只减不增。例外:需要品牌主题化多色着色的图形(参照 `FolderIcon`,用 `rgb(var(--brand-NNN))` 内联 style)。
4. 升级 bisheng-icons 后**必须清 Vite 预构建缓存**`npm run dev -- --force`(总纲 §七 pitfall,否则白屏)。
### 2.2 尺寸阶梯
图标不自定义任意尺寸,只用以下档位(`size` prop 或 Tailwind 类):
| 档 | px | 写法 | 场景 |
|---|---|---|---|
| xs | 12 | `size={12}` / `size-3` | 极小标记(badge 内、密集表格角标),**仅纯展示** |
| sm | 14 | `size={14}` / `size-3.5` | small/medium 按钮的文字+icon(按钮规范 §3.3) |
| **md(默认)** | **16** | `size={16}` / `size-4` | **绝大多数场景**:图标按钮(小/中)、菜单项、输入框内、表格操作 |
| lg | 20 | `size={20}` / `size-5` | 导航栏、侧边栏入口、页头操作 |
| xl | 24 | `size={24}` / `size-6` | 独立展示、弹窗标题图标(原始画布尺寸) |
| 2xl | 32 | `size={32}` / `size-8` | 超大展示:空状态、引导页、大插图旁(strokeWidth 降 1.5,见 §2.2.1 |
- 同一视图内同类图标同档;与文字混排时 icon 不大于行高。
- 20px 档存量暂不重分类,新增按上表约束。
### 2.2.1 线宽 × 尺寸对照
`strokeWidth` 是 24 画布内的值,实际渲染线宽 = `strokeWidth × 尺寸 ÷ 24`。**1424px 一律用默认 2,不覆盖**;只在阶梯两端补偿:
| 尺寸 | strokeWidth | 实际线宽 |
|---|---|---|
| 12 | `2.5` | 1.25px(禁止加到 3,会糊死留白) |
| 14–24 | 2(默认,不覆盖) | ≈1.17 → 2px 等比 |
| 322xl 档及以上) | `1.5` | 视觉线宽回到 ≈2px |
补偿值(12→2.5、≥32→1.5)写在场景组件一处,禁止业务页逐个自调。
### 2.3 着色
- 默认 `currentColor`**继承文字颜色**——图标颜色跟着所在容器的 text 色走,优先用色彩规范的中性语义档(`text-2` 常规、`text-3` 弱化、`text-4` 禁用)。
- 品牌色用 `text-blue-*` 类(自动换肤);语义色(成功/警告/危险)用 §3 语义 token**不写裸 hex**。
- SVG 展示属性里 `var()` 不生效,内联着色用 `style={{ color: 'rgb(var(--brand-500))' }}`(色彩规范 §1.2)。
- `Colored` 系列自带固有色,不接受着色覆盖。
### 2.4 交互与可达性
- **纯图标按钮必须带 Tooltip + `aria-label`**(按钮规范 §3.2);触屏热区 ≥44×44 由 Button 基座统一处理,不逐处自撑。
- 方向语义用库内方向图标(`Left/Right/Up/Down`、`ArrowLeft` 系、`DoubleUp` 系),**不用 CSS 旋转拼方向**;例外:展开/收起的 chevron 翻转动画(`rotate-180` 过渡)。
- 加载态用 `Outlined.Loading` + `animate-spin`,不混用 lucide `Loader2`(迁移目标)。
- 图标与文字间距:8px`gap-2`),small 档可收紧 4px`gap-1`,按钮规范 §3.3)。
## 3. 新增 / 升级流程
1. **设计师**在 Figma 图标库按照绘制规范绘制(先确认库内无同语义图标)→ 插件发布。
2. 库侧自动生成组件、semver 发版。
3. 项目 `npm install bisheng-icons@latest` → `npm run dev -- --force` → 画廊目检新图标。
4. 库补齐了某语义后,由设计师点名把对应的 lucide 兜底逐批换回(不自动批量替换)。
{/* ## 4. 迁移(给实现窗口)——站点不渲染
- 高频 lucide → bisheng-icons 映射(迁移时逐个目检字形):`X→Outlined.Close`、`Check→Check`、`Plus→Plus`、`Search→Search`、`ChevronLeft/Right/Down/Up→Left/Right/Down/Up`、`ListFilter→Filter`、`Loader2→Loading`、`Download→Download`、`Copy→Copy`、`InfoIcon→Info`、`EarthIcon→Earth`、`ArrowUpDown→Sort`。
- 迁移节奏照总纲:设计师逐批点名、每批一笔提交;优先动高频通用图标(用量见附录 A.2)。
- 尺寸写法顺手统一:`h-4 w-4` 等新旧写法迁移时改为 `size-4` 单类(等值,纯写法收敛)。
*/}
{/* ## 附录 A:现状扫描存档(2026-07-16,仅迁移参考)——站点不渲染
口径:`src/frontend/client/src` 业务 TS/TSX,排除 `_gallery/`。
### A.1 两库用量
| 项 | 数据 |
|---|---|
| lucide-react | 159 个文件引用,126 种图标 |
| bisheng-icons | 39 个文件引用 |
| 手写 SVG 图标组件 | `components/icons/` 10 个(FolderIcon 为品牌主题化模板,保留);另 illustrations/ 为插画不算图标 |
### A.2 lucide 高频 topimport 次数)
X 20 · Plus 11 · ChevronLeft 10 · Check 10 · Search 9 · ChevronDown 7 · ListFilter 6 · PlusIcon 5 · Loader2 5 · ChevronRight 5 · Wrench 4 · Minus 4 · InfoIcon 4 · EarthIcon 4 · Download 4 · ArrowUpDown 4——头部全是库里已有语义,即 §4 第一批迁移对象。
### A.3 尺寸写法分布(class 出现次数)
| px | 写法合计 | 明细 |
|---|---|---|
| 16 | ≈473(主流) | `size-4` 323 + `h-4 w-4` 139 + `w-4 h-4` 11 |
| 20 | ≈169 | `size-5` 104 + `h-5 w-5` 51 + `w-5 h-5` 14 |
| 12 | ≈102 | `size-3` 86 + `h-3 w-3` 16 |
| 24 | ≈72 | `size-6` 26 + `h-6 w-6` 37 + `w-6 h-6` 9 |
| 14 | 64 | `size-3.5` |
| `size=` prop | 63 | 16×45 / 14×13 / 18×4 / 12×1 |
### A.4 库指纹(v0.2.22 实测)
- 131 个组件 viewBox 全部 `0 0 24 24`;默认 `size=24`、`color='currentColor'`、`strokeWidth=2`、`strokeLinecap/Join='round'`、Outlined `fill='none'`。
- 路径坐标落 `x.5` 网格(如 Check`M21.5 5.5 L8.4375 18.5 L2.5 12.59`),主体约 20×20。
- Outlined 116 个清单见包导出;Filled 仅 8 个(Application/Attention/Book/CheckCircle/CloseCircle/Home/Info/Rss);Colored 7 个全是文件类型/文件夹。
*/}
{/* ## 改动记录——站点不渲染
| 日期 | 改了什么 | 影响文件 | 提交 |
|---|---|---|---|
| 2026-07-16 | 建档:antd 图标设计原则调研 + bisheng-icons 库指纹实测 + 两库用量/尺寸写法扫描;产出绘制/使用/流程/迁移四段规范 v1 草稿 | 本文件、00-总纲看板 | 不提交(文档夹已 gitignore |
| 2026-07-16 | 设计师过待确认清单:尺寸阶梯(含 12px 档)通过;20px 存量暂不重分类;库缺口补图标暂不管;三风格不作硬性分工;新增 §3.2.1 线宽×尺寸对照表(14–24 默认 2 不覆盖,12→2.5、≥32→1.5 两端补偿)。清单清空删除,v1 定稿 | 本文件、00-总纲看板 | 不提交 |
| 2026-07-16 | 对照 TDesign 式「磅数次线性」表复核 §3.2.1:24px 维持等比 2px、12px 维持补偿 1.25px(设计师二次确认);§2 视觉平衡条吸收「复杂度校正 ≤±0.25px」上限 | 本文件 | 不提交 |
| 2026-07-16 | 删 18px 档(设计师定:大概率用不到),阶梯收为五档 12/14/16/20/24,档名顺移(lg=20、xl=24)。⚠️ 与按钮规范 §3.2/3.3「large 按钮 icon 18px」存在冲突,待设计师定 large 按钮内 icon 改用 16 或 20 | 本文件 | 不提交 |
| 2026-07-16 | 新增 32px2xl)档:超大展示场景,strokeWidth 统一降 1.5;阶梯定为六档 12/14/16/20/24/32。large 按钮内 icon 定为 16px,按钮规范 §3.2/3.3 同步改(18px 冲突消除,全文无 18px 残留) | 本文件、组件-Button按钮.md | 不提交 |
| 2026-07-21 | 文档站页面规整:原 §1「图标库(单一来源)」不再展示(留档于注释);章节号 2–5 顺移为 1–4,§引用同步更新;「Figma 侧/代码侧」改「设计侧/研发侧」;「出血位」改「安全边距」。为内嵌 SVG 将本页 .md → .mdx,内部段落改用 MDX 注释隐藏 | 本文件、00-总纲.md(链接改 .mdx) | 不提交 |
| 2026-07-21 | 绘制规范补齐 6 组 SVG 示意图(安全边距网格 / 撑满 ✓✗ / 圆头圆角 / 像素对齐 / 基本形+同外框 / 视觉平衡 / 命名,输出卫生不配图);Kinyoo 反馈落地:说明文字统一底对齐+居中,✓✗ 对比组左右分散排布 | 本文件 | 不提交 |
| 2026-07-21 | 成对图统一「两等宽半栏各自居中」模板(新增 half 列容器 + Pair 组件),上下 6 组左右列垂直对齐;「形式与韵律」右侧改用真实 File/FileImage/FileWord(共用文件外框最真实的例子),「命名」两例改用真实 Delete 图标——首次在规范 mdx 内嵌 bisheng-iconsrspress.config 加 `bisheng-icons` alias**唯一进仓库的改动** | 本文件、rspress.config.ts | rspress.config 提交 / 本文件不提交 |
| 2026-07-21 | Kinyoo 定:删「视觉平衡」整点(文本+插图+FigBalance 组件全删);「命名」去插图(删 FigNaming/NameCard),正文改为命名规范条目列表(大驼峰/区分字段后缀、按语义不按形状、禁特殊符号、禁数字与顺序编号、同类不重复) | 本文件 | 不提交 |
| 2026-07-21 | 章节标题去括号后缀:绘制规范(设计侧)/使用规范(研发侧)/选用优先级(总纲 §七)→ 去掉括号;FigRhythm 左侧圆方三角应用视觉补偿(overshoot):方形为基准、圆直径 48、三角上下溢出更多,加两条虚线标基准,图注改为说明视觉补偿 | 本文件 | 不提交 |
*/}
@@ -0,0 +1,98 @@
# 多端适配 Responsive
> 设计系统 · v1.2 · 2026-07-14
> 适用:响应式 Web,覆盖手机 / 平板 / 桌面三档,无原生 App。
> 本文只放通用原则;各组件的具体细则写在自己文档的「移动端适配」一节。
---
同一套界面,要同时照顾**手机、平板、桌面**三种尺寸。我们为多端准备了专门的适配方式——**用最小的成本,让每一端都有好的体验**。适配围绕两个真正的差别展开:手指比鼠标粗,屏幕有宽有窄。以下就是各种情况下的适配原则。
---
## 1. 布局和交互分开判断
- 跟**交互**有关的(悬停、点击范围)→ 按「是不是触屏设备」判断
- 跟**布局**有关的(排版、字号)→ 按屏幕窗口宽度判断
屏幕宽度用**两个断点切三档**
| 档位 | 宽度 | 典型设备 |
|---|---|---|
| 手机 | < 576px | 竖屏手机 |
| 平板 | 576–768px | 平板、横屏手机、分屏窗口 |
| 桌面 | > 768px | 电脑 |
例:触屏笔记本屏幕很宽,但它需要触屏适配。
## 2. 触屏设备没有 hover 操作
hover 操作是鼠标悬停时的变色。手指没有「悬停」,点一下之后 hover 色会粘在按钮上不消失。
- 触屏:不显示 hover,按下时用 active(按下态)变色
- 桌面:只显示 hover,不叠 active,否则点击会闪一下
记住一句:**桌面只有 hover,触屏只有 active。**
## 3. 给手指留够点击范围
指尖按下约 44px 宽,比鼠标指针粗得多,小按钮容易点空。
- 触屏上,可点范围一律 ≥ 44×44px
- 做法:按钮外观不变,把可点范围透明地向外扩大
- 高度小于 28px 的小控件别扩(会和邻居打架),直接换大一号
44px 是苹果 HIG、WCAG 无障碍标准的共同推荐值。
## 4. 控件上的文字不变大
手机上正文从 14px 抬到 16px(字体规范定的),但按钮、标签、菜单这些**控件上的字保持不变**。控件的字是扫一眼的,不是阅读的。
唯一例外:输入框文字 ≥ 16px,否则 iOS 会自动放大整个页面。
## 5. 不做手机版组件
所有适配写在同一个组件文件里,用 CSS 条件完成。不引入第二套移动端组件库,不做第二套组件。
原因:两套组件等于每个改动做两遍,很快就会各长各样。
---
## 布局惯例
| 场景 | 手机档 | 平板档 | 桌面档 |
|---|---|---|---|
| 弹窗底部按钮 | 等宽平铺占满一行 | 同桌面 | 右对齐,自适应宽 |
| 页面主操作 | block 占满整行 | 同桌面 | 正常宽度 |
| 弹窗位置 | 左右各留 16px,或从底部滑入 | 居中 | 居中 |
平板档默认走桌面布局,只在组件文档另有说明时单独处理。先例:C 套确认弹窗、AlertDialog 底座。新组件照抄,不自造。
---
## 给实现窗口(技术细节,设计师可跳过)
- 双判断写法:触屏 = `@media (hover: none) and (pointer: coarse)`;屏宽两断点 = **576px / 768px**(与 platform 端现有断点一致)。不自造断点。
- 断点现状与对齐(2026-07-14 盘点):client 活跃断点约 6 个值——576(手写 10 处)、640Tailwind `sm:` 329 处 + css)、767/768hooks、字体重映射、`md:` 80 处)、10221024`lg:` 110 处 + css)、1280`xl:` 41 处)、1536`2xl:` 1 处)。规范以 **576/768** 为准;>768 内部的 `lg:/xl:` 属桌面档内的细分排版,不算档位断点。Tailwind `screens` 改配置会整体挪动现有 `sm:` 行为(576–640 区间受影响),**随组件迁移逐步对齐,不一次性全局切**。字号重映射挂 768 断点(字体规范 §2),即平板档用移动字号。
- hover 禁用**已全局落地**`tailwind.config.cjs` 开启 `future.hoverOnlyWhenSupported`,所有 `hover:` 类自动只在可悬停设备生效,组件无需自行处理。必须写普通 `hover:` 类,禁止自造 hover 前缀(tailwind-merge 认不出冲突,业务页覆盖会失效,见 [组件-Button按钮.md](组件-Button按钮.md) §5.5)。
- active 深档包 `coarse-pointer` 媒体查询,与 hover 互为补集(呼应原则 2)。
- 热区扩展用透明伪元素/padding;窄屏判断可用现成 hooks:`useMediaQuery` / `usePrefersMobileLayout`
- 依据存档:sticky hover 与 antd-mobile 仅 `:active` 的做法;WCAG 2.2 §2.5.8(AA 24px)/§2.5.5(AAA 44px)、iOS HIG 44pt、Material 48dp;控件字号不抬详见字体规范 §5。
---
## 组件适配细则索引
| 组件 | 细则位置 |
|---|---|
| Button | [组件-Button按钮.md](组件-Button按钮.md) §5.5 |
| Modal / 字体 / Select … | 各组件文档补「移动端适配」节,建后登记于此 |
## 改动记录
| 日期 | 改了什么 | 提交 |
|---|---|---|
| 2026-07-14 | 建档:双判定口径 + 4 原则 + 窄屏布局惯例 | 不提交(文档夹已 gitignore |
| 2026-07-14 | 面向零基础读者重写(v1.1):加引言与名词表,原则改「场景→规则→依据」结构 | 不提交 |
| 2026-07-14 | 按 Don't Make Me Think 精简(v1.2):删名词表与结构标签,规则句放段首,技术细节全部移至文末「给实现窗口」 | 不提交 |
| 2026-07-14 | 定位从「手机适配」改为**多端适配**(v1.3):两断点 576/768 切手机/平板/桌面三档(与 platform 端一致);布局惯例表扩为三列,平板默认走桌面布局;client 断点现状混用的对齐策略记入「给实现窗口」 | 不提交 |
@@ -0,0 +1,145 @@
import { FONT_FAMILY, TYPE_SCALE, FONT_WEIGHT } from '~/design-token.cjs';
export const th = { padding: '8px 12px', color: 'rgb(var(--text-1))', whiteSpace: 'nowrap' };
export const td = { padding: '8px 12px', color: 'rgb(var(--text-1))', borderBottom: '1px solid rgb(var(--border-base))' };
export const headRow = { textAlign: 'left' };
export const codeBlock = {
display: 'block',
margin: '16px 0',
padding: '14px 18px',
background: 'rgb(var(--fill-1))',
border: '1px solid rgb(var(--border-base))',
borderRadius: 8,
fontFamily: 'var(--bs-font-mono)',
fontSize: 13,
lineHeight: '22px',
color: 'rgb(var(--text-1))',
overflowX: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
};
export const FontStackBlock = ({ font }) => (
<div style={codeBlock}>
<span style={{ color: 'rgb(var(--brand-500))', fontWeight: 500 }}>{font.token}</span>
{`: ${font.stack.join(', ')};`}
</div>
);
export const PrimitiveTable = () => (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={headRow}><th style={th}>Token</th><th style={th}>字号 px</th><th style={th}>行高 px</th><th style={th}>rem</th></tr></thead>
<tbody>
{TYPE_SCALE.map((s, i) => (
<tr key={s.name}>
<td style={{ ...td, whiteSpace: 'nowrap' }}><code>font-size-{i + 1}</code></td>
<td style={{ ...td, fontVariantNumeric: 'tabular-nums' }}>{s.desktop[0]}</td>
<td style={{ ...td, fontVariantNumeric: 'tabular-nums' }}>{s.desktop[1]}</td>
<td style={{ ...td, fontVariantNumeric: 'tabular-nums' }}>{s.desktop[0] / 16}</td>
</tr>
))}
</tbody>
</table>
);
export const SemanticTable = () => (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={headRow}><th style={th}>Token</th><th style={th}>桌面 size/line</th><th style={th}>移动 size/line</th><th style={th}>字重</th><th style={th}>用途</th><th style={th}>示例</th></tr></thead>
<tbody>
{TYPE_SCALE.map((s) => (
<tr key={s.name} style={{ height: 60 }}>
<td style={{ ...td, whiteSpace: 'nowrap' }}><code>text-{s.name}</code></td>
<td style={{ ...td, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{s.desktop[0]} / {s.desktop[1]}</td>
<td style={{ ...td, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}>{s.mobile[0]} / {s.mobile[1]}</td>
<td style={{ ...td, whiteSpace: 'nowrap' }}>{s.weight}</td>
<td style={{ ...td, whiteSpace: 'nowrap' }}>{s.usage}</td>
<td style={{ ...td, whiteSpace: 'nowrap' }}><span className={`text-${s.name}`}>示例 Ag</span></td>
</tr>
))}
</tbody>
</table>
);
export const WeightTable = () => (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={headRow}><th style={th}>Token</th><th style={th}>值</th><th style={th}>用途</th></tr></thead>
<tbody>
{FONT_WEIGHT.map((w) => (
<tr key={w.token}>
<td style={{ ...td, whiteSpace: 'nowrap' }}><code>{w.token}</code></td>
<td style={td}><span style={{ fontWeight: w.value }}>{w.value}</span></td>
<td style={td}>{w.usage}</td>
</tr>
))}
</tbody>
</table>
);
# 文字规范 Typography
字体是体系化界面设计中最基本的构成之一。我们的用户通过文本来理解内容和完成工作,科学的字体系统将大大提升用户的阅读体验及工作效率。BISHENG 字体方案以「行高 = 字号 + 8px」的偶数栅格规律构建九档字阶,经过大量中后台产品的实际验证后沉淀为规范,推荐给大家。在中后台视觉体系中定义字体系统,我们从下面四个方面出发:
1. 字体家族 Font Family
2. 字阶与行高 Font Size
3. 字重 Font Weight
4. 使用约定
## 1. 字体家族 Font Family
优秀的字体系统首先要选择合适的字体家族。BISHENG 优先使用各操作系统自带的默认界面字体,另配一套利于屏显的备用字体库,在不同平台与浏览器下都维持良好的易读性与一致性 —— 零 webfont 加载成本、多端原生观感。
<FontStackBlock font={FONT_FAMILY.base} />
> 同一份字体家族覆盖所有设备:macOS/iOS 命中 San Francisco + 苹方,Windows 命中 Segoe UI + 微软雅黑,Android 命中 Roboto + 思源黑体,无需为移动端单独配置。
ID、哈希、代码、日志等需要等宽对齐的内容,使用等宽字体族 `font-family-mono`
<FontStackBlock font={FONT_FAMILY.mono} />
> 另外,中后台系统里数字常需纵向对比展示,推荐把数字的 `font-variant-numeric` 设为 `tabular-nums`,使其成为等宽数字,保证纵向对齐、数值变化时不跳动。
## 2. 字阶与行高 Font Size
字阶分成两层。**Primitive(数值层)** 只登记九个具体字号(12~36px),像一套标好刻度的尺子,本身不关心用在哪里;**Semantic(语义层)** 再给每个字号起一个说明用途的名字——正文、卡片标题、页面标题……界面里只用这些名字,不直接写数字。
多这一层,换来三个好处:
- **改一处、全站生效**:想把正文从 14px 调到 15px,只改「正文」这一个指向,所有正文一起变,不用满仓库翻数字。
- **不会各写各的**:大家统一用「正文」「标题」这类有含义的名字,同一层级不会冒出 14、15、16 各种随手值,界面自然整齐。
- **换端不用动代码**:移动端只要把「正文」重新指向大一号的字号即可,页面一个字都不用改(下面 Semantic 表就是这样切换的)。
### Primitive(桌面基准)
primitive 即九档桌面字号的数值源,行高规律 **行高 = 字号 + 8px**(全偶数、对齐 4px 栅格)。
<PrimitiveTable />
### Semantic
下表给每个字号都列了桌面、移动两套值。手机等窄屏(宽度 768px 以内)会自动切到移动那一套,页面不用为此改任何代码——这正是上面「换端不用动代码」的落地。最右「示例」列按真实样式渲染,所见即所得。
<SemanticTable />
移动端的调整思路:正文放大(14→16)方便阅读,标题收小(24→22)节省空间,整个阶梯更「扁」;字号差变小后,层级主要靠字重和间距来拉开。
## 3. 字重 Font Weight
<WeightTable />
**只使用 400 / 500 两档。** 禁用 600/700:微软雅黑缺中间字重,600 以上在 Windows 会回退为合成粗体,视觉粗黑一团。PingFang Medium (500) 降级到雅黑 Regular 可接受。
## 4. 使用约定
### Token 约定
- 组件与业务代码只引用 semantic token,不直接使用 primitive 或裸值。
- 新增字号需求先在现有阶梯中找替代;确需新增走 token 评审加入 primitive 层。
- 同一视图中标题层级连续使用(h1→h2→h3),不跳级取字号。
### 移动端硬性规则
1. **输入框字号 ≥ 16px**。iOS Safari 会在点击字号小于 16px 的输入框时自动放大页面,导致布局错位;保持 ≥ 16px 可完全避免。
2. **可点击文字 ≥ 14px**,触达区域 ≥ 44×44px。12px 仅用于纯展示信息(按输入方式生效)。
3. **尊重系统缩放**token 以 rem 输出,`html` 的 font-size 保持 100%(不写死 px)。
4. **Android 中文渲染**:思源黑体比苹方视觉偏大偏宽,同字号下行高不缩减,宁松勿紧。
@@ -0,0 +1,191 @@
# 插画规范 Illustration
> 设计系统 · 插画部分 **v1 草稿(待设计师确认)** · 2026-07-15
> 组件已先行落地(2026-06-24 完成 7 张主题化插画 + 全部槽位替换),本文是**补档**:把已落地的做法整理成规范,并参考 antd「图形化 / 空状态」与 Arco「Empty」的体系补齐使用规则。
> 与 [00-总纲.md](00-总纲.md)、[基础-色彩规范.md](基础-色彩规范.md) §5、`src/frontend/client/BRAND-THEME-HANDOFF.md` §3/§3.1/§5 配套。业务引用台账见文末附录 A。
---
## 0. 关键结论(先读这个)
1. **插画 = 空状态 / 状态反馈的图形层**,全部做成**内联 SVG React 组件**,统一放 `src/components/illustrations/`(从 `index.ts` 导出)。静态 PNG`empty.png` / `ai-home.png` / `review.png`)已全量清零退役——**PNG 无法主题化,禁止回头再用**。
2. **颜色只走独立插画调色板 `--illus-100/300/500`**,不用 `--brand-*`、不写 hex。原因:绿主题插画用设计稿的鲜绿(`#169C47/#A2D7B5/#DDF0E8`),比 UI 品牌绿更亮、忠于原图;若挂 `--brand-*` 会被压暗。**UI 图标(FolderIcon 等)仍用 `--brand-*`,两套调色板不混用。**
3. **三种颜色模式**:蓝主题(默认)/ 绿主题(`.theme-green` 自动切换)/ 灰稿(组件传 `grey`,主题无关)。业务代码不感知主题,只在需要弱化时传 `grey`
4. 空状态采用三段式结构:**插画 + 状态提示(+ 建议操作)**。插画只负责「发生了什么」,「接下来做什么」交给文案和按钮。
---
## 1. 定位与设计原则
BISHENG 插画体系的定位原则,收窄到本项目的用法:
- **克制**:插画只用于空状态、等待、无权限、成功、系统异常这类「整块区域没有正常内容」的场景。正常内容流里不放装饰性插画;小尺寸提示(表单校验、toast)用图标不用插画。
- **语义明确**:一个场景一张图,图意与文案互相印证(见 §5.1 选图表)。不要因为「好看」跨场景挪用——例如成功态不拿通用空态凑数。
- **品牌一致**:全套插画同一画风、同一调色板(§2),随蓝⇄绿主题自动切换;新增插画必须沿用现有画风与色阶数(§6),不引入新颜色。
- **提供邀请**:空态尽量给出口。插画下方按「主提示(必) → 辅助说明(可选) → 建议操作按钮(可选)」组织,是否给操作按钮由业务场景决定(完成/清空类场景可不给)。
## 2. 调色板 `--illus-*`(✅ 已落地,style.css
三档、三模式。变量为 RGB 通道格式,写法 `rgb(var(--illus-NNN))`
| 档 | 语义 | 蓝主题(`:root` | 绿主题(`.theme-green` | 灰稿(`.illus-grey` |
|---|---|---|---|---|
| `--illus-500` | 主体色 | `#165DFF` | `#169C47` | `#BCBCBC` |
| `--illus-300` | 中间调 | `#6AA1FF` | `#A2D7B5` | `#E5E5E5` ※ |
| `--illus-100` | 浅底 | `#BEDAFF` | `#DDF0E8` | `#E5E5E5` |
| 整图不透明度 | — | 100% | 100% | **80%**`.illus-grey .brand-illustration` 统一降) |
※ 灰稿的 300 档原设计为纯白,白底上洗没了(CrawlingIllustration 放大镜光晕),已改 `#E5E5E5`,仅灰稿覆写。
规则:
- 蓝模式 `--illus-*` = 品牌蓝三档,所以蓝主题下插画依旧跟品牌走;绿模式忠于设计稿鲜绿,**故意不等于** UI 品牌绿 `--brand-*`
- 白色、黑色透明叠加、`#D9D9D9`(mask 占位)等非品牌色**保持原样**,不进调色板,灰稿模式下也不动。
- `.illus-grey` 定义在 `.theme-green` 之后,两者同时命中时灰稿赢——灰稿是**主题无关**的。
- 元素自带的局部透明(如 `fillOpacity: 0.6`)与整图 80% 叠乘,属预期。
### 2.1 源稿色 → token 映射(新插画组件化时照抄)
| 设计稿颜色 | 映射 |
|---|---|
| 主体鲜绿 `#19B476` 系(深) | `rgb(var(--illus-500))` |
| 中绿 `#7CD0B1` 系 | `rgb(var(--illus-300))` |
| 浅绿 `#BDE6D3` 系 | `rgb(var(--illus-100))` |
| 更多绿阶(如系统维护图 6 档) | 按**明度**归并到最近的 500/300/100 三档 |
| 白 / 黑透明 / `#D9D9D9`(mask) / 灰描边 | 保持原样 |
## 3. 资产清单(✅ 7 张已落地)
统一画布 **400×400 viewBox**,等比缩放,尺寸由使用侧 `className` 控制(§5.2)。
| 组件 | 场景语义 | 备注 |
|---|---|---|
| `EmptyStateIllustration` | 通用空状态——列表 / 成员 / 频道等无数据 | 用量最大的兜底空态 |
| `NoPermissionIllustration` | 无权限访问 / 内容待审核不可见 | 含菜单无权限页(经 `WorkbenchEmptyIllustration` 包装) |
| `ListWebLinkIllustration` | 按名称搜索无收录(引导输入完整网址) | |
| `CrawlingIllustration` | 网页爬取等待中 | 替代了写死蓝的旧 `ChannelLoadingIcon` |
| `SuccessIllustration` | 创建成功等成功反馈 | **跟随品牌主题**(设计师已确认非固定语义成功绿) |
| `ArticleQAIllustration` | AI 问答前置 / 对话空态 | 现状 9 处全部以**灰稿**使用(§5.3) |
| `SystemMaintenanceIllustration` | 系统维护(后端 500 全屏弹层) | 放大镜 + 小虫 |
**不算插画的邻居**`KnowledgeSpaceIcon`(同目录导出)是**主题图标**——蓝/绿是两幅不同的画、带专属渐变,不走 `--illus-*`,不受本规范约束,也不进画廊插画版块。
## 4. 组件工程规范(新插画照此实现)
范本:`src/components/illustrations/EmptyStateIllustration.tsx`
1. **内联 SVG 函数组件**props 为 `React.SVGProps<SVGSVGElement> & { grey?: boolean }`;根元素固定拼 class`brand-illustration` + `grey && 'illus-grey'` + 透传 `className`
2. **填色一律内联 `style`**`style={{ fill: 'rgb(var(--illus-500))' }}`。SVG 展示属性 `fill="..."``var()` 不生效(HANDOFF §3 铁律),Tailwind 类可用但插画统一用 style 写法。渐变 stop 同理 `style={{ stopColor: ... }}`
3. **id 唯一化**gradient / mask / clipPath 的 id 一律 `useId()` 拼前缀,防多张同屏串色。
4. 画布 400×400`width/height` 写 400 兜底,实际尺寸由调用侧 class 控制。
5.`src/components/illustrations/`,在 `index.ts` 追加导出;同时在画廊 `_gallery/sections/IllustrationSection.tsx``ILLUSTRATIONS` 清单登记(名称 + 场景一句话)。
6. 验证:`npx tsc --noEmit` 不超基线;画廊三个 tab(蓝/绿/灰稿)各看一遍,检查串色与灰稿观感。
## 5. 使用规范
### 5.1 场景 → 插画速查
| 场景 | 用图 | 建议操作按钮 |
|---|---|---|
| 列表 / 成员 / 搜索结果等无数据 | EmptyState | 视场景(有「新建/添加」入口就给) |
| 无权限 / 待审核不可见 | NoPermission | 一般不给(或「申请权限」) |
| 搜索无收录、引导换关键词/网址 | ListWebLink | 不给,靠文案引导 |
| 爬取/处理等待中 | Crawling | 不给 |
| 创建成功 | Success | 给(「查看/继续创建」类) |
| AI 问答空态 | ArticleQA(现状用灰稿,见 §5.3) | 不给,下方是输入框本身 |
| 后端 500 维护 | SystemMaintenance | 给(「刷新」) |
### 5.2 尺寸与排版档位
| 档位 | 尺寸 | 用在哪 | 惯用写法 |
|---|---|---|---|
| **标准** | **120px** | 页面级 / 面板级空态(绝对主力,30+ 处) | `className="size-[120px] mb-4"` |
| 紧凑 | 80px | AI 问答 dock / 对话空态(配灰稿);矮容器空态(爬取失败,彩色) | `className="size-[80px] mx-auto block"`+ 视场景 `grey` |
- **只有这两档,无例外**(2026-07-15 设计师定档并完成收敛:预览抽屉无权限 140→120 ×2、爬取失败 100→80 并顺带 mb-5→mb-4,全站扫描无其它尺寸)。
- 布局:插画水平居中,下接文案层。
- **彩色插画一律 100% 不透明度**:调用侧不加 `opacity-90`2026-07-15 清零 17 处历史手写惯性,定为规范)。需要弱化用**灰稿**(`grey`,内建 80%),不靠 opacity 微调。
**文案层级间距定档(2026-07-15 设计师确认定档)**
| 相邻层级 | 间距 | 写法 |
|---|---|---|
| 插画 ↔ 主提示 | 16px | 插画 `mb-4` |
| 主提示 ↔ 建议操作按钮 | 20px | 主提示 `mb-5`(成例:AddSourceDropdown 两处) |
| 结果页变体(大标题空态,如创建成功):插画 ↔ 标题 | 16px | `mb-4` |
| 结果页变体:标题 ↔ 辅助说明 | 32px | 标题 `mb-8` |
| 结果页变体:辅助说明 ↔ 按钮组 | 40px | 辅助 `mb-10`(成例:CreateChannelSuccess |
- 主提示文案现状惯例:14px / normal / `#999999`(迁移色彩 token 时对应 `text-3` 语系,以色彩规范定稿为准)。
### 5.3 灰稿模式使用规则
- 用法:`<ArticleQAIllustration grey className="..." />`。也可在任意祖先挂 `.illus-grey` 类做全局灰稿(代码级开关,暂无 UI)。
- **何时用灰**:插画只是氛围陪衬、不希望它抢注意力的场景。现状唯一成例:**AI 问答前置空态全部用灰稿**(9 处,ArticleQA),让视觉焦点留在输入框。**这是现状惯例,暂不上升为硬规则**(设计师 2026-07-15 定)——新场景是否用灰稿逐个定夺,不自动套用。
- 页面级空态(列表无数据、无权限、成功)现状**用彩色**,不用灰稿。
- 灰稿是主题无关的:蓝绿主题切换对它无效,符合预期,别当 bug 修。
### 5.4 禁止项
- ❌ 新增静态 PNG/位图插画(不可主题化);❌ 插画内写死 hex 或用 `--brand-*`;❌ 展示属性里写 `var()`(不生效)。
- ❌ 不经 `useId()` 复制粘贴带 id 的 SVG;❌ 同屏堆多张插画(一个空态区域一张图)。
- ❌ 拿插画当装饰图塞进正常内容流;小场景提示用 bisheng-icons 图标。
- ❌ 彩色插画调用侧加 `opacity-90`(或其它 opacity 微调)——要弱化用灰稿。
## 6. 新增插画流程(设计师 → 组件)
1. **画稿**:400×400 画布;品牌色阶控制在 **≤3 档绿**(多于 3 档也可,组件化时按明度归并,见 §2.1);非品牌色只用白 / 黑透明 / 中性灰。画风与现有 7 张一致(元素在固定网格中绘制、圆角节奏统一)。
2. **交付**:SVG 源码(非位图导出)给到组件窗口。
3. **组件化**:按 §4 落地,登记画廊。
4. **验收**:设计师在画廊蓝/绿/灰三 tab 过一遍 + 业务槽位实际替换处过一遍。
## 7. 落地状态(给实现窗口)
- ✅ 调色板三模式:`style.css` `:root` / `.theme-green` / `.illus-grey`(含 `.brand-illustration` 80% 降透明)。
- ✅ 7 张组件 + `index.ts` 导出;全部业务槽位替换完成,`empty.png` / `ai-home.png` / `review.png` 引用清零(源码内仅剩注释提及)。
- ✅ 画廊「插画 Illustration」版块:`_gallery/sections/IllustrationSection.tsx`——调色板三态表 + 三 tab 全套预览。
- ✅ 尺寸收敛到 120/80 两档;彩色插画 `opacity-90` 清零(详见改动记录)。
- **无迁移欠账,无待确认项**;后续工作只有新插画按 §6 增补。
---
---
## 附录 A:业务引用台账(2026-07-15 扫描,排除 `_gallery/` 与组件自身)
| 组件 | 引用文件数 | 引用位置 |
|---|---|---|
| EmptyStateIllustration | 17 | ChannelMemberDialog、ChannelMemberManagementPanel、Linsight WorkspacePanel/WorkspaceDrawer、ChannelSquare、Subscription(index/ArticleList/ChannelDiscoveryHome/AddToKnowledgeModal/AddSourceDropdown/CrawlPreviewDialog)、knowledge(index/KnowledgeSpacePreviewDrawer/KnowledgeSquare/SpaceDetail)、apps(AppEmptyState/explore) |
| ArticleQAIllustration | 6 | AiChatMessages、appChat ChatEmptyState、FileAiDock、ArticleAiDock、AiAssistantPanel、KnowledgeAiBottomDock**全部 grey + 80px** |
| NoPermissionIllustration | 3 | WorkbenchEmptyIllustration(→ MenuUnavailablePage)、ChannelPreviewDrawer、KnowledgeSpacePreviewDrawer |
| CrawlingIllustration | 2 | AddSourceDropdown、CrawlPreviewDialog |
| SuccessIllustration | 2 | CreateChannelSuccess、CreateKnowledgeSpaceDrawer |
| ListWebLinkIllustration | 1 | AddSourceDropdown |
| SystemMaintenanceIllustration | 1 | SystemMaintenanceOverlay |
| KnowledgeSpaceIcon,非插画) | 1 | KnowledgeSpaceCardItem |
尺寸分布:`120px` ×30+(标准档)、`80px` ×10AI dock 9 处灰稿 + 爬取失败 1 处彩色)。离群值已清零(2026-07-15 收敛,见 §5.2)。
### 代码锚点
- 调色板与灰稿:`src/frontend/client/src/style.css``:root` 约 247-253、`.theme-green` 约 274-277、`.illus-grey` 约 289-305
- 组件目录:`src/frontend/client/src/components/illustrations/``index.ts` 统一导出)
- 画廊:`src/frontend/client/src/pages/_gallery/sections/IllustrationSection.tsx`
- 落地过程全记录(替换了哪些槽位):`src/frontend/client/BRAND-THEME-HANDOFF.md` §5.1
### 外部参考
- antd 空状态设计规范(三段式结构、提供邀请原则):https://ant.design/docs/spec/research-empty-cn
- antd 图形化 HiTu(插画体系化、网格绘制、色板源于 UI 色板但独立调明度——与本项目 `--illus-*` 独立于 `--brand-*` 的做法同理):https://ant.design/docs/spec/illustration-cn
- Arco Design Empty 规范:https://arco.design/docs/spec/empty
---
## 改动记录
| 日期 | 改了什么 | 影响文件 | 提交 |
|---|---|---|---|
| 2026-07-15 | 建档(补档性质):把 2026-06-24 已落地的插画体系(`--illus-*` 三模式、7 张组件、工程约定、槽位替换)整理成规范;参考 antd 空状态/图形化与 Arco Empty 补齐定位原则、场景速查、尺寸档位、灰稿规则、新增流程;扫描业务引用成台账(附录 A);开出 5 项待设计师确认(§7) | 本文件 | 不提交(文档夹已 gitignore |
| 2026-07-15 | 设计师首轮批注回填:①灰稿维持「现状惯例、暂不定规则」(§5.3);②文案层级间距定档(§5.2 新增间距表:图↔主提示 16、主提示↔按钮 20、结果页 16/32/40);③维护页 120px 保持现状(删待确认项);§7 收窄为 2 项并补齐 140/100 离群值与 opacity-90 的具体位置明细 | 本文件 | 不提交 |
| 2026-07-15 | 剩余 2 项(离群尺寸 140/100、opacity-90 口径)设计师决定转交实现窗口处理,§7 由「待确认」改为「遗留问题工单」,含代码定位与处理指引 | 本文件 | 不提交 |
| 2026-07-15 | 尺寸离群值收敛(设计师定档:预览抽屉无权限 140→120、爬取失败 100→80 + mb-5→mb-4);全站扫描确认只剩 120/80 两档,结论回填 §5.2「只有这两档,无例外」;§7 收窄为 opacity-90 一项并补齐带/不带位置明细与灰稿 80% 的区分说明 | KnowledgeSpacePreviewDrawer.tsx、CrawlPreviewDialog.tsx、本文件 | 待 committer 窗口提交 |
| 2026-07-15 | opacity-90 定为「彩色插画一律 100%,弱化用灰稿」:清零 17 处调用侧手写 `opacity-90`(全是 EmptyStateIllustration,精确正则只删 `Illustration className` 结尾的,保留 DialogClose/Avatar/UserPopMenu/ChannelNotebookOneIcon 4 处非插画);§5.2/§5.4 回填规则,§7 遗留工单整节删除(两项均结),落地状态改「无待确认项」;tsc 552 无新增 | 14 个业务 tsxChannelMemberDialog、ChannelMemberManagementPanel、Linsight WorkspacePanel/Drawer、AddToKnowledgeModal、knowledge index、ChannelSquare、ChannelDiscoveryHome、KnowledgeSpacePreviewDrawer、KnowledgeSquare、ArticleList、SpaceDetail、apps explore/AppEmptyState)、本文件 | 待 committer 窗口提交 |
@@ -0,0 +1,80 @@
# 文案规范
> 设计系统 · v1 · 2026-07-14
> 适用:BISHENG 界面里所有面向用户的文字——按钮、标题、提示、空状态、报错。
> 参考:[antd 文案规范](https://ant.design/docs/spec/copywriting-cn)。
---
界面里的每一句话,都是产品在跟用户说话。文案的目标只有一个:**让用户一眼看懂,不用猜。**
---
## 1. 中西文混排,留一个空格
中文与英文、中文与数字相邻时,中间留 1 个半角空格。
| ✅ 推荐 | ❌ 不推荐 |
|---|---|
| 已上传 3 个文件 | 已上传3个文件 |
| 使用 Docker 部署 | 使用Docker部署 |
全角标点前后不用再加空格(标点自带空隙):「已启用 SSO。」而不是「已启用 SSO 。」
## 2. 标点跟着主体语言走
主体是中文,标点全用全角(,。:;?“”);主体是西文,标点全用半角(, . : ; ?)。一句话里不混用。
| ✅ 推荐 | ❌ 不推荐 |
|---|---|
| 上传失败,请重试。 | 上传失败,请重试. |
| Deploy failed. Try again. | Deploy failed。Try again。 |
## 3. 数字用半角阿拉伯数字
统一写 1、2、3,不用一二三,也不用全角的 1 2 3。
| ✅ 推荐 | ❌ 不推荐 |
|---|---|
| 3 个知识库 | 三个知识库 |
| 第 2 步 | 第二步 |
## 4. 按钮文字:让人知道点了会发生什么
**含义要明确**。模糊的按钮没法告诉用户点下去会发生什么,用「动词」或「动词 + 对象」直接说出动作。
| ✅ 推荐 | ❌ 不推荐 |
|---|---|
| 确认删除 / 暂不 | 是 / 否 |
| 保存修改 | 确定 |
| 新建知识库 | 去创建 |
**长度要克制**。冗长的文案反而让人迷惑,在能看懂的前提下越短越好,一般 2–4 个字。
| ✅ 推荐 | ❌ 不推荐 |
|---|---|
| 保存 | 点击此处保存你的修改 |
| 重试 | 再试一次看看 |
先例:二次确认弹窗的「暂不 / 确认删除」(C 套)就是这个写法,新按钮照此风格。
## 5. 说人话,站在用户这边
- 少用技术词和内部术语,说用户能懂的话:「登录已过期,请重新登录」,而不是「Token 已失效」。
- 报错讲清两件事:**发生了什么 + 怎么办**。别只甩一句「操作失败」。
- 称呼用户用「你」,不用「您」;全站统一。
- 少用感叹号,系统消息不需要情绪:「保存成功」,而不是「保存成功!」
## 6. 同一个东西,全站叫同一个名字
- 操作前后叫法一致:按钮叫「新建」,成功提示就是「新建成功」,不要变成「创建成功」。
- 页面标题和菜单入口的名字一致,用户点「知识库」进来,页面标题也叫「知识库」。
- 写新文案前,先看看现有页面怎么叫,跟着叫;发现叫法冲突,登记到本文档、统一后再改。
---
## 改动记录
| 日期 | 改了什么 | 提交 |
|---|---|---|
| 2026-07-14 | 建档:混排空格、全半角标点、数字、按钮文案(明确 + 简洁)、用户视角与语气、命名一致,共 6 条 | 不提交(文档夹已 gitignore |
@@ -0,0 +1,51 @@
# 滚动条 Scrollbar
> 状态:✅ 已定稿并落地(2026-07-15,设计师定)。
> 画廊呈现:不设独立页,作为「设计规范 → 总览 → 设计原则」第 ⑦ 条(设计师 2026-07-15 定);本文件是完整规则与落地记录。
---
## 一、核心规则(一句话)
**滚动条显隐跟随系统设置。** 系统设为「始终显示」就常驻;设为「滚动时显示」就自动隐藏。
不允许出现:系统设了「滚动时显示」,页面里却常驻一根滚动条。
## 二、为什么 / 原理
- 浏览器**原生**滚动条天然跟随系统设置(macOS overlay 滚动条会自动隐藏)。
- 一旦对某元素写了 `::-webkit-scrollbar`(哪怕只设宽度),Chrome/Safari 就会退出 overlay
模式,改画「经典」滚动条 —— **常驻、占布局空间**,系统设置随之失效。
- 所以规范做法是**默认什么都不写**,而不是「写一套仿系统的样式」。
## 三、规则明细
1. **默认不自定义**:任何滚动区域不写 `::-webkit-scrollbar` / `scrollbar-width`
2. **禁止强制常驻**:新增自定义滚动条样式必须先过规范评审(走 token 评审同流程)。
3. **例外只许「更少可见」**,用三个既有 utilitystyle.css),不要新造:
| 类名 | 行为 | 典型场景 |
|---|---|---|
| `no-scrollbar` | 完全隐藏(仍可滚) | 横向 tab 条 |
| `scroll-on-scroll` | 滚动进行时才显示(JS 配合 `data-scrolling` | 通知弹窗列表 |
| `scroll-no-hover` | hover 容器时才显示 | 侧栏/面板长列表 |
4. **`.scrollbar-os` 已废弃**:它原是「退回原生」的逃生口,默认翻转后成为空类。
新代码不要再加;存量约 33 处(业务文件)随改动顺手删除。
## 四、落地记录(2026-07-15
| 改动 | 位置 | 说明 |
|---|---|---|
| 删除全局强制细滚动条 | `src/style.css`(原 `:not(.scrollbar-os)…::-webkit-scrollbar` 一组 4 条) | 默认翻转为原生;原规则是「系统自动隐藏失效」的根源 |
| 删除 `.excel-scroll` 样式 + 用点 | `src/style.css` + `ExcelPreview.tsx` | 其存在目的是覆盖全局 0.1em 高度,全局删除后无意义 |
| 删除 `.scrollbar-transparent` | `src/style.css` | 0 处引用,死代码 |
| 保留三个减显 utility | `src/style.css` | 见上表,属许可例外 |
## 五、遗留例外(待设计师定夺)
| 位置 | 现状 | 待定 |
|---|---|---|
| 灵思 artifact 深色预览面板(`mobile.css` 末尾按 radix tabpanel 属性选择器写死:深色底 + 12px 深色常驻条) | 深色语境下原生浅色条突兀,暂保留 | 是否改成 `scroll-no-hover` 或深色语境专用的减显方案 |
## 六、验收方法
macOS 系统设置 → 外观 → 「显示滚动条」切到「滚动时」:打开画廊「滚动条 Scrollbar」页,
默认演示区平时不应看到滚动条,滚动时才出现;切到「始终」则应常驻。Windows 下始终显示原生条即为正确。
@@ -0,0 +1,185 @@
import { BRAND, BRAND_STEPS, GRAY, TEXT, FILL, BORDER, BG, FUNCTIONAL, TAG } from '~/design-token.cjs';
export const th = { padding: '8px 12px', color: 'rgb(var(--text-1))' };
export const td = { padding: '8px 12px', color: 'rgb(var(--text-1))', borderBottom: '1px solid rgb(var(--border-base))' };
export const headRow = { textAlign: 'left' };
export const overlay = (hex) => {
const h = hex.slice(1);
const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
};
export const mono = 'ui-monospace, "SF Mono", "Cascadia Mono", Consolas, "Liberation Mono", monospace';
export const Strip = ({ title, swatches }) => (
<div style={{ marginBottom: 24 }}>
{title && <div style={{ marginBottom: 12, fontSize: 13, fontWeight: 500, color: 'rgb(var(--text-1))' }}>{title}</div>}
<div style={{ display: 'flex' }}>
{swatches.map((s) => {
const fg = s.fg || overlay(s.hex);
return (
<div key={s.label} className="bs-swatch" style={{ background: s.bg || s.hex, color: fg }}>
{s.usage ? <span className="bs-swatch-usage" style={{ color: fg }}>{s.usage}</span> : <span />}
<div>
<div style={{ fontSize: 13, lineHeight: 1.25, fontWeight: s.primary ? 500 : 400 }}>{s.label}</div>
<div style={{ marginTop: 4, fontSize: 11, lineHeight: 1, fontFamily: mono, opacity: 0.8 }}>{s.value || s.hex}</div>
</div>
{s.primary && <span style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 3, background: s.accent || fg }} />}
</div>
);
})}
</div>
</div>
);
export const BrandStrips = () => (
<div>
{['blue', 'green'].map((theme) => (
<Strip
key={theme}
title={theme === 'blue' ? '蓝' : '绿'}
swatches={[...BRAND_STEPS, 'muted'].map((step) => ({
label: step,
hex: BRAND[theme][step],
primary: step === BRAND.main,
accent: BRAND[theme][BRAND.accentStep],
usage: BRAND.role[step],
}))}
/>
))}
</div>
);
export const GrayStrip = () => (
<Strip
swatches={GRAY.map((g) => ({
label: `gray-${g.n}`,
hex: g.hex,
value: (
<>
<span className="bs-hex-light">{g.hex}</span>
<span className="bs-hex-dark">{g.darkHex}</span>
</>
),
bg: `rgb(var(--arco-gray-${g.n}))`,
fg: g.n <= 5 ? 'rgb(var(--arco-gray-10))' : 'rgb(var(--arco-gray-1))',
usage: g.role,
}))}
/>
);
export const SemanticTable = () => {
const Row = ({ cls, value, usage }) => (
<tr><td style={td}><code>{cls}</code></td><td style={td}>{value}</td><td style={{ ...td, color: 'rgb(var(--text-2))' }}>{usage}</td></tr>
);
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead><tr style={headRow}><th style={th}>类名(语义)</th><th style={th}>取值</th><th style={th}>用途</th></tr></thead>
<tbody>
{TEXT.map((t) => <Row key={t.name} cls={`text-text-${t.name}`} value={t.ref} usage={t.usage} />)}
{FILL.map((f) => <Row key={f.name} cls={`bg-fill-${f.name}`} value={f.ref} usage={f.usage} />)}
{BORDER.map((b) => <Row key={b.name} cls={`border-border-${b.name}`} value={b.ref} usage={b.usage} />)}
{BG.map((b) => (
<Row
key={b.name}
cls={`bg-bg-${b.name}`}
value={<><span className="bs-hex-light">{b.hex}</span><span className="bs-hex-dark">{b.darkHex}</span></>}
usage={b.usage}
/>
))}
</tbody>
</table>
);
};
export const FunctionalStrips = () => (
<div>
{FUNCTIONAL.map((fn) => (
<Strip
key={fn.name}
title={fn.label}
swatches={[
{ label: '主色', hex: fn.main, primary: true, accent: fn.active },
{ label: 'hover', hex: fn.hover },
{ label: 'active', hex: fn.active },
{ label: 'tint 浅底', hex: fn.tint },
]}
/>
))}
</div>
);
export const TagRow = () => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
{TAG.map((t) => (
<span key={t.label} title={t.note} style={{ display: 'inline-flex', alignItems: 'center', borderRadius: 4, padding: '2px 8px', fontSize: 13, background: t.bg, color: t.fg }}>{t.label}</span>
))}
</div>
);
# 色彩规范 Color
具体设计过程中,基于产品色彩进一步定义符合产品调性以及功能诉求的颜色。
- **品牌色**
- **中性色**
- **功能色**
## 1. 品牌色 Brand
品牌色体现产品调性,贯穿关键操作、链接与选中状态。BISHENG 提供**蓝、绿两套品牌主题**,同一色板在两个主题下各有取值,切换主题时品牌色整体跟随变化。
{/* 主题实现机制(--brand-* CSS 变量 / .theme-green 换肤 / Tailwind 接线)见 src/frontend/client/BRAND-THEME-HANDOFF.md,规范页不展示实现细节。 */}
### 1.1 色板
<BrandStrips />
### 1.2 使用规则
- 品牌色始终跟随主题(蓝 / 绿),业务中不写死品牌色的具体色值。
- **列表 / 菜单选中态**统一用品牌主色的浅色透明底(约 7%)。
- **按钮品牌三态**:常态用主色,悬停变浅一档,按下加深一档(具体档位见「组件 → Button」)。
- **固定例外**:审批中标签恒为蓝色、应用中心置顶图标用弱化的品牌色,二者均不随主题切换。
## 2. 中性色 Neutral
中性色用于文字、背景、边框、分割线等基础元素,构成界面信息层级的骨架。BISHENG 采用**一套统一的中性灰阶**作为唯一中性色板,不随主题切换。
### 2.1 色板
<GrayStrip />
### 2.2 语义层
在灰阶之上定义**语义化的角色 token**(如标题文字、次要文字、悬停填充、分割线等)。业务按「用途」取色而非直接取档位,保证同一角色全局一致。
<SemanticTable />
## 3. 功能色 Functional
功能色表达明确的操作反馈与状态语义,**不随品牌主题切换**,在蓝 / 绿两个主题下保持恒定。
<FunctionalStrips />
- **成功 / 警告 / 危险**各含主色及其悬停、按下、浅底四态;浅底用于标签、提示条等大面积背景。
- 链接色、信息色等同品牌色,跟随主题。
## 4. 标签色 Extended
标签色**成对使用:浅色底 + 深色字**(最浅档作底 + 主色档作字)。第三方品牌色(如各家 logo 色)原样保留,不纳入色板。
<TagRow />
## 5. 插画色 Illustration
空状态等插画使用独立于 UI 品牌色的插画调色板,同样支持蓝 / 绿主题,另有一套主题无关的灰稿模式。实时预览见「组件 → Illustration」。
{/* 插画着色实现(--illus-* / SVG 上色手段)见 src/frontend/client/BRAND-THEME-HANDOFF.md §3。 */}
## 6. 使用约定
- **同一语义只使用一个颜色**:如「次要文字」始终取同一 token,不再引入相近的其他灰。
- 组件与业务只使用语义层颜色,不直接引用底层色板档位或写死色值。
- 新增颜色需求先在现有色板内寻找替代;确需新增须经评审后进入色板。
@@ -0,0 +1,190 @@
# 按钮规范 Button
> 设计系统 · 按钮部分 v1 · 2026-07-14
> 参考 antd 规范制定,不迁就现有场景。颜色使用本项目 token:品牌色跟蓝⇄绿主题,危险红固定 `#f53f3f`。
> 与 [00-总纲.md](00-总纲.md)、[01-设计规范.md](01-设计规范.md)、[基础-字体规范.md](基础-字体规范.md) 配套。迁移参考数据见文末附录。
---
## 1. 类型 Type
底层模型采用 **color × variant 双轴**(type 只是组合的别名),对外提供以下常用类型:
> **什么叫双轴**:按钮的样子由两个独立属性拼出来——**color 管颜色**(品牌色 primary / 中性灰 default / 危险红 danger),**variant 管画法**(实心 solid / 描边 outlined / 浅底 filled / 文字 text / 链接 link)。两轴自由组合:红×实心=危险主按钮、红×描边=危险次按钮,3 色 × 5 画法共 15 种组合自动成立,不必逐个定义。
| 类型 | 双轴组合 | 长相 | 用途 |
|---|---|---|---|
| **Primary 主按钮** | `primary` × `solid` | 品牌色实心 + 白字 | 主行动点,**一个操作区域只放一个** |
| **Secondary 次强调** | `primary` × `filled` | 品牌浅底 + 品牌色字,无边框 | 弱于主按钮的品牌强调(如次级新建) |
| **Default 默认按钮** | `default` × `outlined` | 白底 + 灰描边 + 灰字 | 最常用的次级按钮(取消、返回等) |
| **Text 文字按钮** | `default` × `text` | 无底无边,hover 浅灰底 | 最次级操作、表格行内操作、工具栏 |
| **Link 链接按钮** | `primary` × `link` | 品牌色文字,hover 不加底 | 导航型操作,行为如链接 |
| **Danger 危险按钮** | `danger` × `solid / outlined / text` | 红实心 / 红描边 / 红文字 | 删除、移权等,一般配二次确认 |
不设 dashed(虚线)与 ghost(深底透明)类型;「无描边无背景」的诉求由 Text 承接。其余组合(`danger outlined``primary text` 等)按双轴自然推导。
**形状 Shape**(第三个独立属性):`square`(默认,圆角随尺寸 4/6/8px)/ `circle`(正圆,`border-radius: 50%`,**仅用于单 icon 按钮**,文字按钮不可用)。
---
## 2. 尺寸 Size3 档)
| size | 高度 | 字号/行高 | 水平 padding※ | 圆角 | 适用 |
| ---------------- | ---- | ----- | ----------- | --- | ------------- |
| `small` | 24px | 14/22 | 7px | 4px | 表格行内、紧凑工具条 |
| `medium`**默认** | 32px | 14/22 | 15px | 6px | 绝大多数场景 |
| `large` | 40px | 16/24 | 15px | 8px | 登录页、营销页、大表单提交 |
- ※ padding 为含 1px 边框的视觉值;无边框变体(solid/filled/text)取 8 / 16 / 16px,保证同尺寸按钮视觉等宽。
- 高度与圆角:controlHeight 24/32/40、borderRadius 4/6/8。
- 字号行高以 [基础-字体规范.md](基础-字体规范.md) 为准:small/medium 用 `text-body`(14/22)large 用 `font-size-4`(16/24);字体规范调整时按钮跟随。
- 垂直方向不设 padding,由高度定死;文字垂直居中。
- 同一视图内相邻按钮必须同尺寸。
---
## 3. 内容形态
### 3.1 单文字按钮
- 文字不换行(`whitespace-nowrap`)、不省略;文案过长说明该用别的控件。
- 字重 400(`font-normal`),所有尺寸与类型一致。
- 两个汉字的按钮**不加**中间空格(实现时关闭 autoInsertSpace 类行为)。
- 弹窗 footer 的按钮 min-width 60px;移动端 footer 按钮等宽平铺。
### 3.2 单 icon 按钮
- 尺寸 24×24 / 32×32 / 40×40,与三档同高;形状可选 `square`(圆角随尺寸)或 `circle`(正圆)。
- icon 尺寸:small 14px / **medium、large 16px**,居中。
- 可用全部 variant(常用 `text`:工具栏图标钮;`outlined`:独立图标钮)。
- **必须带 Tooltip 说明含义**,并设 `aria-label`
- 图标来源按总纲 §七:bisheng-icons 优先,lucide 兜底。
### 3.3 文字 + icon 按钮
- icon 尺寸与单 icon 按钮同一套:small 14px / **medium、large 16px**;与文字间距 8px(`gap-2`)small 档可收紧至 4px。
- icon 默认在文字左侧;「下一步 →」这类方向语义可放右侧。
- 一个按钮最多一个 iconloading 时 spinner 顶替 icon 位(无 icon 则前置)。
---
## 4. 边距与排布
| 场景 | 规格 |
|---|---|
| 同组相邻按钮间距 | 8px(紧凑场景/工具栏);弹窗 footer 用 12px(对齐 01-设计规范 `gap-3` |
| 弹窗 footer | 右对齐,**主按钮在最右**,取消在其左;danger 场景主位为 danger solid |
| 页面级操作区 | 主按钮在左首位(与弹窗相反),其余依次向右 |
| 与表单控件同排 | 按钮与 input 同高对齐(medium 32 ↔ 输入框统一高度,待 Input 组件期联动定稿) |
| block 按钮 | `width: 100%`,仅用于移动端/窄侧栏/登录页 |
---
## 5. 状态 State(各类型 × 各状态)
### 5.1 色板约定
- **品牌色**`--primary` / `--brand-*` token,跟蓝⇄绿主题;hover 亮一档、active 深一档(档位由主题 token 提供,不写裸 hex)。
- **危险红**(不换肤):base `#f53f3f`、hover `#d6373a`、active `#d02f33`
- **中性灰**:文字 `#4e5969`、边框 `#e5e6eb`、hover 底 `#f7f8fa`。已建语义 token`--btn-*` 系,style.css + tailwind `btn-*`),组件内不留裸 hex。
### 5.2 状态表
| 状态 | Primary(solid) | Default(outlined) | Text | Danger(solid) |
| -------- | -------------- | ------------------------------ | ----------------- | ---------------- |
| 常态 | 品牌色底 + 白字 | 白底 + `#e5e6eb` 边 + `#4e5969` 字 | 透明底 + `#4e5969` 字 | `#f53f3f` 底 + 白字 |
| hover | 品牌色亮一档 | 底 `#f7f8fa`,边字不变 | 底 `#f7f8fa` | `#d6373a` |
| active | 品牌色深一档 | 底再深一档 | 底再深一档 | `#d02f33` |
| disabled | 全类型统一,见下 | | | |
| loading | 全类型统一,见下 | | | |
- **active 档仅触屏生效**:可 hover 设备按下沿用 hover 档(按压时指针必然悬停,单独深档会造成点击闪动);触屏下 active 是唯一按下反馈,取表中「再深一档」值。实现:active 类包 `coarse-pointer` 媒体查询,与 hover 的包裹互为补集。
- **disabled(全类型统一)**:走 `--btn-disabled-bg` / `--btn-disabled-text` / `--btn-disabled-border` 三个 token(浅色 `#F5F5F5` 底 / `#BFBFBF` 字 / `#D9D9D9` 边,深色自动翻转为可见的低对比灰),`cursor: not-allowed`
- **loading(全类型统一)**:内置 spinner 顶替 icon 位,期间不可点,整体 opacity 0.65**禁止业务页自塞 Spinner**。
- **focus**`focus-visible` 才出外环(键盘可达、鼠标点击不出圈);外环色随 color(品牌/红)。
- **outlined(各 color 通用)**hover 一律是**白底染当前色板的淡底**,**边框与文字色不变**(不做纯边框/文字变色式 hover)——primary→`brand-50`、danger→红 10% 透明度、default→`#f7f8fa`;触屏 active 再深一档(`brand-100` / 红 15% / `#f2f3f5`)。
- filled / link 等其余组合的状态按同一逻辑推导:hover 加深底色或染色一档,active 再深一档。
- **色板跟随底色**hover / active 的底色变化发生在**当前底色所属色板内**——品牌底走品牌板、红底走红阶、灰/白底走灰阶,**禁止跨色板变灰**。存量把品牌浅底手写在 `className` 上的(如 `bg-blue-100 hover:bg-blue-200`),迁移时改用 `primary filled`,不靠 className 叠 hover。
---
## 5.5 移动端适配
> 跨组件通用原则(双判定口径、逻辑与依据)见 [基础-多端适配原则.md](基础-多端适配原则.md),此处只写 Button 自己的细则。
| 项 | 触屏/窄屏规则 |
|---|---|
| hover | 触屏(`hover: none`)下**全类型禁用 hover 态**,按下反馈走 §5.2 的 active 档(danger `#d02f33` 等直接复用),不新增色值 |
| 触达 | medium32px)与各档 icon-only 用透明热区扩到 ≥44×44**视觉尺寸不变**;small(24px)在触屏高频场景直接升 medium,不硬撑热区 |
| 字号 | 不随移动端正文抬升,保持 14/14/16(按钮是控件不是正文) |
| 弹层 footer | 窄屏等宽平铺 `flex-1`,桌面 `sm:flex-none` 右对齐 |
| block | 窄屏页面级主操作用 block 占满整行 |
| loading / disabled / focus | 与桌面一致,无额外规则 |
实现注意:hover 禁用与热区扩展**一处生效**,禁止逐业务页处理。hover 禁用用 Tailwind `future.hoverOnlyWhenSupported` 全局开关;组件内**必须写普通 `hover:` 类,禁止自造 hover 变体前缀**——否则 tailwind-merge 认不出冲突,业务页 `className` 的 hover 覆盖会失效。
---
## 6. 落地(给实现窗口)
1. `ui/Button.tsx` 重构为 cva `variants: { color, variant, size, shape }` + `compoundVariants`;保留 `className` 特例口子。基座字重 `font-normal`
2. 颜色全走 token:品牌用 `--primary`/`--brand-*`(目标替代 `btn-brand-primary` !important 换肤 hack,主题机制暂不动则先沿用并记债);灰系/红系建语义变量,不留裸 hex。
3. **旧 API 兼容期**:旧入参自动映射(`variant="outline"``default outlined``submit``primary solid``destructive``danger solid``ghost``default text``secondary``default filled`、size 缺省→`medium`),标 deprecated,业务迁完再删——避免一次改 138 个文件。
4. 全局 CSS 类 `btn btn-*``Generations/Button` 逐批迁入后删除(btn-primary 的写死 ChatGPT 绿即除)。
5. 迁移节奏照总纲:设计师逐批点名、每批一笔提交;画廊 ButtonSection 按本规范重做(标准用法文档 + 现状对比)。
6. 现默认 h-936px)归入 medium32px)全站矮 4px:迁移各批次时**带批量目检回归**。
---
---
## 附录 A:现状扫描存档(2026-07-14,仅迁移参考,不影响规范)
> 规范「不迁就现有场景」,本附录只为迁移排批次、估工作量用。扫描口径:`src/frontend/client/src`,排除 `ui/` 与 `_gallery/`。
### A.1 按钮的 5 路并行体系
| 体系 | 实现 | 用量 |
|---|---|---|
| ① 基准组件 | `ui/Button.tsx`(cva,8 变体 × 4 尺寸) | 269 处 / 138 文件(58% 带 className 手改) |
| ② 全局 CSS 类 | `btn btn-primary / btn-neutral / btn-secondary`(style.css:1419-1545) | 42 处 / 28 文件;btn-primary 写死 ChatGPT 绿 `rgb(16,163,127)` 不换肤 |
| ③ 第二个 Button | `components/Input/Generations/Button.tsx` | 3 消费方(Continue/Stop/Regenerate |
| ④ 原生 `<button>` 手拼 | — | 469 处 / 222 文件(带视觉样式 135 文件,含大量合理图标钮,不全量迁移) |
| ⑤ 衍生包装 | SocialButton、DangerButton 等 | 少量 |
### A.2 现有 variant/size 用量(→ 新规范映射见 §6 第 3 条)
variant:缺省 default 109 + 显式 7 / outline 78 / ghost 40 / secondary 18 / submit 11 / destructive 6 / secondaryBrand 0 / link 0。
size:缺省(h-9 201 / smh-9,与缺省同高名存实亡) 48 / icon 18 / lg 2。
### A.3 className 覆盖三大聚类(与新规范吻合)
- `h-8` 60 处 → 恰为新 medium(32px);另 h-7×8、h-10×8、h-5×5。
- 6px 圆角 41 处(`rounded-[6px]` 26 + `rounded-md` 15),聚集 knowledge/Subscription 新页面 → 恰为新 medium 圆角。
- Arco 灰系 hex 手拼:`#4e5969`×16、`#e5e6eb`×11、`#666666`×7、`#f7f8fa`×6、`#ebecf0`×6 → 恰为新 Default 按钮取值,可直接折叠。
### A.4 疑点与死代码线索
- `DialogButton``Nav/SettingsTabs/DangerButton.tsx`(自身 0 消费方,死代码)与 `Chat/Menus/Presets/EditPresetDialog.tsx`(经 PresetsMenu 可达)import,但**全库找不到定义**——理论上打开编辑预设弹窗会崩,与「可达」矛盾,**待实测复核,未证实别当结论**。
- default 变体挂 `btn-brand-primary` 类,由 style.css:195-215 在绿主题下 `!important` 强刷 `#19b476` 三态(换肤 hack,见 §6 第 2 条记债)。
### A.5 代码锚点
- 基准组件:`src/frontend/client/src/components/ui/Button.tsx`
- 全局类:`src/frontend/client/src/style.css:1419-1545`btn 系)、`:195-215`(换肤 hack
- 灰描边聚类代表:`pages/Subscription/ArticleList/MultiSourceSelect.tsx``pages/knowledge/SpaceDetail/EditTagsModal.tsx`
- 画廊版块:`src/frontend/client/src/pages/_gallery/sections/ButtonSection.tsx`(未按本规范重做,留给组件窗口)
---
## 改动记录
| 日期 | 改了什么 | 影响文件 | 提交 |
|---|---|---|---|
| 2026-07-14 | 现状扫描 + 建文档(Cowork 窗口,只动 docs-ui-refactor/ | 本文件、00-总纲看板 | 不提交(文档夹已 gitignore |
| 2026-07-14 | 设计师定方向「参考 antd、不迁就现有场景」,写入双轴规范草案 | 本文件 | 不提交 |
| 2026-07-14 | 重构为纯规范文档(类型/尺寸/边距/字号/内容形态/状态矩阵);现状梳理降级为附录 A | 本文件、00-总纲看板 | 不提交 |
| 2026-07-14 | 拍板:两汉字按钮不加中间空格 | 本文件 | 不提交 |
| 2026-07-14 | 移动端适配落档:新建 [基础-多端适配原则.md](基础-多端适配原则.md),本文件加 §5.5 | 本文件、基础-多端适配原则.md、00-总纲看板 | 不提交 |
| 2026-07-14 | 拍板:dashed 删除、Default hover 灰底、disabled 灰化、h-9→32 目检回归;danger 原红阶候选否决 | 本文件 | 不提交 |
| 2026-07-14 | danger hover 定稿 `#d6373a`Ghost 删除(诉求由 Text 承接) | 本文件 | 不提交 |
| 2026-07-14 | danger active 定稿 `#d02f33`;全文清理决策过程赘述,**v1 定稿**(§7 待确认清单清空删除) | 本文件、00-总纲看板 | 不提交 |
| 2026-07-14 | **实现窗口:基准组件按 §6 重构落地。**`ui/Button.tsx` 重写为 `color×variant×size` cva + 15 组合 compoundVariants + `iconOnly`/`icon`/`loading` 属性(spinner 用 `Outlined.Loading`);旧 API 全量自动映射(§6.3,含 `size="icon"`→medium+iconOnly、裸 `variant="link"`→primary link),标 deprecated`buttonVariants()` 导出保持兼容(Pagination/BackToChat 消费方不动)。② 语义 token `--btn-*` 落入 style.css :root + tailwind.config `btn-*` 色(danger 三态、灰系 text/border/fill-1..4、disabled 边);disabled 用 `black/[0.04]`+`black/25` 免 token。③ §5.5 落地:新增 tailwind `can-hover` 变体(`not ((hover:none) and (pointer:coarse))`)包裹全部 hover 态;`.btn-touch-hit` 伪元素热区 ≥44px(medium+icon-only),写在 style.css 一处。④ 画廊 ButtonSection 按规范重做(常用类型/15 格矩阵/尺寸/内容形态/disabled+loading/旧 API 迁移台账)。**实现期推导值待设计师画廊确认**:灰 active=`#F2F3F5`、default filled 三态=`F2F3F5/E5E6EB/C9CDD4`Arco 灰阶顺推)、default solid=深灰实心(规范未定义的稀有组合)、danger filled/link 用红 8%20% 透明度阶。已知债:outlined 白底/灰系为亮色模式取值,暗色模式未适配;`btn-brand-primary` 换肤 hack 照 §6.2 沿用。**类型兼容修复**:`color` 轴与原生 HTML color 属性冲突,类型放宽为 `ButtonColor \| (string & {})` + 运行时白名单(SharedLinkButton 的 TooltipAnchor 透传场景)。 | Button.tsx、style.css、tailwind.config.cjs、_gallery/sections/ButtonSection.tsx | 待 committer 窗口提交(注意:**style.css 与 tailwind.config.cjs 本次属组件一笔**,总纲的按路径分 add 清单未含这两个文件,提交时需一并 add) |
| 2026-07-14 | v1 补充三项:新增 shape 属性(square/circle,circle 仅 icon 按钮);icon 尺寸定为 14/16/18 三档(medium=16×16,单 icon 与文字+icon 同一套);字重 500→**400**(字体规范 §3 的「按钮」用途同步移出 500 档) | 本文件、基础-字体规范.md | 不提交 |
| 2026-07-14 | **实现窗口:v1 三项补充落地。**`shape` 轴加入 cvasquare 空/circle=`rounded-full`,声明在 size 之后以覆盖每档圆角);**circle 仅 icon 按钮在 resolveVariants 里机械强制**——非 iconOnly 时静默回落 square,业务写错不会出现圆角文字按钮。② 基座 `font-medium``font-normal`(全尺寸全类型 400)。③ icon 阶梯统一 14/16/18:size 轴 svg 尺寸改为 3.5/4/[18px],icon-only 的 compoundVariants 不再单独覆盖 svg(与文字+icon 同一套)。画廊同步:纯 icon 卡加 circle 示例(outlined 灰 + primary 实心)、字重/icon 尺寸描述更新、规则区补 circle 限制。浏览器实测:字重 400、circle `border-radius:9999px` 32×32、icon 实测 24→14/32→16/40→18loading spinner 包围盒 22px 系 animate-spin 旋转对角线,非尺寸问题)。 | Button.tsx、_gallery/sections/ButtonSection.tsx | 待 committer 窗口提交(与上一行基准重构同一笔) |
| 2026-07-14 | **拍板 + 落地:outlined 系 hover 改为「淡底」。** 原实现 primary/danger outlined 的 hover 只变边框和文字色(antd 式),设计师定为:hover 一律加当前色板的淡底、边字不变——primary→`brand-50`、danger→红 10%、default 原本就是灰 `#f7f8fa`,三列行为同构;触屏 active 再深一档(`brand-100`/红 15%)。§5.2 增补 outlined 通用规则,Button.tsx 两处 compound 更新。 | Button.tsx、本文件 §5.2 | 待 committer 窗口提交(同组件一笔) |
| 2026-07-14 | **拍板 + 落地:active 深档改为仅触屏生效。** 设计师反馈迁移后的「创建知识空间」桌面点击仍「闪一下」——根因即 active 深一档(50→100→200 的第三跳)在桌面瞬时呈现。拍板:可 hover 设备按下沿用 hover 档(指针按压时必然悬停,天然显示 hover 色,无闪动);触屏保留深一档作唯一按压反馈(适配原则 §1 的 active 补偿不受影响)。落地:Button.tsx 全部 15 处 `active:``coarse-pointer:active:`(触屏媒体,与 hover 的 hover-capable 包裹互为补集);style.css 绿主题 `btn-brand-primary` hack 的 `:active` 同样包进触屏媒体(桌面绿主按钮同步不闪);§5.2/适配原则 §1/画廊文案同步。注意:业务页若有自写 `active:` 覆盖(现存约 19 处、多为原生按钮),桌面上仍按其自身写法生效。 | Button.tsx、style.css、本文件 §5.2、基础-多端适配原则.md §1、画廊文案 | 待 committer 窗口提交(同组件一笔) |
| 2026-07-14 | **首例业务迁移(设计师点名):知识空间侧栏「创建知识空间」**`variant="secondary"` + className 手写品牌底(`bg-blue-100 hover:bg-blue-200`,无 active)迁为新 API `color="primary" variant="filled"` + `icon={<Outlined.Plus />}`(lucide Plus→bisheng-icons),className 只剩 `w-full`。三态整套走品牌板(50→100→200),按下闪灰问题根治;底色比原来浅一档(blue-100→blue-50)属规范 Secondary 取值,预期内。画廊台账 secondary 计数 18→17。**注意:此文件是业务页,按总纲规则 5 应尽快单独提交一笔并知会日常窗口**(不与组件笔混合,git add 只加此文件)。 | pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx、画廊台账 | 待 committer 窗口提交(**业务迁移单独一笔**) |
| 2026-07-14 | **hover 变灰 bug 修复 + 规范澄清「色板跟随底色」。** 设计师发现知识空间侧栏「创建知识空间」(品牌浅底 `className="bg-blue-100 hover:bg-blue-200"` + 旧 `variant="secondary"`)hover 变灰。根因:上轮 cva hover 用了自造 `can-hover:` 前缀,tailwind-merge 认不出它与业务 `hover:bg-*` 冲突→cva 灰 hover 胜出,**波及所有手写 hover 覆盖的存量调用**。修复:改用 Tailwind `future.hoverOnlyWhenSupported` 全局开关实现触屏 hover 禁用(全 app 生效,顺带落实适配原则 §1),Button 恢复普通 `hover:` 类,删除 `can-hover` 变体;node 实测 tw-merge 去重恢复(品牌 hover 胜出)。规范侧:§5.2 增补「色板跟随底色」原则(品牌底 hover 走品牌板,禁跨色板变灰),§5.5 实现注意改写(禁止自造 hover 变体前缀),多端适配原则 §1 记全局落地。遗留:该按钮按下仍闪灰(`active:bg-btn-fill-4` 无业务覆盖),根治需迁移该按钮为 `primary filled`——列入迁移点名清单。 | Button.tsx、tailwind.config.cjs、本文件 §5.2/§5.5、基础-多端适配原则.md | 待 committer 窗口提交(同组件一笔) |
@@ -0,0 +1,190 @@
# 组件改造 · Modal 弹窗
> 状态:🟨 进行中 · 优先级:最高(第 1 个改造的组件)
> 每个新会话接手本组件前,先读 [00-总纲.md](00-总纲.md) 再读本文件。
---
## 一、现状:两套并行的弹窗体系(乱的根源)
BISHENG client 有 **两套**弹窗,都包着同一个 Radix `@radix-ui/react-dialog`,但样式不同:
### A 套 —— 标准弹窗原语
- 底层原语:`src/components/ui/Dialog.tsx``Dialog` / `DialogContent` / `DialogHeader` / `DialogFooter` …)
- 便捷模板:`src/components/ui/DialogTemplate.tsx`
- 遮罩:`bg-black/40` **半透明 + `backdrop-blur-md` 毛玻璃模糊**
- 层级:`z-[100]`
### B 套 —— "Original" 原语(OG 前缀)
- 底层原语:`src/components/ui/OriginalDialog.tsx`(导出 `OGDialogContent` / `OGDialogHeader` …)
- 便捷模板:`src/components/ui/OGDialogTemplate.tsx`**用得最多**
- 遮罩:`bg-black/80` **更黑、无模糊**
- 层级:`z-50`
### 差异一览(这就是「同样是弹窗却不一致」的证据)
| 维度 | A 套 (Dialog) | B 套 (OriginalDialog / OG) |
|---|---|---|
| 遮罩颜色 | `bg-black/40`(浅) | `bg-black/80`(深) |
| 毛玻璃模糊 | 有 `backdrop-blur-md` | 无 |
| 层级 z-index | `z-[100]` | `z-50` |
| 便捷模板 | DialogTemplate | OGDialogTemplate |
---
<!-- site-hide -->
## 二、各版本用量(供收敛决策)
> 精确统计(词边界,排除 ui/ 自身与画廊):
| 组件 | 业务文件数 | 处置建议 |
|---|---|---|
| `OGDialogTemplate`B 套模板) | **25** | **收敛基准**(用得最多) |
| `DialogTemplate`A 套模板) | **3** | 迁移到基准后删除 |
| `OriginalDialog`B 套原语) | 仅被 OGDialogTemplate 内部 import | B 套私有底层,**保留**(非死代码) |
| `Dialog`(A 套原语,含 barrel 直接用) | 若干(多为 barrel 引入,按需迁移) | 保留为底层原语 or 合并 |
**死代码线索**:早前扫描 `Combobox` = 0 业务引用(与 Modal 无关,另行清理)。
`OriginalDialog` 已确认**不是**死代码。
### 已知使用 OGDialogTemplate 的业务文件(25 处,节选)
Bookmarks/BookmarkEditDialog · Bookmarks/DeleteBookmarkButton · Chat/Input/Files/DragDropModal ·
Conversations/ConvoOptions/{DeleteButton,ShareButton,SharedLinkButton} · Endpoints/SaveAsPresetDialog ·
Input/SetKeyDialog/SetKeyDialog · Nav/ExportConversation/ExportModal ·
Nav/SettingsTabs/Data/{ClearChats,DeleteCache,RevokeKeysButton,SharedLinks} ·
Nav/SettingsTabs/General/ArchivedChats · Prompts/{AdminSettings,DeleteVersion} ·
Prompts/Groups/DashGroupItem · SidePanel/Agents/{ActionsPanel,AgentTool,Code/ApiKeyDialog,DeleteButton} ·
SidePanel/Builder/{ActionsPanel,AssistantTool} · pages/appChat/components/AppSidebarConvoItem ·
pages/standaloneChat/components/GuestConvoItem
---
<!-- site-hide -->
## 二点五、二次确认弹窗现状(2026-07-02 扫描,本窗口专项)
> 画廊已建独立版块:`/workspace/gallery` → 左侧「二次确认弹窗」。
> **重要发现:二次确认是两套体系并行**:
| 体系 | 实现 | 业务文件数 | 用在哪 | 样式一致性 |
|---|---|---|---|---|
| B 套模板 | `OGDialogTemplate` + `selection` | 21 | 旧页面:会话/书签/Agent/设置/PromptLibreChat 血统) | 差:确认按钮 9 种写法(见下表) |
| **C 套服务** | `useConfirm()``Providers/ConfirmContext.tsx`,底层 AlertDialog | 16 | 新页面:知识空间 / 订阅频道 / 权限 | **好:样式集中一处,destructive/default 两档,改一处全生效** |
> ⚠️ 范围口径(设计师 2026-07-02 拍板):另有 9 个文件直接手拼 `AlertDialog`(频道成员/知识空间成员/爬取反馈与预览/灵思 TaskModeInput 等),**属于普通弹窗、归 Modal 弹窗改造范围,本期二次确认不动**。其中个别(如 ChannelMemberDialog 的移除成员、红 `#F53F3F` 按钮)带确认性质,留待 Modal 期一并处理。
- C 套即设计师截图那个「红垃圾桶图标 + 红标题 + 暂不/确认删除」的弹窗(知识空间删除)。
- C 套外观:居中卡片 `rounded-[20px] p-6 max-w-[400px]`、图标+标题一行、按钮右对齐(移动端等宽平铺)、危险=红 `#f53f3f`(语义色不换肤)、普通=品牌主色。**它是收敛基准的天然候选。**
- C 套 `useConfirm()` 的 16 个文件:knowledgeindex、SpaceDetail/index、VersionHistorySheet、RelateDocumentPanel、SimilarDocumentDialog、CreateKnowledgeSpaceDrawer、sidebar 两个 CardItem/Item)、SubscriptionChannelItem、ChannelActionsMenu、CreateChannelDrawer、AddSourceDropdown、AiChat 三个面板)、permission/PermissionListTab。
- B 套 `selection` 的 21 个文件(`grep "selection={"`,排除 MoveToDialog 同名业务 prop 与 ExportModal 的 `selection={undefined}`)——按用户可见位置分组见下节。
### 确认按钮 selectClasses 的 9 种写法
| # | 写法 | 文件数 | 代表文件 |
|---|---|---|---|
| 1 | `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800` | **8(最多)** | ConvoOptions/DeleteButton、Bookmarks、AgentTool、SharedLinks、AppSidebarConvoItem、GuestConvoItem… |
| 2 | `bg-red-600 hover:bg-red-700 dark:hover:bg-red-800` | 3 | Agents/DeleteButton、Builder/ContextButton、DashGroupItem(删) |
| 3 | `bg-red-600 hover:bg-red-700 dark:hover:bg-red-600` | 1 | PresetItems(清空) |
| 4 | `bg-destructive hover:bg-destructive/80` | 3 | ClearChats、DeleteCache、RevokeKeysButton |
| 5 | `bg-surface-destructive hover:bg-surface-destructive-hover` | 2 | DeleteVersion、AdminSettings |
| 6 | `bg-green-500 hover:bg-green-600`(确认=绿色) | 2 | SaveAsPresetDialog、ApiKeyDialog |
| 7 | `btn btn-primary`(全局 CSS 类) | 1 | SetKeyDialog |
| 8 | `bg-surface-submit hover:bg-surface-submit-hover` | 1 | DashGroupItem(重命名) |
| 9 | 不传 → 模板默认 `bg-gray-800 … dark:bg-gray-200` | — | OGDialogTemplate defaultSelect |
### 当前写死的解剖值(待设计师定稿后统一)
- 容器:`rounded-2xl p-6 gap-4 shadow-lg`OriginalDialog.tsx);遮罩 `bg-black/80` 无模糊
- 标题:`text-lg font-semibold`;正文区 `px-0 py-2`
- 确认按钮:`h-10 rounded-lg px-4 py-2 text-sm`(颜色全靠各页 selectClasses 传)
- 取消按钮:`btn btn-neutral rounded-lg text-sm` —— 走全局 CSS 类,**不是** Button 组件
- 宽度:`w-11/12` + 各页自带 `max-w-[450px]` / `max-w-lg`
- Loading 两种写法并存:`selection.isLoading`(模板内置 Spinner)vs 各页自己把 `<Spinner />` 塞进 `selectText`ClearChats、DeleteCache、RevokeKeysButton、SharedLinks 共 4 处)
### 收敛方案(设计师 2026-07-03 拍板,四步走)
> 目标:全平台二次确认只剩 `useConfirm()` 一个组件、一处样式源。
1. **✅ B 套壳子对齐 C 套视觉**(已完成,见改动记录):遮罩灰底毛玻璃、圆角 16、padding 20、标题 text-base、取消/确认按钮同 C 套;`selection` 增加 `selectVariant: 'danger' | 'primary'` cva 档位,历史 9 种 selectClasses 由模板自动折叠(`red-|destructive`→danger`green-|btn-primary|surface-submit`→primary,未识别原样放行=特例口子),**21 个业务页零改动**。
2. **✅ C 套 `description` 扩展为 ReactNode**(已完成):支持富文本正文(如加粗对象名),为迁移铺路。
### 剩余 9 处的入口地图(2026-07-03 三路代码探查结论)
| 功能 | 界面入口 | 门控 | 可达性 |
|---|---|---|---|
| 书签删除 DeleteBookmarkButton | **死 UI**:唯一渲染点是被注释 SidePanel 里的书签表格(BookmarkTableRow);聊天页 Header 书签菜单只含编辑弹窗,不含此删除确认(主窗口人工复核纠正了探查报告的误判) | — | ❌ 不迁,随 SidePanel 死树清理 |
| 清空预设 PresetItems | 聊天页 Header → Presets 菜单 → Clear all | `interface.presets`(默认 true | ✅ 可达 |
| 分享链接删除 SharedLinkButton | Header 导出/分享菜单 → 分享弹窗 | `sharedLinksEnabled`(默认 true | ✅ 可达 |
| 分享链接管理 SharedLinks | 设置 → 数据 → 管理分享链接 | 无 | ✅ 可达 |
| 免登录会话删除 GuestConvoItem | 分享出去的独立工作流/助手聊天页 `/chat/flow/:flowId`(BISHENG 自有功能,含免登录与登录两种模式) | 无 | ✅ 可达(仅分享场景) |
| 删 Agent / Agent 工具 / 删 Assistant / Assistant 工具(4 处) | 右侧 SidePanel —— **`SidePanelGroup.tsx:117-132` 整段被注释,面板不渲染**(书签删除同属这棵死树,合计 5 处死 UI) | — | ❌ **死 UI,不迁**,等死代码清理 |
| (已迁的 Prompts 3 处) | `/d/prompts` 路由存在但**导航无入口**,仅 URL 直达/聊天输入框 Prompts 快捷命令 | `interface.prompts`(默认 true) | ⚠️ 半死页面(已迁完,无损失) |
3. **✅ 真确认迁移完成(2026-07-03)**:用户可见的真确认共 10 处全部迁 C 套(批次:设置数据页 3 → 删会话系 2 → Prompts 3 → 分享链接管理 + 免登录会话 2)。剩 7 处确认全是死 UISidePanel 死树 5 + Chat/Header 死树 2,见入口地图),**不迁**,随死代码清理处置。:设计师按页面点名逐批做,每批一笔提交 + 真实页面验证。注意两个行为差异:B 套会把焦点还给触发按钮(C 套命令式没有);移动端 B 套按钮纵向堆叠 vs C 套横向等宽。
- ✅ 第一批(设置弹窗-数据页):ClearChats(清空聊天)、DeleteCache(删缓存)、RevokeKeysButton(撤销密钥,含 SetKeyDialog 内嵌用法)。迁移模式:`const ok = await confirm({ variant: 'destructive', title, description, confirmText }); if (!ok) return; mutate(...)`——标题/正文/确认文案沿用原 i18n key,取消文案用 C 套默认「暂不」。DeleteCache 顺带清了未挂载的 confirmClear/contentRef 死代码,并在清完后重查缓存刷新按钮禁用态。
4. **⬜(Modal 期)** 约 5 处「表单弹窗借 selection 当提交按钮」(SetKeyDialog、SaveAsPresetDialog、ApiKeyDialog、DashGroupItem 重命名、ActionsPanel)随 Modal 统一处理,届时 OGDialogTemplate 退役。
已定的样式决策:危险红 = `#f53f3f`(语义色不换肤);普通确认 = 品牌主色(跟蓝⇄绿主题,绿色保存按钮一并折叠进来);取消按钮 = C 套白底描边样式(hover `#f7f8fa`);Loading 统一走 `selection.isLoading`(4 处自塞 Spinner 的随第三步迁移清理)。
<!-- site-hide -->
## 二点六、Modal 期用量重盘(2026-07-09,本窗口开工扫描)
> §二 的旧数字(OGDialogTemplate 25 等)已过时,以本节为准。画廊 Modal 版块已按本节重做。
| 体系 | 实现 | 业务文件数 | 说明 |
|---|---|---|---|
| **A 套 · 原语直接拼** | `Dialog`+`DialogContent` 手拼 | **22(最大人群)** | 新页面为主:知识库 8EditTags/EditEncoding/MoveTo/VersionMgmt/Share/Similar/VersionHistory/index)、订阅 2、审批中心、通知、账号、InviteCode、ShareChat、UploadFileModal、DataTableKnowledge、SearchWebUrls、appChat 2、MarkLabel、**MainLayout(全局弹窗)** |
| A 套 · 模板 | `DialogTemplate` | 3 | EditPresetDialog、PresetItems、ContextButtonContextButton 在 SidePanel 死树) |
| B 套 · 模板 | `OGDialogTemplate` | 16(原 25) | 确认迁移后剩余;其中 SidePanel 死树约 6 处(AgentTool/DeleteButton/ActionsPanel×2/AssistantTool/ApiKeyDialog+ DeleteBookmarkButton 死 UI |
| B 套 · 原语直接拼 | `OGDialog`+`OGDialogContent` 手拼 | 16 | 设置账号 4Avatar/BackupCodes/DeleteAccount/2FA)、SharedLinks、Prompts 4、Chat/Input/Files 4、ShareAgent、Agents/AdminSettings、ActionsAuth |
| 手拼 AlertDialog | `AlertDialogContent`+自拼头尾 | 7 | ChannelMemberDialog、ChannelMemberManagementPanel、TaskModeInput、AddSourceDropdown、CrawlFeedback/CrawlPreview、CreateChannelDrawer |
| C 套 · useConfirm(参照) | ConfirmContext | 26(已收敛 ✅) | 视觉基准:圆角 16 / p-5 / 灰底毛玻璃 |
**壳解剖当前真实值**(源码核对):
- A 套:遮罩 `bg-black/40`+blur、`z-[100]``sm:rounded-lg`8px 移动端直角)、`p-5`、border+`shadow-lg`、标题 `text-base font-semibold`、暗色底 `dark:bg-[#303134]` 写死。
- B 套(已对齐 C 套):遮罩 `bg-gray-500/90`+blur、`z-50``rounded-2xl`(16px)、`p-5`、border `#ebebeb`+淡投影、标题 `text-base font-medium``bg-background` 跟主题。
- AlertDialog 底座:遮罩同 B、`z-[110]``sm:rounded-lg``p-6`、**无边框无阴影**、无内置关闭钮、移动端从底部滑入贴底。
- 遮罩的「毛玻璃 vs 纯深色」之争已不存在(black/80 在二次确认期淘汰),现在是**浅黑毛玻璃(A) vs 灰白毛玻璃(B/C)** 二选一。
- 层级三档并存:z-50 / z-[100] / z-[110],统一时需盘 Drawer/Sheet/Popover 关系。
**待设计师定夺**(画廊 §④ 同步列出):遮罩、圆角 8vs16、内边距 20vs24、标题字重、关闭钮、footer 按钮(Button 组件 vs C 套那对)与间距、层级、以及**原语收敛方向**——A 套 22 处直拼是最大人群,是「把 A 套壳改成标准(业务零改动)」还是「逐批迁 B 套」。
## 三、收敛策略(待设计师拍板的决策点)
1. **选基准**:建议以 `OGDialogTemplate`(B 套,用量最大)为便捷模板基准,底层原语二选一或合并成一套。
2. **定遮罩**:A 套毛玻璃 vs B 套纯深色 —— **二选一,全局统一**(见 01-设计规范 §二·遮罩)。
3. **定圆角/内边距/按钮间距**:按 01-设计规范定稿值。
4. **删死代码**:确认后删除 0 引用版本。
5. **迁移顺序**:设计师逐批点名页面,从基准模板开始替换,每批正常发版。
### 待决策清单(等设计师给链接/数值)
- [ ] 遮罩:毛玻璃 or 纯深色?
- [ ] 圆角:弹窗用几 px
- [ ] 内边距:header / body / footer 各多少?
- [ ] 关闭按钮样式与位置?
- [ ] 标题字号/字重?
- [ ] 是否保留两套原语,还是彻底合并成一套?
---
## 四、改动记录(每次改完在此追加)
| 日期 | 改了什么 | 影响文件 | 提交 |
|---|---|---|---|
| — | 尚未改动组件源码,当前为现状梳理 + 画廊搭建 | — | — |
| 2026-07-02 | 二次确认弹窗现状扫描(两套体系:B 套 21 文件 9 种按钮写法、C 套 useConfirm 16 文件;手拼 AlertDialog 9 文件划归 Modal 范围),画廊新增「二次确认弹窗」版块(体系总览表 + 9 种写法清单 + 解剖表 + 11 个可打开 demo 含 useConfirm 两档);未改组件源码 | `_gallery/sections/ConfirmDialogSection.tsx`(新)、`_gallery/GalleryApp.tsx` | 待 committer 窗口提交 |
| 2026-07-02 | **C 套第一笔定稿改动**padding 24→20`p-6``p-5`)、圆角 20→16`rounded-[20px] sm:rounded-[20px]``rounded-2xl sm:rounded-2xl`),16 处业务全场生效 | `Providers/ConfirmContext.tsx` | 待 committer 窗口提交 |
| 2026-07-02 | C 套焦点环改 `focus-visible`:鼠标打开弹窗不再显示按钮灰圈,键盘 Tab 导航仍显示(无障碍保留);只在 ConfirmContext 覆盖层做,未动 AlertDialog 底层 | `Providers/ConfirmContext.tsx` | 待 committer 窗口提交 |
| 2026-07-02 | C 套图标 lucide→bisheng-iconsTrash2→`Outlined.Delete`、AlertCircle→`Outlined.Attention`(Attention 字形待设计师目检确认) | `Providers/ConfirmContext.tsx` | 待 committer 窗口提交 |
| 2026-07-02 | C 套取消按钮 hover 从不可见的 `bg-white/70` 改为 `bg-[#f7f8fa]`,与知识空间工具栏按钮 hover 一致 | `Providers/ConfirmContext.tsx` | 待 committer 窗口提交 |
| 2026-07-03 | **收敛第一、二步**B 套壳对齐 C 套(遮罩 `bg-gray-500/90`+blur、`p-5``rounded-2xl`+边框淡投影、标题 `text-base font-medium`);OGDialogTemplate 按钮重写(取消=C 套样式,确认=cva `danger`/`primary` 两档,旧 selectClasses 自动折叠,未识别放行);C 套 `description` 改 ReactNode。**注意:OriginalDialog 壳子改动影响所有 OG 弹窗(含表单类),非仅确认弹窗**。tsc 通过 | `ui/OriginalDialog.tsx``ui/OGDialogTemplate.tsx``Providers/ConfirmContext.tsx`、画廊 | 待 committer 窗口提交 |
| 2026-07-03 | B 套 danger 档标题变红 `#f53f3f`(对齐 C 套危险态);仅 selection 解析为 danger 时生效,表单弹窗不受影响 | `ui/OGDialogTemplate.tsx` | 待 committer 窗口提交 |
| 2026-07-03 | **第三步第一批迁移(3/16**:设置-数据页 3 处确认弹窗 B→C(useConfirm)。⚠️ 这批是业务页面文件,按双窗口规则应尽快单独提交一笔(confirm-migration batch 1),别和组件笔混 | `Nav/SettingsTabs/Data/{ClearChats,DeleteCache,RevokeKeysButton}.tsx` | 待 committer 窗口提交 |
| 2026-07-03 | **第三步第四批迁移(收官)**:最后 2 处可达真确认 B→C。SharedLinks(设置→数据→分享链接管理行删除,连带清掉 isDeleteOpen/deleteRow 状态与自塞 Spinner——至此「页面自塞 Spinner」清零)、GuestConvoItem(独立分享聊天页会话删除)。另:入口复查发现清空预设(PresetItems)与分享弹窗删除(SharedLinkButton)属 Chat/Header 死树,改判不迁。画廊全面更新为收官状态。⚠️ 业务文件,尽快单独提交(batch 4)| `SettingsTabs/Data/SharedLinks.tsx``standaloneChat/components/GuestConvoItem.tsx`、画廊 | 待 committer 窗口提交 |
| 2026-07-03 | **第三步第三批迁移**Prompts 3 处 B→CuseConfirm)。DeleteVersion(版本删除,整组件瘦身为按钮+confirm)、DashGroupItem 的删除(重命名表单弹窗保留归 Modal 期)、AdminSettings 的管理员权限变更确认(外层设置表单保留,删除 confirmAdminUseChange 状态机改为直接 await confirm)。画廊同步:⑤ surface-destructive 清零删卡、② red-600 计数 3→2、总览 B 14 / C 24。⚠️ 业务文件,尽快单独提交(batch 3)| `Prompts/{DeleteVersion,AdminSettings}.tsx``Prompts/Groups/DashGroupItem.tsx`、画廊 | 待 committer 窗口提交 |
| 2026-07-09 | **Modal 期开工**:全站弹窗重盘(5 体系 64 文件,见 §二点六;新发现 A 套原语直拼 22 处为最大人群);画廊 Modal 版块整体重做——用量总览表、壳解剖表(源码真实值)、同一内容装进 5 个壳的并排 demo + C 套参照、待决策清单 §④。未改组件源码,等设计师定标准 | `_gallery/sections/ModalSection.tsx`(重写) | 待 committer 窗口提交 |
| 2026-07-10 | **反馈弹窗抽成共享组件 `ui/CommentDialog`**(标题+textarea+取消/提交;容器零 padding、header/body/footer 各 `px-5`、移动端 `calc(100%-48px)` 宽 + 标题居中 + 按钮等宽、每次打开重置草稿、可选 `submitting`/`submittingText` 支持异步提交)。MessageFeedbackButtons 改为消费方(视觉/点踩延迟落库逻辑不变);MenuUnavailablePage 手写申请权限弹窗迁入共享壳——手拼弹窗 -1,行为新增 ESC/遮罩/焦点圈定,桌面端标题改左对齐。画廊「点赞点踩反馈」demo 即真实组件,已实测桌面+移动端 | `ui/CommentDialog.tsx`(新)、`ui/index.ts``Chat/MessageFeedbackButtons.tsx``pages/MenuUnavailablePage.tsx` | 待提交 |
| 2026-07-03 | **第三步第二批迁移**:删会话确认 B→C。新建共享 hook `useDeleteConversationConfirm`(confirm → 删除 → 若删的是当前会话则跳走),三个消费方接线:会话侧栏菜单 ConvoOptions、归档会话表格 ArchivedChatsTable(复用同一组件被迫同批)、应用会话 AppSidebarConvoItem。**删除 `ConvoOptions/DeleteButton.tsx`**,barrel 出口同步更新。正文为纯文本 `确认删除 "标题"`C 套 description 为字符串)。⚠️ 业务文件,尽快单独提交(batch 2)| `ConvoOptions/{useDeleteConversationConfirm.ts 新,ConvoOptions.tsx,index.ts,DeleteButton.tsx 删}``ArchivedChatsTable.tsx``AppSidebarConvoItem.tsx` | 待 committer 窗口提交 |
---
## 五、代码锚点
- A 套原语:`src/frontend/client/src/components/ui/Dialog.tsx`
- A 套模板:`src/frontend/client/src/components/ui/DialogTemplate.tsx`
- B 套原语:`src/frontend/client/src/components/ui/OriginalDialog.tsx`
- B 套模板:`src/frontend/client/src/components/ui/OGDialogTemplate.tsx`
- 画廊 Modal 版块:`src/frontend/client/src/pages/_gallery/sections/ModalSection.tsx`
- 画廊二次确认版块:`src/frontend/client/src/pages/_gallery/sections/ConfirmDialogSection.tsx`
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@bisheng/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "BiSheng shared UI component library — presentation-only components + design tokens. No state management, no HTTP, no i18n keys, no routing (contract, see README).",
"exports": {
".": "./src/index.ts",
"./tokens.css": "./src/styles/tokens.css",
"./tailwind-preset": "./tailwind-preset.cjs",
"./design-token": "./design-token.cjs"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"dependencies": {
"@radix-ui/react-slot": "catalog:",
"bisheng-icons": "catalog:",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"tailwind-merge": "catalog:"
},
"devDependencies": {
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"react": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
}
}
@@ -0,0 +1,350 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva } from 'class-variance-authority';
import { Outlined } from 'bisheng-icons';
import cn from '../../utils/cn';
/**
* Button — design-system base component (docs-ui-refactor/组件-Button按钮.md).
*
* New API is the antd-style dual axis: `color` (primary/default/danger) ×
* `variant` (solid/outlined/filled/text/link) × `size` (small/medium/large),
* plus `iconOnly` for icon buttons and `shape` (square/circle, circle being
* icon-only). All colors go through semantic
* tokens (`btn-*` in tailwind.config / style.css, brand via `blue-*`); hover
* states are disabled on touch (§5.5) and disabled/loading are uniform (§5.2).
*
* The legacy shadcn API (`variant="outline" | "ghost" | ...`, `size="sm" |
* "icon" | ...`) still works through an automatic mapping (§6.3) so existing
* call sites keep rendering; they will be migrated batch-by-batch and the
* mapping removed afterwards.
*/
type ButtonColor = 'primary' | 'default' | 'danger';
type ButtonVariant = 'solid' | 'outlined' | 'filled' | 'text' | 'link';
type ButtonSize = 'small' | 'medium' | 'large';
type ButtonShape = 'square' | 'circle';
/** @deprecated Legacy single-axis variants — auto-mapped to color×variant (§6.3). */
type LegacyVariant =
| 'default'
| 'destructive'
| 'outline'
| 'secondary'
| 'secondaryBrand'
| 'ghost'
| 'submit';
/** @deprecated Legacy sizes — auto-mapped (`icon` → medium + iconOnly). */
type LegacySize = 'default' | 'sm' | 'lg' | 'icon';
const buttonStyles = cva(
// Disabled is uniform across every combo (§5.2) and must beat both the
// combo colors and legacy className overrides, hence the `!` importants.
// `relative` anchors the .btn-touch-hit ::after hot zone (style.css).
// Weight 400 across all sizes/types (§3.1) — heavier weights are not a knob.
'relative inline-flex items-center justify-center whitespace-nowrap font-normal transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:!border-btn-disabled-border disabled:!bg-btn-disabled-bg disabled:!text-btn-disabled-text [&_svg]:shrink-0',
{
variants: {
// Color axis only carries what is combo-independent (focus ring, §5.2).
color: {
primary: 'focus-visible:ring-blue-500/40',
default: 'focus-visible:ring-blue-500/40',
danger: 'focus-visible:ring-btn-danger/40',
},
variant: {
solid: '',
// bg-bg-page (not bg-white) so the opaque fill follows the theme:
// white in light, #121212 in dark — else outlined = a white chip on
// the dark page and its neutral text drops to unreadable.
outlined: 'border bg-bg-page',
filled: '',
text: '',
link: 'underline-offset-4',
},
// Heights/radii per §2 (24/32/40, 4/6/8px). Font sizes reference the
// PRIMITIVE type scale vars on purpose: the semantic --text-body remaps
// 14→16 under 768px, but control text must not follow (§5.5 / 适配原则 §3).
// Icon size is one 14/16/18 ladder for BOTH icon-only and text+icon (§3.2/3.3).
// Horizontal padding here is the borderless value (8/16/16); bordered
// variants override to 7/15/15 in compoundVariants (visual-width parity).
size: {
small:
'h-6 gap-1 rounded px-2 text-[length:var(--font-size-3)] leading-[var(--line-height-3)] [&_svg]:size-3.5',
medium:
'btn-touch-hit h-8 gap-2 rounded-md px-4 text-[length:var(--font-size-3)] leading-[var(--line-height-3)] [&_svg]:size-4',
large:
'h-10 gap-2 rounded-lg px-4 text-[length:var(--font-size-4)] leading-[var(--line-height-4)] [&_svg]:size-[18px]',
},
// `circle` is declared AFTER `size` so its rounded-full wins the merge
// over the per-size radius; resolveVariants restricts it to icon-only (§1).
shape: {
square: '',
circle: 'rounded-full',
},
iconOnly: {
true: '',
false: '',
},
},
compoundVariants: [
/* ---- color × variant matrix (§5.2; combos the spec leaves implicit
follow the same ramp logic: hover one step, active one deeper).
Active steps are TOUCH-ONLY (coarse-pointer): on hover-capable
devices a pressed button keeps its hover color — no click flash;
on touch, where hover is disabled, active is the only feedback. ---- */
{
color: 'primary',
variant: 'solid',
// btn-brand-primary = green-theme !important override (style.css) —
// kept as agreed tech debt until the theme mechanism is reworked (§6.2).
class:
'btn-brand-primary bg-blue-500 text-white hover:bg-blue-400 coarse-pointer:active:bg-blue-600',
},
{
color: 'primary',
variant: 'outlined',
// Outlined hover is a faint tint of the button's own palette, border/
// text unchanged (§5.2) — same shape as default outlined's gray tint.
// Dark: the solid blue-50 tint would be a light chip on #121212, so
// switch to a brand-alpha tint (same technique as danger filled).
class:
'border-blue-500 text-blue-500 hover:bg-blue-50 coarse-pointer:active:bg-blue-100 dark:hover:bg-blue-500/15 dark:coarse-pointer:active:bg-blue-500/25',
},
{
color: 'primary',
variant: 'filled',
class:
'bg-blue-50 text-blue-600 hover:bg-blue-100 coarse-pointer:active:bg-blue-200 dark:bg-blue-500/15 dark:text-blue-400 dark:hover:bg-blue-500/25 dark:coarse-pointer:active:bg-blue-500/[0.35]',
},
{
color: 'primary',
variant: 'text',
class:
'text-blue-500 hover:bg-blue-50 coarse-pointer:active:bg-blue-100 dark:hover:bg-blue-500/15 dark:coarse-pointer:active:bg-blue-500/25',
},
{
color: 'primary',
variant: 'link',
class:
'text-blue-500 hover:text-blue-400 hover:underline coarse-pointer:active:text-blue-600',
},
{
color: 'default',
variant: 'solid',
// Fill uses its own token (not gray-text) so dark flips the text role
// light while the solid fill stays a mid-gray that keeps white legible.
class:
'bg-btn-gray-solid-bg text-white hover:bg-btn-gray-solid-bg/90 coarse-pointer:active:bg-btn-gray-solid-bg/80',
},
{
color: 'default',
variant: 'outlined',
class:
'border-btn-gray-border text-btn-gray-text hover:bg-btn-fill-1 coarse-pointer:active:bg-btn-fill-2',
},
{
color: 'default',
variant: 'filled',
class:
'bg-btn-fill-2 text-btn-gray-text hover:bg-btn-fill-3 coarse-pointer:active:bg-btn-fill-4',
},
{
color: 'default',
variant: 'text',
class:
'text-btn-gray-text hover:bg-btn-fill-1 coarse-pointer:active:bg-btn-fill-2',
},
{
color: 'default',
variant: 'link',
class:
'text-btn-gray-text hover:text-btn-gray-text/80 hover:underline coarse-pointer:active:text-btn-gray-text',
},
{
color: 'danger',
variant: 'solid',
class:
'bg-btn-danger text-white hover:bg-btn-danger-hover coarse-pointer:active:bg-btn-danger-active',
},
{
color: 'danger',
variant: 'outlined',
// Same faint-tint hover as primary outlined, on the red alpha ladder.
class:
'border-btn-danger text-btn-danger hover:bg-btn-danger/10 coarse-pointer:active:bg-btn-danger/[0.15]',
},
{
color: 'danger',
variant: 'filled',
class:
'bg-btn-danger/10 text-btn-danger hover:bg-btn-danger/[0.15] coarse-pointer:active:bg-btn-danger/20',
},
{
color: 'danger',
variant: 'text',
class:
'text-btn-danger hover:bg-btn-danger/10 coarse-pointer:active:bg-btn-danger/[0.15]',
},
{
color: 'danger',
variant: 'link',
class:
'text-btn-danger hover:text-btn-danger-hover hover:underline coarse-pointer:active:text-btn-danger-active',
},
/* ---- bordered padding 7/15/15 incl. 1px border (§2 visual parity) ---- */
{ variant: 'outlined', size: 'small', class: 'px-[7px]' },
{ variant: 'outlined', size: ['medium', 'large'], class: 'px-[15px]' },
/* ---- icon-only squares 24/32/40 (§3.2, icon ladder shared with the
size axis); every size gets the ≥44px touch hot zone (§5.5) ---- */
{ iconOnly: true, size: 'small', class: 'btn-touch-hit w-6 px-0' },
{ iconOnly: true, size: 'medium', class: 'w-8 px-0' },
{ iconOnly: true, size: 'large', class: 'btn-touch-hit w-10 px-0' },
],
// Bare <Button> keeps its historical primary-solid look (§6.3).
defaultVariants: {
color: 'primary',
variant: 'solid',
size: 'medium',
shape: 'square',
iconOnly: false,
},
},
);
export interface ButtonStyleProps {
// `(string & {})` keeps the three literals in autocomplete while still
// accepting `{...props}` spreads that carry the native HTML `color` attr
// (e.g. TooltipAnchor render props); non-axis strings are ignored at runtime.
color?: ButtonColor | (string & {});
variant?: ButtonVariant | LegacyVariant;
size?: ButtonSize | LegacySize;
/** `circle` renders a full circle — icon-only buttons ONLY (§1); ignored otherwise. */
shape?: ButtonShape;
/** Square icon-only button (§3.2) — must ship an `aria-label` + Tooltip. */
iconOnly?: boolean;
}
function isButtonColor(value: unknown): value is ButtonColor {
return value === 'primary' || value === 'default' || value === 'danger';
}
const LEGACY_VARIANT_MAP: Record<string, { color: ButtonColor; variant: ButtonVariant }> = {
default: { color: 'primary', variant: 'solid' },
submit: { color: 'primary', variant: 'solid' },
destructive: { color: 'danger', variant: 'solid' },
outline: { color: 'default', variant: 'outlined' },
secondary: { color: 'default', variant: 'filled' },
secondaryBrand: { color: 'primary', variant: 'filled' },
ghost: { color: 'default', variant: 'text' },
// Bare `variant="link"` predates the color axis — keep its primary look.
link: { color: 'primary', variant: 'link' },
};
function resolveVariants({ color: rawColor, variant, size, shape, iconOnly }: ButtonStyleProps) {
const color = isButtonColor(rawColor) ? rawColor : undefined;
let resolvedColor = color;
let resolvedVariant = variant as ButtonVariant | undefined;
// Legacy values only kick in while the new `color` axis is absent — any
// explicit `color` means the caller is on the new dual-axis API.
if (color === undefined && variant !== undefined && variant in LEGACY_VARIANT_MAP) {
({ color: resolvedColor, variant: resolvedVariant } = LEGACY_VARIANT_MAP[variant]);
} else if (color !== undefined && variant === undefined) {
// New-API ergonomics matching the §1 named types: <Button color="default">
// is THE default button (outlined), primary/danger default to solid.
resolvedVariant = color === 'default' ? 'outlined' : 'solid';
}
let resolvedSize: ButtonSize | undefined;
let resolvedIconOnly = iconOnly;
switch (size) {
case 'default':
case 'sm':
resolvedSize = 'medium';
break;
case 'lg':
resolvedSize = 'large';
break;
case 'icon':
resolvedSize = 'medium';
resolvedIconOnly = iconOnly ?? true;
break;
default:
resolvedSize = size;
}
return {
color: resolvedColor,
variant: resolvedVariant,
size: resolvedSize,
// Circle is an icon-only privilege (§1) — text buttons fall back to square.
shape: shape === 'circle' && resolvedIconOnly ? ('circle' as const) : ('square' as const),
iconOnly: resolvedIconOnly,
};
}
/** Class-only entry point (for <a>/Slot call sites); accepts both APIs. */
export function buttonVariants(props: ButtonStyleProps & { className?: string } = {}) {
const { className, ...styleProps } = props;
return buttonStyles({ ...resolveVariants(styleProps), className });
}
export interface ButtonProps
// Native `color` attr is shadowed by the color axis.
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'color'>,
ButtonStyleProps {
asChild?: boolean;
/** Single leading icon (§3.3, one icon max); replaced by the spinner while loading. */
icon?: React.ReactNode;
/**
* Built-in loading state (§5.2): spinner takes the icon slot, whole button
* at opacity .65 and not clickable. Do NOT pass your own Spinner.
*/
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
color,
variant,
size,
shape,
iconOnly,
icon,
loading = false,
asChild = false,
children,
...props
},
ref,
) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
aria-busy={loading || undefined}
className={cn(
buttonVariants({ color, variant, size, shape, iconOnly }),
loading && 'pointer-events-none opacity-65',
className,
)}
{...props}
>
{asChild ? (
// Slot requires a single element child — icon/spinner injection is
// skipped; asChild callers render their own content.
children
) : (
<>
{loading ? <Outlined.Loading className="animate-spin" /> : icon}
{children}
</>
)}
</Comp>
);
},
);
Button.displayName = 'Button';
export { Button };
+5
View File
@@ -0,0 +1,5 @@
// @bisheng/ui public API — presentation-only components + design-system utils.
// Contract (README): no state management, no HTTP, no i18n keys, no routing.
export { default as cn } from './utils/cn';
export { Button, buttonVariants } from './components/Button/Button';
export type { ButtonProps, ButtonStyleProps } from './components/Button/Button';
@@ -0,0 +1,201 @@
/* ============================================================
* @bisheng/ui design tokens — v0 契约
* 双层结构:primitive(原始数值源)+ semantic(组件唯一可消费层)。
* 值与 client/src/style.css 完全一致(种子来源);client 迁移完成前
* 两份并存且必须保持同步,platform 接入时直接 @import 本文件。
* 覆盖范围 v0:字体阶梯 / Arco 中性色 / 功能色 / 品牌色 / Button 语义 token。
* ============================================================ */
:root {
/* ---------- Typography (基础-字体规范.md) ---------- */
/* Primitive — desktop scale (line height = size + 8px) */
--font-size-1: 0.75rem; --line-height-1: 1.25rem; /* 12 / 20 */
--font-size-2: 0.8125rem; --line-height-2: 1.3125rem; /* 13 / 21 */
--font-size-3: 0.875rem; --line-height-3: 1.375rem; /* 14 / 22 */
--font-size-4: 1rem; --line-height-4: 1.5rem; /* 16 / 24 */
--font-size-5: 1.125rem; --line-height-5: 1.625rem; /* 18 / 26 */
--font-size-6: 1.25rem; --line-height-6: 1.75rem; /* 20 / 28 */
--font-size-7: 1.5rem; --line-height-7: 2rem; /* 24 / 32 */
--font-size-8: 1.875rem; --line-height-8: 2.375rem; /* 30 / 38 */
--font-size-9: 2.25rem; --line-height-9: 2.75rem; /* 36 / 44 */
/* Semantic — consumed via tailwind preset fontSize (text-caption / text-body / text-h1 …) */
--text-caption: var(--font-size-1); --leading-caption: var(--line-height-1);
--text-body-sm: var(--font-size-2); --leading-body-sm: var(--line-height-2);
--text-body: var(--font-size-3); --leading-body: var(--line-height-3);
--text-h4: var(--font-size-4); --leading-h4: var(--line-height-4);
--text-h3: var(--font-size-5); --leading-h3: var(--line-height-5);
--text-h2: var(--font-size-6); --leading-h2: var(--line-height-6);
--text-h1: var(--font-size-7); --leading-h1: var(--line-height-7);
--text-display: var(--font-size-8); --leading-display: var(--line-height-8);
--text-metric: var(--font-size-9); --leading-metric: var(--line-height-9);
/* ---------- Neutral colors (基础-色彩规范.md, Arco ramp) ---------- */
/* Primitive — Arco gray 1-10 (RGB channel triplets for `/<alpha>` support) */
--arco-gray-1: 247 248 250; /* #F7F8FA */
--arco-gray-2: 242 243 245; /* #F2F3F5 */
--arco-gray-3: 229 230 235; /* #E5E6EB */
--arco-gray-4: 201 205 212; /* #C9CDD4 */
--arco-gray-5: 169 174 184; /* #A9AEB8 */
--arco-gray-6: 134 144 156; /* #86909C */
--arco-gray-7: 107 119 133; /* #6B7785 */
--arco-gray-8: 78 89 105; /* #4E5969 */
--arco-gray-9: 39 46 59; /* #272E3B */
--arco-gray-10: 29 33 41; /* #1D2129 */
/* Semantic — neutral text / fill / border (components use ONLY these) */
--text-1: var(--arco-gray-10); /* primary text: titles, body */
--text-2: var(--arco-gray-8); /* secondary text */
--text-3: var(--arco-gray-6); /* hint / timestamp / placeholder */
--text-4: var(--arco-gray-4); /* disabled text */
--fill-1: var(--arco-gray-1); /* hover bg / light page bg */
--fill-2: var(--arco-gray-2); /* active bg / filled control base */
--fill-3: var(--arco-gray-3); /* filled hover */
--fill-4: var(--arco-gray-4); /* filled active */
--border-base: var(--arco-gray-3); /* regular border */
--border-deep: var(--arco-gray-4); /* emphasized divider / hover border */
--bg-page: 255 255 255; /* page surface */
/* Semantic — functional (theme-INDEPENDENT: never follow the brand switch) */
--success: 0 180 42; /* #00B42A (Arco green-6) */
--success-hover: 35 195 67; /* #23C343 */
--success-active: 0 154 41; /* #009A29 */
--success-tint: 232 255 234; /* #E8FFEA */
--warning: 255 125 0; /* #FF7D00 (Arco orange-6) */
--warning-hover: 255 154 46; /* #FF9A2E */
--warning-active: 210 95 0; /* #D25F00 */
--warning-tint: 255 247 232; /* #FFF7E8 */
--danger: 245 63 63; /* #F53F3F (= --btn-danger) */
--danger-hover: 214 55 58; /* #D6373A */
--danger-active: 208 47 51; /* #D02F33 */
--danger-tint: 255 236 232; /* #FFECE8 — TAG bg only */
/* ---------- Brand palette (blue default; .theme-green overrides) ---------- */
--brand-50: 232 243 255; /* #E8F3FF — light tint / selection bg */
--brand-100: 190 218 255; /* #BEDAFF */
--brand-200: 148 191 255; /* #94BFFF */
--brand-300: 106 161 255; /* #6AA1FF */
--brand-400: 64 128 255; /* #4080FF */
--brand-500: 22 93 255; /* #165DFF — main brand blue */
--brand-600: 2 77 227; /* #024DE3 — button / pressed */
--brand-700: 2 57 171; /* #0239AB */
--brand-800: 4 43 128; /* #042B80 */
--brand-900: 5 29 82; /* #051D52 */
--brand-main: 22 93 255; /* #165DFF */
--brand-muted: 87 115 180; /* #5773B4 — muted/low-sat brand accent */
/* ---------- Button semantic tokens (组件-Button按钮.md §5.1) ---------- */
--btn-gray-text: 78 89 105; /* #4E5969 — default-color button text */
--btn-gray-solid-bg: 78 89 105; /* #4E5969 — default SOLID fill (+white text) */
--btn-gray-border: 229 230 235; /* #E5E6EB — default-color button border */
--btn-fill-1: 247 248 250; /* #F7F8FA — neutral fill ramp: hover bg */
--btn-fill-2: 242 243 245; /* #F2F3F5 — active bg / filled base */
--btn-fill-3: 229 230 235; /* #E5E6EB — filled hover */
--btn-fill-4: 201 205 212; /* #C9CDD4 — filled active */
--btn-danger: 245 63 63; /* #F53F3F */
--btn-danger-hover: 214 55 58; /* #D6373A */
--btn-danger-active: 208 47 51; /* #D02F33 */
--btn-disabled-border: 217 217 217; /* #D9D9D9 — bordered variants, disabled */
--btn-disabled-bg: 245 245 245; /* #F5F5F5 — disabled fill */
--btn-disabled-text: 191 191 191; /* #BFBFBF — disabled text */
/* ---------- Radius scale (shadcn-compatible: lg=8 / md=6 / sm=4) ---------- */
--radius: 0.5rem;
}
/* Dark mode — neutral + semantic layers auto-flip (custom props resolve lazily
* at use time). Brand and functional dark ramps still pending (keep light
* values — known debt, mirrors client/src/style.css). */
.dark {
--arco-gray-1: 23 23 26; /* #17171A */
--arco-gray-2: 46 46 48; /* #2E2E30 */
--arco-gray-3: 72 72 73; /* #484849 */
--arco-gray-4: 95 95 96; /* #5F5F60 */
--arco-gray-5: 120 120 122; /* #78787A */
--arco-gray-6: 146 146 147; /* #929293 */
--arco-gray-7: 171 171 172; /* #ABABAC */
--arco-gray-8: 197 197 197; /* #C5C5C5 */
--arco-gray-9: 223 223 223; /* #DFDFDF */
--arco-gray-10: 246 246 246; /* #F6F6F6 */
--bg-page: 18 18 18; /* #121212 — page surface, dark */
/* Disabled button, dark: subtle raised inert fill + muted (not invisible)
text/border. */
--btn-disabled-bg: 42 42 42; /* #2A2A2A */
--btn-disabled-text: 120 120 122; /* gray-5 dark — muted but legible */
--btn-disabled-border: 95 95 96; /* gray-4 dark — visible edge */
/* Button neutral, dark: text flips light; border + fill ramp follow the dark
gray ramp so filled/hover/active lighten (correct elevation on #121212). */
--btn-gray-text: 197 197 197; /* gray-8 dark #C5C5C5 — light text */
--btn-gray-solid-bg: 95 95 96; /* gray-4 dark #5F5F60 — solid fill */
--btn-gray-border: 72 72 73; /* gray-3 dark #484849 */
--btn-fill-1: 38 38 40; /* #262628 — text/outlined hover */
--btn-fill-2: 46 46 48; /* gray-2 dark — filled base */
--btn-fill-3: 72 72 73; /* gray-3 dark — filled hover */
--btn-fill-4: 95 95 96; /* gray-4 dark — filled active */
}
/* Mobile (≤768px) remap of the SEMANTIC typography layer only:
* body raised (14→16), headings lowered (24→22) — flatter ladder.
* Component classNames stay unchanged. */
@media (max-width: 768px) {
:root {
--text-body-sm: var(--font-size-3); --leading-body-sm: var(--line-height-3); /* 13→14 */
--text-body: var(--font-size-4); --leading-body: var(--line-height-4); /* 14→16 */
--text-h3: 1.0625rem; --leading-h3: 1.5625rem; /* 18→17 / 25 */
--text-h2: var(--font-size-5); --leading-h2: var(--line-height-5); /* 20→18 */
--text-h1: 1.375rem; --leading-h1: 1.875rem; /* 24→22 / 30 */
--text-display: 1.625rem; --leading-display: 2.125rem; /* 30→26 / 34 */
--text-metric: var(--font-size-8); --leading-metric: var(--line-height-8); /* 36→30 */
}
}
/* Green brand theme — toggled via `theme-green` class on <html>. */
.theme-green {
--brand-50: 228 241 231; /* #E4F1E7 */
--brand-100: 204 228 210; /* #CCE4D2 */
--brand-200: 163 210 176; /* #A3D2B0 */
--brand-300: 111 186 133; /* #6FBA85 */
--brand-400: 61 155 92; /* #3D9B5C */
--brand-500: 22 156 71; /* #169C47 — main brand green (FIXED) */
--brand-600: 9 139 53; /* #098B35 */
--brand-700: 7 105 41; /* #076929 */
--brand-800: 7 78 32; /* #074E20 */
--brand-900: 6 50 22; /* #063216 */
--brand-main: 22 156 71; /* #169C47 */
--brand-muted: 92 138 119; /* #5C8A77 */
}
/* Brand primary button — GREEN theme override (组件-Button按钮.md §6.2,
* agreed tech debt until the theme mechanism is reworked). */
.theme-green .btn-brand-primary:not(:disabled) {
background-color: #169c47 !important;
color: #fff !important;
}
.theme-green .btn-brand-primary:not(:disabled):hover {
background-color: #148f41 !important;
}
@media not ((hover: hover) and (pointer: fine)) {
.theme-green .btn-brand-primary:not(:disabled):active {
background-color: #12813b !important;
}
}
.theme-green .btn-brand-primary:not(:disabled) > svg {
color: #fff !important;
}
/* Touch hit-area expansion (§5.5): invisible ≥44×44 hot zone on touch devices
* while the visual size stays unchanged. Base components set `relative` + this
* class; never hand-roll per page. */
@media (hover: none) and (pointer: coarse) {
.btn-touch-hit::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: max(100%, 44px);
height: max(100%, 44px);
transform: translate(-50%, -50%);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { extendTailwindMerge } from 'tailwind-merge';
import { type ClassValue, clsx } from 'clsx';
/**
* twMerge taught about the design system's semantic font-size tokens
* (基础-字体规范.md §7 / fontSize in tailwind-preset.cjs).
*
* Why this is required: tailwind-merge's `text-*` handling treats any UNKNOWN
* `text-X` as a text COLOR (catch-all). So `text-body` / `text-h1` / `text-caption`
* … are mis-grouped as colors, and when a real color appears in the SAME cn()
* call, the font size is silently dropped → element falls back to the inherited
* 16px. Registering them in the `font-size` group makes cn() preserve size +
* color together, and collapse size-vs-size correctly.
*
* NOTE: this uses the tailwind-merge **v1.x** config shape (`classGroups` at the
* top level). v2+ nests it under `extend` — do not copy a v2 snippet here while
* the dependency is pinned to ^1.14.0 (kept in the workspace catalog).
*/
const twMerge = extendTailwindMerge({
classGroups: {
'font-size': [
{ text: ['caption', 'body-sm', 'body', 'h1', 'h2', 'h3', 'h4', 'display', 'metric'] },
],
},
});
/**
* Merges the tailwind classes (using twMerge). Conditionally removes false values
* @param inputs The tailwind classes to merge
* @returns className string to apply to an element or HOC
*/
export default function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,134 @@
const plugin = require('tailwindcss/plugin');
/**
* @bisheng/ui tailwind preset — maps the semantic tokens in
* `src/styles/tokens.css` to Tailwind classes. Consumers add:
*
* presets: [require('@bisheng/ui/tailwind-preset')],
* content: [..., '../packages/ui/src/** / *.{ts,tsx}']
*
* Values are seeded verbatim from client/tailwind.config.cjs so adopting the
* preset changes nothing visually. Keep the two in sync until client fully
* migrates onto the preset (then delete the duplicated keys there).
*
* @type {Partial<import('tailwindcss').Config>}
*/
module.exports = {
// 多端适配原则 §1: hover states are disabled on touch APP-WIDE — every
// `hover:` utility compiles wrapped in a hover-capable media query.
// Components keep using plain `hover:` (never a custom variant) so
// tailwind-merge can still dedupe overrides.
future: {
hoverOnlyWhenSupported: true,
},
darkMode: ['class'],
theme: {
fontFamily: {
// 基础-字体规范.md §1 — pure system stack.
sans: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"PingFang SC"',
'"Hiragino Sans GB"',
'"Microsoft YaHei"',
'"Noto Sans CJK SC"',
'sans-serif',
],
mono: ['ui-monospace', '"SF Mono"', '"Cascadia Mono"', 'Consolas', '"Liberation Mono"', 'monospace'],
},
extend: {
// Semantic type scale (基础-字体规范.md §2/§7) — values reference the
// semantic CSS vars in tokens.css, which remap under 768px, so
// classNames never change per breakpoint.
fontSize: {
caption: ['var(--text-caption)', { lineHeight: 'var(--leading-caption)', fontWeight: '400' }],
'body-sm': ['var(--text-body-sm)', { lineHeight: 'var(--leading-body-sm)', fontWeight: '400' }],
body: ['var(--text-body)', { lineHeight: 'var(--leading-body)', fontWeight: '400' }],
h4: ['var(--text-h4)', { lineHeight: 'var(--leading-h4)', fontWeight: '500' }],
h3: ['var(--text-h3)', { lineHeight: 'var(--leading-h3)', fontWeight: '500' }],
h2: ['var(--text-h2)', { lineHeight: 'var(--leading-h2)', fontWeight: '500' }],
h1: ['var(--text-h1)', { lineHeight: 'var(--leading-h1)', fontWeight: '500' }],
display: ['var(--text-display)', { lineHeight: 'var(--leading-display)', fontWeight: '500' }],
metric: ['var(--text-metric)', { lineHeight: 'var(--leading-metric)', fontWeight: '500' }],
},
colors: {
// Brand accent — channel-triplet vars keep `/<alpha>` modifiers working;
// the whole app's `blue-*` utilities follow the blue ⇄ green theme switch.
'blue-main': 'rgb(var(--brand-main) / <alpha-value>)',
blue: {
50: 'rgb(var(--brand-50) / <alpha-value>)',
100: 'rgb(var(--brand-100) / <alpha-value>)',
200: 'rgb(var(--brand-200) / <alpha-value>)',
300: 'rgb(var(--brand-300) / <alpha-value>)',
400: 'rgb(var(--brand-400) / <alpha-value>)',
500: 'rgb(var(--brand-500) / <alpha-value>)',
600: 'rgb(var(--brand-600) / <alpha-value>)',
700: 'rgb(var(--brand-700) / <alpha-value>)',
800: 'rgb(var(--brand-800) / <alpha-value>)',
900: 'rgb(var(--brand-900) / <alpha-value>)',
},
// Page surface: white in light, #121212 in dark.
'bg-page': 'rgb(var(--bg-page) / <alpha-value>)',
// Button semantic tokens (组件-Button按钮.md §5.1)
'btn-gray-text': 'rgb(var(--btn-gray-text) / <alpha-value>)',
'btn-gray-solid-bg': 'rgb(var(--btn-gray-solid-bg) / <alpha-value>)',
'btn-gray-border': 'rgb(var(--btn-gray-border) / <alpha-value>)',
'btn-fill-1': 'rgb(var(--btn-fill-1) / <alpha-value>)',
'btn-fill-2': 'rgb(var(--btn-fill-2) / <alpha-value>)',
'btn-fill-3': 'rgb(var(--btn-fill-3) / <alpha-value>)',
'btn-fill-4': 'rgb(var(--btn-fill-4) / <alpha-value>)',
'btn-danger': 'rgb(var(--btn-danger) / <alpha-value>)',
'btn-danger-hover': 'rgb(var(--btn-danger-hover) / <alpha-value>)',
'btn-danger-active': 'rgb(var(--btn-danger-active) / <alpha-value>)',
'btn-disabled-border': 'rgb(var(--btn-disabled-border) / <alpha-value>)',
'btn-disabled-bg': 'rgb(var(--btn-disabled-bg) / <alpha-value>)',
'btn-disabled-text': 'rgb(var(--btn-disabled-text) / <alpha-value>)',
// Arco semantic layer (基础-色彩规范.md §2/§3/§7) — primitives are
// intentionally NOT wired so components can't bypass semantic names.
'text-1': 'rgb(var(--text-1) / <alpha-value>)',
'text-2': 'rgb(var(--text-2) / <alpha-value>)',
'text-3': 'rgb(var(--text-3) / <alpha-value>)',
'text-4': 'rgb(var(--text-4) / <alpha-value>)',
'fill-1': 'rgb(var(--fill-1) / <alpha-value>)',
'fill-2': 'rgb(var(--fill-2) / <alpha-value>)',
'fill-3': 'rgb(var(--fill-3) / <alpha-value>)',
'fill-4': 'rgb(var(--fill-4) / <alpha-value>)',
'border-base': 'rgb(var(--border-base) / <alpha-value>)',
'border-deep': 'rgb(var(--border-deep) / <alpha-value>)',
success: {
DEFAULT: 'rgb(var(--success) / <alpha-value>)',
hover: 'rgb(var(--success-hover) / <alpha-value>)',
active: 'rgb(var(--success-active) / <alpha-value>)',
tint: 'rgb(var(--success-tint) / <alpha-value>)',
},
warning: {
DEFAULT: 'rgb(var(--warning) / <alpha-value>)',
hover: 'rgb(var(--warning-hover) / <alpha-value>)',
active: 'rgb(var(--warning-active) / <alpha-value>)',
tint: 'rgb(var(--warning-tint) / <alpha-value>)',
},
danger: {
DEFAULT: 'rgb(var(--danger) / <alpha-value>)',
hover: 'rgb(var(--danger-hover) / <alpha-value>)',
active: 'rgb(var(--danger-active) / <alpha-value>)',
tint: 'rgb(var(--danger-tint) / <alpha-value>)',
},
},
// Radius token scale (rounded-md/lg used by Button sizes: 6/8px).
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [
plugin(({ addVariant }) => {
// Primary input: mouse + hover vs touch / coarse pointer.
addVariant('fine-pointer', '@media (hover: hover) and (pointer: fine)');
addVariant('coarse-pointer', '@media not ((hover: hover) and (pointer: fine))');
}),
],
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
// New-code package: strict from day one (unlike the two apps' legacy configs).
"strict": true,
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"resolveJsonModule": true
},
"include": ["src"]
}
+3 -3
View File
@@ -6,9 +6,9 @@ Cross-app boundary + hard rules common to both apps: root `AGENTS.md §4` (singl
## Commands (cwd: `src/frontend/platform/`)
```bash
npm install
npm start -- --host 0.0.0.0 # dev server on :3001
VITE_PROXY_TARGET=http://localhost:8180 npm start -- --host 0.0.0.0 # commercial gateway proxy mode
pnpm install # run at src/frontend/ (pnpm workspace root; npm is disabled)
pnpm start -- --host 0.0.0.0 # dev server on :3001
VITE_PROXY_TARGET=http://localhost:8180 pnpm start -- --host 0.0.0.0 # commercial gateway proxy mode
```
## Tech Stack
+6 -4
View File
@@ -1,11 +1,13 @@
FROM node:18-alpine as frontend_build
FROM node:20-alpine as frontend_build
ARG BACKEND
WORKDIR /app
COPY . /app
RUN cd /app/client && npm install --force --registry=https://registry.npmmirror.com && npm run build
RUN cd /app/platform && npm install --force --registry=https://registry.npmmirror.com && npm run build
# pnpm workspace build (registry comes from .npmrc; packageManager pins the version).
RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
RUN cd /app && pnpm install --frozen-lockfile
RUN cd /app && pnpm --filter bishengchat build && pnpm --filter bisheng build
FROM nginx
COPY --from=frontend_build /app/client/build/ /usr/share/nginx/html/client
COPY --from=frontend_build /app/platform/build/ /usr/share/nginx/html/platform
COPY /nginx.conf /etc/nginx/conf.d/default.conf
COPY /nginx.conf /etc/nginx/conf.d/default.conf
File diff suppressed because it is too large Load Diff
+21135
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
# BiSheng frontend workspace — two apps + shared internal packages.
# Apps keep their own stacks/builds; workspace only links them at the
# package-manager level so packages/* can be consumed without publishing.
packages:
- client
- platform
- packages/*
# Single source of truth for dependency versions shared across the workspace.
# New shared deps go here; app-specific deps stay in each app's package.json.
# (Versions seeded from client to avoid any resolution drift on day one.)
catalog:
react: ^18.2.0
react-dom: ^18.2.0
'@types/react': ^18.2.11
'@types/react-dom': ^18.2.4
'@radix-ui/react-slot': ^1.1.0
bisheng-icons: ^0.2.22
class-variance-authority: ^0.6.0
clsx: ^1.2.1
tailwind-merge: ^1.14.0
tailwindcss: ^3.4.1
postcss: ^8.4.31
autoprefixer: ^10.4.20
typescript: ^5.3.3
vite: ^6.3.6
'@vitejs/plugin-react': ^4.2.1