20261008173212

This commit is contained in:
oneao committed 2026-10-08 17:32:12 +08:00
1 parent ac65b75c73
commit f572dce2f6
48 files changed
+1698 -507

No files matched your search

+1 -1
View File
@@ -99,7 +99,7 @@ Consequence: **API tables are generated from source and must never be hand-writt
## Conventions (must follow)
- **No `scoped` in component `<style>`.** Isolate with the `g3-` prefix + BEM naming so consumers can override.
- **Never hardcode colors or sizes** — always reference `--g3-*` variables, and give every variable a fallback value (e.g. `var(--g3-color-primary, #1677ff)`) so components render correctly even when `@g3soft/tokens` is not loaded.
- **Never hardcode colors or sizes** — always reference `--g3-*` variables, and give every variable a fallback value (e.g. `var(--g3-color-primary, #18181b)`) so components render correctly even when `@g3soft/tokens` is not loaded.
- **Component API docs are generated, not authored** — changes to props/events/slots require `pnpm gen:api`.
- **Examples** live at `docs/examples/<component>/<example>.vue` and are referenced in Markdown as `::demo{name="<component>/<example>"}`. API tables use `<ApiTable name="<component>" />`.
- New components get a `src/<name>/` directory (component `.vue`, `types.ts`, `index.ts`) and must be re-exported from `src/index.ts`.
+9
View File
@@ -0,0 +1,9 @@
export default defineAppConfig({
// Docus 默认把「整棵导航树」渲染进侧边栏;设置 navigation.sub 后,
// 侧边栏只显示当前顶级板块的页面。
// 见 docus/app/composables/useSubNavigation.ts
// 注意:sub='aside' 本会在侧边栏顶部渲染板块切换,已被本地 DocsAsideLeftTop 覆盖为空。
navigation: {
sub: 'aside',
},
})
+5
View File
@@ -0,0 +1,5 @@
/**
* Docus 会把本文件追加进 Nuxt 的 css[](见 docus/modules/css.ts)。
* 这里只引入设计令牌的编译产物;不要在此引入 Tailwind(会被 Docus 判为重复引入并告警)。
*/
@import '@g3soft/tokens';
@@ -0,0 +1,31 @@
<script setup lang="ts">
// Docus 在 header 右上角渲染的占位组件,本地同名文件会覆盖 Layer 里的空实现。
// 两个文档区入口:图标 + 文字(窄屏只留图标),并按当前路由所属板块高亮。
const route = useRoute()
const sections = [
{ to: '/ui/usage/getting-started', prefix: '/ui', icon: 'i-lucide-component', label: 'UI 组件库' },
{ to: '/api/getting-started', prefix: '/api', icon: 'i-lucide-cable', label: '接口文档' },
]
function isSectionActive(prefix: string) {
return route.path === prefix || route.path.startsWith(`${prefix}/`)
}
</script>
<template>
<UButton
v-for="section in sections"
:key="section.prefix"
:to="section.to"
:icon="section.icon"
color="neutral"
variant="ghost"
:active="isSectionActive(section.prefix)"
active-color="primary"
active-variant="soft"
:aria-label="section.label"
>
<span class="hidden lg:inline">{{ section.label }}</span>
</UButton>
</template>
@@ -0,0 +1,8 @@
<template>
<!--
覆盖 Docus 的 DocsAsideLeftTop(默认在侧边栏顶部渲染板块切换)。
这里留空,详情页侧边栏就不再出现板块切换;板块改由右上角入口按钮切换。
navigation.sub='aside' 仍需保留,它负责「侧边栏只显示当前板块」的隔离行为。
-->
<div />
</template>
@@ -0,0 +1,2 @@
title: 接口文档
icon: i-lucide-cable
@@ -0,0 +1,10 @@
---
title: 快速开始
description: 前后端接口的接入说明
---
# 快速开始
本页说明前后端接口的接入方式,内容包括接口约定、鉴权与联调步骤。
> 内容待补充。
@@ -0,0 +1,2 @@
title: 指南
icon: i-lucide-rocket
@@ -1,68 +1,22 @@
---
title: 快速开始
description: 安装组件库并在项目中用起来
description: 认识 G3Soft 技术文档站,快速找到需要的内容
---
# 快速开始
## 安装
G3Soft 技术文档站汇总前端基础设施的文档:自研 Vue 3 组件库、设计变量、前后端接口,以及工程规范与设计决策。
```bash
pnpm add @g3soft/ui @g3soft/tokens
```
这一页帮你先建立整体认知,再快速跳到对应板块。
`vue` 由宿主项目提供(`peerDependencies`),请确保已安装 Vue 3。
## 站点结构
## 引入设计变量
- **指南** —— 站点总览与工程规范,帮助你了解全局约定。
- **UI 组件库** —— 自研 Vue 3 组件库的安装、使用与组件 API。
- **接口文档** —— 前后端接口约定与联调说明。
在应用入口引入一次,组件样式全部依赖这套变量:
## 快速导航
```ts
// main.ts
import '@g3soft/tokens'
```
## 使用组件
按需引入:
```vue
<script setup lang="ts">
import { G3Button } from '@g3soft/ui'
</script>
<template>
<G3Button type="primary" @click="handleClick">主要按钮</G3Button>
</template>
```
或全量注册:
```ts
import { G3UI } from '@g3soft/ui'
import { createApp } from 'vue'
createApp(App).use(G3UI).mount('#app')
```
## 换肤
组件的颜色与尺寸一律引用 `--g3-*` 变量,定制只需覆盖同名变量,不必改组件代码:
```css
:root {
--g3-color-primary: #1677ff;
--g3-radius: 6px;
}
```
暗色主题由 `html[data-g3-theme='dark']` 触发:
```ts
document.documentElement.dataset.g3Theme = 'dark'
```
## 下一步
- 浏览[组件参考](/reference/ui/button)
- 了解[设计变量](/explain/why-tokens)的设计取舍
- 想用组件库?从 [UI 组件库 · 快速上手](/ui/usage/getting-started) 开始。
- 想看某个组件?见 [通用组件 · Button 按钮](/ui/general/button)。
- 要对接接口?看 [接口文档 · 快速开始](/api/getting-started)。
+3 -3
View File
@@ -28,7 +28,7 @@ color: neutral
variant: outline
size: xl
icon: i-lucide-book-open
to: /reference/ui/button
to: /ui/general/button
---
浏览组件
:::
@@ -48,13 +48,13 @@ to: /guide/getting-started
指南
#description
从安装到上手,以及版本升级与迁移说明。
文档站总览与快速导航,帮你先建立整体认知。
:::
:::u-page-feature
---
icon: i-lucide-component
to: /reference/ui/button
to: /ui/general/button
---
#title
参考
@@ -0,0 +1,2 @@
title: UI 组件库
icon: i-lucide-component
@@ -0,0 +1,2 @@
title: 使用
icon: i-lucide-book-open
@@ -0,0 +1,70 @@
---
title: 快速上手
description: 安装、引入并在项目中使用 UI 组件库
---
# 快速上手
本节介绍如何在项目中安装、引入并使用 UI 组件库。
## 安装
```bash
pnpm add @g3soft/ui @g3soft/tokens
```
`vue` 由宿主项目提供(`peerDependencies`),请确保已安装 Vue 3。
## 引入设计变量
在应用入口引入一次,组件样式全部依赖这套变量:
```ts
// main.ts
import '@g3soft/tokens'
```
## 使用组件
按需引入:
```vue
<script setup lang="ts">
import { G3Button } from '@g3soft/ui'
</script>
<template>
<G3Button type="primary" @click="handleClick">主要按钮</G3Button>
</template>
```
或全量注册:
```ts
import { G3UI } from '@g3soft/ui'
import { createApp } from 'vue'
createApp(App).use(G3UI).mount('#app')
```
## 换肤
组件的颜色与尺寸一律引用 `--g3-*` 变量,定制只需覆盖同名变量,不必改组件代码:
```css
:root {
--g3-color-primary: #18181b;
--g3-radius: 6px;
}
```
暗色主题由 `html[data-g3-theme='dark']` 触发:
```ts
document.documentElement.dataset.g3Theme = 'dark'
```
## 下一步
- 浏览[组件参考](/ui/general/button)
- 了解[设计变量](/explain/why-tokens)的设计取舍
@@ -0,0 +1,2 @@
title: 通用组件
icon: i-lucide-boxes
@@ -0,0 +1,50 @@
{
"displayName": "G3ConfigProvider",
"description": "ConfigProvider —— 全局/子树配置。 theme.local(默认):把派生出的 CSS 变量挂到子树根节点(cloneVNode 合并 style,不额外包 DOM), 支持同一页面多主题共存。 theme.global:挂到 :root 做整站换肤,卸载时只清理自己设置过的变量。 用 render function 实现(而非 <template>),因为 local 作用域需要 cloneVNode 精确注入。",
"props": [
{
"name": "namespace",
"description": "",
"type": "string",
"defaultValue": "undefined"
},
{
"name": "theme",
"description": "",
"type": "G3ThemeConfig",
"defaultValue": "undefined"
},
{
"name": "zIndex",
"description": "",
"type": "number",
"defaultValue": "undefined"
},
{
"name": "locale",
"description": "",
"type": "Locale",
"defaultValue": "undefined"
},
{
"name": "popupContainer",
"description": "",
"type": "PopupContainer",
"defaultValue": "undefined"
},
{
"name": "componentDefaults",
"description": "",
"type": "Record<string, Record<string, unknown>>",
"defaultValue": "undefined"
},
{
"name": "emptyText",
"description": "",
"type": "string",
"defaultValue": "undefined"
}
],
"events": [],
"slots": []
}
+16 -6
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
* 几个刻意的选择:
* - extends: ['docus'] —— Docus 是 Nuxt Layer,全部能力(导航、搜索、主题)由它提供
* - alias 直连 packages 源码 —— 组件库尚未构建也能开发,改组件即时热更
* (tokens 例外:它是 SCSS 编译产物,alias 指向 dist/index.css,需先 build tokens)
*
* 部署到子路径时用环境变量 NUXT_APP_BASE_URL=/docs/,不在配置里写死。
*/
@@ -27,12 +28,21 @@ export default defineNuxtConfig({
vite: {
resolve: {
alias: {
'@g3soft/ui': fileURLToPath(new URL('../packages/ui/src/index.ts', import.meta.url)),
'@g3soft/tokens': fileURLToPath(
new URL('../packages/tokens/src/index.css', import.meta.url),
),
},
// 用正则做“精确匹配”:否则 '@g3soft/tokens' 的前缀别名会把
// '@g3soft/tokens/scss' 也一并改写掉,导致组件里的 @use 找不到 SCSS。
alias: [
{
find: /^@g3soft\/ui$/,
replacement: fileURLToPath(new URL('../packages/ui/src/index.ts', import.meta.url)),
},
{
// tokens 是 SCSS 编译产物:仅裸导入指向 dist/index.css(由 @g3soft/tokens 的 build 生成)
find: /^@g3soft\/tokens$/,
replacement: fileURLToPath(
new URL('../packages/tokens/dist/index.css', import.meta.url),
),
},
],
},
server: {
fs: {
+3
View File
@@ -16,5 +16,8 @@
"docus": "^5.14.0",
"nuxt": "4.4.8",
"vue": "catalog:"
},
"devDependencies": {
"sass": "catalog:"
}
}
+2 -2
View File
@@ -8,8 +8,8 @@
"node": ">=20.19.0"
},
"scripts": {
"dev": "pnpm --filter @g3soft/docs dev",
"build:docs": "pnpm --filter @g3soft/docs generate",
"dev": "pnpm --filter @g3soft/tokens build && pnpm --filter @g3soft/docs dev",
"build:docs": "pnpm --filter @g3soft/tokens build && pnpm --filter @g3soft/docs generate",
"preview:docs": "pnpm --filter @g3soft/docs preview",
"build": "pnpm -r --filter \"./packages/**\" build",
"gen:api": "node scripts/gen-api.mjs",
+13 -4
View File
@@ -5,17 +5,26 @@
"license": "UNLICENSED",
"type": "module",
"files": [
"dist",
"src"
],
"sideEffects": [
"*.css"
"**/*.css",
"**/*.scss"
],
"scripts": {
"build": "sass src/index.scss dist/index.css --no-source-map --style=expanded",
"watch": "sass --watch src/index.scss:dist/index.css --no-source-map"
},
"exports": {
".": "./src/index.css",
"./tokens.css": "./src/tokens.css",
"./dark.css": "./src/dark.css",
".": "./dist/index.css",
"./scss": "./src/scss/api.scss",
"./scss/theme": "./src/scss/theme.scss",
"./package.json": "./package.json"
},
"devDependencies": {
"sass": "catalog:"
},
"publishConfig": {
"access": "restricted"
}
@@ -1,21 +0,0 @@
/**
* 暗色主题:只覆盖变量值,不改变量名。
* 启用方式:document.documentElement.dataset.g3Theme = 'dark'
*/
html[data-g3-theme='dark'] {
--g3-color-primary: #4096ff;
--g3-color-primary-hover: #69b1ff;
--g3-color-primary-active: #1677ff;
--g3-text-1: rgba(255, 255, 255, 0.88);
--g3-text-2: rgba(255, 255, 255, 0.65);
--g3-text-3: rgba(255, 255, 255, 0.45);
--g3-text-disabled: rgba(255, 255, 255, 0.3);
--g3-text-inverse: #ffffff;
--g3-bg-container: #141414;
--g3-bg-disabled: rgba(255, 255, 255, 0.08);
--g3-border-color: #424242;
--g3-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
}
@@ -1,7 +0,0 @@
/**
* 设计变量总入口。
* 组件样式一律引用 var(--g3-*),取值只在本包内定义。
* 换肤 / 客户定制 = 覆盖同名变量,不需要改任何组件代码。
*/
@import './tokens.css';
@import './dark.css';
@@ -0,0 +1,4 @@
// 设计变量总入口(CSS 产物)。
// 组件样式一律引用 var(--g3-*),取值只在本包内定义。
// 换肤 / 客户定制 = 覆盖同名变量,不需要改任何组件代码。
@use 'scss/theme';
@@ -0,0 +1,21 @@
// 命名空间单一事实源。
//
// 类名前缀、状态类前缀、CSS 变量前缀、@keyframes 名全部由它派生;
// 运行时 useNamespace() / build-time __G3_NAMESPACE__ 也以此为准。
// 消费方换前缀(示例,见 README):
// @forward '@g3soft/tokens/scss' with ($namespace: 'xx');
$namespace: 'g3' !default;
// 状态类前缀(is-disabled 里的 is),全局唯一。
$state-prefix: 'is' !default;
// 唯一的主色输入(默认参考 shadcn:zinc-900 #18181b)。
// 主色族(色阶 / 交互态 / on-primary / 氛围 / 中性灰)
// 由运行时 derivePalette 在显式传入 theme.primary 时派生;
// 未配置主题时以 theme.scss 的静态值为准。
$seed-primary: #18181b !default;
// 独立固定语义色:不参与主色派生。
$success: #45b114 !default;
$warning: #e79a0d !default;
$danger: #ec4246 !default;
@@ -0,0 +1,38 @@
@use 'sass:string';
@use 'config';
// BEM + 变量助手:与运行时 useNamespace() 1:1 对应。
// 编译期用它们拼类名 / 变量名,保证 <style> 选择器与运行时 class 前缀一致。
@function b($block) {
@return '#{config.$namespace}-#{$block}';
}
@function e($block, $el) {
@return '#{config.$namespace}-#{$block}__#{$el}';
}
@function m($block, $mod) {
@return '#{config.$namespace}-#{$block}--#{$mod}';
}
@function em($block, $el, $mod) {
@return '#{config.$namespace}-#{$block}__#{$el}--#{$mod}';
}
@function is($state) {
@return '#{config.$state-prefix}-#{$state}';
}
// CSS 变量名:前缀强制跟随 $namespace,不允许单独配置。
@function css-var($name) {
@return '--#{config.$namespace}-#{$name}';
}
// 引用一个令牌变量,强制渲染 fallback,保证未加载 @g3soft/tokens 时组件仍可用。
@function v($name, $fallback: null) {
@if $fallback == null {
@return string.unquote('var(#{css-var($name)})');
}
@return string.unquote('var(#{css-var($name)}, #{$fallback})');
}
@@ -0,0 +1,7 @@
@use 'functions' as fn;
// 键盘焦点环(a11y 基线:焦点可见),颜色走主色令牌。
@mixin focus-ring($width: 2px, $offset: 1px) {
outline: $width solid fn.v('color-primary', #18181b);
outline-offset: $offset;
}
@@ -0,0 +1,6 @@
// 组件消费入口:只 @forward,绝不 emit CSS。
// 若这里带了 CSS,每个 .vue 的 <style> 都会重复输出 :root{}。
// 需要 CSS 的场景请用 './theme',且在整个 app 中只导入一次。
@forward 'config';
@forward 'functions';
@forward 'mixins';
@@ -0,0 +1,90 @@
// 默认主题(浅色)—— 未配置主题时的权威来源。
// 唯一 emit CSS 的文件,整个 app 只导入一次(见 src/index.scss)。
// 变量名全部经 fn.css-var() 生成,故 CSS 变量前缀强制跟随 $namespace。
//
// 换肤 = 覆盖同名变量,不需要改任何组件代码。
@forward 'config';
@use 'config';
@use 'functions' as fn;
// 暗色主色(shadcn dark: oklch(0.922 0 0) ≈ #e5e5e5)
$dark-primary: #e5e5e5;
:root {
/* ---------- 品牌色(shadcn 风格:hover/active 用主色透明度)---------- */
#{fn.css-var('color-primary')}: config.$seed-primary;
#{fn.css-var('color-primary-hover')}: rgba(config.$seed-primary, 0.9);
#{fn.css-var('color-primary-active')}: rgba(config.$seed-primary, 0.8);
#{fn.css-var('color-primary-on')}: #fafafa;
#{fn.css-var('color-success')}: config.$success;
#{fn.css-var('color-warning')}: config.$warning;
#{fn.css-var('color-danger')}: config.$danger;
/* ---------- 文本 ---------- */
#{fn.css-var('text-1')}: rgba(0, 0, 0, 0.88);
#{fn.css-var('text-2')}: rgba(0, 0, 0, 0.65);
#{fn.css-var('text-3')}: rgba(0, 0, 0, 0.45);
#{fn.css-var('text-disabled')}: rgba(0, 0, 0, 0.25);
#{fn.css-var('text-inverse')}: #ffffff;
/* ---------- 容器与边框 ---------- */
#{fn.css-var('bg-container')}: #ffffff;
#{fn.css-var('bg-disabled')}: rgba(0, 0, 0, 0.04);
#{fn.css-var('border-color')}: #d9d9d9;
/* ---------- 圆角 ---------- */
#{fn.css-var('radius-sm')}: 4px;
#{fn.css-var('radius')}: 6px;
#{fn.css-var('radius-lg')}: 8px;
/* ---------- 间距 ---------- */
#{fn.css-var('space-1')}: 4px;
#{fn.css-var('space-2')}: 8px;
#{fn.css-var('space-3')}: 12px;
#{fn.css-var('space-4')}: 16px;
#{fn.css-var('space-5')}: 24px;
#{fn.css-var('space-6')}: 32px;
/* ---------- 控件尺寸与字号 ---------- */
#{fn.css-var('control-height-sm')}: 24px;
#{fn.css-var('control-height')}: 32px;
#{fn.css-var('control-height-lg')}: 40px;
#{fn.css-var('font-size-sm')}: 12px;
#{fn.css-var('font-size')}: 14px;
#{fn.css-var('font-size-lg')}: 16px;
/* ---------- 字体 ---------- */
#{fn.css-var('font-family')}: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
#{fn.css-var('font-family-mono')}: ui-monospace, 'SF Mono', 'Cascadia Code', 'JetBrains Mono',
Menlo, Consolas, monospace;
/* ---------- 动效 ---------- */
#{fn.css-var('duration')}: 0.2s;
#{fn.css-var('ease')}: cubic-bezier(0.645, 0.045, 0.355, 1);
/* ---------- 阴影 ---------- */
#{fn.css-var('shadow')}: 0 2px 8px rgba(0, 0, 0, 0.15);
}
// 暗色主题:只覆盖变量值,不改变量名。
// 启用方式:document.documentElement.dataset.g3Theme = 'dark'(前缀随 $namespace)。
html[data-#{config.$namespace}-theme='dark'] {
#{fn.css-var('color-primary')}: $dark-primary;
#{fn.css-var('color-primary-hover')}: rgba($dark-primary, 0.9);
#{fn.css-var('color-primary-active')}: rgba($dark-primary, 0.8);
#{fn.css-var('color-primary-on')}: #18181b;
#{fn.css-var('text-1')}: rgba(255, 255, 255, 0.88);
#{fn.css-var('text-2')}: rgba(255, 255, 255, 0.65);
#{fn.css-var('text-3')}: rgba(255, 255, 255, 0.45);
#{fn.css-var('text-disabled')}: rgba(255, 255, 255, 0.3);
#{fn.css-var('text-inverse')}: #ffffff;
#{fn.css-var('bg-container')}: #141414;
#{fn.css-var('bg-disabled')}: rgba(255, 255, 255, 0.08);
#{fn.css-var('border-color')}: #424242;
#{fn.css-var('shadow')}: 0 2px 8px rgba(0, 0, 0, 0.45);
}
@@ -1,62 +0,0 @@
/**
* G3Soft 设计变量(浅色主题 / 默认值)
*
* 命名规则:--g3-{类别}-{属性}[-{状态}]
* 组件不得写死颜色与尺寸,必须引用本文件中的变量。
*/
:root {
/* ---------- 品牌色 ---------- */
--g3-color-primary: #1677ff;
--g3-color-primary-hover: #4096ff;
--g3-color-primary-active: #0958d9;
--g3-color-success: #52c41a;
--g3-color-warning: #faad14;
--g3-color-danger: #ff4d4f;
/* ---------- 文本 ---------- */
--g3-text-1: rgba(0, 0, 0, 0.88);
--g3-text-2: rgba(0, 0, 0, 0.65);
--g3-text-3: rgba(0, 0, 0, 0.45);
--g3-text-disabled: rgba(0, 0, 0, 0.25);
--g3-text-inverse: #ffffff;
/* ---------- 容器与边框 ---------- */
--g3-bg-container: #ffffff;
--g3-bg-disabled: rgba(0, 0, 0, 0.04);
--g3-border-color: #d9d9d9;
/* ---------- 圆角 ---------- */
--g3-radius-sm: 4px;
--g3-radius: 6px;
--g3-radius-lg: 8px;
/* ---------- 间距 ---------- */
--g3-space-1: 4px;
--g3-space-2: 8px;
--g3-space-3: 12px;
--g3-space-4: 16px;
--g3-space-5: 24px;
--g3-space-6: 32px;
/* ---------- 控件尺寸与字号 ---------- */
--g3-control-height-sm: 24px;
--g3-control-height: 32px;
--g3-control-height-lg: 40px;
--g3-font-size-sm: 12px;
--g3-font-size: 14px;
--g3-font-size-lg: 16px;
/* ---------- 字体 ---------- */
--g3-font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
--g3-font-family-mono:
ui-monospace, 'SF Mono', 'Cascadia Code', 'JetBrains Mono', Menlo, Consolas, monospace;
/* ---------- 动效 ---------- */
--g3-duration: 0.2s;
--g3-ease: cubic-bezier(0.645, 0.045, 0.355, 1);
/* ---------- 阴影 ---------- */
--g3-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
+54 -2
View File
@@ -39,6 +39,57 @@ import { createApp } from 'vue'
createApp(App).use(G3UI).mount('#app')
```
## 主题
只暴露一个主色 `primary`,色阶、交互态、`on-primary`、氛围色与中性灰全部自动派生
(OKLab 空间计算,`success` / `warning` / `danger` 为独立固定语义色,不参与派生):
```vue
<G3ConfigProvider :theme="{ primary: '#18181b' }">
<App />
</G3ConfigProvider>
```
- `theme.scope` 默认 `local`:变量挂到子树根,支持同页多主题;传 `global` 则挂到 `:root` 做整站换肤。
- `theme.dark` 复用同一派生算法切换到暗色。
- 不传 `theme` 时,`@g3soft/tokens` 的静态默认值即权威来源,外观零漂移。
## 命名空间(前缀)
类名 / CSS 变量 / `@keyframes` 前缀统一由编译期 `$namespace`(默认 `g3`)派生,
消费方不能单独配置 CSS 变量前缀。换前缀需重编译样式:
```scss
// 你的样式桥接文件,例如 src/styles/g3.scss
@forward '@g3soft/tokens/scss' with ($namespace: 'xx');
```
```ts
// vite.config.ts
// additionalData 只能 @use 桥接文件;with 不能写在 additionalData 里(会被重复配置而报错)
css: { preprocessorOptions: { scss: { additionalData: `@use "@/styles/g3.scss" as *;` } } },
```
再用相同的 `$namespace` 重编译 `@g3soft/ui` 源码,并设置运行时命名空间:
```ts
import { G3UI } from '@g3soft/ui'
G3UI.config({ namespace: 'xx' })
```
> dev 模式下运行时前缀与编译期前缀不一致会告警。该校验是启发式:能发现「改了运行时前缀却
> 仍在用预编译 g3 样式」,但无法感知「重编译了 SCSS 却跑预编译 JS」——两者必须手动保持一致。
## 全局配置
组件树外的实例(未来 `Message.xxx()` 等)拿不到 inject 上下文,用静态方法写全局兜底:
```ts
import { G3UI, zhCN } from '@g3soft/ui'
G3UI.config({ namespace: 'g3', zIndex: 2000, locale: zhCN })
```
## 构建
```bash
@@ -49,6 +100,7 @@ pnpm build
## 约定
- 样式**不写 `scoped`**,统一用 `g3-` 前缀 + BEM 命名,便于消费方覆盖。
- 样式**不写 `scoped`**,统一用命名空间前缀(默认 `g3-`)+ BEM 命名,便于消费方覆盖。
- 组件内部拼类名一律走 `useNamespace()`,不在模板里硬编码前缀。
- 每个 CSS 变量都带兜底值,保证未引入 tokens 时组件仍可正常显示。
- 组件不写死颜色与尺寸,一律引用 `--g3-*` 变量。
- 组件不写死颜色与尺寸,一律引用 `--g3-*` 变量;文案一律走 `useLocale()`。
@@ -8,6 +8,9 @@
"dist",
"README.md"
],
"sideEffects": [
"**/*.css"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -29,6 +32,7 @@
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"publint": "latest",
"sass": "catalog:",
"vite": "^8.0.0",
"vite-plugin-dts": "latest",
"vue": "catalog:",
@@ -0,0 +1,13 @@
import { computed, type ComputedRef } from 'vue'
import { useConfig } from '../config-provider/context'
import { getGlobalConfig } from '../_utils/global-config'
import { zhCN, type Locale } from '../locale'
/**
* 取当前语言包:就近 ConfigProvider → 全局单例 → zhCN。
* 组件文案一律从这里取,不得硬编码。
*/
export function useLocale(): ComputedRef<Locale> {
const config = useConfig()
return computed(() => config?.locale.value ?? getGlobalConfig().locale ?? zhCN)
}
@@ -0,0 +1,71 @@
import { computed, getCurrentInstance, inject, type ComputedRef } from 'vue'
import { configProviderKey } from '../config-provider/context'
import { getGlobalConfig } from '../_utils/global-config'
declare const __G3_NAMESPACE__: string
declare const __G3_STATE_PREFIX__: string
/** 编译期烘焙的命名空间,必须与组件样式表(SCSS $namespace)一致。 */
export const COMPILE_TIME_NAMESPACE: string =
typeof __G3_NAMESPACE__ !== 'undefined' ? __G3_NAMESPACE__ : 'g3'
/** 编译期烘焙的状态类前缀(is-disabled 里的 is)。 */
export const COMPILE_TIME_STATE_PREFIX: string =
typeof __G3_STATE_PREFIX__ !== 'undefined' ? __G3_STATE_PREFIX__ : 'is'
type StateFlag = string | false | null | undefined
export interface UseNamespaceReturn {
/** 当前生效的命名空间(响应式)。 */
ns: ComputedRef<string>
/** block:g3-button */
b: (block?: string) => string
/** element:g3-button__spinner */
e: (element: string, block?: string) => string
/** modifier:g3-button--primary */
m: (modifier: string, block?: string) => string
/** element + modifier:g3-button__content--large */
em: (element: string, modifier: string, block?: string) => string
/** 状态类:is-disabled / is-loading */
is: (...states: StateFlag[]) => string
/** CSS 变量名:--g3-color-primary */
cssVar: (name: string) => string
}
/**
* 命名空间助手:运行时拼类名,必须与编译期 SCSS 拼出的类名一致。
*
* 解析链:组件 inject(ConfigProvider) → 模块单例(G3UI.config) → 编译期常量。
* 单例兜底是必需的:Message.xxx() 这类组件树外调用拿不到 inject 上下文。
*/
export function useNamespace(block?: string): UseNamespaceReturn {
const instance = getCurrentInstance()
// 非 setup 上下文(如函数式调用)不能 inject,直接走单例兜底。
const injected = instance ? inject(configProviderKey, null) : null
const ns = computed(
() => injected?.namespace.value ?? getGlobalConfig().namespace ?? COMPILE_TIME_NAMESPACE,
)
// dev 一致性校验:只能检出「调了运行时命名空间却仍在用预编译 g3 样式」这类问题;
// 「重编译了 SCSS 却跑预编译 JS」无运行时通道可感知,属已知假阴性(见 README)。
if (import.meta.env?.DEV) {
const runtime = injected?.namespace.value ?? getGlobalConfig().namespace
if (runtime && runtime !== COMPILE_TIME_NAMESPACE) {
console.warn(
`[g3-ui] 运行时命名空间 "${runtime}" 与编译期样式命名空间 "${COMPILE_TIME_NAMESPACE}" 不一致:` +
'类名将匹配不上已发布的样式表。请保持一致,或用相同的 $namespace 从 SCSS 重新编译 @g3soft/ui。',
)
}
}
const b = (blk: string | undefined = block) => `${ns.value}-${blk}`
const e = (el: string, blk: string | undefined = block) => `${b(blk)}__${el}`
const m = (mod: string, blk: string | undefined = block) => `${b(blk)}--${mod}`
const em = (el: string, mod: string, blk: string | undefined = block) => `${e(el, blk)}--${mod}`
const is = (...states: StateFlag[]) =>
`${COMPILE_TIME_STATE_PREFIX}-${states.filter(Boolean).join('-')}`
const cssVar = (name: string) => `--${ns.value}-${name}`
return { ns, b, e, m, em, is, cssVar }
}
@@ -0,0 +1,262 @@
/**
* 颜色工具:sRGB ↔ OKLab/OKLCH 转换 + 主色派生。
*
* 为什么用 OKLab:HSL/sRGB 直接插值感知不均匀(浅色阶发荧光、深色阶发死黑、
* 中段发脏)。OKLab 是感知均匀空间,色阶过渡自然。
* 不依赖原生 oklch() CSS 函数(色域裁剪 + 兼容性),一律在 JS 里算完输出 hex。
*/
export interface RGB {
r: number
g: number
b: number
}
export interface OKLab {
l: number
a: number
b: number
}
export interface OKLCH {
l: number
c: number
h: number
}
const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n)
const cbrt = (n: number) => Math.cbrt(n)
/** 解析 #rgb / #rrggbb / #rrggbbaa / rgb() / rgba();失败返回 null。 */
export function parseColor(input: string): RGB | null {
const value = input.trim()
const hex = value.replace(/^#/, '')
if (/^(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex)) {
const expand = (s: string) => parseInt(s.length === 1 ? s + s : s, 16)
if (hex.length <= 4) {
return { r: expand(hex[0]), g: expand(hex[1]), b: expand(hex[2]) }
}
return {
r: expand(hex.slice(0, 2)),
g: expand(hex.slice(2, 4)),
b: expand(hex.slice(4, 6)),
}
}
const match = value.match(/^rgba?\(([^)]+)\)$/i)
if (match) {
const parts = match[1].split(/[,/\s]+/).filter(Boolean).map(Number)
if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
return { r: parts[0], g: parts[1], b: parts[2] }
}
}
return null
}
export function rgbToHex({ r, g, b }: RGB): string {
const to2 = (n: number) =>
Math.round(clamp01(n / 255) * 255)
.toString(16)
.padStart(2, '0')
return `#${to2(r)}${to2(g)}${to2(b)}`
}
const withAlpha = (hex: string, alpha: number): string => {
const rgb = parseColor(hex)
if (!rgb) return hex
return `rgba(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)}, ${alpha})`
}
/** sRGB 分量(0..1)→ 线性光。 */
export function srgbToLinear(c: number): number {
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
}
/** 线性光 → sRGB 分量(0..1)。 */
export function linearToSrgb(c: number): number {
return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055
}
export function rgbToOklab(rgb: RGB): OKLab {
const r = srgbToLinear(rgb.r / 255)
const g = srgbToLinear(rgb.g / 255)
const b = srgbToLinear(rgb.b / 255)
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
const l_ = cbrt(l)
const m_ = cbrt(m)
const s_ = cbrt(s)
return {
l: 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
a: 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
}
}
export function oklabToRgb(lab: OKLab): RGB {
const l_ = lab.l + 0.3963377774 * lab.a + 0.2158037573 * lab.b
const m_ = lab.l - 0.1055613458 * lab.a - 0.0638541728 * lab.b
const s_ = lab.l - 0.0894841775 * lab.a - 1.291485548 * lab.b
const l = l_ * l_ * l_
const m = m_ * m_ * m_
const s = s_ * s_ * s_
const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
const lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
return {
r: clamp01(linearToSrgb(lr)) * 255,
g: clamp01(linearToSrgb(lg)) * 255,
b: clamp01(linearToSrgb(lb)) * 255,
}
}
export function rgbToOklch(rgb: RGB): OKLCH {
const { l, a, b } = rgbToOklab(rgb)
const c = Math.sqrt(a * a + b * b)
let h = (Math.atan2(b, a) * 180) / Math.PI
if (h < 0) h += 360
return { l, c, h }
}
export function oklchToRgb(lch: OKLCH): RGB {
const rad = (lch.h * Math.PI) / 180
return oklabToRgb({ l: lch.l, a: lch.c * Math.cos(rad), b: lch.c * Math.sin(rad) })
}
const oklchToHex = (lch: OKLCH): string => rgbToHex(oklchToRgb(lch))
/** 在 OKLab 空间做平面插值:t=0 → a,t=1 → b。 */
export function mixOklab(a: string, b: string, t: number): string {
const ca = parseColor(a)
const cb = parseColor(b)
if (!ca || !cb) return a
const la = rgbToOklab(ca)
const lb = rgbToOklab(cb)
return rgbToHex(
oklabToRgb({
l: la.l + (lb.l - la.l) * t,
a: la.a + (lb.a - la.a) * t,
b: la.b + (lb.b - la.b) * t,
}),
)
}
/** 加深:OKLab 里减小 L。 */
export function darken(hex: string, amount: number): string {
const rgb = parseColor(hex)
if (!rgb) return hex
const lab = rgbToOklab(rgb)
return rgbToHex(oklabToRgb({ ...lab, l: clamp01(lab.l - amount) }))
}
/** WCAG 相对亮度(0..1)。 */
export function relativeLuminance(hex: string): number {
const rgb = parseColor(hex)
if (!rgb) return 0
return (
0.2126 * srgbToLinear(rgb.r / 255) +
0.7152 * srgbToLinear(rgb.g / 255) +
0.0722 * srgbToLinear(rgb.b / 255)
)
}
/** WCAG 对比度(1..21)。 */
export function contrastRatio(a: string, b: string): number {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
const hi = Math.max(la, lb)
const lo = Math.min(la, lb)
return (hi + 0.05) / (lo + 0.05)
}
export interface PickOnColorOptions {
white?: string
black?: string
/**
* 白字对比度阈值。低于它才改用黑字。
* 不用「取对比度更高者」是因为那会把一些中等明度主色判成黑字,与 shadcn 观感不符。
*/
whiteThreshold?: number
}
/** 按对比度自动挑前景色(黑白)。 */
export function pickOnColor(bg: string, options: PickOnColorOptions = {}): string {
const { white = '#ffffff', black = '#000000', whiteThreshold = 3.2 } = options
return contrastRatio(bg, white) >= whiteThreshold ? white : black
}
/**
* 主色派生:唯一输入 primary,输出整套主色族 + 中性灰。
* success/warning/danger 不在此列(独立固定语义色)。
*
* 暗色模式复用同一函数(dark:true 仅反转色阶目标与 hover/active 方向),无第二套算法。
* 结果带记忆化缓存。
*/
export function derivePalette(
primary: string,
{ dark = false }: { dark?: boolean } = {},
): Record<string, string> {
const cacheKey = `${primary}|${dark}`
const cached = paletteCache.get(cacheKey)
if (cached) return cached
const rgb = parseColor(primary)
if (!rgb) return {}
const { c, h } = rgbToOklch(rgb)
// shadcn 风格:hover/active 用主色透明度(等价 Tailwind 的 bg-primary/90、/80),
// 随背景明暗自适应、不产生脏色,因此不再需要「浅主色反向加深」的特判。
const hover = withAlpha(primary, 0.9)
const active = withAlpha(primary, 0.8)
// 色阶插值目标:浅色主题向白,暗色主题向 #141414。
const rampTarget = dark ? '#141414' : '#ffffff'
const tokens: Record<string, string> = {
'color-primary': primary,
'color-primary-hover': hover,
'color-primary-active': active,
'color-primary-dark-2': darken(primary, 0.2),
// on-primary:shadcn 的近白 / 近黑。
'color-primary-on': pickOnColor(primary, { white: '#fafafa', black: '#18181b' }),
'color-primary-ring': withAlpha(primary, dark ? 0.35 : 0.2),
'color-primary-shadow': `0 2px 8px ${withAlpha(primary, 0.25)}`,
// 禁用态:前景不写死 #fff(浅主色下会看不见)。
'color-primary-disabled': dark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.04)',
'color-primary-disabled-fg': dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.25)',
}
// 色阶:向白(暗色下向 #141414)插值,中段不过脏。
const ramp: Record<string, number> = {
'light-3': 0.7,
'light-5': 0.5,
'light-7': 0.3,
'light-8': 0.2,
'light-9': 0.1,
}
for (const [key, t] of Object.entries(ramp)) {
tokens[`color-primary-${key}`] = mixOklab(primary, rampTarget, t)
}
// 中性灰:取主色色相、彩度压到极低 → 有温度的灰,避免蓝按钮配纯灰边框的割裂感。
const nc = Math.min(c * 0.06, 0.02)
tokens['text-1'] = oklchToHex({ l: dark ? 0.9 : 0.16, c: nc, h })
tokens['text-2'] = oklchToHex({ l: dark ? 0.72 : 0.38, c: nc, h })
tokens['text-3'] = oklchToHex({ l: dark ? 0.55 : 0.54, c: nc, h })
tokens['border-color'] = oklchToHex({ l: dark ? 0.32 : 0.86, c: nc * 1.5, h })
paletteCache.set(cacheKey, tokens)
return tokens
}
const paletteCache = new Map<string, Record<string, string>>()
@@ -0,0 +1,28 @@
import type { Locale } from '../locale'
/** 弹层挂载容器:选择器 / 元素 / 返回元素的函数。 */
export type PopupContainer = string | HTMLElement | (() => string | HTMLElement | null)
/**
* 全局配置(模块单例)。
*
* 存在的意义:Message.xxx() 这类在组件树外创建的实例拿不到 inject 上下文,
* 必须有一个组件外的兜底来源。组件内优先用 ConfigProvider(inject),否则读这里。
*/
export interface GlobalConfig {
namespace?: string
zIndex?: number
locale?: Locale
popupContainer?: PopupContainer
}
let config: GlobalConfig = {}
/** 合并写入全局配置。 */
export function setGlobalConfig(patch: GlobalConfig): void {
config = { ...config, ...patch }
}
export function getGlobalConfig(): GlobalConfig {
return config
}
@@ -0,0 +1,31 @@
/**
* zIndex 全局注册表。
*
* 弹层类组件(Dropdown / Modal / Message …)谁压谁必须统一分配,
* 禁止组件自己写死 z-index。起点来自 ConfigProvider 的 zIndex(默认 2000)。
*/
const DEFAULT_BASE_Z_INDEX = 2000
let base = DEFAULT_BASE_Z_INDEX
let current = base
/** 由 ConfigProvider 挂载时设置基准层级。 */
export function setBaseZIndex(value: number): void {
base = value
current = value
}
export function getBaseZIndex(): number {
return base
}
/** 取下一个可用层级(按序分配)。 */
export function nextZIndex(): number {
return ++current
}
/** 重置到基准层级(用于测试 / 整页卸载)。 */
export function resetZIndex(): void {
current = base
}
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useNamespace } from '../_composables/useNamespace'
import type { ButtonProps } from './types'
/**
@@ -25,15 +26,17 @@ const props = withDefaults(defineProps<ButtonProps>(), {
*/
const emit = defineEmits<{ click: [ev: MouseEvent] }>()
const { b, e, m, is } = useNamespace('button')
const classes = computed(() => [
'g3-button',
`g3-button--${props.type}`,
`g3-button--${props.size}`,
b(),
m(props.type),
m(props.size),
{
'is-disabled': props.disabled,
'is-loading': props.loading,
'is-block': props.block,
'is-icon-only': props.iconOnly,
[is('disabled')]: props.disabled,
[is('loading')]: props.loading,
[is('block')]: props.block,
[is('icon-only')]: props.iconOnly,
},
])
@@ -54,158 +57,160 @@ function handleClick(ev: MouseEvent) {
:disabled="disabled || loading"
@click="handleClick"
>
<span v-if="loading" class="g3-button__spinner" aria-hidden="true" />
<span v-if="$slots.icon" class="g3-button__icon"><slot name="icon" /></span>
<span v-if="$slots.default" class="g3-button__content"><slot /></span>
<span v-if="loading" :class="e('spinner')" aria-hidden="true" />
<span v-if="$slots.icon" :class="e('icon')"><slot name="icon" /></span>
<span v-if="$slots.default" :class="e('content')"><slot /></span>
</button>
</template>
<style>
<style lang="scss">
/**
* 不写 scoped:组件库用 g3- 前缀 + BEM 做隔离,便于消费方覆盖样式。
* 不写 scoped:组件库用命名空间前缀 + BEM 做隔离,便于消费方覆盖样式。
* 类名与 CSS 变量名全部由 @g3soft/tokens 的 $namespace 派生,
* 每个变量都带兜底值,保证未引入 @g3soft/tokens 时组件仍可正常显示。
*/
.g3-button {
@use '@g3soft/tokens/scss' as *;
.#{b('button')} {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
box-sizing: border-box;
height: var(--g3-control-height, 32px);
height: v('control-height', 32px);
padding: 0 15px;
border: 1px solid var(--g3-border-color, #d9d9d9);
border-radius: var(--g3-radius, 6px);
background: var(--g3-bg-container, #fff);
color: var(--g3-text-1, rgba(0, 0, 0, 0.88));
border: 1px solid v('border-color', #d9d9d9);
border-radius: v('radius', 6px);
background: v('bg-container', #fff);
color: v('text-1', rgba(0, 0, 0, 0.88));
font-family: inherit;
font-size: var(--g3-font-size, 14px);
font-size: v('font-size', 14px);
line-height: 1;
white-space: nowrap;
cursor: pointer;
user-select: none;
transition: all var(--g3-duration, 0.2s) var(--g3-ease, ease);
transition: all v('duration', 0.2s) v('ease', ease);
}
.g3-button:hover:not(:disabled) {
border-color: var(--g3-color-primary, #1677ff);
color: var(--g3-color-primary, #1677ff);
.#{b('button')}:hover:not(:disabled) {
border-color: v('color-primary', #18181b);
color: v('color-primary', #18181b);
}
.g3-button:focus-visible {
outline: 2px solid var(--g3-color-primary, #1677ff);
outline-offset: 1px;
.#{b('button')}:focus-visible {
@include focus-ring();
}
.g3-button:disabled {
background: var(--g3-bg-disabled, rgba(0, 0, 0, 0.04));
border-color: var(--g3-border-color, #d9d9d9);
color: var(--g3-text-disabled, rgba(0, 0, 0, 0.25));
.#{b('button')}:disabled {
background: v('bg-disabled', rgba(0, 0, 0, 0.04));
border-color: v('border-color', #d9d9d9);
color: v('text-disabled', rgba(0, 0, 0, 0.25));
cursor: not-allowed;
}
/* ---------- 尺寸 ---------- */
.g3-button--small {
height: var(--g3-control-height-sm, 24px);
.#{m('button', 'small')} {
height: v('control-height-sm', 24px);
padding: 0 7px;
font-size: var(--g3-font-size-sm, 12px);
font-size: v('font-size-sm', 12px);
}
.g3-button--large {
height: var(--g3-control-height-lg, 40px);
.#{m('button', 'large')} {
height: v('control-height-lg', 40px);
padding: 0 19px;
font-size: var(--g3-font-size-lg, 16px);
font-size: v('font-size-lg', 16px);
}
.g3-button.is-block {
.#{b('button')}.#{is('block')} {
display: flex;
width: 100%;
}
.g3-button.is-icon-only {
width: var(--g3-control-height, 32px);
.#{b('button')}.#{is('icon-only')} {
width: v('control-height', 32px);
padding: 0;
}
.g3-button.is-icon-only.g3-button--small {
width: var(--g3-control-height-sm, 24px);
.#{b('button')}.#{is('icon-only')}.#{m('button', 'small')} {
width: v('control-height-sm', 24px);
}
.g3-button.is-icon-only.g3-button--large {
width: var(--g3-control-height-lg, 40px);
.#{b('button')}.#{is('icon-only')}.#{m('button', 'large')} {
width: v('control-height-lg', 40px);
}
/* ---------- primary ---------- */
.g3-button--primary {
background: var(--g3-color-primary, #1677ff);
border-color: var(--g3-color-primary, #1677ff);
color: var(--g3-text-inverse, #fff);
/* ---------- primary(shadcn 风格:hover/active 用主色透明度)---------- */
.#{m('button', 'primary')} {
background: v('color-primary', #18181b);
border-color: v('color-primary', #18181b);
color: v('color-primary-on', #fafafa);
}
.g3-button--primary:hover:not(:disabled) {
background: var(--g3-color-primary-hover, #4096ff);
border-color: var(--g3-color-primary-hover, #4096ff);
color: var(--g3-text-inverse, #fff);
.#{m('button', 'primary')}:hover:not(:disabled) {
background: v('color-primary-hover', rgba(24, 24, 27, 0.9));
border-color: v('color-primary-hover', rgba(24, 24, 27, 0.9));
color: v('color-primary-on', #fafafa);
}
.g3-button--primary:active:not(:disabled) {
background: var(--g3-color-primary-active, #0958d9);
border-color: var(--g3-color-primary-active, #0958d9);
.#{m('button', 'primary')}:active:not(:disabled) {
background: v('color-primary-active', rgba(24, 24, 27, 0.8));
border-color: v('color-primary-active', rgba(24, 24, 27, 0.8));
}
.g3-button--primary:disabled {
background: var(--g3-bg-disabled, rgba(0, 0, 0, 0.04));
border-color: var(--g3-border-color, #d9d9d9);
color: var(--g3-text-disabled, rgba(0, 0, 0, 0.25));
.#{m('button', 'primary')}:disabled {
background: v('bg-disabled', rgba(0, 0, 0, 0.04));
border-color: v('border-color', #d9d9d9);
color: v('text-disabled', rgba(0, 0, 0, 0.25));
}
/* ---------- success / warning / danger ---------- */
.g3-button--success {
background: var(--g3-color-success, #52c41a);
border-color: var(--g3-color-success, #52c41a);
color: var(--g3-text-inverse, #fff);
.#{m('button', 'success')} {
background: v('color-success', #45b114);
border-color: v('color-success', #45b114);
color: v('text-inverse', #fff);
}
.g3-button--warning {
background: var(--g3-color-warning, #faad14);
border-color: var(--g3-color-warning, #faad14);
color: var(--g3-text-inverse, #fff);
.#{m('button', 'warning')} {
background: v('color-warning', #e79a0d);
border-color: v('color-warning', #e79a0d);
color: v('text-inverse', #fff);
}
.g3-button--danger {
background: var(--g3-color-danger, #ff4d4f);
border-color: var(--g3-color-danger, #ff4d4f);
color: var(--g3-text-inverse, #fff);
.#{m('button', 'danger')} {
background: v('color-danger', #ec4246);
border-color: v('color-danger', #ec4246);
color: v('text-inverse', #fff);
}
/* ---------- text / link ---------- */
.g3-button--text,
.g3-button--link {
.#{m('button', 'text')},
.#{m('button', 'link')} {
height: auto;
padding: 0 4px;
border-color: transparent;
background: transparent;
}
.g3-button--link {
color: var(--g3-color-primary, #1677ff);
.#{m('button', 'link')} {
color: v('color-primary', #18181b);
}
/* ---------- loading ---------- */
.g3-button.is-loading {
.#{b('button')}.#{is('loading')} {
cursor: default;
opacity: 0.65;
}
.g3-button__spinner {
.#{e('button', 'spinner')} {
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: g3-button-spin 0.8s linear infinite;
animation: #{b('button')}-spin 0.8s linear infinite;
}
@keyframes g3-button-spin {
@keyframes #{b('button')}-spin {
to {
transform: rotate(360deg);
}
@@ -0,0 +1,133 @@
<script lang="ts">
import {
cloneVNode,
computed,
defineComponent,
h,
onBeforeUnmount,
onMounted,
provide,
watch,
type PropType,
} from 'vue'
import { derivePalette } from '../_utils/color'
import { setBaseZIndex } from '../_utils/z-index'
import { useNamespace } from '../_composables/useNamespace'
import { configProviderKey } from './context'
import type { G3ThemeConfig } from './types'
import type { Locale } from '../locale'
import type { PopupContainer } from '../_utils/global-config'
const EMPTY_VARS: Record<string, string> = {}
/**
* ConfigProvider —— 全局/子树配置。
*
* theme.local(默认):把派生出的 CSS 变量挂到子树根节点(cloneVNode 合并 style,不额外包 DOM),
* 支持同一页面多主题共存。
* theme.global:挂到 :root 做整站换肤,卸载时只清理自己设置过的变量。
*
* 用 render function 实现(而非 <template>),因为 local 作用域需要 cloneVNode 精确注入。
*/
export default defineComponent({
name: 'G3ConfigProvider',
// 手动转发 attrs(见 render),关掉自动继承避免单根时重复应用。
inheritAttrs: false,
props: {
namespace: { type: String, default: undefined },
theme: { type: Object as PropType<G3ThemeConfig>, default: undefined },
zIndex: { type: Number, default: undefined },
locale: { type: Object as PropType<Locale>, default: undefined },
popupContainer: {
type: [String, Object, Function] as unknown as PropType<PopupContainer>,
default: undefined,
},
componentDefaults: {
type: Object as PropType<Record<string, Record<string, unknown>>>,
default: undefined,
},
emptyText: { type: String, default: undefined },
},
setup(props, { slots, attrs }) {
// 在 provide 之前调用:useNamespace 读到的 inject 是「父级 ConfigProvider」,用于就近覆盖。
const { ns: ancestorNs } = useNamespace()
const effectiveNs = computed(() => props.namespace ?? ancestorNs.value)
const themeVars = computed<Record<string, string>>(() => {
const theme = props.theme
if (!theme?.primary) return EMPTY_VARS
const palette = derivePalette(theme.primary, { dark: theme.dark })
const out: Record<string, string> = {}
const prefix = effectiveNs.value
for (const [suffix, value] of Object.entries(palette)) {
out[`--${prefix}-${suffix}`] = value
}
return out
})
const scope = computed(() => props.theme?.scope ?? 'local')
provide(configProviderKey, {
namespace: computed(() => props.namespace),
locale: computed(() => props.locale),
zIndex: computed(() => props.zIndex),
popupContainer: computed(() => props.popupContainer),
componentDefaults: computed(() => props.componentDefaults),
emptyText: computed(() => props.emptyText),
})
onMounted(() => {
if (props.zIndex != null) setBaseZIndex(props.zIndex)
})
// ---------- global 作用域:挂到 :root,只清理自己设置过的变量 ----------
let appliedGlobalVars: string[] = []
const applyGlobalVars = () => {
if (typeof document === 'undefined') return
const root = document.documentElement
for (const name of appliedGlobalVars) root.style.removeProperty(name)
appliedGlobalVars = []
if (scope.value !== 'global') return
for (const [name, value] of Object.entries(themeVars.value)) {
root.style.setProperty(name, value)
appliedGlobalVars.push(name)
}
}
watch([themeVars, scope], applyGlobalVars, { deep: true })
onMounted(applyGlobalVars)
onBeforeUnmount(() => {
if (typeof document === 'undefined') return
for (const name of appliedGlobalVars) {
document.documentElement.style.removeProperty(name)
}
appliedGlobalVars = []
})
return () => {
const children = slots.default?.() ?? []
// global 或无主题:不干预 DOM,直接渲染子节点。
if (scope.value === 'global' || Object.keys(themeVars.value).length === 0) {
// 透传非配置类 attrs 到单个根子节点,尽量不丢用户属性。
if (children.length === 1 && Object.keys(attrs).length > 0) {
return cloneVNode(children[0], attrs)
}
return children
}
const style = themeVars.value
// 单根子节点:合并 style,零额外 DOM。
if (children.length === 1) {
return cloneVNode(children[0], { ...attrs, style: [attrs.style, style] })
}
// 多根子节点:回退到 display:contents 包装(不产生盒子,尽量避免破坏布局)。
return h('div', { ...attrs, style: [attrs.style, style, { display: 'contents' }] }, children)
}
},
})
</script>
@@ -0,0 +1,20 @@
import { inject, type ComputedRef, type InjectionKey } from 'vue'
import type { PopupContainer } from '../_utils/global-config'
import type { Locale } from '../locale'
/** ConfigProvider 通过 provide 下发的上下文。 */
export interface ConfigContext {
namespace: ComputedRef<string | undefined>
locale: ComputedRef<Locale | undefined>
zIndex: ComputedRef<number | undefined>
popupContainer: ComputedRef<PopupContainer | undefined>
componentDefaults: ComputedRef<Record<string, Record<string, unknown>> | undefined>
emptyText: ComputedRef<string | undefined>
}
export const configProviderKey: InjectionKey<ConfigContext> = Symbol('g3-config')
/** 读取最近的 ConfigProvider 上下文;无则返回 null。 */
export function useConfig(): ConfigContext | null {
return inject(configProviderKey, null)
}
@@ -0,0 +1,4 @@
export { default as G3ConfigProvider } from './ConfigProvider.vue'
export { configProviderKey, useConfig } from './context'
export type { ConfigContext } from './context'
export type { ConfigProviderProps, G3ThemeConfig, ThemeScope } from './types'
@@ -0,0 +1,33 @@
import type { Locale } from '../locale'
import type { PopupContainer } from '../_utils/global-config'
export type ThemeScope = 'local' | 'global'
export interface G3ThemeConfig {
/** 主色。传入即触发整套主色族派生(色阶 / 交互态 / on-primary / 氛围 / 中性灰)。 */
primary?: string
/** 暗色模式:复用同一派生算法,仅反转 L 映射方向。 */
dark?: boolean
/** local(默认)= 变量挂子树根,支持同页多主题;global = 挂 :root 做整站换肤。 */
scope?: ThemeScope
}
export interface ConfigProviderProps {
/**
* 运行时命名空间。仅当你用自定义 $namespace 重新编译过样式时才需要设置;
* 与编译期不一致时 dev 会告警(见 useNamespace)。
*/
namespace?: string
/** 主题配置 */
theme?: G3ThemeConfig
/** 弹层基准层级,默认 2000 */
zIndex?: number
/** 语言包出口,组件文案不得硬编码 */
locale?: Locale
/** 弹层挂载容器默认值 */
popupContainer?: PopupContainer
/** 按组件维度的默认 props,如 { button: { size: 'small' } } */
componentDefaults?: Record<string, Record<string, unknown>>
/** 全局空状态文案 */
emptyText?: string
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
// 编译期注入的命名空间常量,见 vite.config.ts 的 define。
// 非打包环境(如直接跑源码)下可能不存在,读取时需做 typeof 守卫。
declare const __G3_NAMESPACE__: string
declare const __G3_STATE_PREFIX__: string
+42 -4
View File
@@ -1,21 +1,59 @@
import type { App, Plugin } from 'vue'
import G3Button from './button/Button.vue'
import G3ConfigProvider from './config-provider/ConfigProvider.vue'
import { setGlobalConfig, type GlobalConfig } from './_utils/global-config'
import { setBaseZIndex } from './_utils/z-index'
export { G3Button }
export { G3ConfigProvider }
export type { ButtonProps, ButtonSize, ButtonType } from './button/types'
// ConfigProvider / config
export { configProviderKey, useConfig } from './config-provider/context'
export type { ConfigContext } from './config-provider/context'
export type { ConfigProviderProps, G3ThemeConfig, ThemeScope } from './config-provider/types'
// composables
export { useNamespace, COMPILE_TIME_NAMESPACE, COMPILE_TIME_STATE_PREFIX } from './_composables/useNamespace'
export type { UseNamespaceReturn } from './_composables/useNamespace'
export { useLocale } from './_composables/useLocale'
// utils
export { getGlobalConfig, setGlobalConfig } from './_utils/global-config'
export type { GlobalConfig, PopupContainer } from './_utils/global-config'
export { getBaseZIndex, nextZIndex, resetZIndex, setBaseZIndex } from './_utils/z-index'
export { derivePalette } from './_utils/color'
// locale
export { enUS, zhCN } from './locale'
export type { Locale } from './locale'
export interface G3UIOptions extends GlobalConfig {}
const components = { G3Button, G3ConfigProvider }
function applyConfig(options: G3UIOptions): void {
setGlobalConfig(options)
if (options.zIndex != null) setBaseZIndex(options.zIndex)
}
/**
* 可选的全量注册插件。
* 按需 import 是主路径;这里只是为不熟悉按需引入的团队提供便利入口。
*
* 函数式调用(Message.xxx() 等组件树外实例)拿不到 inject 上下文,
* 用 G3UI.config({ namespace }) 或 app.use(G3UI, { namespace }) 写入全局兜底。
*/
const components = { G3Button }
export const G3UI: Plugin = {
install(app: App) {
export const G3UI: Plugin & { config: (options?: G3UIOptions) => void } = {
install(app: App, options: G3UIOptions = {}) {
applyConfig(options)
Object.entries(components).forEach(([name, component]) => {
app.component(name, component)
})
},
config(options: G3UIOptions = {}) {
applyConfig(options)
},
}
export default G3UI
@@ -0,0 +1,23 @@
/**
* 语言包出口。
*
* 组件不得硬编码中文文案,一律从 locale 取,便于整站切换 / 定制。
*/
export interface Locale {
name: string
/** 空状态等通用文案。 */
empty: {
description: string
}
[key: string]: unknown
}
export const zhCN: Locale = {
name: 'zh-CN',
empty: { description: '暂无数据' },
}
export const enUS: Locale = {
name: 'en-US',
empty: { description: 'No data' },
}
@@ -1,8 +1,27 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'
// 命名空间单一事实源在 @g3soft/tokens 的 SCSS 里(SCSS 无法读 JS,故 JS 向 SCSS 取)。
// 这里烘焙成 JS 常量 __G3_NAMESPACE__:运行时 useNamespace() 的默认值 + dev 一致性校验都用它。
// 消费方若用自定义 $namespace 重编译,需在自身构建里同步 define 同名常量(见 README)。
const configScssPath = fileURLToPath(
new URL('../tokens/src/scss/_config.scss', import.meta.url),
)
const configScss = readFileSync(configScssPath, 'utf-8')
function readScssDefault(name: string, fallback: string): string {
const match = configScss.match(
new RegExp(`\\$${name}\\s*:\\s*['"]([^'"]+)['"]\\s*!default`),
)
return match ? match[1] : fallback
}
const NAMESPACE = readScssDefault('namespace', 'g3')
const STATE_PREFIX = readScssDefault('state-prefix', 'is')
/**
* 组件库构建:产出 ESM + 类型声明。
*
@@ -10,6 +29,7 @@ import dts from 'vite-plugin-dts'
* - vue 必须外置(external),否则包里会打进一份 Vue,消费方出现双实例
* - preserveModules 保留源码结构,消费方能按需 tree-shaking
* - vite-plugin-dts 生成 dist/index.d.ts,配合 package.json 的 exports.types
* - define.__G3_NAMESPACE__ 把编译期命名空间烘焙进 JS,供运行时 useNamespace 使用
*/
export default defineConfig({
plugins: [
@@ -21,6 +41,18 @@ export default defineConfig({
rollupTypes: true,
}),
],
define: {
__G3_NAMESPACE__: JSON.stringify(NAMESPACE),
__G3_STATE_PREFIX__: JSON.stringify(STATE_PREFIX),
},
css: {
preprocessorOptions: {
// 组件 SCSS 一律走 modern compiler API;库自身只用默认 $namespace,不加 additionalData。
scss: {
api: 'modern-compiler',
},
},
},
build: {
lib: {
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
+441 -259
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -6,15 +6,20 @@ packages:
catalog:
vue: ^3.5.41
typescript: ^5.9.0
sass: ^1.80.0
# pnpm 11 的构建脚本白名单(map 形式;v10 的 onlyBuiltDependencies 数组写法在 v11 已失效)。
# better-sqlite3 —— Docus 全文搜索(FTS5)依赖的原生模块,必须放行才能下载/编译二进制
# vue-demi —— vue-docgen-api 的传递依赖,安装时要跑脚本切换 Vue 版本 shim
# esbuild —— Nuxt / Vite 底层
# @parcel/watcher —— Docs(Nuxt) dev 的文件监听原生模块,放行以启用快速热更新。
# 注意:pnpm 11 会把「未放行的构建脚本」当作错误让后续命令失败,
# 所以这个条目必须显式存在(true=放行,false=显式忽略)。
allowBuilds:
esbuild: true
better-sqlite3: true
vue-demi: true
'@parcel/watcher': true
# 依赖严格隔离(不开启 shamefully-hoist / nodeLinker: hoisted)。
#