add vitepress docs
This commit is contained in:
+6
-1
@@ -1,9 +1,14 @@
|
||||
/.eslintrc-auto-import.json
|
||||
/components.d.ts
|
||||
/examples/auto-imports.d.ts
|
||||
/auto-imports.d.ts
|
||||
/AGENTS.md
|
||||
/.npmrc
|
||||
|
||||
# 文档站
|
||||
/docs/.vitepress/cache
|
||||
/docs/.vitepress/dist
|
||||
/docs/.npmrc
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -26,6 +26,30 @@ Vue 3 移动端通用组件库,依赖 `timi-web` 提供基础组件和编辑
|
||||
|
||||
通知组件使用同样的 `uploadImage`、`uploadVideo` 和 `resolveAttachmentUrl` 回调。没有附件上传需求时,可以不传上传回调。
|
||||
|
||||
## 文档与演示
|
||||
|
||||
组件演示与 API 文档由 VitePress 驱动,源码位于 `docs/`。它作为独立子包维护,依赖版本与根项目隔离(根项目用 Vite 7,文档站需要 VitePress 自带的 Vite 5)。
|
||||
|
||||
首次使用先安装文档站依赖:
|
||||
|
||||
```bash
|
||||
pnpm -C docs install
|
||||
```
|
||||
|
||||
启动演示站(默认 http://localhost:3004):
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
构建静态站点:
|
||||
|
||||
```bash
|
||||
pnpm run build:doc
|
||||
```
|
||||
|
||||
演示直接引用 `src` 源码,改动组件即可热更新;API 表格由 `vue-docgen-api` 在构建期解析组件源码生成,无需手写。演示统一渲染在手机宽度的视口里——这是移动端组件库,按桌面宽度铺开会失去真实形态。新增组件的完整步骤见文档站的「演示与文档约定」页面。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
Vendored
+16
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 文档站全局样式。
|
||||
*
|
||||
* 只处理 VitePress 正文与演示容器、API 表格的交界处;
|
||||
* 组件自身的样式一律各自 scoped,避免相互污染。
|
||||
*/
|
||||
|
||||
.vp-doc {
|
||||
// 演示容器与正文的呼吸感
|
||||
.tui-demo {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
// API 表格里的类型与默认值不换行,避免长签名把列挤变形
|
||||
table code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
@@ -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) | 折叠展示模型的思考分段,生成中自动展开。 |
|
||||
@@ -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" />
|
||||
@@ -0,0 +1,19 @@
|
||||
# NotifyView 通知视图
|
||||
|
||||
渲染通知的正文内容,是 `ArticleView` 在通知场景下的薄封装。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 传入 NotifyDetail 即可渲染正文
|
||||
|
||||
demo-preview=../demos/notify-view/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要在通知详情页只读展示正文时使用。主题、状态等元数据由业务页面自行呈现,组件只负责正文。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="NotifyView" />
|
||||
@@ -0,0 +1,23 @@
|
||||
# RootLayout 根布局
|
||||
|
||||
移动端页面骨架,主体内容之外自动渲染版权与备案信息。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 插槽填充主体内容,底部版权区在窄屏下自适应
|
||||
|
||||
demo-preview=../demos/root-layout/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要一套带页脚版权信息的整页容器时使用。它只负责骨架与页脚,不含导航栏,业务可以自行组合。
|
||||
|
||||
## 版权区
|
||||
|
||||
底部由 timi-web 的 `Copyright` 组件渲染,`icp` 会链接到工信部网站,`domain` 与 `author` 同时传入时才渲染版权行。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="RootLayout" />
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
@@ -0,0 +1,23 @@
|
||||
# TiptapView 富文本视图
|
||||
|
||||
Tiptap 内容的只读渲染视图,与编辑器共用同一套样式与扩展。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 超出 maxHeight 时可滚动查看
|
||||
|
||||
demo-preview=../demos/tiptap-view/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要在详情页、审批流等只读场景渲染富文本时使用。相比直接 `v-html`,它复用了编辑器的扩展与样式,展示与编辑效果一致。
|
||||
|
||||
## 空内容
|
||||
|
||||
内容为空或规范化后为空字符串时,渲染 `emptyText` 占位文案而不是空白区域。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="TiptapView" />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,但也别把它当作服务端渲染能力的验证。
|
||||
@@ -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),向文档站补充新组件
|
||||
@@ -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,文档与代码不会脱节。
|
||||
---
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+2845
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,22 @@
|
||||
# 通知编辑与预览
|
||||
|
||||
通知复用文章编辑器的正文能力,同时保留主题、状态等自有字段。
|
||||
|
||||
## 组合演示
|
||||
|
||||
:::preview 编辑与预览联动 || 状态字段由外层维护,切状态不影响正文
|
||||
|
||||
demo-preview=../demos/scenarios/notify.vue
|
||||
|
||||
:::
|
||||
|
||||
## 这个场景覆盖了什么
|
||||
|
||||
- **能力复用**:`NotifyEditor` 内部就是 `ArticleEditor`,正文体验一致
|
||||
- **元数据独立**:`subject`、`status` 等字段与正文互不干扰
|
||||
- **字段透传**:业务字段在编辑过程中原样保留
|
||||
|
||||
## 相关组件
|
||||
|
||||
- [NotifyEditor](/components/notify-editor)
|
||||
- [NotifyView](/components/notify-view)
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
<template>
|
||||
<div class="demo-app">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">timi-tdesign-mobile</p>
|
||||
<h1>移动端组件演示台</h1>
|
||||
<p>每个页面都使用真实公开组件和可重复的本地演示数据。</p>
|
||||
</div>
|
||||
<t-tag theme="primary" variant="light">TDesign Mobile</t-tag>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" aria-label="演示分类">
|
||||
<t-button
|
||||
v-for="item in tabs"
|
||||
:key="item.value"
|
||||
size="small"
|
||||
:theme="activeTab === item.value ? 'primary' : 'default'"
|
||||
variant="outline"
|
||||
@click="activeTab = item.value"
|
||||
v-text="item.label"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<editor-showcase v-if="activeTab === 'editor'" />
|
||||
<article-showcase v-else-if="activeTab === 'article'" />
|
||||
<notify-showcase v-else-if="activeTab === 'notify'" />
|
||||
<duration-showcase v-else />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ArticleShowcase from "./components/ArticleShowcase.vue";
|
||||
import DurationShowcase from "./components/DurationShowcase.vue";
|
||||
import EditorShowcase from "./components/EditorShowcase.vue";
|
||||
import NotifyShowcase from "./components/NotifyShowcase.vue";
|
||||
|
||||
type TabValue = "editor" | "article" | "notify" | "duration";
|
||||
|
||||
const tabs: Array<{ label: string; value: TabValue }> = [
|
||||
{ label: "编辑器", value: "editor" },
|
||||
{ label: "文章", value: "article" },
|
||||
{ label: "通知", value: "notify" },
|
||||
{ label: "移动端组件", value: "duration" }
|
||||
];
|
||||
const activeTab = ref<TabValue>("editor");
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo-app {
|
||||
width: min(100% - 2rem, 80rem);
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 4rem;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h1 {
|
||||
margin: .25rem 0;
|
||||
font-size: clamp(1.75rem, 6vw, 3rem);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: .75rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.demo-app {
|
||||
width: min(100% - 1rem, 80rem);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,177 +0,0 @@
|
||||
<template>
|
||||
<section class="demo-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">文章场景</p>
|
||||
<h2>文章编辑与预览</h2>
|
||||
<p>切换正文类型,验证统一的 Article 数据结构。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
v-for="item in contentTypeOptions"
|
||||
:key="item.value"
|
||||
size="small"
|
||||
:theme="articleValue.contentType === item.value ? 'primary' : 'default'"
|
||||
variant="outline"
|
||||
@click="selectContentType(item.value)"
|
||||
v-text="item.label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-grid">
|
||||
<article class="demo-card">
|
||||
<div class="card-heading">
|
||||
<h3>ArticleEditor</h3>
|
||||
<t-button size="small" theme="primary" @click="submitArticle">提交文章</t-button>
|
||||
</div>
|
||||
<t-input v-model="articleValue.title" class="title-input" placeholder="文章标题" />
|
||||
<article-editor
|
||||
ref="articleEditorRef"
|
||||
:model-value="articleValue"
|
||||
:upload-image="uploadDemoMedia"
|
||||
:upload-video="uploadDemoMedia"
|
||||
@change="onArticleChange"
|
||||
@update:model-value="onArticleChange"
|
||||
/>
|
||||
<p class="status" v-text="articleStatus" />
|
||||
</article>
|
||||
|
||||
<article class="demo-card">
|
||||
<div class="card-heading">
|
||||
<h3>ArticleView</h3>
|
||||
<span v-text="articleValue.contentType" />
|
||||
</div>
|
||||
<article-view :article="articleValue" />
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ArticleEditor,
|
||||
ArticleView
|
||||
} from "timi-tdesign-mobile";
|
||||
import type { ArticleEditorExpose } from "timi-tdesign-mobile";
|
||||
import type { Article, ArticleContentType } from "timi-tdesign-mobile";
|
||||
import { DEMO_ARTICLE_CONTENT, cloneArticle, uploadDemoMedia } from "../demo-data";
|
||||
|
||||
const contentTypeOptions: Array<{ label: string; value: ArticleContentType }> = [
|
||||
{ label: "纯文本", value: "TEXT" },
|
||||
{ label: "Markdown", value: "MARKDOWN" },
|
||||
{ label: "Tiptap", value: "TIP_TAP" }
|
||||
];
|
||||
const articleValue = ref<Article>(cloneArticle());
|
||||
const articleEditorRef = ref<ArticleEditorExpose>();
|
||||
const articleStatus = ref("等待提交");
|
||||
|
||||
function selectContentType(contentType: ArticleContentType) {
|
||||
const content = contentType === "TEXT"
|
||||
? "这是一段纯文本示例。"
|
||||
: contentType === "MARKDOWN"
|
||||
? "## Markdown 示例\n\n这是一段 Markdown 正文。"
|
||||
: DEMO_ARTICLE_CONTENT;
|
||||
articleValue.value = {
|
||||
...articleValue.value,
|
||||
contentType,
|
||||
content
|
||||
};
|
||||
}
|
||||
|
||||
function onArticleChange(value: Article) {
|
||||
articleValue.value = value;
|
||||
}
|
||||
|
||||
async function submitArticle() {
|
||||
const value = await articleEditorRef.value?.submit();
|
||||
if (value) {
|
||||
articleValue.value = value;
|
||||
articleStatus.value = `已提交,${value.attachmentList?.length || 0} 个附件`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo-section {
|
||||
.section-heading {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: .75rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.actions,
|
||||
.card-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .75rem;
|
||||
background: var(--td-bg-color-container);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span,
|
||||
.status {
|
||||
font-size: .75rem;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.title-input {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.section-heading,
|
||||
.card-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<section class="demo-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">移动端专属</p>
|
||||
<h2>DurationPicker</h2>
|
||||
<p>验证 Picker 交互和毫秒值双向转换。</p>
|
||||
</div>
|
||||
<span class="value" v-text="`${durationMs} ms`" />
|
||||
</div>
|
||||
<div class="demo-card">
|
||||
<t-cell title="当前毫秒值" arrow @click="visible = true">
|
||||
<span v-text="formatDuration(durationMs)" />
|
||||
</t-cell>
|
||||
</div>
|
||||
<t-popup v-model="visible" placement="bottom" destroy-on-close>
|
||||
<div class="picker-panel">
|
||||
<duration-picker v-model="durationMs" @change="visible = false" />
|
||||
</div>
|
||||
</t-popup>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DurationPicker } from "timi-tdesign-mobile";
|
||||
|
||||
const visible = ref(false);
|
||||
const durationMs = ref(1000);
|
||||
|
||||
function formatDuration(value: number) {
|
||||
if (60 * 60 * 1000 <= value && value % (60 * 60 * 1000) === 0) {
|
||||
return `${value / (60 * 60 * 1000)} 小时`;
|
||||
}
|
||||
if (60 * 1000 <= value && value % (60 * 1000) === 0) {
|
||||
return `${value / (60 * 1000)} 分钟`;
|
||||
}
|
||||
if (1000 <= value && value % 1000 === 0) {
|
||||
return `${value / 1000} 秒`;
|
||||
}
|
||||
return `${value} 毫秒`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo-section {
|
||||
.section-heading {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: .75rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
padding: .25rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .75rem;
|
||||
background: var(--td-bg-color-container);
|
||||
}
|
||||
|
||||
.picker-panel {
|
||||
padding-top: .5rem;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
background: var(--td-bg-color-container);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,186 +0,0 @@
|
||||
<template>
|
||||
<section class="demo-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">编辑器基础能力</p>
|
||||
<h2>文本、富文本和代码</h2>
|
||||
<p>通过同一组公共数据规则验证移动端 TDesign 交互。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<t-button size="small" theme="primary" @click="openSearch">打开搜索</t-button>
|
||||
<t-button size="small" variant="outline" @click="submitEditor">提交富文本</t-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-grid">
|
||||
<article class="demo-card wide">
|
||||
<div class="card-heading">
|
||||
<h3>Tiptap 编辑器</h3>
|
||||
<span v-text="editorStatus" />
|
||||
</div>
|
||||
<div class="editor-box rich-editor">
|
||||
<tiptap-editor
|
||||
ref="tiptapEditorRef"
|
||||
v-model="richContent"
|
||||
:upload-image="uploadDemoMedia"
|
||||
:upload-video="uploadDemoMedia"
|
||||
@change="editorStatus = `内容已更新,${$event.length} 个字符`"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="demo-card wide">
|
||||
<div class="card-heading">
|
||||
<h3>Tiptap 只读视图</h3>
|
||||
<t-input v-model="maxHeight" class="height-input" placeholder="最大高度,如 20rem" />
|
||||
</div>
|
||||
<tiptap-view :content="richContent" :max-height="maxHeight" />
|
||||
</article>
|
||||
|
||||
<article class="demo-card wide">
|
||||
<div class="card-heading">
|
||||
<h3>代码编辑器</h3>
|
||||
<t-button size="small" variant="text" @click="resetCode">恢复示例</t-button>
|
||||
</div>
|
||||
<div class="editor-box code-editor">
|
||||
<code-editor
|
||||
ref="codeEditorRef"
|
||||
v-model="codeContent"
|
||||
language="typescript"
|
||||
placeholder="请输入代码"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
CodeEditor,
|
||||
TiptapEditor,
|
||||
TiptapView
|
||||
} from "timi-tdesign-mobile";
|
||||
import type {
|
||||
CodeEditorExpose,
|
||||
TiptapEditorExpose
|
||||
} from "timi-tdesign-mobile";
|
||||
import { DEMO_ARTICLE_CONTENT, DEMO_CODE, uploadDemoMedia } from "../demo-data";
|
||||
|
||||
const richContent = ref(DEMO_ARTICLE_CONTENT);
|
||||
const codeContent = ref(DEMO_CODE);
|
||||
const maxHeight = ref("20rem");
|
||||
const editorStatus = ref("等待编辑");
|
||||
const tiptapEditorRef = ref<TiptapEditorExpose>();
|
||||
const codeEditorRef = ref<CodeEditorExpose>();
|
||||
|
||||
function openSearch() {
|
||||
tiptapEditorRef.value?.openSearch(true);
|
||||
codeEditorRef.value?.openSearch(true);
|
||||
}
|
||||
|
||||
async function submitEditor() {
|
||||
const value = await tiptapEditorRef.value?.submit();
|
||||
if (value !== undefined) {
|
||||
editorStatus.value = `已提交,${value.length} 个字符`;
|
||||
}
|
||||
}
|
||||
|
||||
function resetCode() {
|
||||
codeContent.value = DEMO_CODE;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo-section {
|
||||
.section-heading {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: .75rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.actions,
|
||||
.card-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .75rem;
|
||||
background: var(--td-bg-color-container);
|
||||
box-shadow: 0 .25rem 1rem rgba(0, 0, 0, .04);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: .75rem;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 24rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
height: 28rem;
|
||||
}
|
||||
|
||||
.height-input {
|
||||
width: 9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.section-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.height-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,137 +0,0 @@
|
||||
<template>
|
||||
<section class="demo-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">通知场景</p>
|
||||
<h2>通知编辑与预览</h2>
|
||||
<p>Notify 组件复用文章编辑器,同时保留通知自己的元数据。</p>
|
||||
</div>
|
||||
<t-button size="small" theme="primary" @click="submitNotify">提交通知</t-button>
|
||||
</div>
|
||||
|
||||
<div class="demo-grid">
|
||||
<article class="demo-card">
|
||||
<div class="card-heading">
|
||||
<h3>NotifyEditor</h3>
|
||||
<span v-text="notifyStatus" />
|
||||
</div>
|
||||
<t-input v-model="notifyValue.subject" class="subject-input" placeholder="通知标题" />
|
||||
<notify-editor
|
||||
ref="notifyEditorRef"
|
||||
:model-value="notifyValue"
|
||||
:upload-image="uploadDemoMedia"
|
||||
:upload-video="uploadDemoMedia"
|
||||
@change="onNotifyChange"
|
||||
@update:model-value="onNotifyChange"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="demo-card">
|
||||
<div class="card-heading">
|
||||
<h3>NotifyView</h3>
|
||||
<span v-text="notifyValue.status" />
|
||||
</div>
|
||||
<notify-view :detail="notifyValue" />
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
NotifyEditor,
|
||||
NotifyView
|
||||
} from "timi-tdesign-mobile";
|
||||
import type { NotifyEditorExpose } from "timi-tdesign-mobile";
|
||||
import type { NotifyDetail } from "timi-tdesign-mobile";
|
||||
import { cloneNotify, uploadDemoMedia } from "../demo-data";
|
||||
|
||||
const notifyValue = ref<NotifyDetail>(cloneNotify());
|
||||
const notifyEditorRef = ref<NotifyEditorExpose>();
|
||||
const notifyStatus = ref("等待提交");
|
||||
|
||||
function onNotifyChange(value: NotifyDetail) {
|
||||
notifyValue.value = value;
|
||||
}
|
||||
|
||||
async function submitNotify() {
|
||||
const value = await notifyEditorRef.value?.submit();
|
||||
if (value) {
|
||||
notifyValue.value = value;
|
||||
notifyStatus.value = `已提交,${value.content?.length || 0} 个字符`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo-section {
|
||||
.section-heading {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: .75rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
justify-content: space-between;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: .75rem;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .75rem;
|
||||
background: var(--td-bg-color-container);
|
||||
}
|
||||
|
||||
.subject-input {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.section-heading,
|
||||
.card-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Article, NotifyDetail, UploadResult } from "timi-tdesign-mobile";
|
||||
|
||||
/** 编辑器演示使用的固定内容,避免每个页面各自维护一份 HTML。 */
|
||||
export const DEMO_ARTICLE_CONTENT = `<h2>跨平台编辑器</h2>
|
||||
<p>这段内容同时验证标题、段落、链接、表格和代码块的渲染。</p>
|
||||
<blockquote><p>移动端和 PC 端只替换交互层,共享文档模型。</p></blockquote>
|
||||
<pre><code class="language-typescript">const message: string = "timi editor";</code></pre>`;
|
||||
|
||||
export const DEMO_CODE = `type DemoUser = {
|
||||
\tid: string;
|
||||
\tname: string;
|
||||
};
|
||||
|
||||
const user: DemoUser = {
|
||||
\tid: "demo-001",
|
||||
\tname: "Timi"
|
||||
};
|
||||
|
||||
console.log(user.name);`;
|
||||
|
||||
export const DEMO_ARTICLE: Article = {
|
||||
title: "编辑器组件演示文章",
|
||||
description: "用于验证文章编辑、预览和附件处理。",
|
||||
contentType: "TIP_TAP",
|
||||
content: DEMO_ARTICLE_CONTENT,
|
||||
attachmentList: []
|
||||
};
|
||||
|
||||
export const DEMO_NOTIFY: NotifyDetail = {
|
||||
subject: "编辑器通知演示",
|
||||
msgType: "INTERNAL",
|
||||
contentType: "TIP_TAP",
|
||||
content: DEMO_ARTICLE_CONTENT,
|
||||
status: "WAITING",
|
||||
attachmentList: []
|
||||
};
|
||||
|
||||
export function cloneArticle(): Article {
|
||||
return {
|
||||
...DEMO_ARTICLE,
|
||||
attachmentList: [...(DEMO_ARTICLE.attachmentList || [])]
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneNotify(): NotifyDetail {
|
||||
return {
|
||||
...DEMO_NOTIFY,
|
||||
attachmentList: [...(DEMO_NOTIFY.attachmentList || [])]
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回本地预览地址,让示例无需依赖后端上传接口。 */
|
||||
export async function uploadDemoMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
import Root from "./Root.vue";
|
||||
|
||||
import TimiWebUI from "timi-web";
|
||||
|
||||
import "timi-web/style.css";
|
||||
import "tdesign-mobile-vue/es/style/index.css";
|
||||
import "../src/assets/style/tdesign-custom.less";
|
||||
|
||||
const app = createApp(Root);
|
||||
app.use(TimiWebUI);
|
||||
app.mount("#root");
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f5f5f5"/>
|
||||
<meta name="color-scheme" content="light dark"/>
|
||||
<meta name="format-detection" content="telephone=no"/>
|
||||
<meta name="mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
|
||||
<meta name="apple-mobile-web-app-title" content="Timi Mobile"/>
|
||||
<title>Timi TDesign Mobile</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/examples/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-3
@@ -8,10 +8,10 @@
|
||||
"version": "0.0.10",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:doc": "pnpm run -C docs dev",
|
||||
"dev": "pnpm run -C docs dev",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"build:doc": "pnpm run -C docs build",
|
||||
"preview:doc": "pnpm run -C docs preview",
|
||||
"pub": "pnpm run build && npm publish --registry=https://nexus.imyeyu.com/repository/npm-timi/"
|
||||
},
|
||||
"publishConfig": {
|
||||
@@ -20,7 +20,6 @@
|
||||
"files": [
|
||||
"dist/**",
|
||||
"src/**",
|
||||
"examples/**",
|
||||
"README.md",
|
||||
"package.json"
|
||||
],
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
import type { PickerValue } from "tdesign-mobile-vue";
|
||||
import { supportedCodeLanguages } from "timi-web/editor";
|
||||
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||
import BottomPopup from "../bottom-popup/index.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapCodeBlockButton"
|
||||
|
||||
+1
-7
@@ -17,9 +17,6 @@
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./examples/*"
|
||||
],
|
||||
"timi-web/editor": [
|
||||
"../timi-web/src/editor/index.ts"
|
||||
],
|
||||
@@ -32,10 +29,7 @@
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.vue",
|
||||
"examples/**/*.ts",
|
||||
"examples/**/*.d.ts",
|
||||
"examples/**/*.vue",
|
||||
"node_modules/.pnpm/unplugin-auto-import@19.1.2/node_modules/unplugin-auto-import/auto-imports.d.ts"
|
||||
"auto-imports.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
+1
-5
@@ -8,10 +8,6 @@ import { TDesignResolver } from "unplugin-vue-components/resolvers";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
const alias: Alias[] = [
|
||||
{
|
||||
find: "@",
|
||||
replacement: resolve(__dirname, "./examples")
|
||||
},
|
||||
{
|
||||
find: /^timi-web\/editor$/,
|
||||
replacement: resolve(__dirname, "../timi-web/src/editor/index.ts")
|
||||
@@ -67,7 +63,7 @@ export default defineConfig({
|
||||
"vue",
|
||||
"vue-router"
|
||||
],
|
||||
dts: "examples/auto-imports.d.ts",
|
||||
dts: "auto-imports.d.ts",
|
||||
eslintrc: {
|
||||
enabled: true,
|
||||
globalsPropValue: true
|
||||
|
||||
Reference in New Issue
Block a user