This commit is contained in:
oneao committed 2026-10-08 22:38:34 +08:00
1 parent f572dce2f6
commit e99a9fb274
356 files changed
+28877 -1055

No files matched your search

@@ -0,0 +1,53 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>G3 组件 playground</title>
<style>
body {
margin: 0;
background: #fff;
color: #18181b;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
.page {
max-width: 1120px;
margin: 0 auto;
padding: 20px;
}
/* 只作用于页面自己的分区(`.page` 的直接子元素)。
不能用裸 `section` / `h2`:Drawer 的面板本体就是 `<section class="g3-drawer">`,
元素选择器会连它一起命中(曾把面板撑出 16px 的底部间距、还漏进虚线边框和 padding),
表现为「抽屉高度撑不满」。 */
.page > section {
margin-bottom: 16px;
padding: 12px 16px 16px;
border: 1px dashed #e5e7eb;
border-radius: 8px;
}
.page > section > h2 {
margin: 0 0 12px;
color: #52525b;
font-size: 15px;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.box {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
background: #fafafa;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>
@@ -0,0 +1,420 @@
import { createApp, reactive, ref } from 'vue'
import { Plus, Search, Settings } from '@lucide/vue'
import {
G3Button,
G3ButtonGroup,
G3ButtonGroupSeparator,
G3Checkbox,
G3Col,
G3DatePicker,
G3Drawer,
G3Dropdown,
G3DropdownItem,
G3Form,
G3FormGroup,
G3FormItem,
G3Input,
G3Message,
G3Modal,
G3Notification,
G3Pagination,
G3Popover,
G3Radio,
G3RadioGroup,
G3RangePicker,
G3Row,
G3Scrollbar,
G3Segmented,
G3Select,
G3Space,
G3Spin,
G3Splitter,
G3SplitterPanel,
G3Switch,
G3TabPane,
G3Tabs,
G3Tag,
G3TimeSelect,
G3Tooltip,
G3Tree,
} from '@g3soft/ui'
import '@g3soft/tokens'
/**
* playground —— 组件在真实浏览器里的落脚点。
*
* 约定:所有 E2E 与人工手测都通过 `data-testid` 找元素,
* 不要依赖文本或层级结构(文案会改、结构会调,testid 保持稳定)。
*/
const ROLE_OPTIONS = [
{ label: '管理员', value: 1 },
{ label: '会员', value: 2 },
{ label: '访客', value: 3 },
]
const TREE_DATA = [
{
key: '1',
title: '前端',
children: [
{ key: '1-1', title: 'Vue', children: [{ key: '1-1-1', title: '组合式 API' }] },
{ key: '1-2', title: 'React' },
],
},
{ key: '2', title: '后端', children: [{ key: '2-1', title: 'Node.js' }] },
]
const LONG_TEXT = Array.from({ length: 40 }, (_, index) => `第 ${index + 1} 行内容`).join('\n')
const App = {
components: {
G3Button,
G3ButtonGroup,
G3ButtonGroupSeparator,
G3Checkbox,
G3Col,
G3DatePicker,
G3Drawer,
G3Dropdown,
G3DropdownItem,
G3Form,
G3FormGroup,
G3FormItem,
G3Input,
G3Modal,
G3Pagination,
G3Popover,
G3Radio,
G3RadioGroup,
G3RangePicker,
G3Row,
G3Scrollbar,
G3Segmented,
G3Select,
G3Space,
G3Spin,
G3Splitter,
G3SplitterPanel,
G3Switch,
G3TabPane,
G3Tabs,
G3Tag,
G3TimeSelect,
G3Tooltip,
G3Tree,
Plus,
Search,
Settings,
},
setup() {
const model = reactive({
name: '张三',
intro: '这是多行文本',
role: 1 as number | undefined,
city: [] as number[],
agree: true,
level: 2,
gender: 'male',
date: '2026-10-08 09:30:00',
range: ['2026-10-01', '2026-10-08'] as string[],
})
const tab = ref('a')
// 选项是中文原始值,初始值必须对上,否则没有选中项
const seg = ref('日')
const time = ref(new Date(2026, 9, 8, 9, 30, 0))
const modalOpen = ref(false)
const modalConfirmOpen = ref(false)
const drawerOpen = ref(false)
// 四个方向都要能验:上下面板的宽度必须撑满视口(曾因用 flex 对齐而缩到内容宽)
const drawerPlacement = ref<'left' | 'right' | 'top' | 'bottom'>('right')
// 挂载即展开的浮层:验证「初始 open」也能拿到 z-index
const popoverAOpen = ref(true)
const popoverBOpen = ref(true)
// 扁平树:两个节点都不是父节点,用来验「叶子不该留展开箭头占位」
const flatTreeData = [
{ key: 'flat-1', title: '节点一(无子节点)' },
{ key: 'flat-2', title: '节点二(无子节点)' },
]
const treeMenuItems = [
{ key: 'add', label: '新增子节点' },
{ key: 'rename', label: '重命名' },
{ key: 'delete', label: '删除', danger: true },
]
function toast(placement?: string) {
G3Message.success(`操作成功`, placement ? { placement: placement as never } : undefined)
}
function notify() {
G3Notification.info({ title: '定时任务完成', description: '本次共同步 128 条数据。' })
}
function openDrawer(placement: 'left' | 'right' | 'top' | 'bottom') {
drawerPlacement.value = placement
drawerOpen.value = true
}
function openCommandConfirm() {
G3Modal.confirm({
title: '删除确认',
content: '确定要删除这条记录吗?',
onConfirm: () => new Promise<void>((resolve) => setTimeout(resolve, 600)),
})
}
return {
model,
tab,
seg,
time,
modalOpen,
modalConfirmOpen,
drawerOpen,
drawerPlacement,
openDrawer,
popoverAOpen,
popoverBOpen,
flatTreeData,
treeMenuItems,
roleOptions: ROLE_OPTIONS,
treeData: TREE_DATA,
longText: LONG_TEXT,
toast,
notify,
openCommandConfirm,
}
},
template: `
<div class="page">
<section id="s-basic">
<h2>基础:Button / Tag / Spin / Segmented / Space</h2>
<div class="row">
<G3Button data-testid="btn-primary">主要</G3Button>
<G3Button type="secondary">次要</G3Button>
<G3Button type="outline">描边</G3Button>
<G3Button type="ghost">幽灵</G3Button>
<G3Button type="danger">危险</G3Button>
<G3Button type="danger-outline">危险描边</G3Button>
<G3Button type="link">链接</G3Button>
<G3Button type="icon" aria-label="新增"><Plus /></G3Button>
<G3Button size="small">小</G3Button>
<G3Button size="large">大</G3Button>
<G3Button loading>加载中</G3Button>
<G3Button disabled>禁用</G3Button>
</div>
<div class="row">
<G3ButtonGroup data-testid="btn-group">
<G3Button type="outline">左</G3Button>
<G3ButtonGroupSeparator />
<G3Button type="outline">中</G3Button>
<G3ButtonGroupSeparator />
<G3Button type="outline">右</G3Button>
</G3ButtonGroup>
<!-- 无分隔线的连续按钮组:相邻边框重叠,hover 时被压住的那条必须可见 -->
<G3ButtonGroup data-testid="btn-group-plain">
<G3Button type="outline">第一</G3Button>
<G3Button type="outline">第二</G3Button>
<G3Button type="outline">第三</G3Button>
</G3ButtonGroup>
<G3ButtonGroup orientation="vertical" data-testid="btn-group-vertical">
<G3Button type="outline">上</G3Button>
<G3Button type="outline">中</G3Button>
<G3Button type="outline">下</G3Button>
</G3ButtonGroup>
<G3Tag variant="default">default</G3Tag>
<G3Tag variant="secondary">secondary</G3Tag>
<G3Tag variant="outline">outline</G3Tag>
<G3Tag variant="success">success</G3Tag>
<G3Tag variant="danger">danger</G3Tag>
<G3Tag variant="ghost">ghost</G3Tag>
<G3Tag variant="success" closable data-testid="tag-closable">可关闭</G3Tag>
<G3Spin data-testid="spin" />
</div>
<div class="row">
<G3Segmented v-model="seg" :options="['日', '周', '月']" data-testid="segmented" />
<G3Space :size="8"><G3Tag>间距</G3Tag><G3Tag>测试</G3Tag></G3Space>
</div>
</section>
<section id="s-input">
<h2>Input(含 textarea 与拖拽手柄)</h2>
<div class="row">
<G3Input v-model="model.name" data-testid="input-basic" placeholder="默认" style="width: 180px" />
<G3Input v-model="model.name" type="password" data-testid="input-password" style="width: 180px" />
<G3Input v-model="model.level" type="number" data-testid="input-number" style="width: 120px" />
<G3Input v-model="model.name" allow-clear data-testid="input-clear" style="width: 180px" />
<G3Input v-model="model.name" show-count :maxlength="20" style="width: 180px" />
<G3Input v-model="model.name" status="error" style="width: 160px" />
<G3Input v-model="model.name" disabled style="width: 160px" />
<G3Input v-model="model.name" placeholder="带前缀">
<template #prefix><Search /></template>
</G3Input>
</div>
<div class="row">
<G3Input v-model="model.intro" type="textarea" :rows="3" data-testid="textarea-basic" style="width: 300px" />
<G3Input
v-model="model.intro"
type="textarea"
:rows="3"
show-count
:maxlength="100"
data-testid="textarea-count"
style="width: 300px"
/>
</div>
</section>
<section id="s-form">
<h2>Form / Select / Date / Switch / Radio / Checkbox</h2>
<G3Form :model="model" :label-width="90" data-testid="form">
<G3FormItem name="name" label="名称"><G3Input v-model="model.name" /></G3FormItem>
<G3FormItem name="role" label="角色">
<G3Select v-model="model.role" :options="roleOptions" data-testid="select-basic" placeholder="请选择" />
</G3FormItem>
<G3FormItem name="city" label="城市">
<G3Select
v-model="model.city"
:options="roleOptions"
multiple
data-testid="select-multiple"
placeholder="多选"
/>
</G3FormItem>
<G3FormItem name="date" label="日期时间">
<G3DatePicker v-model="model.date" format="YYYY-MM-DD HH:mm:ss" data-testid="date-picker" />
</G3FormItem>
<G3FormItem name="range" label="日期区间">
<G3RangePicker v-model="model.range" format="YYYY-MM-DD" data-testid="range-picker" />
</G3FormItem>
<G3FormItem name="intro" label="简介">
<G3Input v-model="model.intro" type="textarea" :rows="3" />
</G3FormItem>
<G3FormItem name="agree" label="开关"><G3Switch v-model="model.agree" data-testid="switch" /></G3FormItem>
<G3FormItem name="gender" label="单选">
<G3RadioGroup v-model="model.gender">
<G3Radio value="male">男</G3Radio>
<G3Radio value="female">女</G3Radio>
</G3RadioGroup>
</G3FormItem>
<G3FormItem name="level" label="复选">
<G3Checkbox :model-value="true">已选中</G3Checkbox>
<G3Checkbox :model-value="false">未选中</G3Checkbox>
<G3Checkbox :model-value="false" disabled>禁用</G3Checkbox>
</G3FormItem>
</G3Form>
<G3FormGroup title="分组(可折叠)" style="margin-top: 12px" data-testid="form-group">
<G3FormItem name="name" label="组内字段"><G3Input v-model="model.name" /></G3FormItem>
</G3FormGroup>
</section>
<section id="s-data">
<h2>Tabs / Tree / Pagination / Splitter / Scrollbar / TimeSelect</h2>
<div class="row" style="align-items: flex-start">
<div style="width: 380px">
<!-- data-testid 挂在包裹层:G3Tabs / G3Tree 是多根或 Teleport 根节点,
直接挂在组件上会触发 Vue 的 attrs 继承警告(E2E 仍可用它定位) -->
<div data-testid="tabs">
<G3Tabs v-model="tab">
<G3TabPane tab-key="a" label="标签一">标签一的内容</G3TabPane>
<G3TabPane tab-key="b" label="标签二">标签二的内容</G3TabPane>
<G3TabPane tab-key="c" label="禁用" disabled>不可见</G3TabPane>
</G3Tabs>
</div>
<G3Tabs v-model="tab" placement="left" type="line">
<G3TabPane tab-key="a" label="左侧一">左侧标签页内容</G3TabPane>
<G3TabPane tab-key="b" label="左侧二">第二个面板</G3TabPane>
</G3Tabs>
<G3Tabs v-model="tab" placement="right" type="line">
<G3TabPane tab-key="a" label="右侧一">右侧标签页内容</G3TabPane>
<G3TabPane tab-key="b" label="右侧二">第二个面板</G3TabPane>
</G3Tabs>
</div>
<div style="width: 280px">
<div data-testid="tree">
<G3Tree :tree-data="treeData" checkable default-expand-all block-node />
</div>
<!-- 右键菜单:两个节点都不是父节点(验证叶子不占展开位) -->
<G3Tree :tree-data="flatTreeData" :context-menu="treeMenuItems" />
<!-- 拖拽排序:长按 400ms 才武装,避免手抖 -->
<G3Tree :tree-data="treeData" draggable default-expand-all />
</div>
<div style="width: 240px">
<G3TimeSelect v-model="time" data-testid="time-select" />
</div>
</div>
<div style="margin-top: 12px">
<G3Pagination :total="126" show-total show-size-changer show-quick-jumper data-testid="pagination" />
</div>
<div style="margin-top: 12px; height: 120px; display: flex; gap: 12px">
<G3Splitter style="height: 120px; flex: 1" data-testid="splitter">
<G3SplitterPanel :default-size="'40%'"><div class="box">左</div></G3SplitterPanel>
<G3SplitterPanel><div class="box">右</div></G3SplitterPanel>
</G3Splitter>
<!-- auto-hide=never:滚动条常驻,E2E 才能稳定点到轨道 -->
<G3Scrollbar
style="height: 120px; width: 220px; border: 1px solid #e5e7eb"
auto-hide="never"
data-testid="scrollbar"
>
<pre style="margin: 0; padding: 8px">{{ longText }}</pre>
</G3Scrollbar>
</div>
</section>
<section id="s-overlay">
<h2>Dropdown / Tooltip / Popover(首两个挂载即展开,用于层级回归)</h2>
<div class="row">
<G3Dropdown data-testid="dropdown">
<template #trigger><G3Button type="outline">下拉菜单</G3Button></template>
<G3DropdownItem item-key="edit">编辑</G3DropdownItem>
<G3DropdownItem item-key="copy">复制</G3DropdownItem>
<G3DropdownItem divider />
<G3DropdownItem item-key="del" danger>删除</G3DropdownItem>
</G3Dropdown>
<G3Tooltip content="这是一段提示文案">
<G3Button type="outline" data-testid="tooltip-trigger">悬停提示</G3Button>
</G3Tooltip>
<G3Popover v-model:open="popoverAOpen" trigger="manual" placement="bottom-start" data-testid="popover-a">
<template #trigger><G3Button type="outline">初始展开 A</G3Button></template>
<template #content><div style="width: 120px">浮层 A</div></template>
</G3Popover>
<G3Popover v-model:open="popoverBOpen" trigger="manual" placement="bottom-start" data-testid="popover-b">
<template #trigger><G3Button type="outline">初始展开 B</G3Button></template>
<template #content><div style="width: 120px">浮层 B</div></template>
</G3Popover>
</div>
</section>
<section id="s-feedback">
<h2>Modal / Drawer / Message / Notification</h2>
<div class="row">
<G3Button data-testid="open-modal" @click="modalOpen = true">打开 Modal</G3Button>
<G3Button data-testid="open-drawer" @click="openDrawer('right')">右侧 Drawer</G3Button>
<G3Button data-testid="open-drawer-left" @click="openDrawer('left')">左侧 Drawer</G3Button>
<G3Button data-testid="open-drawer-top" @click="openDrawer('top')">上方 Drawer</G3Button>
<G3Button data-testid="open-drawer-bottom" @click="openDrawer('bottom')">下方 Drawer</G3Button>
<G3Button data-testid="open-confirm" @click="openCommandConfirm">Modal.confirm</G3Button>
<G3Button data-testid="toast-default" @click="toast()">Message 默认</G3Button>
<G3Button data-testid="toast-top-left" @click="toast('top-left')">Message 左上</G3Button>
<G3Button data-testid="toast-bottom-right" @click="toast('bottom-right')">Message 右下</G3Button>
<G3Button data-testid="notify" @click="notify">Notification</G3Button>
</div>
<G3Modal v-model:open="modalOpen" title="对话框标题" data-testid="modal">
<p>第一段内容。</p>
<G3Select v-model="model.role" :options="roleOptions" data-testid="modal-select" placeholder="弹窗内选择器" />
<p v-for="i in 20" :key="i">第 {{ i }} 段内容,用于验证缩放时内容区是否内部滚动。</p>
</G3Modal>
<G3Drawer v-model:open="drawerOpen" :placement="drawerPlacement" title="抽屉标题" data-testid="drawer">
<p>抽屉内容</p>
<G3Select v-model="model.role" :options="roleOptions" data-testid="drawer-select" placeholder="抽屉内选择" />
</G3Drawer>
</section>
</div>
`,
}
createApp(App).mount('#app')
@@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
/**
* 组件 playground —— E2E(Playwright)与人工/ AI 手测共用的宿主页面。
*
* 只有两个用途:把组件跑在真实浏览器里、给每个可交互元素一个稳定的 data-testid。
* 不参与打包(files 只发布 dist),也不影响组件库构建。
*/
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
}
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [vue()],
resolve: {
alias: [
{
find: /^@g3soft\/ui$/,
replacement: fileURLToPath(new URL('../src/index.ts', import.meta.url)),
},
{
find: /^@g3soft\/tokens$/,
replacement: fileURLToPath(new URL('../../tokens/dist/index.css', import.meta.url)),
},
{ find: /^vue$/, replacement: 'vue/dist/vue.esm-bundler.js' },
],
},
css: {
preprocessorOptions: { scss: { api: 'modern-compiler' } },
},
define: {
__G3_NAMESPACE__: JSON.stringify(readScssDefault('namespace', 'g3')),
__G3_STATE_PREFIX__: JSON.stringify(readScssDefault('state-prefix', 'is')),
},
server: { port: 5200, strictPort: true },
})