add vitepress docs

This commit is contained in:
Timi
2026-09-21 16:05:14 +08:00
parent b435bf992e
commit 37cf38fe27
73 changed files with 7003 additions and 802 deletions
+83
View File
@@ -0,0 +1,83 @@
/**
* API 文档数据类型。
*
* 这些结构由 Node 侧的 `plugins/docgen.ts` 产出,通过虚拟模块交给浏览器渲染,
* 因此刻意保持为不含第三方类型的纯数据,避免 `vue-docgen-api` 被打进客户端产物。
*/
/** 单个 prop 的表格行数据。 */
export interface ApiPropRow {
/** prop 名称。 */
name: string;
/** 类型签名,如 `string`、`Article`。 */
type: string;
/** 默认值,未声明时为空字符串。 */
defaultValue: string;
/** 是否必填。 */
required: boolean;
/** 注释说明,取自源码 JSDoc。 */
description: string;
}
/** 单个事件的表格行数据。 */
export interface ApiEventRow {
/** 事件名,如 `update:modelValue`。 */
name: string;
/** 回调参数签名。 */
type: string;
/** 注释说明。 */
description: string;
}
/** 单个插槽的表格行数据。 */
export interface ApiSlotRow {
/** 插槽名。 */
name: string;
/** 注释说明或作用域参数。 */
description: string;
}
/** 单个暴露方法或属性的表格行数据。 */
export interface ApiExposeRow {
/** 暴露的名称。 */
name: string;
/** 类型签名。 */
type: string;
/** 注释说明。 */
description: string;
}
/** 一个组件解析完成后的完整 API 数据。 */
export interface ComponentApi {
/** 组件名。 */
name: string;
/** 组件自身的注释说明。 */
description: string;
/**
* 源码是否解析成功。
*
* 用来区分「解析失败」与「组件本身就没有 props/events/slots/expose」,
* 两者都会得到空数组,但提示语完全不同。
*/
parsed: boolean;
props: ApiPropRow[];
events: ApiEventRow[];
slots: ApiSlotRow[];
exposes: ApiExposeRow[];
}
/** 全部组件的 API 数据,键为组件名。 */
export type ComponentApiMap = Record<string, ComponentApi>;
/** 生成一个空的 API 数据,用于解析失败时兜底,保证文档站不因单个组件而白屏。 */
export function createEmptyApi(name: string): ComponentApi {
return {
name,
description: "",
parsed: false,
props: [],
events: [],
slots: [],
exposes: []
};
}
+180
View File
@@ -0,0 +1,180 @@
/**
* 组件元数据清单。
*
* 这是文档站的唯一数据源,三处消费它:
* - `config.ts` 生成侧边栏导航
* - `plugins/docgen.ts` 按 `entry` 定位源码并交给 vue-docgen-api 解析
* - 页面内的 `<api-table />` 按 `name` 查询解析结果
*
* 新增组件时只需在此追加一项,导航、API 表、文档路由会同步生效。
*/
/** 分类展示顺序即数组顺序。 */
export const CATEGORY_LIST = [
"布局组件",
"编辑器组件",
"交互组件",
"AI 对话组件"
] as const;
export type ComponentCategory = (typeof CATEGORY_LIST)[number];
export interface ComponentMeta {
/** 组件名,与组件内 `defineOptions` 的 name 一致,同时是 api-table 的查询键。 */
name: string;
/** 文档与侧边栏展示的中文标题。 */
title: string;
/** 一句话功能描述。 */
description: string;
/** 所属分类。 */
category: ComponentCategory;
/** 组件入口源码,相对仓库根目录。 */
entry: string;
/** 文档路径覆盖,默认由文件名或目录名推导。 */
slug?: string;
}
export const COMPONENT_LIST: ComponentMeta[] = [
// ---------- 布局组件 ----------
{
name: "RootLayout",
title: "RootLayout 根布局",
description: "移动端页面骨架,主体内容之外自动渲染版权与备案信息。",
category: "布局组件",
entry: "src/components/root-layout/index.vue"
},
{
name: "Space",
title: "Space 间距",
description: "控制子元素之间的间距与排列方向,支持分隔符。",
category: "布局组件",
entry: "src/components/space/index.vue"
},
// ---------- 编辑器组件 ----------
{
name: "CodeEditor",
title: "CodeEditor 代码编辑器",
description: "带行号、语法高亮与查找替换的代码输入框。",
category: "编辑器组件",
entry: "src/components/code-editor/index.vue"
},
{
name: "TiptapEditor",
title: "TiptapEditor 富文本编辑器",
description: "基于 Tiptap 的富文本编辑器,工具栏覆盖媒体、表格、链接与代码块。",
category: "编辑器组件",
entry: "src/components/tiptap-editor/index.vue"
},
{
name: "TiptapView",
title: "TiptapView 富文本视图",
description: "Tiptap 内容的只读渲染视图,与编辑器共用同一套样式。",
category: "编辑器组件",
entry: "src/components/tiptap-view/index.vue"
},
{
name: "ArticleEditor",
title: "ArticleEditor 文章编辑器",
description: "以 Article 为数据模型,按正文类型切换纯文本、Markdown 与富文本编辑。",
category: "编辑器组件",
entry: "src/components/article-editor/index.vue"
},
{
name: "ArticleView",
title: "ArticleView 文章视图",
description: "按 Article 的正文类型渲染正文与附件列表。",
category: "编辑器组件",
entry: "src/components/article-view/index.vue"
},
{
name: "NotifyEditor",
title: "NotifyEditor 通知编辑器",
description: "以 NotifyDetail 为数据模型的通知编辑表单,复用文章编辑器能力。",
category: "编辑器组件",
entry: "src/components/notify-editor/index.vue"
},
{
name: "NotifyView",
title: "NotifyView 通知视图",
description: "渲染通知的正文内容。",
category: "编辑器组件",
entry: "src/components/notify-view/index.vue"
},
// ---------- 交互组件 ----------
{
name: "BottomPopup",
title: "BottomPopup 底部弹层",
description: "从底部升起的操作面板,可带标题与左右按钮。",
category: "交互组件",
entry: "src/components/bottom-popup/index.vue"
},
{
name: "DurationPicker",
title: "DurationPicker 时长选择",
description: "按时长值选择并回填,单位与候选项由外部决定。",
category: "交互组件",
entry: "src/components/duration-picker/index.vue"
},
// ---------- AI 对话组件 ----------
{
name: "AiChatPanel",
title: "AiChatPanel 对话面板",
description: "把消息列表与输入框组装成完整对话界面,由业务侧驱动数据。",
category: "AI 对话组件",
entry: "src/components/ai/AiChatPanel.vue"
},
{
name: "AiChatMessageList",
title: "AiChatMessageList 消息列表",
description: "渲染对话消息,含思考过程、引用与流式光标。",
category: "AI 对话组件",
entry: "src/components/ai/AiChatMessageList.vue"
},
{
name: "AiChatComposer",
title: "AiChatComposer 输入框",
description: "对话输入框,按状态在发送、停止与重试之间切换。",
category: "AI 对话组件",
entry: "src/components/ai/AiChatComposer.vue"
},
{
name: "AiChatThinking",
title: "AiChatThinking 思考过程",
description: "折叠展示模型的思考分段,生成中自动展开。",
category: "AI 对话组件",
entry: "src/components/ai/AiChatThinking.vue"
}
];
/**
* 文档路径与路由片段。
*
* `index.vue` 取所在目录名,具名文件取文件名——`ai` 目录下有多个组件,
* 只靠目录名会全部撞成 `ai`。两种写法都会再转成短横线形式。
*/
export function toSlug(meta: ComponentMeta): string {
if (meta.slug) {
return meta.slug;
}
const segments = meta.entry.split("/");
const fileName = segments[segments.length - 1];
const baseName = fileName.replace(/\.\w+$/, "");
const raw = baseName === "index" ? segments[segments.length - 2] : baseName;
return raw.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
}
/** 组件文档页的站内绝对路径。 */
export function toDocLink(meta: ComponentMeta): string {
return `/components/${toSlug(meta)}`;
}
/** 按分类分组,保持 CATEGORY_LIST 的声明顺序。 */
export function groupByCategory(): Array<{ category: ComponentCategory; items: ComponentMeta[] }> {
return CATEGORY_LIST.map(category => ({
category,
items: COMPONENT_LIST.filter(item => item.category === category)
})).filter(group => group.items.length > 0);
}
+130
View File
@@ -0,0 +1,130 @@
import { resolve } from "node:path";
import { defineConfig } from "vitepress";
import {
componentPreview,
containerPreview
} from "@vitepress-demo-preview/plugin";
import AutoImport from "unplugin-auto-import/vite";
import { groupByCategory, toDocLink } from "./components";
import { docgenPlugin } from "./plugins/docgen";
import { timiWebSsrStub } from "./plugins/ssr-stub";
/** `__dirname` 由 VitePress 打包配置时注入,指向 `docs/.vitepress`。 */
const docsDir = resolve(__dirname, "..");
/** 仓库根目录,组件源码位于它的 `src` 下。 */
const repoRoot = resolve(docsDir, "..");
/** timi-web 仓库目录,docs 通过 link 依赖它。 */
const timiWebRoot = resolve(repoRoot, "../timi-web");
/**
* 源码引用别名。
*
* demo 直接引用 `src` 源码而非构建产物,改组件即可热更新文档。
* 组件源码自身的传递依赖(tiptap、prismjs 等)由 timi-web 的 node_modules 解析,
* docs 无需重复声明。
*/
const alias = {
"timi-tdesign-mobile": resolve(repoRoot, "src/index.ts"),
"timi-web/editor": resolve(timiWebRoot, "src/editor/index.ts")
};
/** 组件文档侧边栏,按 components.ts 的分类与顺序生成。 */
const componentSidebar = groupByCategory().map(group => ({
text: group.category,
collapsed: false,
items: group.items.map(item => ({
text: item.title,
link: toDocLink(item)
}))
}));
export default defineConfig({
title: "timi-tdesign-mobile",
description: "移动端 TDesign 组件库的演示与 API 文档",
lang: "zh-CN",
cleanUrls: true,
themeConfig: {
nav: [
{ text: "指南", link: "/guide/" },
{ text: "场景", link: "/scenarios/editor" },
{ text: "组件", link: "/components/" }
],
sidebar: [
{
text: "指南",
items: [
{ text: "快速开始", link: "/guide/" },
{ text: "演示与文档约定", link: "/guide/demo" }
]
},
{
text: "场景示例",
collapsed: false,
items: [
{ text: "编辑器能力总览", link: "/scenarios/editor" },
{ text: "文章编辑与预览", link: "/scenarios/article" },
{ text: "通知编辑与预览", link: "/scenarios/notify" },
{ text: "移动端组件", link: "/scenarios/mobile" },
{ text: "AI 对话", link: "/scenarios/ai" }
]
},
...componentSidebar
],
outline: {
level: [2, 3],
label: "本页目录"
},
search: {
provider: "local"
},
docFooter: {
prev: "上一篇",
next: "下一篇"
},
darkModeSwitchLabel: "主题",
sidebarMenuLabel: "目录",
returnToTopLabel: "回到顶部"
},
markdown: {
theme: {
light: "vitesse-light",
dark: "vitesse-dark"
},
lineNumbers: true,
config(md) {
// 组件依赖浏览器 API(Tiptap、Prism、ResizeObserver、SSE 等),
// 统一开启 clientOnly 交给浏览器渲染,避免 SSR 阶段报错。
md.use(componentPreview, { clientOnly: true, alias });
md.use(containerPreview, { clientOnly: true, alias });
}
},
vite: {
resolve: {
alias
},
plugins: [
// 必须最先执行:SSR 阶段拦截 timi-web,否则整个构建无法通过。
timiWebSsrStub(docsDir),
docgenPlugin(repoRoot),
// src 源码依赖自动导入(内部没有显式 import vue 的文件不在少数),
// 这里仅为其提供支持;docs 自身的代码一律显式导入。
AutoImport({
imports: ["vue"],
dts: false
})
],
server: {
port: 3004,
host: true,
fs: {
// demo 需要读取 docs 之外的 src 与 timi-web 源码。
allow: [repoRoot, timiWebRoot]
}
}
}
});
+160
View File
@@ -0,0 +1,160 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { parse } from "vue-docgen-api";
import type { Plugin } from "vite";
import { COMPONENT_LIST } from "../components";
import {
createEmptyApi,
type ApiEventRow,
type ApiExposeRow,
type ApiPropRow,
type ApiSlotRow,
type ComponentApi,
type ComponentApiMap
} from "../api-types";
/** 虚拟模块的引用标识,业务代码与类型声明共用。 */
export const API_MODULE_ID = "virtual:timi-api";
/** Vite 内部使用的解析后标识,前置 `\0` 避免被其他插件处理。 */
const RESOLVED_ID = `\0${API_MODULE_ID}`;
/**
* 把 vue-docgen-api 的联合类型描述压平成可读文本。
* 不同版本对 `defineEmits` 的产出结构不一致,这里统一兜底。
*/
function normalizeType(type: unknown): string {
if (!type || typeof type !== "object") {
return "-";
}
const candidate = type as { name?: unknown; names?: unknown };
if (Array.isArray(candidate.names)) {
return candidate.names.length > 0 ? candidate.names.join(" | ") : "-";
}
if (typeof candidate.name === "string" && candidate.name.length > 0) {
return candidate.name;
}
return "-";
}
/** 转换 prop 描述符。 */
function toPropRow(descriptor: any): ApiPropRow {
return {
name: String(descriptor.name ?? ""),
type: normalizeType(descriptor.type),
defaultValue: descriptor.defaultValue?.value === undefined ? "-" : String(descriptor.defaultValue.value),
required: descriptor.required === true,
description: String(descriptor.description ?? "")
};
}
/** 转换事件描述符。 */
function toEventRow(descriptor: any): ApiEventRow {
return {
name: String(descriptor.name ?? ""),
type: normalizeType(descriptor.type),
description: String(descriptor.description ?? "")
};
}
/** 转换插槽描述符,把作用域参数并入说明列。 */
function toSlotRow(descriptor: any): ApiSlotRow {
const bindings = Array.isArray(descriptor.bindings)
? descriptor.bindings.map((item: any) => String(item.name ?? "")).filter(Boolean)
: [];
const description = String(descriptor.description ?? "");
const scope = bindings.length > 0 ? `作用域参数:${bindings.join("、")}` : "";
return {
name: String(descriptor.name ?? ""),
description: [description, scope].filter(Boolean).join(";")
};
}
/** 转换暴露描述符。 */
function toExposeRow(descriptor: any): ApiExposeRow {
return {
name: String(descriptor.name ?? ""),
type: normalizeType(descriptor.type),
description: String(descriptor.description ?? "")
};
}
/** 把单个组件文档规范化为纯数据。 */
function toComponentApi(name: string, doc: any): ComponentApi {
return {
name,
description: String(doc?.description ?? ""),
parsed: true,
props: Array.isArray(doc?.props) ? doc.props.map(toPropRow) : [],
events: Array.isArray(doc?.events) ? doc.events.map(toEventRow) : [],
slots: Array.isArray(doc?.slots) ? doc.slots.map(toSlotRow) : [],
exposes: Array.isArray(doc?.expose) ? doc.expose.map(toExposeRow) : []
};
}
/**
* 逐个解析组件源码。
* 单个组件解析失败只降级为空 API,不让整个文档站因一处语法而无法构建。
*/
async function parseAll(repoRoot: string): Promise<ComponentApiMap> {
const result: ComponentApiMap = {};
for (const meta of COMPONENT_LIST) {
const filePath = resolve(repoRoot, meta.entry);
if (!existsSync(filePath)) {
console.warn(`[timi-docgen] 未找到组件入口,已跳过:${meta.entry}`);
result[meta.name] = createEmptyApi(meta.name);
continue;
}
try {
const doc = await parse(filePath);
result[meta.name] = toComponentApi(meta.name, doc);
} catch (error) {
console.warn(`[timi-docgen] 解析 ${meta.name} 失败,已降级为空 API:`, error);
result[meta.name] = createEmptyApi(meta.name);
}
}
return result;
}
/**
* 提供 `virtual:timi-api` 虚拟模块,内容为全部组件的 API 数据。
*
* 解析结果按需缓存,仅在组件源码变更时失效,避免每次页面请求都重复解析。
* @param repoRoot 仓库根目录的绝对路径
*/
export function docgenPlugin(repoRoot: string): Plugin {
const entryPathSet = new Set(COMPONENT_LIST.map(meta => resolve(repoRoot, meta.entry)));
let cache: ComponentApiMap | null = null;
return {
name: "timi-docgen",
resolveId(id: string) {
return id === API_MODULE_ID ? RESOLVED_ID : null;
},
async load(id: string) {
if (id !== RESOLVED_ID) {
return null;
}
if (cache === null) {
cache = await parseAll(repoRoot);
}
return `export default ${JSON.stringify(cache)};`;
},
handleHotUpdate({ file, server }) {
if (!entryPathSet.has(file)) {
return;
}
cache = null;
const invalidated = server.moduleGraph.getModuleById(RESOLVED_ID);
if (invalidated) {
server.moduleGraph.invalidateModule(invalidated);
server.ws.send({ type: "full-reload" });
}
}
};
}
+133
View File
@@ -0,0 +1,133 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { Plugin } from "vite";
/** 虚拟 stub 模块标识。 */
const STUB_ID = "\0timi-web-ssr-stub";
/** 被 stub 的模块说明符。 */
const STUBBED_MODULES = new Set(["timi-web", "timi-web/editor"]);
/**
* SSR 阶段必须存在的具名导出。
*
* 取自 `src` 与 `docs` 中所有 `timi-web` / `timi-web/editor` 值导入的并集,
* 是 stub 的保底名单;若构建产物可读,`collectExportNames` 会在此基础上补全全部导出。
*/
const REQUIRED_EXPORTS = [
// ---------- timi-web ----------
"VPopup",
"Popup",
"PopupType",
"Copyright",
"MarkdownView",
"MarkdownEditor",
"Toolkit",
"WebToolkit",
// ---------- timi-web/editor ----------
"findTextMatches",
"replaceAllText",
"replaceMatchText",
"buildSearchRegex",
"highlightCode",
"resolveCodeEditorKeydown",
"resolvePrismLanguage",
"useTiptapEditor",
"useTiptapView",
"headingMeta",
"ATTACHMENT_ID_ATTR",
"TEMP_FILE_ID_ATTR",
"LOCAL_MEDIA_ID_PREFIX",
"isLocalMediaId",
"normalizeContent",
"supportedCodeLanguages",
"TABLE_PICKER_SIZE"
];
/** 从构建产物中提取具名导出,让 stub 自动跟上上游变化。 */
function collectExportNames(distFile: string): Set<string> {
const names = new Set<string>(REQUIRED_EXPORTS);
if (!existsSync(distFile)) {
return names;
}
try {
const source = readFileSync(distFile, "utf-8");
for (const match of source.matchAll(/export\s*\{([^}]*)\}/g)) {
for (const part of match[1].split(",")) {
const bits = part.trim().split(/\s+as\s+/);
const name = (bits[1] ?? bits[0]).trim();
if (/^[A-Za-z_$][\w$]*$/.test(name)) {
names.add(name);
}
}
}
} catch {
// 读取失败时退回保底名单,不影响构建。
}
return names;
}
/** 生成 stub 模块源码:每个导出都是可调用、可解构、可当插件安装的空实现。 */
function buildStubSource(names: Set<string>): string {
const named = [...names]
.filter(name => name !== "default")
.map(name => `export const ${name} = stub;`)
.join("\n");
return `const stub = new Proxy(function () {}, {
get(target, key) {
if (key === Symbol.toPrimitive || key === "valueOf" || key === "toString") {
return () => "";
}
return stub;
},
apply() {
return stub;
},
construct() {
return {};
}
});
export default stub;
${named}
`;
}
/**
* 在 SSR 阶段把 timi-web 替换为空实现。
*
* timi-web 的构建产物在模块顶层就访问 `window` 并启动 Prism 自动高亮定时器,
* 在 Node 中 import 会直接抛错,导致 VitePress 的 SSR 构建必然失败。
*
* 文档站里真正需要 timi-web 的只有演示组件,它们全部通过 `clientOnly` 延迟到浏览器渲染,
* SSR 阶段只需要「能 import 且不报错」,因此这里返回一个 Proxy 空实现。
* 客户端构建不受影响,`options.ssr` 为 false 时照常解析到真实模块。
* @param docsDir docs 目录绝对路径,用于定位 timi-web 构建产物
*/
export function timiWebSsrStub(docsDir: string): Plugin {
const distFile = resolve(docsDir, "node_modules/timi-web/dist/timi-web.mjs");
let stubSource: string | null = null;
return {
name: "timi-web-ssr-stub",
enforce: "pre",
resolveId(source, importer, options) {
// 只拦截精确的包名,`timi-web/style.css` 等子路径照常交给 Vite 处理。
if (options?.ssr && STUBBED_MODULES.has(source)) {
return STUB_ID;
}
return null;
},
load(id) {
if (id !== STUB_ID) {
return null;
}
if (stubSource === null) {
stubSource = buildStubSource(collectExportNames(distFile));
}
return stubSource;
}
};
}
@@ -0,0 +1,162 @@
<template>
<div v-if="api" class="tui-api">
<section v-if="api.props.length > 0">
<h3>Props</h3>
<table>
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>默认值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in api.props" :key="row.name">
<td class="name">
<code v-text="row.name" />
<span v-if="row.required" class="required" title="必填">*</span>
</td>
<td><code v-text="row.type" /></td>
<td><code v-text="row.defaultValue" /></td>
<td v-text="row.description" />
</tr>
</tbody>
</table>
</section>
<section v-if="api.events.length > 0">
<h3>Events</h3>
<table>
<thead>
<tr>
<th>名称</th>
<th>参数</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in api.events" :key="row.name">
<td><code v-text="row.name" /></td>
<td><code v-text="row.type" /></td>
<td v-text="row.description" />
</tr>
</tbody>
</table>
</section>
<section v-if="api.slots.length > 0">
<h3>Slots</h3>
<table>
<thead>
<tr>
<th>名称</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in api.slots" :key="row.name">
<td><code v-text="row.name" /></td>
<td v-text="row.description" />
</tr>
</tbody>
</table>
</section>
<section v-if="api.exposes.length > 0">
<h3>Expose</h3>
<table>
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in api.exposes" :key="row.name">
<td><code v-text="row.name" /></td>
<td><code v-text="row.type" /></td>
<td v-text="row.description" />
</tr>
</tbody>
</table>
</section>
<p v-if="isEmpty && !api.parsed" class="notice warn">
未能从源码中解析出 API 信息,请检查组件是否使用了 defineProps / defineEmits / defineExpose。
</p>
<p v-else-if="isEmpty" class="notice">
该组件不需要任何 props、events、slots 或实例方法。
</p>
</div>
<p v-else class="notice warn" v-text="`未找到组件 ${name} 的 API 数据,请检查 components.ts 中的 name 与 entry 是否正确`" />
</template>
<script lang="ts" setup>
import { computed } from "vue";
import apiMap from "virtual:timi-api";
/**
* 组件 API 表格。
*
* 数据来自构建期 `vue-docgen-api` 对组件源码的解析结果,
* 无需在 markdown 中手写 props / events / slots 表格。
* 某个分区没有内容时整段不渲染,避免出现空表。
*/
const props = defineProps<{
/** 组件名,需与 components.ts 中的 name 一致。 */
name: string;
}>();
const api = computed(() => apiMap[props.name]);
const isEmpty = computed(() => {
if (!api.value) {
return false;
}
const { props: propRows, events, slots, exposes } = api.value;
return propRows.length === 0 && events.length === 0 && slots.length === 0 && exposes.length === 0;
});
</script>
<style lang="less" scoped>
.tui-api {
section {
margin-bottom: 1.5rem;
&:last-child {
margin-bottom: 0;
}
}
h3 {
margin: 1.5rem 0 .75rem;
font-size: 1rem;
}
.name {
white-space: nowrap;
}
.required {
margin-left: .125rem;
color: var(--vp-c-danger-1);
}
.notice {
margin: 1rem 0;
padding: .75rem 1rem;
font-size: .875rem;
border-radius: .5rem;
color: var(--vp-c-text-2);
background: var(--vp-c-bg-soft);
&.warn {
color: var(--vp-c-warning-1);
background: var(--vp-c-warning-soft);
}
}
}
</style>
@@ -0,0 +1,207 @@
<template>
<div class="tui-demo">
<section class="stage">
<div class="viewport">
<slot />
</div>
</section>
<section class="meta">
<div class="info">
<p v-if="title" class="title" v-text="title" />
<p v-if="description" class="desc" v-text="description" />
</div>
<div class="actions">
<button
class="action"
type="button"
@click="handleCopy"
v-text="copied ? '已复制' : '复制代码'"
/>
<button
class="action"
type="button"
@click="toggleExpanded"
v-text="expanded ? '收起代码' : '展开代码'"
/>
</div>
</section>
<section v-show="expanded" class="source">
<div class="language-vue" v-html="sourceHtml" />
</section>
</div>
</template>
<script lang="ts" setup>
import { computed, onUnmounted, ref } from "vue";
/**
* 预览容器。
*
* props 契约由 `@vitepress-demo-preview/plugin` 决定,它把 markdown 里的
* `:::preview` 容器编译成 `<demo-preview>` 标签并传入下列属性:
* - `code`:`encodeURI` 处理过的组件源码
* - `showCode`:`encodeURIComponent` 处理过的高亮 HTML
*
* 这里刻意不依赖官方容器,也不引入额外 UI 库,只用原生元素配合 VitePress 的
* CSS 变量,深色模式随之自动适配。
*
* 演示统一放在手机宽度的视口里:这些是移动端组件,按桌面宽度铺开会失去真实形态。
* 需要调整宽度时改 `--tui-demo-viewport` 即可。
*/
const props = withDefaults(defineProps<{
/** 源码原文,经 encodeURI 编码。 */
code?: string;
/** 高亮后的源码 HTML,经 encodeURIComponent 编码。 */
showCode?: string;
/** 演示标题,来自 `:::preview` 后方第一段文字。 */
title?: string;
/** 演示描述,来自 `:::preview` 中 `||` 之后的内容。 */
description?: string;
}>(), {
code: "",
showCode: "",
title: "",
description: ""
});
/** 源码区是否展开,默认收起以保持文档阅读节奏。 */
const expanded = ref(false);
/** 复制成功后的短暂反馈。 */
const copied = ref(false);
let copiedTimer: ReturnType<typeof setTimeout> | null = null;
const sourceHtml = computed(() => decodeURIComponent(props.showCode));
const sourceText = computed(() => decodeURIComponent(props.code));
/** 复制源码到剪贴板,clipboard 不可用时保持原按钮文案。 */
async function handleCopy() {
try {
await navigator.clipboard.writeText(sourceText.value);
} catch {
return;
}
copied.value = true;
if (copiedTimer !== null) {
clearTimeout(copiedTimer);
}
copiedTimer = setTimeout(() => {
copied.value = false;
copiedTimer = null;
}, 1500);
}
function toggleExpanded() {
expanded.value = !expanded.value;
}
onUnmounted(() => {
if (copiedTimer !== null) {
clearTimeout(copiedTimer);
}
});
</script>
<style lang="less" scoped>
.tui-demo {
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: .75rem;
background: var(--vp-c-bg);
.stage {
display: flex;
padding: 1.5rem;
justify-content: center;
background: var(--vp-c-bg-soft);
}
// 手机视口:移动组件在桌面宽度下会被拉伸,这里统一收到移动端宽度
.viewport {
width: 100%;
max-width: var(--tui-demo-viewport, 26rem);
padding: 1rem;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: .75rem;
background: var(--vp-c-bg);
}
.meta {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
padding: .75rem 1.5rem;
border-top: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
}
.info {
min-width: 0;
}
.title {
margin: 0;
font-size: .875rem;
font-weight: 600;
color: var(--vp-c-text-1);
}
.desc {
margin: .25rem 0 0;
font-size: .75rem;
line-height: 1.5;
color: var(--vp-c-text-2);
}
.actions {
display: flex;
flex: none;
gap: .25rem;
}
.action {
padding: .25rem .5rem;
font-size: .75rem;
cursor: pointer;
border: none;
border-radius: .25rem;
color: var(--vp-c-text-2);
background: transparent;
&:hover {
color: var(--vp-c-brand-1);
background: var(--vp-c-default-soft);
}
}
.source {
border-top: 1px solid var(--vp-c-divider);
:deep(div[class*="language-"]) {
margin: 0;
border-radius: 0;
}
}
}
@media (max-width: 48rem) {
.tui-demo {
.stage {
padding: .75rem;
}
.viewport {
padding: .5rem;
}
.meta {
align-items: flex-start;
flex-direction: column;
padding: .75rem 1rem;
}
}
}
</style>
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vitepress/client" />
/** 构建期由 vue-docgen-api 解析生成的组件 API 数据。 */
declare module "virtual:timi-api" {
import type { ComponentApiMap } from "../api-types";
const apiMap: ComponentApiMap;
export default apiMap;
}
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
+48
View File
@@ -0,0 +1,48 @@
import DefaultTheme from "vitepress/theme";
import { watch } from "vue";
import { useData } from "vitepress";
import type { App } from "vue";
import TDesignMobile from "tdesign-mobile-vue";
import TimiWebUI, { VPopup } from "timi-web";
// 必须引完整样式:`tdesign-mobile-vue/es/style/index.css` 只含全局 token,
// 不含任何组件类,全量注册时会导致所有组件无样式渲染。
import "tdesign-mobile-vue/dist/tdesign.css";
import "./styles/index.less";
import DemoPreview from "./containers/DemoPreview.vue";
import ApiTable from "./components/ApiTable.vue";
/**
* 同步深色模式。
*
* VitePress 通过 `html.dark` 切换深色,而 TDesign 依赖 `html[theme-mode="dark"]`,
* 两者不是同一套机制,不同步会导致深色模式下 TDesign 组件仍是亮色。
*/
function syncThemeMode(dark: boolean) {
if (typeof document === "undefined") {
return;
}
document.documentElement.setAttribute("theme-mode", dark ? "dark" : "light");
}
export default {
extends: DefaultTheme,
enhanceApp({ app }: { app: App }) {
// 全量注册 TDesign Mobile,演示区内所有 t-* 组件直接可用。
app.use(TDesignMobile);
// 组件内部大量使用 timi-web 的 v-popup 指令,且用在组件模板里,
// 无法由演示局部注入,只能在主题里全局注册。
app.use(TimiWebUI);
app.directive("popup", VPopup);
app.component("demo-preview", DemoPreview);
app.component("api-table", ApiTable);
},
setup() {
const { isDark } = useData();
watch(isDark, syncThemeMode, { immediate: true });
}
};
+18
View File
@@ -0,0 +1,18 @@
/**
* 文档站全局样式。
*
* 只处理 VitePress 正文与演示容器、API 表格的交界处;
* 组件自身的样式一律各自 scoped,避免相互污染。
*/
.vp-doc {
// 演示容器与正文的呼吸感
.tui-demo {
margin: 1rem 0;
}
// API 表格里的类型与默认值不换行,避免长签名把列挤变形
table code {
white-space: nowrap;
}
}