20261008173212
This commit is contained in:
1 parent
ac65b75c73
commit
f572dce2f6
48 files changed
+1698
-507
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export default defineAppConfig({
|
||||
// Docus 默认把「整棵导航树」渲染进侧边栏;设置 navigation.sub 后,
|
||||
// 侧边栏只显示当前顶级板块的页面。
|
||||
// 见 docus/app/composables/useSubNavigation.ts
|
||||
// 注意:sub='aside' 本会在侧边栏顶部渲染板块切换,已被本地 DocsAsideLeftTop 覆盖为空。
|
||||
navigation: {
|
||||
sub: 'aside',
|
||||
},
|
||||
})
|
||||
@@ -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)。
|
||||
@@ -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
|
||||
File renamed without changes.
@@ -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": []
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -16,5 +16,8 @@
|
||||
"docus": "^5.14.0",
|
||||
"nuxt": "4.4.8",
|
||||
"vue": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sass": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// 编译期注入的命名空间常量,见 vite.config.ts 的 define。
|
||||
// 非打包环境(如直接跑源码)下可能不存在,读取时需做 typeof 守卫。
|
||||
declare const __G3_NAMESPACE__: string
|
||||
declare const __G3_STATE_PREFIX__: string
|
||||
@@ -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)),
|
||||
|
||||
Generated
+441
-259
File diff suppressed because it is too large.
Load diff
@@ -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)。
|
||||
#
|
||||
|
||||
Reference in new issue
Block a user