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;
}
}
+41
View File
@@ -0,0 +1,41 @@
# AiChatComposer 输入框
对话输入框,按状态在发送、停止与重试之间切换。
## 基础用法
:::preview 基础用法 || 发送后进入生成态,2 秒后自动恢复,可观察按钮的三种形态
demo-preview=../demos/ai-chat-composer/basic.vue
:::
## 何时使用
需要单独摆放输入框时使用。多数情况下直接用 [AiChatPanel](/components/ai-chat-panel) 更省事。
## 三种按钮形态
| 状态 | 按钮 |
| --- | --- |
| 默认 | 发送(`sendText`) |
| `isGenerating` 为真 | 停止(`stopText`,危险色) |
| 有 `lastError` 且 `canRetry` | 错误条 + 重试(`retryText`) |
生成中或 `disabled` 为真时输入框不可编辑,`send` 事件也不会抛出。
## 事件
- `send`:抛出**去除首尾空白后**的内容,抛完自动清空输入框;空内容不抛
- `stop`:点停止按钮
- `retry`:点错误条上的重试
输入框内容用 `v-model` 绑定,`maxLength` 默认 2000。
## 自定义插槽
`send`、`stop`、`error` 三个插槽可以替换对应区域的渲染,都带作用域参数(`disabled`、`onSend`、`onStop`、`error`、`canRetry`)。
## API
<api-table name="AiChatComposer" />
+42
View File
@@ -0,0 +1,42 @@
# AiChatMessageList 消息列表
渲染对话消息,含思考过程、引用与流式光标。
## 基础用法
:::preview 基础用法 || 消息里的 thinkingList 与 citationList 会渲染为思考过程与引用
demo-preview=../demos/ai-chat-message-list/basic.vue
:::
## 何时使用
需要单独控制消息区域的布局时使用。多数情况下直接用 [AiChatPanel](/components/ai-chat-panel) 更省事。
## 消息结构
每条消息是 `AiChatMessage`:
| 字段 | 说明 |
| --- | --- |
| `id` | 唯一标识。 |
| `role` | `"user"` 或 `"assistant"`。 |
| `content` | 正文,按 Markdown 渲染。 |
| `thinkingList` | 思考分段,传给 `AiChatThinking` 展示。 |
| `citationList` | 引用信息。 |
| `createdAt` | 生成时间戳。 |
| `statusText` | 状态文案,如工具调用的当前步骤。 |
| `duration` | 本次生成耗时(毫秒)。 |
## 角色文案
`roleNames` 覆盖默认的「助手 / 我」,需要更复杂的逻辑时用 `roleNameFormatter`。
## 插槽
`welcome`、`message`、`role-name`、`thinking`、`citation`、`message-status`、`assistant-icon`、`user-icon` 可以替换对应区域,都带作用域参数。
## API
<api-table name="AiChatMessageList" />
+70
View File
@@ -0,0 +1,70 @@
# AiChatPanel 对话面板
把消息列表与输入框组装成完整对话界面,由业务侧驱动数据。
## 基础用法
:::preview 基础用法 || 回答由本地假流逐块吐出,可随时点「停止」
demo-preview=../demos/ai-chat-panel/basic.vue
:::
## 何时使用
需要一个开箱即用的对话界面时使用。面板本身是**纯展示组件**——它不发起请求、不维护会话,全部状态由外部传入。
## 与 useAiChat 接线
库提供了 `useAiChat` 组合式函数管理会话与流式消息,把它的返回值直接摊给面板即可:
```vue
<script lang="ts" setup>
import { AiChatPanel, useAiChat } from "timi-tdesign-mobile";
const {
messages, isGenerating, activeToolStatus, lastError, canRetry,
sendMessage, stopGeneration, retryLastMessage
} = useAiChat({
request: ({ message, signal }) => fetchReply(message, signal)
});
</script>
<template>
<ai-chat-panel
:messages="messages"
:is-generating="isGenerating"
:active-tool-status="activeToolStatus"
:last-error="lastError"
:can-retry="canRetry"
@send="sendMessage"
@stop="stopGeneration"
@retry="retryLastMessage"
/>
</template>
```
`request` 需要返回一个 SSE 流式的 `Response`,其余能力(历史会话、错误文案、上下文切换)都通过 `useAiChat` 的可选配置注入,组件侧不用改。
## SSE 事件协议
`useAiChat` 内置了一套事件协议,`event:` 后跟事件名、`data:` 后跟 JSON 或纯文本:
| 事件名(含别名) | 作用 | 载荷取值字段 |
| --- | --- | --- |
| `thinking` / `think` / `agent_thought` | 思考过程分段 | `thought`、`thinking`、`reasoning`、`observation`、`text`、`content` |
| `answer` / `message` / `text` | 回答正文增量 | `answer`、`text`、`content` |
| `tool-status` / `tool_status` | 工具调用状态 | `text`、`message`、`statusText`、`status` |
| `citation` / `source` | 引用信息 | `source`、`dataDomain`、`timeRange`、`caliber`、`generatedAt` |
| `done` / `finish` / `completed` | 生成结束 | `conversationId`、`duration` |
| `error` | 流内错误 | `message`、`code` |
载荷是纯文本时会直接当作内容使用。协议不匹配时可以用 `parseEvent` 自行转换。
## 自定义
`welcome`、`composer`、`tool-status`、`message` 等插槽可以替换对应区域。`composer` 插槽会拿到 `draft`、`on-send`、`on-stop`、`on-retry` 等,便于接入自定义输入框。
## API
<api-table name="AiChatPanel" />
+29
View File
@@ -0,0 +1,29 @@
# AiChatThinking 思考过程
折叠展示模型的思考分段,生成中自动展开。
## 基础用法
:::preview 基础用法 || 逐段追加内容,生成中会自动滚到底部
demo-preview=../demos/ai-chat-thinking/basic.vue
:::
## 何时使用
需要把模型的推理过程单独呈现时使用,通常由 [AiChatMessageList](/components/ai-chat-message-list) 内部调用,也可以单独摆放。
## 行为
- `isGenerating` 为真时展开并跟随滚动到底部,结束后收起
- 内容受 `max-height` 限制,超出时内部滚动
- 容器带 `role="log"` 与 `aria-live="polite"`,屏幕阅读器会播报增量
## 与消息的关系
消息的 `thinkingList` 是一组分段(`{ id, content }`),[AiChatMessageList](/components/ai-chat-message-list) 会把它们拼成完整文本后传进来。
## API
<api-table name="AiChatThinking" />
+41
View File
@@ -0,0 +1,41 @@
# ArticleEditor 文章编辑器
以 `Article` 为数据模型,按正文类型切换纯文本、Markdown 与富文本三种编辑形态。
## 基础用法
:::preview 基础用法 || 切换正文类型会同时替换内容,提交时读取完整文章
demo-preview=../demos/article-editor/basic.vue
:::
## 何时使用
需要在一套表单里同时支持多种正文格式时使用。切换只改 `contentType`,上层不需要关心具体编辑器。
## 数据模型
组件只约束三个字段,其他字段会原样保留:
| 字段 | 说明 |
| --- | --- |
| `contentType` | 正文类型,支持 `TEXT`、`MARKDOWN`、`TIP_TAP`(`TIPTAP` 为兼容写法)。 |
| `content` | 正文内容,随类型分别是纯文本、Markdown 源码或 HTML。 |
| `attachmentList` | 附件列表,元素至少包含 `id`、`name`、`url`。 |
## 附件与媒体
`uploadImage`、`uploadVideo` 负责上传,`resolveAttachmentUrl`、`resolveTempUrl` 负责把附件标识换成可访问地址。四个回调都可选,不传时对应能力自动降级。
## 提交内容
编辑过程中抛出的是实时值,提交时建议调用 `submit()`,它会等待富文本里未完成的上传再返回最终数据:
```ts
const article = await editorRef.value?.submit();
```
## API
<api-table name="ArticleEditor" />
+29
View File
@@ -0,0 +1,29 @@
# ArticleView 文章视图
按 `Article` 的正文类型渲染正文内容。
## 基础用法
:::preview 基础用法 || 把 contentType 换成 TEXT 或 MARKDOWN 会切换渲染分支
demo-preview=../demos/article-view/basic.vue
:::
## 何时使用
需要在详情页只读展示文章正文时使用,与 `ArticleEditor` 共用同一套数据类型。
## 渲染分支
| contentType | 渲染方式 |
| --- | --- |
| `TEXT` | 只读文本域。 |
| `MARKDOWN` | 由 timi-web 的 MarkdownView 渲染。 |
| `TIP_TAP` | 由 [TiptapView](/components/tiptap-view) 渲染。 |
内容为空时统一显示 `emptyText`。
## API
<api-table name="ArticleView" />
+37
View File
@@ -0,0 +1,37 @@
# BottomPopup 底部弹层
从底部升起的操作面板,可带标题与左右按钮。
## 基础用法
:::preview 基础用法 || 弹层从视口底部升起,标题栏在内容滚动时吸顶
demo-preview=../demos/bottom-popup/basic.vue
:::
## 何时使用
需要在移动端承载一组操作或一段表单时使用。它基于 TDesign Mobile 的 `Popup`,因此遮罩、层级与动画都沿用框架行为。
## 按钮只抛事件
左右按钮**不会自动关闭弹层**,只抛出 `leftButtonClick` / `rightButtonClick`,关闭与否由业务决定:
```vue
<bottom-popup
v-model="visible"
title="选择操作"
@right-button-click="handleConfirm"
/>
```
按钮文案传空字符串即可隐藏对应按钮;也可以分别用 `leftButton` / `rightButton` 插槽完全接管,插槽会收到 `onClick`。
## 高度
`height` 默认 `"auto"`,按内容撑开;传具体值(如 `"60vh"`)时内容区滚动、标题栏吸顶。
## API
<api-table name="BottomPopup" />
+36
View File
@@ -0,0 +1,36 @@
# CodeEditor 代码编辑器
带行号、语法高亮与查找替换的代码输入框。
## 基础用法
:::preview 基础用法 || 通过 v-model 绑定内容,实例暴露 openSearch 等方法
demo-preview=../demos/code-editor/basic.vue
:::
## 何时使用
需要让用户输入或查看结构化代码时使用。它不做代码校验,只负责输入、高亮与检索。
## 内容绑定
`modelValue` 与 `value` 都可作为输入源,`value` 优先级更高。输入时同步抛出 `update:modelValue` 与 `change`。
## 实例方法
通过模板引用调用暴露的方法,例如打开查找栏:
```ts
import type { CodeEditorExpose } from "timi-tdesign-mobile";
const editorRef = ref<CodeEditorExpose>();
editorRef.value?.openSearch(true);
```
移动端受限于宽度,代码行会横向滚动而不是折行。
## API
<api-table name="CodeEditor" />
+42
View File
@@ -0,0 +1,42 @@
# DurationPicker 时长选择
按时长值选择并回填,单位与候选项由组件内部推导。
## 基础用法
:::preview 基础用法 || 选择后回填的是毫秒数
demo-preview=../demos/duration-picker/basic.vue
:::
## 何时使用
需要让用户选择一个时间跨度(缓存有效期、超时时长等)时使用。对外只暴露毫秒值,单位换算是内部细节。
## 取值与单位
`modelValue` 是**毫秒数**。组件内置了年 / 天 / 小时 / 分钟 / 秒 / 毫秒六个单位,各自带有取值范围与步长:
| 单位 | 范围 | 步长 |
| --- | --- | --- |
| 年 | 1 - 100 | 1 |
| 天 | 1 - 364 | 1 |
| 小时 | 1 - 23 | 1 |
| 分钟 | 1 - 59 | 1 |
| 秒 | 1 - 59 | 1 |
| 毫秒 | 50 - 950 | 50 |
默认值为 1000 毫秒(1 秒)。
## 两个事件
`update:modelValue` 在滚动选择时触发,`change` 在确认选择时触发:
```vue
<duration-picker v-model="duration" @change="onConfirm" />
```
## API
<api-table name="DurationPicker" />
+38
View File
@@ -0,0 +1,38 @@
# 组件总览
共 15 个组件,分为布局、编辑器、交互与 AI 对话四类。
## 布局组件
| 组件 | 说明 |
| --- | --- |
| [RootLayout](/components/root-layout) | 移动端页面骨架,主体内容之外自动渲染版权与备案信息。 |
| [Space](/components/space) | 控制子元素之间的间距与排列方向,支持分隔符。 |
## 编辑器组件
| 组件 | 说明 |
| --- | --- |
| [CodeEditor](/components/code-editor) | 带行号、语法高亮与查找替换的代码输入框。 |
| [TiptapEditor](/components/tiptap-editor) | 基于 Tiptap 的富文本编辑器,工具栏覆盖媒体、表格、链接与代码块。 |
| [TiptapView](/components/tiptap-view) | Tiptap 内容的只读渲染视图,与编辑器共用同一套样式。 |
| [ArticleEditor](/components/article-editor) | 以 Article 为数据模型,按正文类型切换纯文本、Markdown 与富文本编辑。 |
| [ArticleView](/components/article-view) | 按 Article 的正文类型渲染正文与附件列表。 |
| [NotifyEditor](/components/notify-editor) | 以 NotifyDetail 为数据模型的通知编辑表单,复用文章编辑器能力。 |
| [NotifyView](/components/notify-view) | 渲染通知的正文内容。 |
## 交互组件
| 组件 | 说明 |
| --- | --- |
| [BottomPopup](/components/bottom-popup) | 从底部升起的操作面板,可带标题与左右按钮。 |
| [DurationPicker](/components/duration-picker) | 按时长值选择并回填,单位与候选项由组件内部推导。 |
## AI 对话组件
| 组件 | 说明 |
| --- | --- |
| [AiChatPanel](/components/ai-chat-panel) | 把消息列表与输入框组装成完整对话界面,由业务侧驱动数据。 |
| [AiChatMessageList](/components/ai-chat-message-list) | 渲染对话消息,含思考过程、引用与流式光标。 |
| [AiChatComposer](/components/ai-chat-composer) | 对话输入框,按状态在发送、停止与重试之间切换。 |
| [AiChatThinking](/components/ai-chat-thinking) | 折叠展示模型的思考分段,生成中自动展开。 |
+37
View File
@@ -0,0 +1,37 @@
# NotifyEditor 通知编辑器
以 `NotifyDetail` 为数据模型的通知编辑表单,正文部分复用文章编辑器。
## 基础用法
:::preview 基础用法 || 通知复用文章编辑器,同时保留主题等自有字段
demo-preview=../demos/notify-editor/basic.vue
:::
## 何时使用
需要编辑系统通知、站内信等内容时使用。通知与文章的差别只在元数据,正文能力完全一致。
## 数据模型
`NotifyDetail` 只约束正文相关字段,主题、状态等业务字段由外层维护并原样保留:
| 字段 | 说明 |
| --- | --- |
| `content` | 正文内容。 |
| `contentType` | 正文类型,支持 `TEXT`、`TIP_TAP`。 |
| `attachmentList` | 附件列表。 |
## 提交内容
```ts
const detail = await editorRef.value?.submit();
```
与文章编辑器一致,`submit()` 会等待未完成的媒体上传。
## API
<api-table name="NotifyEditor" />
+19
View File
@@ -0,0 +1,19 @@
# NotifyView 通知视图
渲染通知的正文内容,是 `ArticleView` 在通知场景下的薄封装。
## 基础用法
:::preview 基础用法 || 传入 NotifyDetail 即可渲染正文
demo-preview=../demos/notify-view/basic.vue
:::
## 何时使用
需要在通知详情页只读展示正文时使用。主题、状态等元数据由业务页面自行呈现,组件只负责正文。
## API
<api-table name="NotifyView" />
+23
View File
@@ -0,0 +1,23 @@
# RootLayout 根布局
移动端页面骨架,主体内容之外自动渲染版权与备案信息。
## 基础用法
:::preview 基础用法 || 插槽填充主体内容,底部版权区在窄屏下自适应
demo-preview=../demos/root-layout/basic.vue
:::
## 何时使用
需要一套带页脚版权信息的整页容器时使用。它只负责骨架与页脚,不含导航栏,业务可以自行组合。
## 版权区
底部由 timi-web 的 `Copyright` 组件渲染,`icp` 会链接到工信部网站,`domain` 与 `author` 同时传入时才渲染版权行。
## API
<api-table name="RootLayout" />
+33
View File
@@ -0,0 +1,33 @@
# Space 间距
控制子元素之间的间距与排列方向,支持分隔符。
## 基础用法
:::preview 基础用法 || 尺寸可用预设名或具体数值,数组形式分别指定横向与纵向
demo-preview=../demos/space/basic.vue
:::
## 何时使用
需要给一组元素之间加统一间距时使用。相比手写 `margin`,它把间距收敛到一个属性上,调整时不必逐个改样式。
## 尺寸
`size` 接受三种写法:
| 写法 | 说明 |
| --- | --- |
| `"small"` / `"medium"` / `"large"` | 预设值,分别为 8 / 16 / 24 像素 |
| 数字或带单位的字符串 | 自定义间距,如 `12`、`"1.5rem"` |
| 数组 | 依次表示横向与纵向间距 |
## 分隔符
`separator` 或 `separator` 插槽会在相邻元素之间插入内容,注意**末尾元素之后不会插入**。
## API
<api-table name="Space" />
+37
View File
@@ -0,0 +1,37 @@
# TiptapEditor 富文本编辑器
基于 Tiptap 的富文本编辑器,工具栏覆盖媒体、表格、链接与代码块。
## 基础用法
:::preview 基础用法 || 工具栏按移动端宽度自动换行排列
demo-preview=../demos/tiptap-editor/basic.vue
:::
## 何时使用
需要所见即所得的正文编辑时使用。内容以 HTML 字符串双向绑定,可直接入库。
## 媒体上传
图片与视频通过 `uploadImage`、`uploadVideo` 回调上传,组件不绑定具体接口。拖拽或粘贴文件时会先插入占位内容,上传成功后替换为真实地址。
未完成的媒体会在提交时统一处理,`getPendingMediaCount()` 可查询待处理数量。
## 提交内容
编辑过程中的 `v-model` 是实时值,提交时建议调用 `submit()` 以等待未完成的上传并归一化内容:
```ts
const html = await editorRef.value?.submit();
```
## 移动端差异
工具栏按钮在窄屏下换行排列而不是收进折叠菜单,`v-popup` 提示在触屏下不可用,按钮已带文字或图标语义。
## API
<api-table name="TiptapEditor" />
+23
View File
@@ -0,0 +1,23 @@
# TiptapView 富文本视图
Tiptap 内容的只读渲染视图,与编辑器共用同一套样式与扩展。
## 基础用法
:::preview 基础用法 || 超出 maxHeight 时可滚动查看
demo-preview=../demos/tiptap-view/basic.vue
:::
## 何时使用
需要在详情页、审批流等只读场景渲染富文本时使用。相比直接 `v-html`,它复用了编辑器的扩展与样式,展示与编辑效果一致。
## 空内容
内容为空或规范化后为空字符串时,渲染 `emptyText` 占位文案而不是空白区域。
## API
<api-table name="TiptapView" />
+91
View File
@@ -0,0 +1,91 @@
<template>
<div class="demo">
<ai-chat-composer
v-model="draft"
:is-generating="generating"
:last-error="error"
:can-retry="Boolean(error)"
@send="onSend"
@stop="onStop"
@retry="onRetry"
/>
<div class="bar">
<button type="button" class="btn" @click="error = '网络中断,请重试'" v-text="'制造一个错误'" />
</div>
<ol class="log">
<li v-for="(item, index) in log" :key="index" v-text="item" />
</ol>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { AiChatComposer } from "timi-tdesign-mobile";
const draft = ref("");
const generating = ref(false);
const error = ref("");
const log = ref<string[]>([]);
function push(message: string) {
log.value = [`${new Date().toLocaleTimeString()} ${message}`, ...log.value].slice(0, 5);
}
/** 发送后进入生成态,2 秒后自动恢复,便于观察按钮的三种形态。 */
function onSend(message: string) {
error.value = "";
generating.value = true;
push(`send("${message}")`);
setTimeout(() => {
generating.value = false;
push("生成结束");
}, 2000);
}
function onStop() {
generating.value = false;
push("stop()");
}
function onRetry() {
error.value = "";
push("retry()");
}
</script>
<style lang="less" scoped>
.demo {
.bar {
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&:hover {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
}
.log {
margin: .75rem 0 0;
padding-left: 1.25rem;
font-size: .75rem;
font-family: monospace;
color: var(--vp-c-text-2);
li {
margin-bottom: .25rem;
}
}
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div class="demo">
<div class="phone">
<ai-chat-message-list :messages="messages" :is-generating="false" />
</div>
<p class="note" v-text="'消息里的 thinkingList 与 citationList 会渲染为思考过程与引用。'" />
</div>
</template>
<script lang="ts" setup>
import { AiChatMessageList } from "timi-tdesign-mobile";
import { createMessage } from "../ai/mock-sse";
const messages = [
createMessage(1, "user", "上个月订单量怎么样?"),
createMessage(2, "assistant", "近 30 天订单量**环比上升 12%**,其中移动端贡献了主要增量。", {
thinkingList: [
{ id: 1, content: "先确认时间范围是自然月还是滚动 30 天。" },
{ id: 2, content: "再按端类型拆分,确认增量来源。" }
],
citationList: [
{ source: "分析中心", dataDomain: "订单域", timeRange: "近 30 天", caliber: "支付口径" }
]
})
];
</script>
<style lang="less" scoped>
.demo {
.phone {
height: 26rem;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.note {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<template>
<div class="demo">
<div class="phone">
<ai-chat-panel
:messages="messages"
:is-generating="isGenerating"
:active-tool-status="activeToolStatus"
:last-error="lastError"
:can-retry="canRetry"
@send="sendMessage"
@stop="stopGeneration"
@retry="retryLastMessage"
/>
</div>
<p class="note" v-text="'输入任意问题:回答由本地假流逐块吐出,可随时点「停止」。'" />
</div>
</template>
<script lang="ts" setup>
import { AiChatPanel, useAiChat } from "timi-tdesign-mobile";
import { createMockReply } from "../ai/mock-sse";
/**
* 面板本身是纯展示组件,会话状态全部由 `useAiChat` 提供。
* 真实项目里把 `request` 换成调后端 SSE 接口即可,组件侧不用改。
*/
const {
messages,
isGenerating,
activeToolStatus,
lastError,
canRetry,
sendMessage,
stopGeneration,
retryLastMessage
} = useAiChat({
request: async ({ message, signal }) => createMockReply(message, signal)
});
</script>
<style lang="less" scoped>
.demo {
.phone {
height: 30rem;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.note {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<template>
<div class="demo">
<ai-chat-thinking :content="content" :is-generating="generating" />
<div class="bar">
<button type="button" class="btn" :disabled="generating" @click="start" v-text="'开始生成'" />
<button type="button" class="btn" @click="reset" v-text="'重置'" />
</div>
</div>
</template>
<script lang="ts" setup>
import { onUnmounted, ref } from "vue";
import { AiChatThinking } from "timi-tdesign-mobile";
const SEGMENTS = [
"先理解问题:用户想知道上个月的订单情况。",
"确认时间范围:按自然月还是滚动 30 天?这里取自然月。",
"确认口径:使用支付口径而非下单口径。",
"开始查询数据并汇总……"
];
const content = ref("");
const generating = ref(false);
let timer: ReturnType<typeof setInterval> | null = null;
/** 逐段追加,模拟思考内容流式到达;生成中组件会自动滚到底部。 */
function start() {
reset();
generating.value = true;
let index = 0;
timer = setInterval(() => {
if (index >= SEGMENTS.length) {
stop();
return;
}
content.value += (content.value ? "\n" : "") + SEGMENTS[index];
index += 1;
}, 700);
}
function stop() {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
generating.value = false;
}
function reset() {
stop();
content.value = "";
}
onUnmounted(stop);
</script>
<style lang="less" scoped>
.demo {
.bar {
display: flex;
gap: .5rem;
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&:hover:not(:disabled) {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
&:disabled {
cursor: not-allowed;
opacity: .5;
}
}
}
</style>
+93
View File
@@ -0,0 +1,93 @@
/**
* 演示专用的假 SSE 流。
*
* `useAiChat` 要求 `request` 返回一个 `Response`,真实的实现由后端提供。
* 这里用 ReadableStream 按内置协议逐条吐出事件,让文档站无需后端也能跑通
* 流式对话的完整链路。
*
* 内置协议的事件名与载荷字段(见 `parseDefaultEvent` / `extractText` 等):
* - `thinking` 载荷取 `thought` / `thinking` / `reasoning`
* - `answer` 载荷取 `answer` / `text` / `content`
* - `tool-status` 载荷取 `text` / `message` / `status`
* - `citation` 载荷为引用对象
* - `done` 载荷可带 `conversationId` 与 `duration`
*/
/** 单条 SSE 事件。 */
interface MockEvent {
event: string;
data: Record<string, unknown>;
}
/** 每个事件之间的间隔,模拟真实的流式节奏。 */
const STEP_DELAY = 420;
function buildEvents(question: string): MockEvent[] {
return [
{ event: "thinking", data: { thought: `先理解问题:「${question}」` } },
{ event: "thinking", data: { thought: "然后确认需要的数据口径与时间范围。" } },
{ event: "tool-status", data: { text: "正在查询数据" } },
{ event: "answer", data: { text: "根据当前口径," } },
{ event: "answer", data: { text: `关于「${question}」的结论如下:` } },
{ event: "answer", data: { text: "\n\n- 近 30 天整体**环比上升 12%**" } },
{ event: "answer", data: { text: "\n- 其中移动端贡献了主要增量" } },
{ event: "citation", data: { source: "分析中心", dataDomain: "订单域", timeRange: "近 30 天" } },
{ event: "done", data: { conversationId: "demo-conversation", duration: STEP_DELAY * 9 } }
];
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 生成一段假的流式回复。
*
* @param question 用户提问,会回显在回答里
* @param signal 取消信号,用户点「停止」时中断
*/
export function createMockReply(question: string, signal?: AbortSignal): Response {
const encoder = new TextEncoder();
const events = buildEvents(question);
let index = 0;
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
if (signal?.aborted) {
controller.error(new DOMException("已停止生成", "AbortError"));
return;
}
if (index >= events.length) {
controller.close();
return;
}
const item = events[index];
index += 1;
controller.enqueue(encoder.encode(`event: ${item.event}\ndata: ${JSON.stringify(item.data)}\n\n`));
await sleep(STEP_DELAY);
}
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream" }
});
}
/** 构造一条用于静态展示的消息。 */
export function createMessage(
id: number,
role: "user" | "assistant",
content: string,
extra: Partial<{ thinkingList: Array<{ id: number; content: string }>; citationList: unknown[] }> = {}
) {
return {
id,
role,
content,
thinkingList: extra.thinkingList ?? [],
citationList: extra.citationList ?? [],
createdAt: Date.now() - (10 - id) * 60_000,
statusText: "",
duration: role === "assistant" ? 2400 : undefined
};
}
+152
View File
@@ -0,0 +1,152 @@
<template>
<div class="demo">
<div class="bar">
<button
v-for="item in contentTypeList"
:key="item.value"
type="button"
class="btn"
:class="{ active: value.contentType === item.value }"
@click="switchContentType(item.value)"
v-text="item.label"
/>
</div>
<input v-model="value.title" class="field" placeholder="文章标题" />
<article-editor
ref="editorRef"
:model-value="value"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
@update:model-value="onChange"
/>
<div class="footer">
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
<span class="status" v-text="status" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { ArticleEditor } from "timi-tdesign-mobile";
import type {
Article,
ArticleContentType,
ArticleEditorExpose,
UploadResult
} from "timi-tdesign-mobile";
const RICH_CONTENT = `<h2>正文类型可切换</h2>
<p>同一份 <code>Article</code> 数据,按 <code>contentType</code> 切换三种编辑形态。</p>`;
const contentTypeList: Array<{ label: string; value: ArticleContentType }> = [
{ label: "纯文本", value: "TEXT" },
{ label: "Markdown", value: "MARKDOWN" },
{ label: "富文本", value: "TIP_TAP" }
];
const value = ref<Article>({
title: "编辑器演示文章",
contentType: "TIP_TAP",
content: RICH_CONTENT,
attachmentList: []
});
const status = ref("等待提交");
const editorRef = ref<ArticleEditorExpose>();
/** 切换正文类型时同步替换内容,避免不同格式互相污染。 */
function switchContentType(contentType: ArticleContentType) {
const content = contentType === "TEXT"
? "这是一段纯文本正文。"
: contentType === "MARKDOWN"
? "## Markdown 标题\n\n这是一段 Markdown 正文。"
: RICH_CONTENT;
value.value = {
...value.value,
contentType,
content
};
}
function onChange(article: Article) {
value.value = article;
}
async function submit() {
const article = await editorRef.value?.submit();
if (article) {
value.value = article;
status.value = `已提交,标题为「${article.title}」`;
}
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.bar {
display: flex;
flex-wrap: wrap;
gap: .375rem;
margin-bottom: .75rem;
}
.btn {
padding: .25rem .625rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.active {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
&.primary {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.footer {
display: flex;
gap: .75rem;
align-items: center;
margin-top: .75rem;
}
.status {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<template>
<div class="demo">
<article-view :article="article" />
</div>
</template>
<script lang="ts" setup>
import { ArticleView } from "timi-tdesign-mobile";
import type { Article } from "timi-tdesign-mobile";
/** ArticleView 按 contentType 选择渲染方式,这里演示富文本形态。 */
const article: Article = {
title: "文章视图",
contentType: "TIP_TAP",
content: `<h2>文章视图</h2>
<p>正文按 <code>Article</code> 结构渲染,空内容时显示占位文案。</p>
<blockquote><p>把 <code>contentType</code> 换成 TEXT 或 MARKDOWN 会切换渲染分支。</p></blockquote>`,
attachmentList: []
};
</script>
+76
View File
@@ -0,0 +1,76 @@
<template>
<div class="demo">
<button type="button" class="btn" @click="visible = true" v-text="'打开底部弹层'" />
<p class="status" v-text="`最近一次操作:${lastAction}`" />
<bottom-popup
v-model="visible"
title="选择操作"
left-button-text="取消"
right-button-text="确定"
@left-button-click="onCancel"
@right-button-click="onConfirm"
>
<div class="content">
<p v-text="'弹层从视口底部升起,默认带遮罩,内容区可滚动。'" />
<p v-text="'标题栏在内容滚动时会吸顶。'" />
</div>
</bottom-popup>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { BottomPopup } from "timi-tdesign-mobile";
const visible = ref(false);
const lastAction = ref("无");
/** 左右按钮只抛事件,关闭与否由业务决定——这里两者都关闭。 */
function onCancel() {
visible.value = false;
lastAction.value = "取消";
}
function onConfirm() {
visible.value = false;
lastAction.value = "确定";
}
</script>
<style lang="less" scoped>
.demo {
.btn {
width: 100%;
padding: .625rem;
font-size: .875rem;
cursor: pointer;
border: none;
border-radius: .5rem;
color: #fff;
background: var(--vp-c-brand-1);
}
.status {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
.content {
padding: 1rem;
p {
margin: 0 0 .75rem;
font-size: .8125rem;
line-height: 1.6;
color: var(--vp-c-text-2);
&:last-child {
margin-bottom: 0;
}
}
}
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<template>
<div class="demo">
<div class="bar">
<button type="button" class="btn" @click="readValue" v-text="'读取内容'" />
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
</div>
<div class="box">
<code-editor
ref="editorRef"
v-model="code"
language="typescript"
placeholder="请输入代码"
@change="onChange"
/>
</div>
<p class="status" v-text="status" />
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { CodeEditor } from "timi-tdesign-mobile";
import type { CodeEditorExpose } from "timi-tdesign-mobile";
const DEMO_CODE = `type DemoUser = {
id: string;
name: string;
};
const user: DemoUser = {
id: "demo-001",
name: "Timi"
};`;
const code = ref(DEMO_CODE);
const status = ref("等待操作");
const editorRef = ref<CodeEditorExpose>();
function onChange(value: string) {
status.value = `内容已更新,${value.length} 个字符`;
}
function readValue() {
status.value = `当前共 ${code.value.length} 个字符`;
}
function openSearch() {
editorRef.value?.openSearch(true);
}
</script>
<style lang="less" scoped>
.demo {
.bar {
display: flex;
gap: .5rem;
margin-bottom: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&:hover {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
}
.box {
height: 18rem;
}
.status {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<template>
<div class="demo">
<duration-picker v-model="duration" @change="onChange" />
<div class="result">
<p class="value" v-text="`${duration} ms`" />
<p class="text" v-text="humanized" />
<p class="text" v-text="`变更次数:${changeCount}`" />
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { DurationPicker } from "timi-tdesign-mobile";
const duration = ref(1000);
const changeCount = ref(0);
/** 把毫秒换算成便于阅读的形式,仅用于演示展示。 */
const humanized = computed(() => {
const value = duration.value;
if (value < 1000) {
return `${value} 毫秒`;
}
if (value < 60_000) {
return `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 2)} 秒`;
}
if (value < 3600_000) {
return `${(value / 60_000).toFixed(2)} 分钟`;
}
if (value < 86400_000) {
return `${(value / 3600_000).toFixed(2)} 小时`;
}
return `${(value / 86400_000).toFixed(2)} 天`;
});
function onChange() {
changeCount.value += 1;
}
</script>
<style lang="less" scoped>
.demo {
.result {
margin-top: 1rem;
text-align: center;
}
.value {
margin: 0 0 .25rem;
font-size: 1.125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--vp-c-brand-1);
}
.text {
margin: 0 0 .25rem;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="demo">
<input v-model="value.subject" class="field" placeholder="通知主题" />
<notify-editor
ref="editorRef"
:model-value="value"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
@update:model-value="onChange"
/>
<div class="footer">
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
<span class="status" v-text="status" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { NotifyEditor } from "timi-tdesign-mobile";
import type { NotifyDetail, NotifyEditorExpose, UploadResult } from "timi-tdesign-mobile";
const value = ref<NotifyDetail>({
subject: "编辑器通知演示",
contentType: "TIP_TAP",
content: `<h2>通知内容</h2>
<p>通知复用文章编辑器,同时保留自己的元数据字段(如 <code>subject</code>)。</p>`,
attachmentList: []
});
const status = ref("等待提交");
const editorRef = ref<NotifyEditorExpose>();
function onChange(detail: NotifyDetail) {
value.value = detail;
}
async function submit() {
const detail = await editorRef.value?.submit();
if (detail) {
value.value = detail;
status.value = `已提交,正文 ${detail.content?.length || 0} 个字符`;
}
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.footer {
display: flex;
gap: .75rem;
align-items: center;
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.primary {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.status {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="demo">
<notify-view :detail="detail" />
</div>
</template>
<script lang="ts" setup>
import { NotifyView } from "timi-tdesign-mobile";
import type { NotifyDetail } from "timi-tdesign-mobile";
/** NotifyView 只关心正文相关字段,主题与状态由业务页面自行呈现。 */
const detail: NotifyDetail = {
subject: "系统维护通知",
contentType: "TIP_TAP",
content: `<h2>系统维护通知</h2>
<p>本周六 02:00 - 04:00 进行数据库升级,期间服务不可用。</p>
<ul><li>请提前保存工作内容</li><li>升级完成后会同步通知</li></ul>`
};
</script>
+38
View File
@@ -0,0 +1,38 @@
<template>
<div class="demo">
<root-layout
icp="京ICP备 00000000 号"
domain="example.com"
author="Timi"
>
<div class="body">
<h3 v-text="'RootLayout'" />
<p v-text="'移动端页面骨架:插槽填充主体,底部自动渲染版权与备案信息。'" />
</div>
</root-layout>
</div>
</template>
<script lang="ts" setup>
import { RootLayout } from "timi-tdesign-mobile";
</script>
<style lang="less" scoped>
.demo {
.body {
padding: 1.5rem 1rem;
h3 {
margin: 0 0 .5rem;
font-size: 1rem;
}
p {
margin: 0;
font-size: .8125rem;
line-height: 1.6;
color: var(--vp-c-text-2);
}
}
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<template>
<div class="demo">
<div class="phone">
<header class="head">
<span class="title" v-text="'智能助手'" />
<button type="button" class="reset" @click="resetConversation" v-text="'清空'" />
</header>
<ai-chat-panel
class="panel"
:messages="messages"
:is-generating="isGenerating"
:active-tool-status="activeToolStatus"
:last-error="lastError"
:can-retry="canRetry"
:loading="isLoadingConversation"
:role-names="{ assistant: '分析助手', user: '我' }"
@send="sendMessage"
@stop="stopGeneration"
@retry="retryLastMessage"
>
<template #welcome>
<div class="welcome">
<p class="welcome-title" v-text="'可以问我关于数据的问题'" />
<p class="welcome-tip" v-text="'例如:上个月订单量怎么样?'" />
</div>
</template>
</ai-chat-panel>
</div>
<p class="note" v-text="'面板嵌在带标题栏的页面骨架里,欢迎语与角色名都做了自定义。'" />
</div>
</template>
<script lang="ts" setup>
import { AiChatPanel, useAiChat } from "timi-tdesign-mobile";
import { createMockReply } from "../ai/mock-sse";
/**
* 完整场景:标题栏 + 面板 + 自定义欢迎语。
* 会话状态全部来自 useAiChat,真实项目里把 request 换成后端 SSE 接口即可。
*/
const {
messages,
isGenerating,
isLoadingConversation,
activeToolStatus,
lastError,
canRetry,
sendMessage,
stopGeneration,
retryLastMessage,
resetConversation
} = useAiChat({
request: async ({ message, signal }) => createMockReply(message, signal)
});
</script>
<style lang="less" scoped>
.demo {
.phone {
height: 32rem;
display: flex;
overflow: hidden;
flex-direction: column;
border: 1px solid var(--vp-c-divider);
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.head {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: .625rem .75rem;
border-bottom: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
}
.title {
font-size: .875rem;
font-weight: 600;
}
.reset {
padding: .25rem .5rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
}
.panel {
flex: 1;
min-height: 0;
}
.welcome {
padding: 2rem 1rem;
text-align: center;
}
.welcome-title {
margin: 0 0 .375rem;
font-size: .875rem;
font-weight: 600;
color: var(--vp-c-text-1);
}
.welcome-tip {
margin: 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
.note {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<template>
<div class="demo">
<div class="tabs">
<button
type="button"
class="tab"
:class="{ active: tab === 'edit' }"
@click="tab = 'edit'"
v-text="'编辑'"
/>
<button
type="button"
class="tab"
:class="{ active: tab === 'view' }"
@click="tab = 'view'"
v-text="'预览'"
/>
</div>
<div class="switcher">
<button
v-for="item in contentTypeList"
:key="item.value"
type="button"
class="chip"
:class="{ active: article.contentType === item.value }"
@click="switchContentType(item.value)"
v-text="item.label"
/>
</div>
<section v-show="tab === 'edit'" class="pane">
<input v-model="article.title" class="field" placeholder="文章标题" />
<article-editor
ref="editorRef"
:model-value="article"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
@update:model-value="onChange"
/>
<div class="footer">
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
<span class="status" v-text="status" />
</div>
</section>
<section v-show="tab === 'view'" class="pane">
<article-view :article="article" />
</section>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { ArticleEditor, ArticleView } from "timi-tdesign-mobile";
import type {
Article,
ArticleContentType,
ArticleEditorExpose,
UploadResult
} from "timi-tdesign-mobile";
/** 本场景验证「同一份 Article 数据,编辑与预览共用」。 */
const RICH_CONTENT = `<h2>文章编辑与预览</h2>
<p>两侧共用同一个 <code>Article</code> 对象,切换标签即可对比。</p>
<blockquote><p>移动端用标签切换替代左右分栏。</p></blockquote>`;
const contentTypeList: Array<{ label: string; value: ArticleContentType }> = [
{ label: "纯文本", value: "TEXT" },
{ label: "Markdown", value: "MARKDOWN" },
{ label: "富文本", value: "TIP_TAP" }
];
const tab = ref("edit");
const article = ref<Article>({
title: "编辑器组件演示文章",
contentType: "TIP_TAP",
content: RICH_CONTENT,
attachmentList: []
});
const status = ref("等待提交");
const editorRef = ref<ArticleEditorExpose>();
function switchContentType(contentType: ArticleContentType) {
const content = contentType === "TEXT"
? "这是一段纯文本示例。"
: contentType === "MARKDOWN"
? "## Markdown 示例\n\n这是一段 Markdown 正文。"
: RICH_CONTENT;
article.value = {
...article.value,
contentType,
content
};
status.value = `已切换到 ${contentType}`;
}
function onChange(value: Article) {
article.value = value;
}
async function submit() {
const value = await editorRef.value?.submit();
if (value) {
article.value = value;
status.value = `已提交,${value.attachmentList?.length || 0} 个附件`;
}
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.tabs {
display: flex;
gap: .25rem;
padding: .25rem;
margin-bottom: .75rem;
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.tab {
flex: 1;
padding: .375rem;
font-size: .75rem;
cursor: pointer;
border: none;
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
&.active {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg);
}
}
.switcher {
display: flex;
flex-wrap: wrap;
gap: .375rem;
margin-bottom: .75rem;
}
.chip {
padding: .25rem .625rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: 1rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.active {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.footer {
display: flex;
gap: .75rem;
align-items: center;
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.primary {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.status {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<template>
<div class="demo">
<div class="tabs">
<button
v-for="item in tabList"
:key="item.value"
type="button"
class="tab"
:class="{ active: tab === item.value }"
@click="tab = item.value"
v-text="item.label"
/>
</div>
<section v-show="tab === 'rich'" class="pane">
<div class="bar">
<button type="button" class="btn" @click="submitRich" v-text="'提交富文本'" />
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
</div>
<div class="box tall">
<tiptap-editor
ref="tiptapRef"
v-model="rich"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onRichChange"
/>
</div>
<p class="status" v-text="status" />
</section>
<section v-show="tab === 'view'" class="pane">
<tiptap-view :content="rich" max-height="20rem" />
</section>
<section v-show="tab === 'code'" class="pane">
<div class="bar">
<button type="button" class="btn" @click="resetCode" v-text="'恢复示例'" />
</div>
<div class="box">
<code-editor v-model="code" language="typescript" placeholder="请输入代码" />
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { CodeEditor, TiptapEditor, TiptapView } from "timi-tdesign-mobile";
import type { TiptapEditorExpose, UploadResult } from "timi-tdesign-mobile";
/** 本场景把三类编辑器收在一个页面里,用标签切换而不是并排——移动端宽度放不下分栏。 */
const DEMO_CODE = `type DemoUser = {
id: string;
name: string;
};
const user: DemoUser = { id: "u1", name: "Timi" };`;
const tabList = [
{ label: "富文本", value: "rich" },
{ label: "只读视图", value: "view" },
{ label: "代码", value: "code" }
];
const tab = ref("rich");
const rich = ref(`<h2>跨平台编辑器</h2>
<p>这段内容同时验证标题、段落、引用与代码块的渲染。</p>
<blockquote><p>移动端和 PC 端只替换交互层,共享文档模型。</p></blockquote>`);
const code = ref(DEMO_CODE);
const status = ref("等待操作");
const tiptapRef = ref<TiptapEditorExpose>();
function onRichChange(value: string) {
status.value = `内容已更新,${value.length} 个字符`;
}
function resetCode() {
code.value = DEMO_CODE;
}
async function submitRich() {
const value = await tiptapRef.value?.submit();
if (value !== undefined) {
rich.value = value;
status.value = `已提交,${value.length} 个字符`;
}
}
function openSearch() {
tiptapRef.value?.openSearch(true);
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.tabs {
display: flex;
gap: .25rem;
padding: .25rem;
margin-bottom: .75rem;
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.tab {
flex: 1;
padding: .375rem;
font-size: .75rem;
cursor: pointer;
border: none;
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
&.active {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg);
}
}
.bar {
display: flex;
gap: .5rem;
margin-bottom: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&:hover {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
}
.box {
height: 16rem;
}
.tall {
height: 22rem;
}
.status {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<template>
<div class="demo">
<div class="card">
<t-cell title="缓存有效期" arrow @click="pickerVisible = true">
<span v-text="humanized" />
</t-cell>
</div>
<div class="bar">
<button type="button" class="btn" @click="sheetVisible = true" v-text="'打开底部弹层'" />
</div>
<p class="status" v-text="`弹层最近一次操作:${lastAction}`" />
<space size="small" class="tags">
<span v-for="item in 3" :key="item" class="tag" v-text="`标签 ${item}`" />
</space>
<t-popup v-model="pickerVisible" placement="bottom" destroy-on-close>
<div class="picker-panel">
<duration-picker v-model="durationMs" @change="pickerVisible = false" />
</div>
</t-popup>
<bottom-popup
v-model="sheetVisible"
title="选择操作"
@left-button-click="onAction('取消')"
@right-button-click="onAction('确定')"
>
<div class="sheet-content">
<p v-text="'底部弹层适合承载一组操作或一段简短表单。'" />
</div>
</bottom-popup>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { BottomPopup, DurationPicker, Space } from "timi-tdesign-mobile";
/**
* 本场景是移动端最典型的两种浮现方式:整块选择面板用 `t-popup` 从底部升起,
* 操作确认用 BottomPopup。
*/
const durationMs = ref(1000);
const pickerVisible = ref(false);
const sheetVisible = ref(false);
const lastAction = ref("无");
/** 把毫秒换算成便于阅读的形式。 */
const humanized = computed(() => {
const value = durationMs.value;
if (value % (60 * 60 * 1000) === 0) {
return `${value / (60 * 60 * 1000)} 小时`;
}
if (value % (60 * 1000) === 0) {
return `${value / (60 * 1000)} 分钟`;
}
if (value % 1000 === 0) {
return `${value / 1000} 秒`;
}
return `${value} 毫秒`;
});
function onAction(action: string) {
lastAction.value = action;
sheetVisible.value = false;
}
</script>
<style lang="less" scoped>
.demo {
.card {
overflow: hidden;
margin-bottom: .75rem;
border: 1px solid var(--vp-c-divider);
border-radius: .5rem;
}
.bar {
margin-bottom: .75rem;
}
.btn {
width: 100%;
padding: .5rem;
font-size: .8125rem;
cursor: pointer;
border: none;
border-radius: .5rem;
color: #fff;
background: var(--vp-c-brand-1);
}
.status {
margin: 0 0 .75rem;
font-size: .75rem;
color: var(--vp-c-text-2);
}
.tags {
margin-bottom: 0;
}
.tag {
padding: .25rem .5rem;
font-size: .6875rem;
border-radius: .25rem;
color: var(--vp-c-brand-1);
background: var(--vp-c-default-soft);
}
.picker-panel {
padding-top: .5rem;
border-radius: 1rem 1rem 0 0;
background: var(--vp-c-bg);
}
.sheet-content {
padding: 1rem;
p {
margin: 0;
font-size: .8125rem;
line-height: 1.6;
color: var(--vp-c-text-2);
}
}
}
</style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<div class="demo">
<div class="tabs">
<button
type="button"
class="tab"
:class="{ active: tab === 'edit' }"
@click="tab = 'edit'"
v-text="'编辑'"
/>
<button
type="button"
class="tab"
:class="{ active: tab === 'view' }"
@click="tab = 'view'"
v-text="'预览'"
/>
</div>
<div class="status-bar">
<button
type="button"
class="chip"
@click="switchStatus"
v-text="'切换状态'"
/>
<span class="tag" v-text="detail.status" />
</div>
<section v-show="tab === 'edit'" class="pane">
<input v-model="detail.subject" class="field" placeholder="通知主题" />
<notify-editor
ref="editorRef"
:model-value="detail"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
@update:model-value="onChange"
/>
<div class="footer">
<button type="button" class="btn primary" @click="submit" v-text="'提交通知'" />
<span class="status" v-text="status" />
</div>
</section>
<section v-show="tab === 'view'" class="pane">
<notify-view :detail="detail" />
</section>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { NotifyEditor, NotifyView } from "timi-tdesign-mobile";
import type { NotifyDetail, NotifyEditorExpose, UploadResult } from "timi-tdesign-mobile";
/** 本场景验证通知复用文章编辑能力,同时保留自己的元数据字段。 */
const RICH_CONTENT = `<h2>系统维护通知</h2>
<p>本周六 02:00 - 04:00 进行数据库升级,期间服务不可用。</p>
<ul><li>请提前保存工作内容</li><li>升级完成后会同步通知</li></ul>`;
const statusList = ["WAITING", "SENT", "FAILED"];
const tab = ref("edit");
const detail = ref<NotifyDetail>({
subject: "编辑器通知演示",
contentType: "TIP_TAP",
content: RICH_CONTENT,
status: "WAITING",
attachmentList: []
});
const status = ref("等待提交");
const editorRef = ref<NotifyEditorExpose>();
function switchStatus() {
const index = statusList.indexOf(detail.value.status);
const next = statusList[(index + 1) % statusList.length];
detail.value = {
...detail.value,
status: next
};
}
function onChange(value: NotifyDetail) {
detail.value = value;
}
async function submit() {
const value = await editorRef.value?.submit();
if (value) {
detail.value = value;
status.value = `已提交,正文 ${value.content?.length || 0} 个字符`;
}
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.tabs {
display: flex;
gap: .25rem;
padding: .25rem;
margin-bottom: .75rem;
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.tab {
flex: 1;
padding: .375rem;
font-size: .75rem;
cursor: pointer;
border: none;
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
&.active {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg);
}
}
.status-bar {
display: flex;
gap: .5rem;
align-items: center;
margin-bottom: .75rem;
}
.chip {
padding: .25rem .625rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: 1rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
}
.tag {
padding: .125rem .5rem;
font-size: .6875rem;
border-radius: .25rem;
color: var(--vp-c-brand-1);
background: var(--vp-c-default-soft);
}
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.footer {
display: flex;
gap: .75rem;
align-items: center;
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.primary {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.status {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="demo">
<section class="block">
<h4 v-text="'水平(默认 medium)'" />
<space>
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
</space>
</section>
<section class="block">
<h4 v-text="'预设尺寸 small / large'" />
<space size="small">
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
</space>
<space size="large">
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
</space>
</section>
<section class="block">
<h4 v-text="'垂直排列'" />
<space direction="vertical">
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
</space>
</section>
<section class="block">
<h4 v-text="'分隔符'" />
<space separator="|">
<span v-for="item in 3" :key="item" v-text="`项 ${item}`" />
</space>
</section>
</div>
</template>
<script lang="ts" setup>
import { Space } from "timi-tdesign-mobile";
</script>
<style lang="less" scoped>
.demo {
.block {
margin-bottom: 1.25rem;
&:last-child {
margin-bottom: 0;
}
}
h4 {
margin: 0 0 .5rem;
font-size: .8125rem;
}
.box {
padding: .375rem .625rem;
font-size: .75rem;
border-radius: .375rem;
color: var(--vp-c-brand-1);
background: var(--vp-c-default-soft);
}
span {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<template>
<div class="demo">
<div class="bar">
<button type="button" class="btn" @click="submit" v-text="'提交'" />
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
</div>
<div class="box">
<tiptap-editor
ref="editorRef"
v-model="content"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
/>
</div>
<p class="status" v-text="status" />
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { TiptapEditor } from "timi-tdesign-mobile";
import type { TiptapEditorExpose, UploadResult } from "timi-tdesign-mobile";
const content = ref(`<h2>富文本编辑器</h2>
<p>工具栏覆盖标题、颜色、列表、表格、链接与代码块。</p>
<blockquote><p>移动端工具栏会自动换行排列。</p></blockquote>`);
const status = ref("等待操作");
const editorRef = ref<TiptapEditorExpose>();
function onChange(value: string) {
status.value = `内容已更新,${value.length} 个字符`;
}
async function submit() {
const value = await editorRef.value?.submit();
if (value !== undefined) {
content.value = value;
status.value = `已提交,${value.length} 个字符`;
}
}
function openSearch() {
editorRef.value?.openSearch(true);
}
/** 演示用上传:直接把本地文件转成临时地址,无需后端。 */
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.bar {
display: flex;
gap: .5rem;
margin-bottom: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&:hover {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
}
.box {
height: 24rem;
}
.status {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<template>
<div class="demo">
<input v-model="maxHeight" class="field" placeholder="最大高度,如 16rem" />
<tiptap-view :content="content" :max-height="maxHeight" />
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { TiptapView } from "timi-tdesign-mobile";
const content = `<h2>只读视图</h2>
<p>与编辑器共用同一套渲染规则,内容超出 <code>maxHeight</code> 时可滚动查看。</p>
<blockquote><p>适合详情页、审批流等只需要阅读的场景。</p></blockquote>`;
const maxHeight = ref("16rem");
</script>
<style lang="less" scoped>
.demo {
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
}
</style>
+122
View File
@@ -0,0 +1,122 @@
# 演示与文档约定
本站的演示与 API 文档都由源码驱动,新增组件时只需按下面的顺序补齐三处内容。
## 目录结构
```text
docs/
├── .vitepress/
│ ├── components.ts # 组件元数据清单(唯一数据源)
│ ├── config.ts # 站点配置、侧边栏、markdown 插件
│ ├── plugins/
│ │ ├── docgen.ts # vue-docgen-api 解析,产出虚拟模块
│ │ └── ssr-stub.ts # SSR 阶段替换 timi-web
│ └── theme/
│ ├── index.ts # 主题入口,注册 TDesign Mobile 与自定义组件
│ ├── containers/ # :::preview 的预览容器
│ └── components/ # api-table 等文档组件
├── demos/ # 演示组件,按组件名分目录
│ ├── ai/ # AI 演示共用的假流
│ └── scenarios/ # 跨组件的组合场景演示
├── components/ # 组件文档页
└── scenarios/ # 组合场景文档页
```
单组件文档只回答「这个组件怎么用」,跨组件的协作方式放在 `scenarios/` 下。
## 新增一个组件
### 1. 登记元数据
在 `docs/.vitepress/components.ts` 的 `COMPONENT_LIST` 中追加一项。侧边栏导航、API 数据解析都从这里读取:
```ts
{
name: "CodeEditor", // 必须与组件 defineOptions 的 name 一致
title: "CodeEditor 代码编辑器",
description: "带行号、语法高亮与查找替换的代码输入框。",
category: "编辑器组件",
entry: "src/components/code-editor/index.vue"
}
```
### 2. 编写演示
演示组件放在 `docs/demos/<文档路径>/` 下。演示直接引用 `src` 源码,改组件会即时热更新:
```vue
<script lang="ts" setup>
import { ref } from "vue";
import { CodeEditor } from "timi-tdesign-mobile";
const code = ref("");
</script>
<template>
<code-editor v-model="code" />
</template>
```
### 3. 编写文档页
在 `docs/components/` 下建立同名 markdown,用 `:::preview` 容器引用演示:
````md
# CodeEditor 代码编辑器
带行号、语法高亮与查找替换的代码输入框。
## 基础用法
:::preview 基础用法 || 通过 v-model 双向绑定内容
demo-preview=../demos/code-editor/basic.vue
:::
## API
<api-table name="CodeEditor" />
````
路径以 markdown 文件所在位置为基准,因此 `docs/components/` 下的页面统一使用 `../demos/`。
## 文档路径的来历
`index.vue` 取所在**目录名**,具名文件取**文件名**——`ai` 目录下有多个组件,只靠目录名会全部撞成 `ai`。两者都会再转成短横线形式,`AiChatComposer` 得到 `ai-chat-composer`。需要特殊路径时用元数据里的 `slug` 覆盖。
## API 表从哪来
`<api-table>` 的数据由 `vue-docgen-api` 在构建期解析组件源码得到,通过虚拟模块 `virtual:timi-api` 交给页面渲染。因此不需要手写 props / events / slots 表格,改源码后文档自动更新。
想让说明出现在表里,就在 props 类型上写 JSDoc 注释:
```ts
const props = withDefaults(defineProps<{
/** 距离底部小于等于该值时触发 */
distance?: number;
}>(), {
distance: 120
});
```
某个组件解析失败时只会让该组件的 API 区降级为空,不会影响整站构建;构建日志里会打出对应的组件名。
## 演示统一放在手机视口里
这是移动端组件库,按桌面宽度铺开会失去真实形态。容器把演示收在 `26rem` 宽的视口内,宽度可以通过 CSS 变量调整:
```less
.tui-demo {
--tui-demo-viewport: 30rem;
}
```
需要全宽的演示(如整页骨架)可以在自己的样式里覆盖这个变量。
## 关于 SSR
`timi-web` 的构建产物在模块顶层访问 `window`,无法在 Node 中加载,而主题必须静态引入它来注册 `v-popup` 指令。因此 SSR 阶段由 `ssr-stub.ts` 提供空实现。
演示组件本身以 `clientOnly` 延迟渲染,不需要额外处理 SSR,但也别把它当作服务端渲染能力的验证。
+102
View File
@@ -0,0 +1,102 @@
# 快速开始
`timi-tdesign-mobile` 是面向移动端的 Vue 3 业务组件库,底层复用 `timi-web` 的编辑器与内容渲染能力,UI 基于 TDesign Mobile。
## 安装
```sh
pnpm add timi-tdesign-mobile tdesign-mobile-vue
```
## 按需引入
组件在 `<script setup>` 中导入后即可在模板里使用,这是推荐方式:
```vue
<script lang="ts" setup>
import { ref } from "vue";
import { CodeEditor } from "timi-tdesign-mobile";
const code = ref("const answer = 42;");
</script>
<template>
<code-editor v-model="code" language="typescript" />
</template>
```
## 全局注册
每个组件都带有 `install` 方法,可以单独注册为全局组件:
```ts
import { createApp } from "vue";
import { CodeEditor, TiptapEditor } from "timi-tdesign-mobile";
const app = createApp(App);
app.use(CodeEditor);
app.use(TiptapEditor);
```
## 必须注册 v-popup 指令
富文本工具栏等组件内部使用了 `timi-web` 的 `v-popup` 指令,且用在组件模板里,宿主的应用入口需要注册一次:
```ts
import TimiWebUI, { VPopup } from "timi-web";
app.use(TimiWebUI);
app.directive("popup", VPopup);
```
漏掉这一步不会报错,但工具栏的悬停提示会静默失效。
## 引入样式
```ts
import "timi-tdesign-mobile/style.css";
```
该样式入口已包含 TDesign 定制与编辑器正文样式,`timi-web` 的样式也会一并带入。
**另外还需要引入 TDesign Mobile 自己的样式**,注意要引完整包而不是 `es/style/index.css`——后者只含全局 token,不含任何组件类:
```ts
import "tdesign-mobile-vue/dist/tdesign.css";
```
## 数据与回调
文章、通知类组件只约束对象的最小字段,业务对象上的其他字段会原样保留。上传与附件地址通过回调注入,库本身不绑定任何接口实现:
```vue
<article-editor
:model-value="article"
:upload-image="uploadImage"
:upload-video="uploadVideo"
:resolve-attachment-url="resolveAttachmentUrl"
@update:model-value="article = $event"
/>
```
没有附件上传需求时,可以不传上传回调。
## AI 对话
对话能力由 `useAiChat` 组合式函数提供,组件只负责渲染:
```ts
import { useAiChat } from "timi-tdesign-mobile";
const { messages, isGenerating, sendMessage } = useAiChat({
request: ({ message, signal }) => fetchSseReply(message, signal)
});
```
详见 [AiChatPanel](/components/ai-chat-panel) 与 [AI 对话场景](/scenarios/ai)。
## 下一步
- 在[组件总览](/components/)中浏览全部组件
- 看[编辑器能力总览](/scenarios/editor)等场景,了解多个组件如何协作
- 了解本站的[演示与文档约定](/guide/demo),向文档站补充新组件
+23
View File
@@ -0,0 +1,23 @@
---
layout: home
hero:
name: timi-tdesign-mobile
text: 移动端 TDesign 组件库
tagline: 15 个业务组件,配套可交互演示与由源码自动生成的 API 文档
actions:
- theme: brand
text: 快速开始
link: /guide/
- theme: alt
text: 浏览组件
link: /components/
features:
- title: 真实可交互
details: 演示直接运行 src 源码而非静态截图,改动组件源码后文档即时热更新。
- title: 手机视口呈现
details: 演示统一放在移动端宽度的视口里,避免移动组件被桌面宽度拉伸失真。
- title: API 自动生成
details: 由 vue-docgen-api 在构建期解析组件源码提取 props、events、slots 与 expose,文档与代码不会脱节。
---
+27
View File
@@ -0,0 +1,27 @@
{
"name": "timi-tdesign-mobile-docs",
"private": true,
"version": "0.0.1",
"type": "module",
"description": "timi-tdesign-mobile 组件演示与 API 文档站",
"scripts": {
"dev": "vitepress dev .",
"build": "vitepress build .",
"preview": "vitepress preview ."
},
"dependencies": {
"tdesign-mobile-vue": "^1.13.2",
"timi-web": "link:E:/WebProject/timi-web",
"vue": "^3.5.26"
},
"devDependencies": {
"@types/node": "^25.0.8",
"@vitepress-demo-preview/plugin": "^1.4.1",
"less": "^4.5.1",
"markdown-it-container": "^3.0.0",
"typescript": "^5.9.3",
"unplugin-auto-import": "^21.0.0",
"vitepress": "^1.6.4",
"vue-docgen-api": "^4.79.2"
}
}
+2845
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
# AI 对话
把对话面板嵌进带标题栏的页面骨架,并自定义欢迎语与角色名。
## 组合演示
:::preview 完整对话 || 回答由本地假流逐块吐出,可随时点「停止」或用「清空」重开
demo-preview=../demos/scenarios/ai.vue
:::
## 这个场景覆盖了什么
- **会话接线**:`useAiChat` 提供 `messages` 与生成状态,面板只负责渲染
- **页面骨架**:标题栏与面板组合成完整页面,`清空` 调用 `resetConversation`
- **自定义文案**:`roleNames` 把「助手」换成「分析助手」,`welcome` 插槽替换空态
- **流式呈现**:思考过程、工具状态、回答与引用按事件依次出现
## 换成真实后端
示例里的 `request` 返回的是本地构造的假流。接真实接口时只要返回一个 SSE 流式的 `Response` 即可,其余不用改:
```ts
const chat = useAiChat({
request: ({ message, signal }) => fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
signal
})
});
```
事件名与载荷字段见 [AiChatPanel](/components/ai-chat-panel) 的协议表;后端用的是另一套事件名时,用 `parseEvent` 转换。
## 相关组件
- [AiChatPanel](/components/ai-chat-panel)
- [AiChatMessageList](/components/ai-chat-message-list)
- [AiChatComposer](/components/ai-chat-composer)
- [AiChatThinking](/components/ai-chat-thinking)
+27
View File
@@ -0,0 +1,27 @@
# 文章编辑与预览
编辑与预览共用同一个 `Article` 对象,用标签在两种视图之间切换。
## 组合演示
:::preview 编辑与预览联动 || 切换正文类型时两个标签页同时切换渲染分支
demo-preview=../demos/scenarios/article.vue
:::
## 这个场景覆盖了什么
- **正文类型切换**:纯文本、Markdown、富文本三种形态共用一份数据
- **数据联动**:`ArticleEditor` 抛出的更新直接喂给 `ArticleView`
- **字段保留**:`title` 等业务字段由外层维护,编辑器不会丢字段
- **提交语义**:`submit()` 等待未完成的媒体上传后再返回最终数据
## 为什么用标签而不是分栏
PC 端可以左右并排,移动端宽度不够。用标签切换既保证了对比的便利,又不牺牲编辑区的可用宽度。
## 相关组件
- [ArticleEditor](/components/article-editor)
- [ArticleView](/components/article-view)
+24
View File
@@ -0,0 +1,24 @@
# 编辑器能力总览
把富文本编辑器、只读视图与代码编辑器放在一个页面里,验证它们共用同一套内容模型与检索能力。
## 组合演示
:::preview 组合用法 || 标签切换而不是左右分栏——移动端宽度放不下分栏
demo-preview=../demos/scenarios/editor.vue
:::
## 这个场景覆盖了什么
- **富文本编辑**:工具栏、媒体上传、提交前的占位内容处理
- **只读渲染**:同一份 HTML 交给 `TiptapView`,展示效果与编辑态一致
- **代码编辑**:行号、语法高亮与查找替换
- **统一检索**:`openSearch()` 在两类编辑器上语义一致,便于封装统一的快捷键
## 相关组件
- [TiptapEditor](/components/tiptap-editor)
- [TiptapView](/components/tiptap-view)
- [CodeEditor](/components/code-editor)
+27
View File
@@ -0,0 +1,27 @@
# 移动端组件
移动端最典型的两种浮现方式:整块选择面板与操作确认,都从底部升起。
## 组合演示
:::preview 组合用法 || 点击「缓存有效期」行打开 Picker,点按钮打开底部弹层
demo-preview=../demos/scenarios/mobile.vue
:::
## 这个场景覆盖了什么
- **列表触发的选择面板**:`t-cell` 当前值 + 箭头,点击后用 `t-popup` 从底部升起 `DurationPicker`
- **操作确认**:`BottomPopup` 承载标题与左右按钮,按钮只抛事件、关闭由业务决定
- **间距**:`Space` 统一控制一组元素之间的间距
## 关于 destroy-on-close
示例里的 Picker 弹层加了 `destroy-on-close`,每次打开都重建,避免上次的选择残留。选择面板这类有内部滚动状态的组件建议都带上。
## 相关组件
- [DurationPicker](/components/duration-picker)
- [BottomPopup](/components/bottom-popup)
- [Space](/components/space)
+22
View File
@@ -0,0 +1,22 @@
# 通知编辑与预览
通知复用文章编辑器的正文能力,同时保留主题、状态等自有字段。
## 组合演示
:::preview 编辑与预览联动 || 状态字段由外层维护,切状态不影响正文
demo-preview=../demos/scenarios/notify.vue
:::
## 这个场景覆盖了什么
- **能力复用**:`NotifyEditor` 内部就是 `ArticleEditor`,正文体验一致
- **元数据独立**:`subject`、`status` 等字段与正文互不干扰
- **字段透传**:业务字段在编辑过程中原样保留
## 相关组件
- [NotifyEditor](/components/notify-editor)
- [NotifyView](/components/notify-view)
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"jsx": "preserve",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": [
"node"
],
"baseUrl": ".",
"paths": {
"timi-tdesign-mobile": [
"../src/index.ts"
],
"timi-web/editor": [
"../../timi-web/src/editor/index.ts"
]
}
},
"include": [
"./**/*.ts",
"./**/*.d.ts",
"./**/*.vue"
],
"exclude": [
"node_modules",
".vitepress/cache",
".vitepress/dist"
]
}