Compare commits

..
18 Commits
Author SHA1 Message Date
Timi 72ed15ae34 update style 2026-10-12 16:18:06 +08:00
Timi 393bc8aa1b add step for entrance-transition 2026-10-12 16:17:50 +08:00
Timi b09011251a add card-container 2026-10-12 16:17:14 +08:00
Timi a20e051e8a support attach 2026-09-26 14:20:18 +08:00
Timi 2b92012c39 fix transition invalid 2026-09-26 09:29:10 +08:00
Timi 0a4425e11f fix overflow x 2026-09-26 09:28:56 +08:00
Timi e8b9a21b82 update style 2026-09-22 21:28:04 +08:00
Timi aeea93c0bb add entrance-transition.ts 2026-09-22 21:27:53 +08:00
Timi b54c07fe49 add new session button 2026-09-22 21:27:33 +08:00
Timi 45f77d3168 add PageTransition 2026-09-22 21:27:08 +08:00
Timi 37cf38fe27 add vitepress docs 2026-09-21 16:05:14 +08:00
Timi b435bf992e add aiChat 2026-09-20 12:03:03 +08:00
Timi 3d2968cb3b fix font size 2026-09-09 15:00:14 +08:00
Timi fc0348aeae fix code editor border radius 2026-09-09 11:26:08 +08:00
Timi f14ebcfc85 fix zIndex 2026-09-07 12:07:57 +08:00
Timi aadc8fa8fb add swipe-cell-button 2026-08-31 16:18:57 +08:00
Timi b3c324653b fix update slot fail 2026-08-31 15:57:40 +08:00
Timi 5b5a5190e3 fix padding top 2026-08-31 15:57:20 +08:00
102 changed files with 9833 additions and 844 deletions
+6 -1
View File
@@ -1,9 +1,14 @@
/.eslintrc-auto-import.json /.eslintrc-auto-import.json
/components.d.ts /components.d.ts
/examples/auto-imports.d.ts /auto-imports.d.ts
/AGENTS.md /AGENTS.md
/.npmrc /.npmrc
# 文档站
/docs/.vitepress/cache
/docs/.vitepress/dist
/docs/.npmrc
# Logs # Logs
logs logs
*.log *.log
+3
View File
@@ -0,0 +1,3 @@
# 项目概览
`timi-tdesign-mobile` 是 Vue 3 组件与前端通用能力依赖库,使用 TypeScript、Vite、Less、Axios、TDesign Mobile Vue。主要源码在 `src/components`、`src/api`、`src/assets`、`src/types`、`src/store`、`src/utils`,包入口为 `src/index.ts`。仓库也包含 `docs` 子项目。开发环境为 Windows,命令通过 PowerShell 执行。
@@ -0,0 +1,7 @@
# 风格与约定
- Vue 3 Composition API,优先 `<script setup>`;组件标签使用小写 kebab-case。
- TypeScript 优先明确类型并为公共 API 写 JSDoc;Promise 使用 async/await。
- Less 使用嵌套规则,属性尽量简写,优先 rem;属性按名称长度排序,类名简短。
- 文本文件使用 UTF-8;遵循仓库 `.eslintrc.js`。ESLint 9 当前使用旧式 `.eslintrc` 时,设置 `$env:ESLINT_USE_FLAT_CONFIG = 'false'` 后再运行。
- 不随意重命名公共类型,不无故增加依赖;不添加测试,除非用户明确要求。
+8
View File
@@ -0,0 +1,8 @@
# 常用命令(PowerShell)
- `pnpm dev`:启动 docs 开发服务(根脚本转发到 `docs`)。
- `pnpm build`:运行 `vue-tsc --noEmit` 并打包组件库。
- `pnpm run build:doc`:构建文档。
- `pnpm run preview:doc`:预览构建后的文档。
- ESLint 没有根脚本;旧式配置可用 `$env:ESLINT_USE_FLAT_CONFIG = 'false'; pnpm exec eslint <路径>`。
- 根 `package.json` 没有测试脚本。查看状态用 `git status --short`,文件搜索用 `rg` / `rg --files`。
+3
View File
@@ -0,0 +1,3 @@
# 完成任务时
先查看 `git status --short`,保留用户已有改动;遵循 `.eslintrc.js` 和 `AGENTS.md`。通常针对改动文件检查 ESLint 与 TypeScript 语法;与声明生成/打包相关的问题可运行 `pnpm build`。除非用户明确要求,不添加测试或运行测试。
+24
View File
@@ -26,6 +26,30 @@ Vue 3 移动端通用组件库,依赖 `timi-web` 提供基础组件和编辑
通知组件使用同样的 `uploadImage`、`uploadVideo` 和 `resolveAttachmentUrl` 回调。没有附件上传需求时,可以不传上传回调。 通知组件使用同样的 `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 ```bash
+83
View File
@@ -0,0 +1,83 @@
/**
* API 文档数据类型。
*
* 这些结构由 Node 侧的 `plugins/docgen.ts` 产出,通过虚拟模块交给浏览器渲染,
* 因此刻意保持为不含第三方类型的纯数据,避免 `vue-docgen-api` 被打进客户端产物。
*/
/** 单个 prop 的表格行数据。 */
export interface ApiPropRow {
/** prop 名称。 */
name: string;
/** 类型签名,如 `string`、`Article`。 */
type: string;
/** 默认值,未声明时为空字符串。 */
defaultValue: string;
/** 是否必填。 */
required: boolean;
/** 注释说明,取自源码 JSDoc。 */
description: string;
}
/** 单个事件的表格行数据。 */
export interface ApiEventRow {
/** 事件名,如 `update:modelValue`。 */
name: string;
/** 回调参数签名。 */
type: string;
/** 注释说明。 */
description: string;
}
/** 单个插槽的表格行数据。 */
export interface ApiSlotRow {
/** 插槽名。 */
name: string;
/** 注释说明或作用域参数。 */
description: string;
}
/** 单个暴露方法或属性的表格行数据。 */
export interface ApiExposeRow {
/** 暴露的名称。 */
name: string;
/** 类型签名。 */
type: string;
/** 注释说明。 */
description: string;
}
/** 一个组件解析完成后的完整 API 数据。 */
export interface ComponentApi {
/** 组件名。 */
name: string;
/** 组件自身的注释说明。 */
description: string;
/**
* 源码是否解析成功。
*
* 用来区分「解析失败」与「组件本身就没有 props/events/slots/expose」,
* 两者都会得到空数组,但提示语完全不同。
*/
parsed: boolean;
props: ApiPropRow[];
events: ApiEventRow[];
slots: ApiSlotRow[];
exposes: ApiExposeRow[];
}
/** 全部组件的 API 数据,键为组件名。 */
export type ComponentApiMap = Record<string, ComponentApi>;
/** 生成一个空的 API 数据,用于解析失败时兜底,保证文档站不因单个组件而白屏。 */
export function createEmptyApi(name: string): ComponentApi {
return {
name,
description: "",
parsed: false,
props: [],
events: [],
slots: [],
exposes: []
};
}
+187
View File
@@ -0,0 +1,187 @@
/**
* 组件元数据清单。
*
* 这是文档站的唯一数据源,三处消费它:
* - `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: "PageTransition",
title: "PageTransition 页面过渡",
description: "为 Vue Router 页面提供可配置的层级滑动过渡与按路由缓存能力。",
category: "布局组件",
entry: "src/components/page-transition/index.vue"
},
{
name: "RootLayout",
title: "RootLayout 根布局",
description: "移动端页面骨架,主体内容之外自动渲染版权与备案信息。",
category: "布局组件",
entry: "src/components/root-layout/index.vue"
},
{
name: "Space",
title: "Space 间距",
description: "控制子元素之间的间距与排列方向,支持分隔符。",
category: "布局组件",
entry: "src/components/space/index.vue"
},
// ---------- 编辑器组件 ----------
{
name: "CodeEditor",
title: "CodeEditor 代码编辑器",
description: "带行号、语法高亮与查找替换的代码输入框。",
category: "编辑器组件",
entry: "src/components/code-editor/index.vue"
},
{
name: "TiptapEditor",
title: "TiptapEditor 富文本编辑器",
description: "基于 Tiptap 的富文本编辑器,工具栏覆盖媒体、表格、链接与代码块。",
category: "编辑器组件",
entry: "src/components/tiptap-editor/index.vue"
},
{
name: "TiptapView",
title: "TiptapView 富文本视图",
description: "Tiptap 内容的只读渲染视图,与编辑器共用同一套样式。",
category: "编辑器组件",
entry: "src/components/tiptap-view/index.vue"
},
{
name: "ArticleEditor",
title: "ArticleEditor 文章编辑器",
description: "以 Article 为数据模型,按正文类型切换纯文本、Markdown 与富文本编辑。",
category: "编辑器组件",
entry: "src/components/article-editor/index.vue"
},
{
name: "ArticleView",
title: "ArticleView 文章视图",
description: "按 Article 的正文类型渲染正文与附件列表。",
category: "编辑器组件",
entry: "src/components/article-view/index.vue"
},
{
name: "NotifyEditor",
title: "NotifyEditor 通知编辑器",
description: "以 NotifyDetail 为数据模型的通知编辑表单,复用文章编辑器能力。",
category: "编辑器组件",
entry: "src/components/notify-editor/index.vue"
},
{
name: "NotifyView",
title: "NotifyView 通知视图",
description: "渲染通知的正文内容。",
category: "编辑器组件",
entry: "src/components/notify-view/index.vue"
},
// ---------- 交互组件 ----------
{
name: "BottomPopup",
title: "BottomPopup 底部弹层",
description: "从底部升起的操作面板,可带标题与左右按钮。",
category: "交互组件",
entry: "src/components/bottom-popup/index.vue"
},
{
name: "DurationPicker",
title: "DurationPicker 时长选择",
description: "按时长值选择并回填,单位与候选项由外部决定。",
category: "交互组件",
entry: "src/components/duration-picker/index.vue"
},
// ---------- AI 对话组件 ----------
{
name: "AiChatPanel",
title: "AiChatPanel 对话面板",
description: "把消息列表与输入框组装成完整对话界面,由业务侧驱动数据。",
category: "AI 对话组件",
entry: "src/components/ai/AiChatPanel.vue"
},
{
name: "AiChatMessageList",
title: "AiChatMessageList 消息列表",
description: "渲染对话消息,含思考过程、引用与流式光标。",
category: "AI 对话组件",
entry: "src/components/ai/AiChatMessageList.vue"
},
{
name: "AiChatComposer",
title: "AiChatComposer 输入框",
description: "对话输入框,按状态在发送、停止与重试之间切换。",
category: "AI 对话组件",
entry: "src/components/ai/AiChatComposer.vue"
},
{
name: "AiChatThinking",
title: "AiChatThinking 思考过程",
description: "折叠展示模型的思考分段,生成中自动展开。",
category: "AI 对话组件",
entry: "src/components/ai/AiChatThinking.vue"
}
];
/**
* 文档路径与路由片段。
*
* `index.vue` 取所在目录名,具名文件取文件名——`ai` 目录下有多个组件,
* 只靠目录名会全部撞成 `ai`。两种写法都会再转成短横线形式。
*/
export function toSlug(meta: ComponentMeta): string {
if (meta.slug) {
return meta.slug;
}
const segments = meta.entry.split("/");
const fileName = segments[segments.length - 1];
const baseName = fileName.replace(/\.\w+$/, "");
const raw = baseName === "index" ? segments[segments.length - 2] : baseName;
return raw.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
}
/** 组件文档页的站内绝对路径。 */
export function toDocLink(meta: ComponentMeta): string {
return `/components/${toSlug(meta)}`;
}
/** 按分类分组,保持 CATEGORY_LIST 的声明顺序。 */
export function groupByCategory(): Array<{ category: ComponentCategory; items: ComponentMeta[] }> {
return CATEGORY_LIST.map(category => ({
category,
items: COMPONENT_LIST.filter(item => item.category === category)
})).filter(group => group.items.length > 0);
}
+130
View File
@@ -0,0 +1,130 @@
import { resolve } from "node:path";
import { defineConfig } from "vitepress";
import {
componentPreview,
containerPreview
} from "@vitepress-demo-preview/plugin";
import AutoImport from "unplugin-auto-import/vite";
import { groupByCategory, toDocLink } from "./components";
import { docgenPlugin } from "./plugins/docgen";
import { timiWebSsrStub } from "./plugins/ssr-stub";
/** `__dirname` 由 VitePress 打包配置时注入,指向 `docs/.vitepress`。 */
const docsDir = resolve(__dirname, "..");
/** 仓库根目录,组件源码位于它的 `src` 下。 */
const repoRoot = resolve(docsDir, "..");
/** timi-web 仓库目录,docs 通过 link 依赖它。 */
const timiWebRoot = resolve(repoRoot, "../timi-web");
/**
* 源码引用别名。
*
* demo 直接引用 `src` 源码而非构建产物,改组件即可热更新文档。
* 组件源码自身的传递依赖(tiptap、prismjs 等)由 timi-web 的 node_modules 解析,
* docs 无需重复声明。
*/
const alias = {
"timi-tdesign-mobile": resolve(repoRoot, "src/index.ts"),
"timi-web/editor": resolve(timiWebRoot, "src/editor/index.ts")
};
/** 组件文档侧边栏,按 components.ts 的分类与顺序生成。 */
const componentSidebar = groupByCategory().map(group => ({
text: group.category,
collapsed: false,
items: group.items.map(item => ({
text: item.title,
link: toDocLink(item)
}))
}));
export default defineConfig({
title: "timi-tdesign-mobile",
description: "移动端 TDesign 组件库的演示与 API 文档",
lang: "zh-CN",
cleanUrls: true,
themeConfig: {
nav: [
{ text: "指南", link: "/guide/" },
{ text: "场景", link: "/scenarios/editor" },
{ text: "组件", link: "/components/" }
],
sidebar: [
{
text: "指南",
items: [
{ text: "快速开始", link: "/guide/" },
{ text: "演示与文档约定", link: "/guide/demo" }
]
},
{
text: "场景示例",
collapsed: false,
items: [
{ text: "编辑器能力总览", link: "/scenarios/editor" },
{ text: "文章编辑与预览", link: "/scenarios/article" },
{ text: "通知编辑与预览", link: "/scenarios/notify" },
{ text: "移动端组件", link: "/scenarios/mobile" },
{ text: "AI 对话", link: "/scenarios/ai" }
]
},
...componentSidebar
],
outline: {
level: [2, 3],
label: "本页目录"
},
search: {
provider: "local"
},
docFooter: {
prev: "上一篇",
next: "下一篇"
},
darkModeSwitchLabel: "主题",
sidebarMenuLabel: "目录",
returnToTopLabel: "回到顶部"
},
markdown: {
theme: {
light: "vitesse-light",
dark: "vitesse-dark"
},
lineNumbers: true,
config(md) {
// 组件依赖浏览器 API(Tiptap、Prism、ResizeObserver、SSE 等),
// 统一开启 clientOnly 交给浏览器渲染,避免 SSR 阶段报错。
md.use(componentPreview, { clientOnly: true, alias });
md.use(containerPreview, { clientOnly: true, alias });
}
},
vite: {
resolve: {
alias
},
plugins: [
// 必须最先执行:SSR 阶段拦截 timi-web,否则整个构建无法通过。
timiWebSsrStub(docsDir),
docgenPlugin(repoRoot),
// src 源码依赖自动导入(内部没有显式 import vue 的文件不在少数),
// 这里仅为其提供支持;docs 自身的代码一律显式导入。
AutoImport({
imports: ["vue"],
dts: false
})
],
server: {
port: 3004,
host: true,
fs: {
// demo 需要读取 docs 之外的 src 与 timi-web 源码。
allow: [repoRoot, timiWebRoot]
}
}
}
});
+160
View File
@@ -0,0 +1,160 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { parse } from "vue-docgen-api";
import type { Plugin } from "vite";
import { COMPONENT_LIST } from "../components";
import {
createEmptyApi,
type ApiEventRow,
type ApiExposeRow,
type ApiPropRow,
type ApiSlotRow,
type ComponentApi,
type ComponentApiMap
} from "../api-types";
/** 虚拟模块的引用标识,业务代码与类型声明共用。 */
export const API_MODULE_ID = "virtual:timi-api";
/** Vite 内部使用的解析后标识,前置 `\0` 避免被其他插件处理。 */
const RESOLVED_ID = `\0${API_MODULE_ID}`;
/**
* 把 vue-docgen-api 的联合类型描述压平成可读文本。
* 不同版本对 `defineEmits` 的产出结构不一致,这里统一兜底。
*/
function normalizeType(type: unknown): string {
if (!type || typeof type !== "object") {
return "-";
}
const candidate = type as { name?: unknown; names?: unknown };
if (Array.isArray(candidate.names)) {
return candidate.names.length > 0 ? candidate.names.join(" | ") : "-";
}
if (typeof candidate.name === "string" && candidate.name.length > 0) {
return candidate.name;
}
return "-";
}
/** 转换 prop 描述符。 */
function toPropRow(descriptor: any): ApiPropRow {
return {
name: String(descriptor.name ?? ""),
type: normalizeType(descriptor.type),
defaultValue: descriptor.defaultValue?.value === undefined ? "-" : String(descriptor.defaultValue.value),
required: descriptor.required === true,
description: String(descriptor.description ?? "")
};
}
/** 转换事件描述符。 */
function toEventRow(descriptor: any): ApiEventRow {
return {
name: String(descriptor.name ?? ""),
type: normalizeType(descriptor.type),
description: String(descriptor.description ?? "")
};
}
/** 转换插槽描述符,把作用域参数并入说明列。 */
function toSlotRow(descriptor: any): ApiSlotRow {
const bindings = Array.isArray(descriptor.bindings)
? descriptor.bindings.map((item: any) => String(item.name ?? "")).filter(Boolean)
: [];
const description = String(descriptor.description ?? "");
const scope = bindings.length > 0 ? `作用域参数:${bindings.join("、")}` : "";
return {
name: String(descriptor.name ?? ""),
description: [description, scope].filter(Boolean).join(";")
};
}
/** 转换暴露描述符。 */
function toExposeRow(descriptor: any): ApiExposeRow {
return {
name: String(descriptor.name ?? ""),
type: normalizeType(descriptor.type),
description: String(descriptor.description ?? "")
};
}
/** 把单个组件文档规范化为纯数据。 */
function toComponentApi(name: string, doc: any): ComponentApi {
return {
name,
description: String(doc?.description ?? ""),
parsed: true,
props: Array.isArray(doc?.props) ? doc.props.map(toPropRow) : [],
events: Array.isArray(doc?.events) ? doc.events.map(toEventRow) : [],
slots: Array.isArray(doc?.slots) ? doc.slots.map(toSlotRow) : [],
exposes: Array.isArray(doc?.expose) ? doc.expose.map(toExposeRow) : []
};
}
/**
* 逐个解析组件源码。
* 单个组件解析失败只降级为空 API,不让整个文档站因一处语法而无法构建。
*/
async function parseAll(repoRoot: string): Promise<ComponentApiMap> {
const result: ComponentApiMap = {};
for (const meta of COMPONENT_LIST) {
const filePath = resolve(repoRoot, meta.entry);
if (!existsSync(filePath)) {
console.warn(`[timi-docgen] 未找到组件入口,已跳过:${meta.entry}`);
result[meta.name] = createEmptyApi(meta.name);
continue;
}
try {
const doc = await parse(filePath);
result[meta.name] = toComponentApi(meta.name, doc);
} catch (error) {
console.warn(`[timi-docgen] 解析 ${meta.name} 失败,已降级为空 API:`, error);
result[meta.name] = createEmptyApi(meta.name);
}
}
return result;
}
/**
* 提供 `virtual:timi-api` 虚拟模块,内容为全部组件的 API 数据。
*
* 解析结果按需缓存,仅在组件源码变更时失效,避免每次页面请求都重复解析。
* @param repoRoot 仓库根目录的绝对路径
*/
export function docgenPlugin(repoRoot: string): Plugin {
const entryPathSet = new Set(COMPONENT_LIST.map(meta => resolve(repoRoot, meta.entry)));
let cache: ComponentApiMap | null = null;
return {
name: "timi-docgen",
resolveId(id: string) {
return id === API_MODULE_ID ? RESOLVED_ID : null;
},
async load(id: string) {
if (id !== RESOLVED_ID) {
return null;
}
if (cache === null) {
cache = await parseAll(repoRoot);
}
return `export default ${JSON.stringify(cache)};`;
},
handleHotUpdate({ file, server }) {
if (!entryPathSet.has(file)) {
return;
}
cache = null;
const invalidated = server.moduleGraph.getModuleById(RESOLVED_ID);
if (invalidated) {
server.moduleGraph.invalidateModule(invalidated);
server.ws.send({ type: "full-reload" });
}
}
};
}
+133
View File
@@ -0,0 +1,133 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import type { Plugin } from "vite";
/** 虚拟 stub 模块标识。 */
const STUB_ID = "\0timi-web-ssr-stub";
/** 被 stub 的模块说明符。 */
const STUBBED_MODULES = new Set(["timi-web", "timi-web/editor"]);
/**
* SSR 阶段必须存在的具名导出。
*
* 取自 `src` 与 `docs` 中所有 `timi-web` / `timi-web/editor` 值导入的并集,
* 是 stub 的保底名单;若构建产物可读,`collectExportNames` 会在此基础上补全全部导出。
*/
const REQUIRED_EXPORTS = [
// ---------- timi-web ----------
"VPopup",
"Popup",
"PopupType",
"Copyright",
"MarkdownView",
"MarkdownEditor",
"Toolkit",
"WebToolkit",
// ---------- timi-web/editor ----------
"findTextMatches",
"replaceAllText",
"replaceMatchText",
"buildSearchRegex",
"highlightCode",
"resolveCodeEditorKeydown",
"resolvePrismLanguage",
"useTiptapEditor",
"useTiptapView",
"headingMeta",
"ATTACHMENT_ID_ATTR",
"TEMP_FILE_ID_ATTR",
"LOCAL_MEDIA_ID_PREFIX",
"isLocalMediaId",
"normalizeContent",
"supportedCodeLanguages",
"TABLE_PICKER_SIZE"
];
/** 从构建产物中提取具名导出,让 stub 自动跟上上游变化。 */
function collectExportNames(distFile: string): Set<string> {
const names = new Set<string>(REQUIRED_EXPORTS);
if (!existsSync(distFile)) {
return names;
}
try {
const source = readFileSync(distFile, "utf-8");
for (const match of source.matchAll(/export\s*\{([^}]*)\}/g)) {
for (const part of match[1].split(",")) {
const bits = part.trim().split(/\s+as\s+/);
const name = (bits[1] ?? bits[0]).trim();
if (/^[A-Za-z_$][\w$]*$/.test(name)) {
names.add(name);
}
}
}
} catch {
// 读取失败时退回保底名单,不影响构建。
}
return names;
}
/** 生成 stub 模块源码:每个导出都是可调用、可解构、可当插件安装的空实现。 */
function buildStubSource(names: Set<string>): string {
const named = [...names]
.filter(name => name !== "default")
.map(name => `export const ${name} = stub;`)
.join("\n");
return `const stub = new Proxy(function () {}, {
get(target, key) {
if (key === Symbol.toPrimitive || key === "valueOf" || key === "toString") {
return () => "";
}
return stub;
},
apply() {
return stub;
},
construct() {
return {};
}
});
export default stub;
${named}
`;
}
/**
* 在 SSR 阶段把 timi-web 替换为空实现。
*
* timi-web 的构建产物在模块顶层就访问 `window` 并启动 Prism 自动高亮定时器,
* 在 Node 中 import 会直接抛错,导致 VitePress 的 SSR 构建必然失败。
*
* 文档站里真正需要 timi-web 的只有演示组件,它们全部通过 `clientOnly` 延迟到浏览器渲染,
* SSR 阶段只需要「能 import 且不报错」,因此这里返回一个 Proxy 空实现。
* 客户端构建不受影响,`options.ssr` 为 false 时照常解析到真实模块。
* @param docsDir docs 目录绝对路径,用于定位 timi-web 构建产物
*/
export function timiWebSsrStub(docsDir: string): Plugin {
const distFile = resolve(docsDir, "node_modules/timi-web/dist/timi-web.mjs");
let stubSource: string | null = null;
return {
name: "timi-web-ssr-stub",
enforce: "pre",
resolveId(source, importer, options) {
// 只拦截精确的包名,`timi-web/style.css` 等子路径照常交给 Vite 处理。
if (options?.ssr && STUBBED_MODULES.has(source)) {
return STUB_ID;
}
return null;
},
load(id) {
if (id !== STUB_ID) {
return null;
}
if (stubSource === null) {
stubSource = buildStubSource(collectExportNames(distFile));
}
return stubSource;
}
};
}
@@ -0,0 +1,162 @@
<template>
<div v-if="api" class="tui-api">
<section v-if="api.props.length > 0">
<h3>Props</h3>
<table>
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>默认值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in api.props" :key="row.name">
<td class="name">
<code v-text="row.name" />
<span v-if="row.required" class="required" title="必填">*</span>
</td>
<td><code v-text="row.type" /></td>
<td><code v-text="row.defaultValue" /></td>
<td v-text="row.description" />
</tr>
</tbody>
</table>
</section>
<section v-if="api.events.length > 0">
<h3>Events</h3>
<table>
<thead>
<tr>
<th>名称</th>
<th>参数</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in api.events" :key="row.name">
<td><code v-text="row.name" /></td>
<td><code v-text="row.type" /></td>
<td v-text="row.description" />
</tr>
</tbody>
</table>
</section>
<section v-if="api.slots.length > 0">
<h3>Slots</h3>
<table>
<thead>
<tr>
<th>名称</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in api.slots" :key="row.name">
<td><code v-text="row.name" /></td>
<td v-text="row.description" />
</tr>
</tbody>
</table>
</section>
<section v-if="api.exposes.length > 0">
<h3>Expose</h3>
<table>
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="row in api.exposes" :key="row.name">
<td><code v-text="row.name" /></td>
<td><code v-text="row.type" /></td>
<td v-text="row.description" />
</tr>
</tbody>
</table>
</section>
<p v-if="isEmpty && !api.parsed" class="notice warn">
未能从源码中解析出 API 信息,请检查组件是否使用了 defineProps / defineEmits / defineExpose。
</p>
<p v-else-if="isEmpty" class="notice">
该组件不需要任何 props、events、slots 或实例方法。
</p>
</div>
<p v-else class="notice warn" v-text="`未找到组件 ${name} 的 API 数据,请检查 components.ts 中的 name 与 entry 是否正确`" />
</template>
<script lang="ts" setup>
import { computed } from "vue";
import apiMap from "virtual:timi-api";
/**
* 组件 API 表格。
*
* 数据来自构建期 `vue-docgen-api` 对组件源码的解析结果,
* 无需在 markdown 中手写 props / events / slots 表格。
* 某个分区没有内容时整段不渲染,避免出现空表。
*/
const props = defineProps<{
/** 组件名,需与 components.ts 中的 name 一致。 */
name: string;
}>();
const api = computed(() => apiMap[props.name]);
const isEmpty = computed(() => {
if (!api.value) {
return false;
}
const { props: propRows, events, slots, exposes } = api.value;
return propRows.length === 0 && events.length === 0 && slots.length === 0 && exposes.length === 0;
});
</script>
<style lang="less" scoped>
.tui-api {
section {
margin-bottom: 1.5rem;
&:last-child {
margin-bottom: 0;
}
}
h3 {
margin: 1.5rem 0 .75rem;
font-size: 1rem;
}
.name {
white-space: nowrap;
}
.required {
margin-left: .125rem;
color: var(--vp-c-danger-1);
}
.notice {
margin: 1rem 0;
padding: .75rem 1rem;
font-size: .875rem;
border-radius: .5rem;
color: var(--vp-c-text-2);
background: var(--vp-c-bg-soft);
&.warn {
color: var(--vp-c-warning-1);
background: var(--vp-c-warning-soft);
}
}
}
</style>
@@ -0,0 +1,207 @@
<template>
<div class="tui-demo">
<section class="stage">
<div class="viewport">
<slot />
</div>
</section>
<section class="meta">
<div class="info">
<p v-if="title" class="title" v-text="title" />
<p v-if="description" class="desc" v-text="description" />
</div>
<div class="actions">
<button
class="action"
type="button"
@click="handleCopy"
v-text="copied ? '已复制' : '复制代码'"
/>
<button
class="action"
type="button"
@click="toggleExpanded"
v-text="expanded ? '收起代码' : '展开代码'"
/>
</div>
</section>
<section v-show="expanded" class="source">
<div class="language-vue" v-html="sourceHtml" />
</section>
</div>
</template>
<script lang="ts" setup>
import { computed, onUnmounted, ref } from "vue";
/**
* 预览容器。
*
* props 契约由 `@vitepress-demo-preview/plugin` 决定,它把 markdown 里的
* `:::preview` 容器编译成 `<demo-preview>` 标签并传入下列属性:
* - `code`:`encodeURI` 处理过的组件源码
* - `showCode`:`encodeURIComponent` 处理过的高亮 HTML
*
* 这里刻意不依赖官方容器,也不引入额外 UI 库,只用原生元素配合 VitePress 的
* CSS 变量,深色模式随之自动适配。
*
* 演示统一放在手机宽度的视口里:这些是移动端组件,按桌面宽度铺开会失去真实形态。
* 需要调整宽度时改 `--tui-demo-viewport` 即可。
*/
const props = withDefaults(defineProps<{
/** 源码原文,经 encodeURI 编码。 */
code?: string;
/** 高亮后的源码 HTML,经 encodeURIComponent 编码。 */
showCode?: string;
/** 演示标题,来自 `:::preview` 后方第一段文字。 */
title?: string;
/** 演示描述,来自 `:::preview` 中 `||` 之后的内容。 */
description?: string;
}>(), {
code: "",
showCode: "",
title: "",
description: ""
});
/** 源码区是否展开,默认收起以保持文档阅读节奏。 */
const expanded = ref(false);
/** 复制成功后的短暂反馈。 */
const copied = ref(false);
let copiedTimer: ReturnType<typeof setTimeout> | null = null;
const sourceHtml = computed(() => decodeURIComponent(props.showCode));
const sourceText = computed(() => decodeURIComponent(props.code));
/** 复制源码到剪贴板,clipboard 不可用时保持原按钮文案。 */
async function handleCopy() {
try {
await navigator.clipboard.writeText(sourceText.value);
} catch {
return;
}
copied.value = true;
if (copiedTimer !== null) {
clearTimeout(copiedTimer);
}
copiedTimer = setTimeout(() => {
copied.value = false;
copiedTimer = null;
}, 1500);
}
function toggleExpanded() {
expanded.value = !expanded.value;
}
onUnmounted(() => {
if (copiedTimer !== null) {
clearTimeout(copiedTimer);
}
});
</script>
<style lang="less" scoped>
.tui-demo {
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: .75rem;
background: var(--vp-c-bg);
.stage {
display: flex;
padding: 1.5rem;
justify-content: center;
background: var(--vp-c-bg-soft);
}
// 手机视口:移动组件在桌面宽度下会被拉伸,这里统一收到移动端宽度
.viewport {
width: 100%;
max-width: var(--tui-demo-viewport, 26rem);
padding: 1rem;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: .75rem;
background: var(--vp-c-bg);
}
.meta {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
padding: .75rem 1.5rem;
border-top: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
}
.info {
min-width: 0;
}
.title {
margin: 0;
font-size: .875rem;
font-weight: 600;
color: var(--vp-c-text-1);
}
.desc {
margin: .25rem 0 0;
font-size: .75rem;
line-height: 1.5;
color: var(--vp-c-text-2);
}
.actions {
display: flex;
flex: none;
gap: .25rem;
}
.action {
padding: .25rem .5rem;
font-size: .75rem;
cursor: pointer;
border: none;
border-radius: .25rem;
color: var(--vp-c-text-2);
background: transparent;
&:hover {
color: var(--vp-c-brand-1);
background: var(--vp-c-default-soft);
}
}
.source {
border-top: 1px solid var(--vp-c-divider);
:deep(div[class*="language-"]) {
margin: 0;
border-radius: 0;
}
}
}
@media (max-width: 48rem) {
.tui-demo {
.stage {
padding: .75rem;
}
.viewport {
padding: .5rem;
}
.meta {
align-items: flex-start;
flex-direction: column;
padding: .75rem 1rem;
}
}
}
</style>
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vitepress/client" />
/** 构建期由 vue-docgen-api 解析生成的组件 API 数据。 */
declare module "virtual:timi-api" {
import type { ComponentApiMap } from "../api-types";
const apiMap: ComponentApiMap;
export default apiMap;
}
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}
+48
View File
@@ -0,0 +1,48 @@
import DefaultTheme from "vitepress/theme";
import { watch } from "vue";
import { useData } from "vitepress";
import type { App } from "vue";
import TDesignMobile from "tdesign-mobile-vue";
import TimiWebUI, { VPopup } from "timi-web";
// 必须引完整样式:`tdesign-mobile-vue/es/style/index.css` 只含全局 token,
// 不含任何组件类,全量注册时会导致所有组件无样式渲染。
import "tdesign-mobile-vue/dist/tdesign.css";
import "./styles/index.less";
import DemoPreview from "./containers/DemoPreview.vue";
import ApiTable from "./components/ApiTable.vue";
/**
* 同步深色模式。
*
* VitePress 通过 `html.dark` 切换深色,而 TDesign 依赖 `html[theme-mode="dark"]`,
* 两者不是同一套机制,不同步会导致深色模式下 TDesign 组件仍是亮色。
*/
function syncThemeMode(dark: boolean) {
if (typeof document === "undefined") {
return;
}
document.documentElement.setAttribute("theme-mode", dark ? "dark" : "light");
}
export default {
extends: DefaultTheme,
enhanceApp({ app }: { app: App }) {
// 全量注册 TDesign Mobile,演示区内所有 t-* 组件直接可用。
app.use(TDesignMobile);
// 组件内部大量使用 timi-web 的 v-popup 指令,且用在组件模板里,
// 无法由演示局部注入,只能在主题里全局注册。
app.use(TimiWebUI);
app.directive("popup", VPopup);
app.component("demo-preview", DemoPreview);
app.component("api-table", ApiTable);
},
setup() {
const { isDark } = useData();
watch(isDark, syncThemeMode, { immediate: true });
}
};
+18
View File
@@ -0,0 +1,18 @@
/**
* 文档站全局样式。
*
* 只处理 VitePress 正文与演示容器、API 表格的交界处;
* 组件自身的样式一律各自 scoped,避免相互污染。
*/
.vp-doc {
// 演示容器与正文的呼吸感
.tui-demo {
margin: 1rem 0;
}
// API 表格里的类型与默认值不换行,避免长签名把列挤变形
table code {
white-space: nowrap;
}
}
+45
View File
@@ -0,0 +1,45 @@
# AiChatComposer 输入框
对话输入框采用上下布局:整行文本域下方提供新开会话、添加附件和发送或停止操作。
## 基础用法
:::preview 基础用法 || 发送后进入生成态,2 秒后自动恢复,可观察按钮的三种形态
demo-preview=../demos/ai-chat-composer/basic.vue
:::
## 何时使用
需要单独摆放输入框时使用。多数情况下直接用 [AiChatPanel](/components/ai-chat-panel) 更省事。
## 输入区与操作按钮
文本域占满整行并随内容自动增高。下一行左侧是新开会话(`chat-add`)和添加附件(`file-add`),右侧根据状态显示纯文本发送或停止按钮。
| 状态 | 按钮 |
| --- | --- |
| 空闲且未禁用 | 新开会话、添加附件、发送(`sendText`) |
| `isGenerating` 为真 | 停止(`stopText`,危险色) |
| 有 `lastError` 且 `canRetry` | 错误条 + 重试(`retryText`) |
生成中或 `disabled` 为真时输入框不可编辑,新开会话、添加附件和 `send` 事件不会触发。
## 事件
- `send`:抛出**去除首尾空白后**的内容,抛完自动清空输入框;空内容不抛
- `stop`:点停止按钮
- `retry`:点错误条上的重试
- `new-conversation`:清空输入框并请求业务侧开始一个新会话
- `add-attachment`:请求业务侧添加附件
输入框内容用 `v-model` 绑定,`maxLength` 默认 2000。
## 自定义插槽
`send`、`stop`、`error` 三个插槽可以替换对应区域的渲染,都带作用域参数(`disabled`、`onSend`、`onStop`、`error`、`canRetry`)。
## API
<api-table name="AiChatComposer" />
+42
View File
@@ -0,0 +1,42 @@
# AiChatMessageList 消息列表
渲染对话消息,含思考过程、引用与流式光标。
## 基础用法
:::preview 基础用法 || 消息里的 thinkingList 与 citationList 会渲染为思考过程与引用
demo-preview=../demos/ai-chat-message-list/basic.vue
:::
## 何时使用
需要单独控制消息区域的布局时使用。多数情况下直接用 [AiChatPanel](/components/ai-chat-panel) 更省事。
## 消息结构
每条消息是 `AiChatMessage`:
| 字段 | 说明 |
| --- | --- |
| `id` | 唯一标识。 |
| `role` | `"user"` 或 `"assistant"`。 |
| `content` | 正文,按 Markdown 渲染。 |
| `thinkingList` | 思考分段,传给 `AiChatThinking` 展示。 |
| `citationList` | 引用信息。 |
| `createdAt` | 生成时间戳。 |
| `statusText` | 状态文案,如工具调用的当前步骤。 |
| `duration` | 本次生成耗时(毫秒)。 |
## 角色文案
`roleNames` 覆盖默认的「助手 / 我」,需要更复杂的逻辑时用 `roleNameFormatter`。
## 插槽
`welcome`、`message`、`role-name`、`thinking`、`citation`、`message-status`、`assistant-icon`、`user-icon` 可以替换对应区域,都带作用域参数。
## API
<api-table name="AiChatMessageList" />
+76
View File
@@ -0,0 +1,76 @@
# 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, resetConversation
} = useAiChat({
request: ({ message, signal }) => fetchReply(message, signal)
});
function selectAttachment() {
// 在业务侧打开文件选择器
}
</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"
@new-conversation="resetConversation"
@add-attachment="selectAttachment"
/>
</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`、`on-new-conversation`、`on-add-attachment` 等,便于接入自定义输入框。
## API
<api-table name="AiChatPanel" />
+29
View File
@@ -0,0 +1,29 @@
# AiChatThinking 思考过程
折叠展示模型的思考分段,生成中自动展开。
## 基础用法
:::preview 基础用法 || 逐段追加内容,生成中会自动滚到底部
demo-preview=../demos/ai-chat-thinking/basic.vue
:::
## 何时使用
需要把模型的推理过程单独呈现时使用,通常由 [AiChatMessageList](/components/ai-chat-message-list) 内部调用,也可以单独摆放。
## 行为
- `isGenerating` 为真时展开并跟随滚动到底部,结束后收起
- 内容受 `max-height` 限制,超出时内部滚动
- 容器带 `role="log"` 与 `aria-live="polite"`,屏幕阅读器会播报增量
## 与消息的关系
消息的 `thinkingList` 是一组分段(`{ id, content }`),[AiChatMessageList](/components/ai-chat-message-list) 会把它们拼成完整文本后传进来。
## API
<api-table name="AiChatThinking" />
+41
View File
@@ -0,0 +1,41 @@
# ArticleEditor 文章编辑器
以 `Article` 为数据模型,按正文类型切换纯文本、Markdown 与富文本三种编辑形态。
## 基础用法
:::preview 基础用法 || 切换正文类型会同时替换内容,提交时读取完整文章
demo-preview=../demos/article-editor/basic.vue
:::
## 何时使用
需要在一套表单里同时支持多种正文格式时使用。切换只改 `contentType`,上层不需要关心具体编辑器。
## 数据模型
组件只约束三个字段,其他字段会原样保留:
| 字段 | 说明 |
| --- | --- |
| `contentType` | 正文类型,支持 `TEXT`、`MARKDOWN`、`TIP_TAP`(`TIPTAP` 为兼容写法)。 |
| `content` | 正文内容,随类型分别是纯文本、Markdown 源码或 HTML。 |
| `attachmentList` | 附件列表,元素至少包含 `id`、`name`、`url`。 |
## 附件与媒体
`uploadImage`、`uploadVideo` 负责上传,`resolveAttachmentUrl`、`resolveTempUrl` 负责把附件标识换成可访问地址。四个回调都可选,不传时对应能力自动降级。
## 提交内容
编辑过程中抛出的是实时值,提交时建议调用 `submit()`,它会等待富文本里未完成的上传再返回最终数据:
```ts
const article = await editorRef.value?.submit();
```
## API
<api-table name="ArticleEditor" />
+29
View File
@@ -0,0 +1,29 @@
# ArticleView 文章视图
按 `Article` 的正文类型渲染正文内容。
## 基础用法
:::preview 基础用法 || 把 contentType 换成 TEXT 或 MARKDOWN 会切换渲染分支
demo-preview=../demos/article-view/basic.vue
:::
## 何时使用
需要在详情页只读展示文章正文时使用,与 `ArticleEditor` 共用同一套数据类型。
## 渲染分支
| contentType | 渲染方式 |
| --- | --- |
| `TEXT` | 只读文本域。 |
| `MARKDOWN` | 由 timi-web 的 MarkdownView 渲染。 |
| `TIP_TAP` | 由 [TiptapView](/components/tiptap-view) 渲染。 |
内容为空时统一显示 `emptyText`。
## API
<api-table name="ArticleView" />
+37
View File
@@ -0,0 +1,37 @@
# BottomPopup 底部弹层
从底部升起的操作面板,可带标题与左右按钮。
## 基础用法
:::preview 基础用法 || 弹层从视口底部升起,标题栏在内容滚动时吸顶
demo-preview=../demos/bottom-popup/basic.vue
:::
## 何时使用
需要在移动端承载一组操作或一段表单时使用。它基于 TDesign Mobile 的 `Popup`,因此遮罩、层级与动画都沿用框架行为。
## 按钮只抛事件
左右按钮**不会自动关闭弹层**,只抛出 `leftButtonClick` / `rightButtonClick`,关闭与否由业务决定:
```vue
<bottom-popup
v-model="visible"
title="选择操作"
@right-button-click="handleConfirm"
/>
```
按钮文案传空字符串即可隐藏对应按钮;也可以分别用 `leftButton` / `rightButton` 插槽完全接管,插槽会收到 `onClick`。
## 高度
`height` 默认 `"auto"`,按内容撑开;传具体值(如 `"60vh"`)时内容区滚动、标题栏吸顶。
## API
<api-table name="BottomPopup" />
+36
View File
@@ -0,0 +1,36 @@
# CodeEditor 代码编辑器
带行号、语法高亮与查找替换的代码输入框。
## 基础用法
:::preview 基础用法 || 通过 v-model 绑定内容,实例暴露 openSearch 等方法
demo-preview=../demos/code-editor/basic.vue
:::
## 何时使用
需要让用户输入或查看结构化代码时使用。它不做代码校验,只负责输入、高亮与检索。
## 内容绑定
`modelValue` 与 `value` 都可作为输入源,`value` 优先级更高。输入时同步抛出 `update:modelValue` 与 `change`。
## 实例方法
通过模板引用调用暴露的方法,例如打开查找栏:
```ts
import type { CodeEditorExpose } from "timi-tdesign-mobile";
const editorRef = ref<CodeEditorExpose>();
editorRef.value?.openSearch(true);
```
移动端受限于宽度,代码行会横向滚动而不是折行。
## API
<api-table name="CodeEditor" />
+42
View File
@@ -0,0 +1,42 @@
# DurationPicker 时长选择
按时长值选择并回填,单位与候选项由组件内部推导。
## 基础用法
:::preview 基础用法 || 选择后回填的是毫秒数
demo-preview=../demos/duration-picker/basic.vue
:::
## 何时使用
需要让用户选择一个时间跨度(缓存有效期、超时时长等)时使用。对外只暴露毫秒值,单位换算是内部细节。
## 取值与单位
`modelValue` 是**毫秒数**。组件内置了年 / 天 / 小时 / 分钟 / 秒 / 毫秒六个单位,各自带有取值范围与步长:
| 单位 | 范围 | 步长 |
| --- | --- | --- |
| 年 | 1 - 100 | 1 |
| 天 | 1 - 364 | 1 |
| 小时 | 1 - 23 | 1 |
| 分钟 | 1 - 59 | 1 |
| 秒 | 1 - 59 | 1 |
| 毫秒 | 50 - 950 | 50 |
默认值为 1000 毫秒(1 秒)。
## 两个事件
`update:modelValue` 在滚动选择时触发,`change` 在确认选择时触发:
```vue
<duration-picker v-model="duration" @change="onConfirm" />
```
## API
<api-table name="DurationPicker" />
+39
View File
@@ -0,0 +1,39 @@
# 组件总览
共 16 个组件,分为布局、编辑器、交互与 AI 对话四类。
## 布局组件
| 组件 | 说明 |
| --- | --- |
| [PageTransition](/components/page-transition) | 为 Vue Router 页面提供可配置的层级滑动过渡与按路由缓存能力。 |
| [RootLayout](/components/root-layout) | 移动端页面骨架,主体内容之外自动渲染版权与备案信息。 |
| [Space](/components/space) | 控制子元素之间的间距与排列方向,支持分隔符。 |
## 编辑器组件
| 组件 | 说明 |
| --- | --- |
| [CodeEditor](/components/code-editor) | 带行号、语法高亮与查找替换的代码输入框。 |
| [TiptapEditor](/components/tiptap-editor) | 基于 Tiptap 的富文本编辑器,工具栏覆盖媒体、表格、链接与代码块。 |
| [TiptapView](/components/tiptap-view) | Tiptap 内容的只读渲染视图,与编辑器共用同一套样式。 |
| [ArticleEditor](/components/article-editor) | 以 Article 为数据模型,按正文类型切换纯文本、Markdown 与富文本编辑。 |
| [ArticleView](/components/article-view) | 按 Article 的正文类型渲染正文与附件列表。 |
| [NotifyEditor](/components/notify-editor) | 以 NotifyDetail 为数据模型的通知编辑表单,复用文章编辑器能力。 |
| [NotifyView](/components/notify-view) | 渲染通知的正文内容。 |
## 交互组件
| 组件 | 说明 |
| --- | --- |
| [BottomPopup](/components/bottom-popup) | 从底部升起的操作面板,可带标题与左右按钮。 |
| [DurationPicker](/components/duration-picker) | 按时长值选择并回填,单位与候选项由组件内部推导。 |
## AI 对话组件
| 组件 | 说明 |
| --- | --- |
| [AiChatPanel](/components/ai-chat-panel) | 把消息列表与输入框组装成完整对话界面,由业务侧驱动数据。 |
| [AiChatMessageList](/components/ai-chat-message-list) | 渲染对话消息,含思考过程、引用与流式光标。 |
| [AiChatComposer](/components/ai-chat-composer) | 对话输入框,按状态在发送、停止与重试之间切换。 |
| [AiChatThinking](/components/ai-chat-thinking) | 折叠展示模型的思考分段,生成中自动展开。 |
+37
View File
@@ -0,0 +1,37 @@
# NotifyEditor 通知编辑器
以 `NotifyDetail` 为数据模型的通知编辑表单,正文部分复用文章编辑器。
## 基础用法
:::preview 基础用法 || 通知复用文章编辑器,同时保留主题等自有字段
demo-preview=../demos/notify-editor/basic.vue
:::
## 何时使用
需要编辑系统通知、站内信等内容时使用。通知与文章的差别只在元数据,正文能力完全一致。
## 数据模型
`NotifyDetail` 只约束正文相关字段,主题、状态等业务字段由外层维护并原样保留:
| 字段 | 说明 |
| --- | --- |
| `content` | 正文内容。 |
| `contentType` | 正文类型,支持 `TEXT`、`TIP_TAP`。 |
| `attachmentList` | 附件列表。 |
## 提交内容
```ts
const detail = await editorRef.value?.submit();
```
与文章编辑器一致,`submit()` 会等待未完成的媒体上传。
## API
<api-table name="NotifyEditor" />
+19
View File
@@ -0,0 +1,19 @@
# NotifyView 通知视图
渲染通知的正文内容,是 `ArticleView` 在通知场景下的薄封装。
## 基础用法
:::preview 基础用法 || 传入 NotifyDetail 即可渲染正文
demo-preview=../demos/notify-view/basic.vue
:::
## 何时使用
需要在通知详情页只读展示正文时使用。主题、状态等元数据由业务页面自行呈现,组件只负责正文。
## API
<api-table name="NotifyView" />
+37
View File
@@ -0,0 +1,37 @@
# PageTransition 页面过渡
为 Vue Router 页面提供可配置的页面过渡和按路由缓存能力。
## 基础用法
`PageTransition` 必须放在 `RouterView` 所在的路由布局中,并且自身需要有明确的宽高。
```vue
<template>
<div class="layout">
<page-transition :keep-alive="route => route.meta.keepAlive === true" />
</div>
</template>
<style scoped lang="less">
.layout {
height: 100vh;
}
</style>
```
组件默认根据路由层级选择 `push-left` 和 `push-right`。层级的解析顺序是:`transitionDepth` query、`depth` meta、动态层级 meta,最后回退到 `matched.length`。业务路由结构特殊时,直接传入 `get-route-depth`,不要让组件猜测业务规则。
## 缓存页面
`keep-alive` 支持布尔值或路由谓词。谓词返回 `true` 的页面会进入缓存,`keep-alive-max` 用于限制缓存数量。默认不缓存页面,避免通用布局意外保留业务表单状态。
## 自定义过渡
`transition` 默认值为 `auto`,也可以传入 `false` 禁用过渡、传入字符串使用自定义过渡名,或传入函数按目标路由和来源路由返回过渡名。组件内置 `push-left`、`push-right` 和 `fade`。
组件默认尊重系统的“减少动态效果”设置;如果业务必须保持页面翻页动画,可以设置 `:respect-reduced-motion="false"`。
## API
<api-table name="PageTransition" />
+23
View File
@@ -0,0 +1,23 @@
# RootLayout 根布局
移动端页面骨架,主体内容之外自动渲染版权与备案信息。
## 基础用法
:::preview 基础用法 || 插槽填充主体内容,底部版权区在窄屏下自适应
demo-preview=../demos/root-layout/basic.vue
:::
## 何时使用
需要一套带页脚版权信息的整页容器时使用。它只负责骨架与页脚,不含导航栏,业务可以自行组合。
## 版权区
底部由 timi-web 的 `Copyright` 组件渲染,`icp` 会链接到工信部网站,`domain` 与 `author` 同时传入时才渲染版权行。
## API
<api-table name="RootLayout" />
+33
View File
@@ -0,0 +1,33 @@
# Space 间距
控制子元素之间的间距与排列方向,支持分隔符。
## 基础用法
:::preview 基础用法 || 尺寸可用预设名或具体数值,数组形式分别指定横向与纵向
demo-preview=../demos/space/basic.vue
:::
## 何时使用
需要给一组元素之间加统一间距时使用。相比手写 `margin`,它把间距收敛到一个属性上,调整时不必逐个改样式。
## 尺寸
`size` 接受三种写法:
| 写法 | 说明 |
| --- | --- |
| `"small"` / `"medium"` / `"large"` | 预设值,分别为 8 / 16 / 24 像素 |
| 数字或带单位的字符串 | 自定义间距,如 `12`、`"1.5rem"` |
| 数组 | 依次表示横向与纵向间距 |
## 分隔符
`separator` 或 `separator` 插槽会在相邻元素之间插入内容,注意**末尾元素之后不会插入**。
## API
<api-table name="Space" />
+37
View File
@@ -0,0 +1,37 @@
# TiptapEditor 富文本编辑器
基于 Tiptap 的富文本编辑器,工具栏覆盖媒体、表格、链接与代码块。
## 基础用法
:::preview 基础用法 || 工具栏按移动端宽度自动换行排列
demo-preview=../demos/tiptap-editor/basic.vue
:::
## 何时使用
需要所见即所得的正文编辑时使用。内容以 HTML 字符串双向绑定,可直接入库。
## 媒体上传
图片与视频通过 `uploadImage`、`uploadVideo` 回调上传,组件不绑定具体接口。拖拽或粘贴文件时会先插入占位内容,上传成功后替换为真实地址。
未完成的媒体会在提交时统一处理,`getPendingMediaCount()` 可查询待处理数量。
## 提交内容
编辑过程中的 `v-model` 是实时值,提交时建议调用 `submit()` 以等待未完成的上传并归一化内容:
```ts
const html = await editorRef.value?.submit();
```
## 移动端差异
工具栏按钮在窄屏下换行排列而不是收进折叠菜单,`v-popup` 提示在触屏下不可用,按钮已带文字或图标语义。
## API
<api-table name="TiptapEditor" />
+23
View File
@@ -0,0 +1,23 @@
# TiptapView 富文本视图
Tiptap 内容的只读渲染视图,与编辑器共用同一套样式与扩展。
## 基础用法
:::preview 基础用法 || 超出 maxHeight 时可滚动查看
demo-preview=../demos/tiptap-view/basic.vue
:::
## 何时使用
需要在详情页、审批流等只读场景渲染富文本时使用。相比直接 `v-html`,它复用了编辑器的扩展与样式,展示与编辑效果一致。
## 空内容
内容为空或规范化后为空字符串时,渲染 `emptyText` 占位文案而不是空白区域。
## API
<api-table name="TiptapView" />
+102
View File
@@ -0,0 +1,102 @@
<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"
@new-conversation="onNewConversation"
@add-attachment="onAddAttachment"
/>
<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()");
}
function onNewConversation() {
error.value = "";
push("new-conversation()");
}
function onAddAttachment() {
push("add-attachment()");
}
</script>
<style lang="less" scoped>
.demo {
.bar {
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&:hover {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
}
.log {
margin: .75rem 0 0;
padding-left: 1.25rem;
font-size: .75rem;
font-family: monospace;
color: var(--vp-c-text-2);
li {
margin-bottom: .25rem;
}
}
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div class="demo">
<div class="phone">
<ai-chat-message-list :messages="messages" :is-generating="false" />
</div>
<p class="note" v-text="'消息里的 thinkingList 与 citationList 会渲染为思考过程与引用。'" />
</div>
</template>
<script lang="ts" setup>
import { AiChatMessageList } from "timi-tdesign-mobile";
import { createMessage } from "../ai/mock-sse";
const messages = [
createMessage(1, "user", "上个月订单量怎么样?"),
createMessage(2, "assistant", "近 30 天订单量**环比上升 12%**,其中移动端贡献了主要增量。", {
thinkingList: [
{ id: 1, content: "先确认时间范围是自然月还是滚动 30 天。" },
{ id: 2, content: "再按端类型拆分,确认增量来源。" }
],
citationList: [
{ source: "分析中心", dataDomain: "订单域", timeRange: "近 30 天", caliber: "支付口径" }
]
})
];
</script>
<style lang="less" scoped>
.demo {
.phone {
height: 26rem;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.note {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+60
View File
@@ -0,0 +1,60 @@
<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"
@new-conversation="resetConversation"
/>
</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,
resetConversation
} = useAiChat({
request: async ({ message, signal }) => createMockReply(message, signal)
});
</script>
<style lang="less" scoped>
.demo {
.phone {
height: 30rem;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.note {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<template>
<div class="demo">
<ai-chat-thinking :content="content" :is-generating="generating" />
<div class="bar">
<button type="button" class="btn" :disabled="generating" @click="start" v-text="'开始生成'" />
<button type="button" class="btn" @click="reset" v-text="'重置'" />
</div>
</div>
</template>
<script lang="ts" setup>
import { onUnmounted, ref } from "vue";
import { AiChatThinking } from "timi-tdesign-mobile";
const SEGMENTS = [
"先理解问题:用户想知道上个月的订单情况。",
"确认时间范围:按自然月还是滚动 30 天?这里取自然月。",
"确认口径:使用支付口径而非下单口径。",
"开始查询数据并汇总……"
];
const content = ref("");
const generating = ref(false);
let timer: ReturnType<typeof setInterval> | null = null;
/** 逐段追加,模拟思考内容流式到达;生成中组件会自动滚到底部。 */
function start() {
reset();
generating.value = true;
let index = 0;
timer = setInterval(() => {
if (index >= SEGMENTS.length) {
stop();
return;
}
content.value += (content.value ? "\n" : "") + SEGMENTS[index];
index += 1;
}, 700);
}
function stop() {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
generating.value = false;
}
function reset() {
stop();
content.value = "";
}
onUnmounted(stop);
</script>
<style lang="less" scoped>
.demo {
.bar {
display: flex;
gap: .5rem;
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&:hover:not(:disabled) {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
&:disabled {
cursor: not-allowed;
opacity: .5;
}
}
}
</style>
+93
View File
@@ -0,0 +1,93 @@
/**
* 演示专用的假 SSE 流。
*
* `useAiChat` 要求 `request` 返回一个 `Response`,真实的实现由后端提供。
* 这里用 ReadableStream 按内置协议逐条吐出事件,让文档站无需后端也能跑通
* 流式对话的完整链路。
*
* 内置协议的事件名与载荷字段(见 `parseDefaultEvent` / `extractText` 等):
* - `thinking` 载荷取 `thought` / `thinking` / `reasoning`
* - `answer` 载荷取 `answer` / `text` / `content`
* - `tool-status` 载荷取 `text` / `message` / `status`
* - `citation` 载荷为引用对象
* - `done` 载荷可带 `conversationId` 与 `duration`
*/
/** 单条 SSE 事件。 */
interface MockEvent {
event: string;
data: Record<string, unknown>;
}
/** 每个事件之间的间隔,模拟真实的流式节奏。 */
const STEP_DELAY = 420;
function buildEvents(question: string): MockEvent[] {
return [
{ event: "thinking", data: { thought: `先理解问题:「${question}」` } },
{ event: "thinking", data: { thought: "然后确认需要的数据口径与时间范围。" } },
{ event: "tool-status", data: { text: "正在查询数据" } },
{ event: "answer", data: { text: "根据当前口径," } },
{ event: "answer", data: { text: `关于「${question}」的结论如下:` } },
{ event: "answer", data: { text: "\n\n- 近 30 天整体**环比上升 12%**" } },
{ event: "answer", data: { text: "\n- 其中移动端贡献了主要增量" } },
{ event: "citation", data: { source: "分析中心", dataDomain: "订单域", timeRange: "近 30 天" } },
{ event: "done", data: { conversationId: "demo-conversation", duration: STEP_DELAY * 9 } }
];
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 生成一段假的流式回复。
*
* @param question 用户提问,会回显在回答里
* @param signal 取消信号,用户点「停止」时中断
*/
export function createMockReply(question: string, signal?: AbortSignal): Response {
const encoder = new TextEncoder();
const events = buildEvents(question);
let index = 0;
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
if (signal?.aborted) {
controller.error(new DOMException("已停止生成", "AbortError"));
return;
}
if (index >= events.length) {
controller.close();
return;
}
const item = events[index];
index += 1;
controller.enqueue(encoder.encode(`event: ${item.event}\ndata: ${JSON.stringify(item.data)}\n\n`));
await sleep(STEP_DELAY);
}
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream" }
});
}
/** 构造一条用于静态展示的消息。 */
export function createMessage(
id: number,
role: "user" | "assistant",
content: string,
extra: Partial<{ thinkingList: Array<{ id: number; content: string }>; citationList: unknown[] }> = {}
) {
return {
id,
role,
content,
thinkingList: extra.thinkingList ?? [],
citationList: extra.citationList ?? [],
createdAt: Date.now() - (10 - id) * 60_000,
statusText: "",
duration: role === "assistant" ? 2400 : undefined
};
}
+152
View File
@@ -0,0 +1,152 @@
<template>
<div class="demo">
<div class="bar">
<button
v-for="item in contentTypeList"
:key="item.value"
type="button"
class="btn"
:class="{ active: value.contentType === item.value }"
@click="switchContentType(item.value)"
v-text="item.label"
/>
</div>
<input v-model="value.title" class="field" placeholder="文章标题" />
<article-editor
ref="editorRef"
:model-value="value"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
@update:model-value="onChange"
/>
<div class="footer">
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
<span class="status" v-text="status" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { ArticleEditor } from "timi-tdesign-mobile";
import type {
Article,
ArticleContentType,
ArticleEditorExpose,
UploadResult
} from "timi-tdesign-mobile";
const RICH_CONTENT = `<h2>正文类型可切换</h2>
<p>同一份 <code>Article</code> 数据,按 <code>contentType</code> 切换三种编辑形态。</p>`;
const contentTypeList: Array<{ label: string; value: ArticleContentType }> = [
{ label: "纯文本", value: "TEXT" },
{ label: "Markdown", value: "MARKDOWN" },
{ label: "富文本", value: "TIP_TAP" }
];
const value = ref<Article>({
title: "编辑器演示文章",
contentType: "TIP_TAP",
content: RICH_CONTENT,
attachmentList: []
});
const status = ref("等待提交");
const editorRef = ref<ArticleEditorExpose>();
/** 切换正文类型时同步替换内容,避免不同格式互相污染。 */
function switchContentType(contentType: ArticleContentType) {
const content = contentType === "TEXT"
? "这是一段纯文本正文。"
: contentType === "MARKDOWN"
? "## Markdown 标题\n\n这是一段 Markdown 正文。"
: RICH_CONTENT;
value.value = {
...value.value,
contentType,
content
};
}
function onChange(article: Article) {
value.value = article;
}
async function submit() {
const article = await editorRef.value?.submit();
if (article) {
value.value = article;
status.value = `已提交,标题为「${article.title}」`;
}
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.bar {
display: flex;
flex-wrap: wrap;
gap: .375rem;
margin-bottom: .75rem;
}
.btn {
padding: .25rem .625rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.active {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
&.primary {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.footer {
display: flex;
gap: .75rem;
align-items: center;
margin-top: .75rem;
}
.status {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+20
View File
@@ -0,0 +1,20 @@
<template>
<div class="demo">
<article-view :article="article" />
</div>
</template>
<script lang="ts" setup>
import { ArticleView } from "timi-tdesign-mobile";
import type { Article } from "timi-tdesign-mobile";
/** ArticleView 按 contentType 选择渲染方式,这里演示富文本形态。 */
const article: Article = {
title: "文章视图",
contentType: "TIP_TAP",
content: `<h2>文章视图</h2>
<p>正文按 <code>Article</code> 结构渲染,空内容时显示占位文案。</p>
<blockquote><p>把 <code>contentType</code> 换成 TEXT 或 MARKDOWN 会切换渲染分支。</p></blockquote>`,
attachmentList: []
};
</script>
+76
View File
@@ -0,0 +1,76 @@
<template>
<div class="demo">
<button type="button" class="btn" @click="visible = true" v-text="'打开底部弹层'" />
<p class="status" v-text="`最近一次操作:${lastAction}`" />
<bottom-popup
v-model="visible"
title="选择操作"
left-button-text="取消"
right-button-text="确定"
@left-button-click="onCancel"
@right-button-click="onConfirm"
>
<div class="content">
<p v-text="'弹层从视口底部升起,默认带遮罩,内容区可滚动。'" />
<p v-text="'标题栏在内容滚动时会吸顶。'" />
</div>
</bottom-popup>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { BottomPopup } from "timi-tdesign-mobile";
const visible = ref(false);
const lastAction = ref("无");
/** 左右按钮只抛事件,关闭与否由业务决定——这里两者都关闭。 */
function onCancel() {
visible.value = false;
lastAction.value = "取消";
}
function onConfirm() {
visible.value = false;
lastAction.value = "确定";
}
</script>
<style lang="less" scoped>
.demo {
.btn {
width: 100%;
padding: .625rem;
font-size: .875rem;
cursor: pointer;
border: none;
border-radius: .5rem;
color: #fff;
background: var(--vp-c-brand-1);
}
.status {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
.content {
padding: 1rem;
p {
margin: 0 0 .75rem;
font-size: .8125rem;
line-height: 1.6;
color: var(--vp-c-text-2);
&:last-child {
margin-bottom: 0;
}
}
}
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<template>
<div class="demo">
<div class="bar">
<button type="button" class="btn" @click="readValue" v-text="'读取内容'" />
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
</div>
<div class="box">
<code-editor
ref="editorRef"
v-model="code"
language="typescript"
placeholder="请输入代码"
@change="onChange"
/>
</div>
<p class="status" v-text="status" />
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { CodeEditor } from "timi-tdesign-mobile";
import type { CodeEditorExpose } from "timi-tdesign-mobile";
const DEMO_CODE = `type DemoUser = {
id: string;
name: string;
};
const user: DemoUser = {
id: "demo-001",
name: "Timi"
};`;
const code = ref(DEMO_CODE);
const status = ref("等待操作");
const editorRef = ref<CodeEditorExpose>();
function onChange(value: string) {
status.value = `内容已更新,${value.length} 个字符`;
}
function readValue() {
status.value = `当前共 ${code.value.length} 个字符`;
}
function openSearch() {
editorRef.value?.openSearch(true);
}
</script>
<style lang="less" scoped>
.demo {
.bar {
display: flex;
gap: .5rem;
margin-bottom: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&:hover {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
}
.box {
height: 18rem;
}
.status {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<template>
<div class="demo">
<duration-picker v-model="duration" @change="onChange" />
<div class="result">
<p class="value" v-text="`${duration} ms`" />
<p class="text" v-text="humanized" />
<p class="text" v-text="`变更次数:${changeCount}`" />
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { DurationPicker } from "timi-tdesign-mobile";
const duration = ref(1000);
const changeCount = ref(0);
/** 把毫秒换算成便于阅读的形式,仅用于演示展示。 */
const humanized = computed(() => {
const value = duration.value;
if (value < 1000) {
return `${value} 毫秒`;
}
if (value < 60_000) {
return `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 2)} 秒`;
}
if (value < 3600_000) {
return `${(value / 60_000).toFixed(2)} 分钟`;
}
if (value < 86400_000) {
return `${(value / 3600_000).toFixed(2)} 小时`;
}
return `${(value / 86400_000).toFixed(2)} 天`;
});
function onChange() {
changeCount.value += 1;
}
</script>
<style lang="less" scoped>
.demo {
.result {
margin-top: 1rem;
text-align: center;
}
.value {
margin: 0 0 .25rem;
font-size: 1.125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--vp-c-brand-1);
}
.text {
margin: 0 0 .25rem;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="demo">
<input v-model="value.subject" class="field" placeholder="通知主题" />
<notify-editor
ref="editorRef"
:model-value="value"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
@update:model-value="onChange"
/>
<div class="footer">
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
<span class="status" v-text="status" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { NotifyEditor } from "timi-tdesign-mobile";
import type { NotifyDetail, NotifyEditorExpose, UploadResult } from "timi-tdesign-mobile";
const value = ref<NotifyDetail>({
subject: "编辑器通知演示",
contentType: "TIP_TAP",
content: `<h2>通知内容</h2>
<p>通知复用文章编辑器,同时保留自己的元数据字段(如 <code>subject</code>)。</p>`,
attachmentList: []
});
const status = ref("等待提交");
const editorRef = ref<NotifyEditorExpose>();
function onChange(detail: NotifyDetail) {
value.value = detail;
}
async function submit() {
const detail = await editorRef.value?.submit();
if (detail) {
value.value = detail;
status.value = `已提交,正文 ${detail.content?.length || 0} 个字符`;
}
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.footer {
display: flex;
gap: .75rem;
align-items: center;
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.primary {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.status {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="demo">
<notify-view :detail="detail" />
</div>
</template>
<script lang="ts" setup>
import { NotifyView } from "timi-tdesign-mobile";
import type { NotifyDetail } from "timi-tdesign-mobile";
/** NotifyView 只关心正文相关字段,主题与状态由业务页面自行呈现。 */
const detail: NotifyDetail = {
subject: "系统维护通知",
contentType: "TIP_TAP",
content: `<h2>系统维护通知</h2>
<p>本周六 02:00 - 04:00 进行数据库升级,期间服务不可用。</p>
<ul><li>请提前保存工作内容</li><li>升级完成后会同步通知</li></ul>`
};
</script>
+38
View File
@@ -0,0 +1,38 @@
<template>
<div class="demo">
<root-layout
icp="京ICP备 00000000 号"
domain="example.com"
author="Timi"
>
<div class="body">
<h3 v-text="'RootLayout'" />
<p v-text="'移动端页面骨架:插槽填充主体,底部自动渲染版权与备案信息。'" />
</div>
</root-layout>
</div>
</template>
<script lang="ts" setup>
import { RootLayout } from "timi-tdesign-mobile";
</script>
<style lang="less" scoped>
.demo {
.body {
padding: 1.5rem 1rem;
h3 {
margin: 0 0 .5rem;
font-size: 1rem;
}
p {
margin: 0;
font-size: .8125rem;
line-height: 1.6;
color: var(--vp-c-text-2);
}
}
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<template>
<div class="demo">
<div class="phone">
<header class="head">
<span class="title" v-text="'智能助手'" />
<button type="button" class="reset" @click="resetConversation" v-text="'清空'" />
</header>
<ai-chat-panel
class="panel"
:messages="messages"
:is-generating="isGenerating"
:active-tool-status="activeToolStatus"
:last-error="lastError"
:can-retry="canRetry"
:loading="isLoadingConversation"
:role-names="{ assistant: '分析助手', user: '我' }"
@send="sendMessage"
@stop="stopGeneration"
@retry="retryLastMessage"
>
<template #welcome>
<div class="welcome">
<p class="welcome-title" v-text="'可以问我关于数据的问题'" />
<p class="welcome-tip" v-text="'例如:上个月订单量怎么样?'" />
</div>
</template>
</ai-chat-panel>
</div>
<p class="note" v-text="'面板嵌在带标题栏的页面骨架里,欢迎语与角色名都做了自定义。'" />
</div>
</template>
<script lang="ts" setup>
import { AiChatPanel, useAiChat } from "timi-tdesign-mobile";
import { createMockReply } from "../ai/mock-sse";
/**
* 完整场景:标题栏 + 面板 + 自定义欢迎语。
* 会话状态全部来自 useAiChat,真实项目里把 request 换成后端 SSE 接口即可。
*/
const {
messages,
isGenerating,
isLoadingConversation,
activeToolStatus,
lastError,
canRetry,
sendMessage,
stopGeneration,
retryLastMessage,
resetConversation
} = useAiChat({
request: async ({ message, signal }) => createMockReply(message, signal)
});
</script>
<style lang="less" scoped>
.demo {
.phone {
height: 32rem;
display: flex;
overflow: hidden;
flex-direction: column;
border: 1px solid var(--vp-c-divider);
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.head {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: .625rem .75rem;
border-bottom: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
}
.title {
font-size: .875rem;
font-weight: 600;
}
.reset {
padding: .25rem .5rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
}
.panel {
flex: 1;
min-height: 0;
}
.welcome {
padding: 2rem 1rem;
text-align: center;
}
.welcome-title {
margin: 0 0 .375rem;
font-size: .875rem;
font-weight: 600;
color: var(--vp-c-text-1);
}
.welcome-tip {
margin: 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
.note {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<template>
<div class="demo">
<div class="tabs">
<button
type="button"
class="tab"
:class="{ active: tab === 'edit' }"
@click="tab = 'edit'"
v-text="'编辑'"
/>
<button
type="button"
class="tab"
:class="{ active: tab === 'view' }"
@click="tab = 'view'"
v-text="'预览'"
/>
</div>
<div class="switcher">
<button
v-for="item in contentTypeList"
:key="item.value"
type="button"
class="chip"
:class="{ active: article.contentType === item.value }"
@click="switchContentType(item.value)"
v-text="item.label"
/>
</div>
<section v-show="tab === 'edit'" class="pane">
<input v-model="article.title" class="field" placeholder="文章标题" />
<article-editor
ref="editorRef"
:model-value="article"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
@update:model-value="onChange"
/>
<div class="footer">
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
<span class="status" v-text="status" />
</div>
</section>
<section v-show="tab === 'view'" class="pane">
<article-view :article="article" />
</section>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { ArticleEditor, ArticleView } from "timi-tdesign-mobile";
import type {
Article,
ArticleContentType,
ArticleEditorExpose,
UploadResult
} from "timi-tdesign-mobile";
/** 本场景验证「同一份 Article 数据,编辑与预览共用」。 */
const RICH_CONTENT = `<h2>文章编辑与预览</h2>
<p>两侧共用同一个 <code>Article</code> 对象,切换标签即可对比。</p>
<blockquote><p>移动端用标签切换替代左右分栏。</p></blockquote>`;
const contentTypeList: Array<{ label: string; value: ArticleContentType }> = [
{ label: "纯文本", value: "TEXT" },
{ label: "Markdown", value: "MARKDOWN" },
{ label: "富文本", value: "TIP_TAP" }
];
const tab = ref("edit");
const article = ref<Article>({
title: "编辑器组件演示文章",
contentType: "TIP_TAP",
content: RICH_CONTENT,
attachmentList: []
});
const status = ref("等待提交");
const editorRef = ref<ArticleEditorExpose>();
function switchContentType(contentType: ArticleContentType) {
const content = contentType === "TEXT"
? "这是一段纯文本示例。"
: contentType === "MARKDOWN"
? "## Markdown 示例\n\n这是一段 Markdown 正文。"
: RICH_CONTENT;
article.value = {
...article.value,
contentType,
content
};
status.value = `已切换到 ${contentType}`;
}
function onChange(value: Article) {
article.value = value;
}
async function submit() {
const value = await editorRef.value?.submit();
if (value) {
article.value = value;
status.value = `已提交,${value.attachmentList?.length || 0} 个附件`;
}
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.tabs {
display: flex;
gap: .25rem;
padding: .25rem;
margin-bottom: .75rem;
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.tab {
flex: 1;
padding: .375rem;
font-size: .75rem;
cursor: pointer;
border: none;
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
&.active {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg);
}
}
.switcher {
display: flex;
flex-wrap: wrap;
gap: .375rem;
margin-bottom: .75rem;
}
.chip {
padding: .25rem .625rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: 1rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.active {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.footer {
display: flex;
gap: .75rem;
align-items: center;
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.primary {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.status {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<template>
<div class="demo">
<div class="tabs">
<button
v-for="item in tabList"
:key="item.value"
type="button"
class="tab"
:class="{ active: tab === item.value }"
@click="tab = item.value"
v-text="item.label"
/>
</div>
<section v-show="tab === 'rich'" class="pane">
<div class="bar">
<button type="button" class="btn" @click="submitRich" v-text="'提交富文本'" />
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
</div>
<div class="box tall">
<tiptap-editor
ref="tiptapRef"
v-model="rich"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onRichChange"
/>
</div>
<p class="status" v-text="status" />
</section>
<section v-show="tab === 'view'" class="pane">
<tiptap-view :content="rich" max-height="20rem" />
</section>
<section v-show="tab === 'code'" class="pane">
<div class="bar">
<button type="button" class="btn" @click="resetCode" v-text="'恢复示例'" />
</div>
<div class="box">
<code-editor v-model="code" language="typescript" placeholder="请输入代码" />
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { CodeEditor, TiptapEditor, TiptapView } from "timi-tdesign-mobile";
import type { TiptapEditorExpose, UploadResult } from "timi-tdesign-mobile";
/** 本场景把三类编辑器收在一个页面里,用标签切换而不是并排——移动端宽度放不下分栏。 */
const DEMO_CODE = `type DemoUser = {
id: string;
name: string;
};
const user: DemoUser = { id: "u1", name: "Timi" };`;
const tabList = [
{ label: "富文本", value: "rich" },
{ label: "只读视图", value: "view" },
{ label: "代码", value: "code" }
];
const tab = ref("rich");
const rich = ref(`<h2>跨平台编辑器</h2>
<p>这段内容同时验证标题、段落、引用与代码块的渲染。</p>
<blockquote><p>移动端和 PC 端只替换交互层,共享文档模型。</p></blockquote>`);
const code = ref(DEMO_CODE);
const status = ref("等待操作");
const tiptapRef = ref<TiptapEditorExpose>();
function onRichChange(value: string) {
status.value = `内容已更新,${value.length} 个字符`;
}
function resetCode() {
code.value = DEMO_CODE;
}
async function submitRich() {
const value = await tiptapRef.value?.submit();
if (value !== undefined) {
rich.value = value;
status.value = `已提交,${value.length} 个字符`;
}
}
function openSearch() {
tiptapRef.value?.openSearch(true);
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.tabs {
display: flex;
gap: .25rem;
padding: .25rem;
margin-bottom: .75rem;
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.tab {
flex: 1;
padding: .375rem;
font-size: .75rem;
cursor: pointer;
border: none;
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
&.active {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg);
}
}
.bar {
display: flex;
gap: .5rem;
margin-bottom: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&:hover {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
}
.box {
height: 16rem;
}
.tall {
height: 22rem;
}
.status {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<template>
<div class="demo">
<div class="card">
<t-cell title="缓存有效期" arrow @click="pickerVisible = true">
<span v-text="humanized" />
</t-cell>
</div>
<div class="bar">
<button type="button" class="btn" @click="sheetVisible = true" v-text="'打开底部弹层'" />
</div>
<p class="status" v-text="`弹层最近一次操作:${lastAction}`" />
<space size="small" class="tags">
<span v-for="item in 3" :key="item" class="tag" v-text="`标签 ${item}`" />
</space>
<t-popup v-model="pickerVisible" placement="bottom" destroy-on-close>
<div class="picker-panel">
<duration-picker v-model="durationMs" @change="pickerVisible = false" />
</div>
</t-popup>
<bottom-popup
v-model="sheetVisible"
title="选择操作"
@left-button-click="onAction('取消')"
@right-button-click="onAction('确定')"
>
<div class="sheet-content">
<p v-text="'底部弹层适合承载一组操作或一段简短表单。'" />
</div>
</bottom-popup>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { BottomPopup, DurationPicker, Space } from "timi-tdesign-mobile";
/**
* 本场景是移动端最典型的两种浮现方式:整块选择面板用 `t-popup` 从底部升起,
* 操作确认用 BottomPopup。
*/
const durationMs = ref(1000);
const pickerVisible = ref(false);
const sheetVisible = ref(false);
const lastAction = ref("无");
/** 把毫秒换算成便于阅读的形式。 */
const humanized = computed(() => {
const value = durationMs.value;
if (value % (60 * 60 * 1000) === 0) {
return `${value / (60 * 60 * 1000)} 小时`;
}
if (value % (60 * 1000) === 0) {
return `${value / (60 * 1000)} 分钟`;
}
if (value % 1000 === 0) {
return `${value / 1000} 秒`;
}
return `${value} 毫秒`;
});
function onAction(action: string) {
lastAction.value = action;
sheetVisible.value = false;
}
</script>
<style lang="less" scoped>
.demo {
.card {
overflow: hidden;
margin-bottom: .75rem;
border: 1px solid var(--vp-c-divider);
border-radius: .5rem;
}
.bar {
margin-bottom: .75rem;
}
.btn {
width: 100%;
padding: .5rem;
font-size: .8125rem;
cursor: pointer;
border: none;
border-radius: .5rem;
color: #fff;
background: var(--vp-c-brand-1);
}
.status {
margin: 0 0 .75rem;
font-size: .75rem;
color: var(--vp-c-text-2);
}
.tags {
margin-bottom: 0;
}
.tag {
padding: .25rem .5rem;
font-size: .6875rem;
border-radius: .25rem;
color: var(--vp-c-brand-1);
background: var(--vp-c-default-soft);
}
.picker-panel {
padding-top: .5rem;
border-radius: 1rem 1rem 0 0;
background: var(--vp-c-bg);
}
.sheet-content {
padding: 1rem;
p {
margin: 0;
font-size: .8125rem;
line-height: 1.6;
color: var(--vp-c-text-2);
}
}
}
</style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<div class="demo">
<div class="tabs">
<button
type="button"
class="tab"
:class="{ active: tab === 'edit' }"
@click="tab = 'edit'"
v-text="'编辑'"
/>
<button
type="button"
class="tab"
:class="{ active: tab === 'view' }"
@click="tab = 'view'"
v-text="'预览'"
/>
</div>
<div class="status-bar">
<button
type="button"
class="chip"
@click="switchStatus"
v-text="'切换状态'"
/>
<span class="tag" v-text="detail.status" />
</div>
<section v-show="tab === 'edit'" class="pane">
<input v-model="detail.subject" class="field" placeholder="通知主题" />
<notify-editor
ref="editorRef"
:model-value="detail"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
@update:model-value="onChange"
/>
<div class="footer">
<button type="button" class="btn primary" @click="submit" v-text="'提交通知'" />
<span class="status" v-text="status" />
</div>
</section>
<section v-show="tab === 'view'" class="pane">
<notify-view :detail="detail" />
</section>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { NotifyEditor, NotifyView } from "timi-tdesign-mobile";
import type { NotifyDetail, NotifyEditorExpose, UploadResult } from "timi-tdesign-mobile";
/** 本场景验证通知复用文章编辑能力,同时保留自己的元数据字段。 */
const RICH_CONTENT = `<h2>系统维护通知</h2>
<p>本周六 02:00 - 04:00 进行数据库升级,期间服务不可用。</p>
<ul><li>请提前保存工作内容</li><li>升级完成后会同步通知</li></ul>`;
const statusList = ["WAITING", "SENT", "FAILED"];
const tab = ref("edit");
const detail = ref<NotifyDetail>({
subject: "编辑器通知演示",
contentType: "TIP_TAP",
content: RICH_CONTENT,
status: "WAITING",
attachmentList: []
});
const status = ref("等待提交");
const editorRef = ref<NotifyEditorExpose>();
function switchStatus() {
const index = statusList.indexOf(detail.value.status);
const next = statusList[(index + 1) % statusList.length];
detail.value = {
...detail.value,
status: next
};
}
function onChange(value: NotifyDetail) {
detail.value = value;
}
async function submit() {
const value = await editorRef.value?.submit();
if (value) {
detail.value = value;
status.value = `已提交,正文 ${value.content?.length || 0} 个字符`;
}
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.tabs {
display: flex;
gap: .25rem;
padding: .25rem;
margin-bottom: .75rem;
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.tab {
flex: 1;
padding: .375rem;
font-size: .75rem;
cursor: pointer;
border: none;
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
&.active {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg);
}
}
.status-bar {
display: flex;
gap: .5rem;
align-items: center;
margin-bottom: .75rem;
}
.chip {
padding: .25rem .625rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: 1rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
}
.tag {
padding: .125rem .5rem;
font-size: .6875rem;
border-radius: .25rem;
color: var(--vp-c-brand-1);
background: var(--vp-c-default-soft);
}
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.footer {
display: flex;
gap: .75rem;
align-items: center;
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.primary {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.status {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="demo">
<section class="block">
<h4 v-text="'水平(默认 medium)'" />
<space>
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
</space>
</section>
<section class="block">
<h4 v-text="'预设尺寸 small / large'" />
<space size="small">
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
</space>
<space size="large">
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
</space>
</section>
<section class="block">
<h4 v-text="'垂直排列'" />
<space direction="vertical">
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
</space>
</section>
<section class="block">
<h4 v-text="'分隔符'" />
<space separator="|">
<span v-for="item in 3" :key="item" v-text="`项 ${item}`" />
</space>
</section>
</div>
</template>
<script lang="ts" setup>
import { Space } from "timi-tdesign-mobile";
</script>
<style lang="less" scoped>
.demo {
.block {
margin-bottom: 1.25rem;
&:last-child {
margin-bottom: 0;
}
}
h4 {
margin: 0 0 .5rem;
font-size: .8125rem;
}
.box {
padding: .375rem .625rem;
font-size: .75rem;
border-radius: .375rem;
color: var(--vp-c-brand-1);
background: var(--vp-c-default-soft);
}
span {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<template>
<div class="demo">
<div class="bar">
<button type="button" class="btn" @click="submit" v-text="'提交'" />
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
</div>
<div class="box">
<tiptap-editor
ref="editorRef"
v-model="content"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
/>
</div>
<p class="status" v-text="status" />
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { TiptapEditor } from "timi-tdesign-mobile";
import type { TiptapEditorExpose, UploadResult } from "timi-tdesign-mobile";
const content = ref(`<h2>富文本编辑器</h2>
<p>工具栏覆盖标题、颜色、列表、表格、链接与代码块。</p>
<blockquote><p>移动端工具栏会自动换行排列。</p></blockquote>`);
const status = ref("等待操作");
const editorRef = ref<TiptapEditorExpose>();
function onChange(value: string) {
status.value = `内容已更新,${value.length} 个字符`;
}
async function submit() {
const value = await editorRef.value?.submit();
if (value !== undefined) {
content.value = value;
status.value = `已提交,${value.length} 个字符`;
}
}
function openSearch() {
editorRef.value?.openSearch(true);
}
/** 演示用上传:直接把本地文件转成临时地址,无需后端。 */
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.bar {
display: flex;
gap: .5rem;
margin-bottom: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&:hover {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
}
.box {
height: 24rem;
}
.status {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<template>
<div class="demo">
<input v-model="maxHeight" class="field" placeholder="最大高度,如 16rem" />
<tiptap-view :content="content" :max-height="maxHeight" />
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { TiptapView } from "timi-tdesign-mobile";
const content = `<h2>只读视图</h2>
<p>与编辑器共用同一套渲染规则,内容超出 <code>maxHeight</code> 时可滚动查看。</p>
<blockquote><p>适合详情页、审批流等只需要阅读的场景。</p></blockquote>`;
const maxHeight = ref("16rem");
</script>
<style lang="less" scoped>
.demo {
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
}
</style>
+122
View File
@@ -0,0 +1,122 @@
# 演示与文档约定
本站的演示与 API 文档都由源码驱动,新增组件时只需按下面的顺序补齐三处内容。
## 目录结构
```text
docs/
├── .vitepress/
│ ├── components.ts # 组件元数据清单(唯一数据源)
│ ├── config.ts # 站点配置、侧边栏、markdown 插件
│ ├── plugins/
│ │ ├── docgen.ts # vue-docgen-api 解析,产出虚拟模块
│ │ └── ssr-stub.ts # SSR 阶段替换 timi-web
│ └── theme/
│ ├── index.ts # 主题入口,注册 TDesign Mobile 与自定义组件
│ ├── containers/ # :::preview 的预览容器
│ └── components/ # api-table 等文档组件
├── demos/ # 演示组件,按组件名分目录
│ ├── ai/ # AI 演示共用的假流
│ └── scenarios/ # 跨组件的组合场景演示
├── components/ # 组件文档页
└── scenarios/ # 组合场景文档页
```
单组件文档只回答「这个组件怎么用」,跨组件的协作方式放在 `scenarios/` 下。
## 新增一个组件
### 1. 登记元数据
在 `docs/.vitepress/components.ts` 的 `COMPONENT_LIST` 中追加一项。侧边栏导航、API 数据解析都从这里读取:
```ts
{
name: "CodeEditor", // 必须与组件 defineOptions 的 name 一致
title: "CodeEditor 代码编辑器",
description: "带行号、语法高亮与查找替换的代码输入框。",
category: "编辑器组件",
entry: "src/components/code-editor/index.vue"
}
```
### 2. 编写演示
演示组件放在 `docs/demos/<文档路径>/` 下。演示直接引用 `src` 源码,改组件会即时热更新:
```vue
<script lang="ts" setup>
import { ref } from "vue";
import { CodeEditor } from "timi-tdesign-mobile";
const code = ref("");
</script>
<template>
<code-editor v-model="code" />
</template>
```
### 3. 编写文档页
在 `docs/components/` 下建立同名 markdown,用 `:::preview` 容器引用演示:
````md
# CodeEditor 代码编辑器
带行号、语法高亮与查找替换的代码输入框。
## 基础用法
:::preview 基础用法 || 通过 v-model 双向绑定内容
demo-preview=../demos/code-editor/basic.vue
:::
## API
<api-table name="CodeEditor" />
````
路径以 markdown 文件所在位置为基准,因此 `docs/components/` 下的页面统一使用 `../demos/`。
## 文档路径的来历
`index.vue` 取所在**目录名**,具名文件取**文件名**——`ai` 目录下有多个组件,只靠目录名会全部撞成 `ai`。两者都会再转成短横线形式,`AiChatComposer` 得到 `ai-chat-composer`。需要特殊路径时用元数据里的 `slug` 覆盖。
## API 表从哪来
`<api-table>` 的数据由 `vue-docgen-api` 在构建期解析组件源码得到,通过虚拟模块 `virtual:timi-api` 交给页面渲染。因此不需要手写 props / events / slots 表格,改源码后文档自动更新。
想让说明出现在表里,就在 props 类型上写 JSDoc 注释:
```ts
const props = withDefaults(defineProps<{
/** 距离底部小于等于该值时触发 */
distance?: number;
}>(), {
distance: 120
});
```
某个组件解析失败时只会让该组件的 API 区降级为空,不会影响整站构建;构建日志里会打出对应的组件名。
## 演示统一放在手机视口里
这是移动端组件库,按桌面宽度铺开会失去真实形态。容器把演示收在 `26rem` 宽的视口内,宽度可以通过 CSS 变量调整:
```less
.tui-demo {
--tui-demo-viewport: 30rem;
}
```
需要全宽的演示(如整页骨架)可以在自己的样式里覆盖这个变量。
## 关于 SSR
`timi-web` 的构建产物在模块顶层访问 `window`,无法在 Node 中加载,而主题必须静态引入它来注册 `v-popup` 指令。因此 SSR 阶段由 `ssr-stub.ts` 提供空实现。
演示组件本身以 `clientOnly` 延迟渲染,不需要额外处理 SSR,但也别把它当作服务端渲染能力的验证。
+102
View File
@@ -0,0 +1,102 @@
# 快速开始
`timi-tdesign-mobile` 是面向移动端的 Vue 3 业务组件库,底层复用 `timi-web` 的编辑器与内容渲染能力,UI 基于 TDesign Mobile。
## 安装
```sh
pnpm add timi-tdesign-mobile tdesign-mobile-vue
```
## 按需引入
组件在 `<script setup>` 中导入后即可在模板里使用,这是推荐方式:
```vue
<script lang="ts" setup>
import { ref } from "vue";
import { CodeEditor } from "timi-tdesign-mobile";
const code = ref("const answer = 42;");
</script>
<template>
<code-editor v-model="code" language="typescript" />
</template>
```
## 全局注册
每个组件都带有 `install` 方法,可以单独注册为全局组件:
```ts
import { createApp } from "vue";
import { CodeEditor, TiptapEditor } from "timi-tdesign-mobile";
const app = createApp(App);
app.use(CodeEditor);
app.use(TiptapEditor);
```
## 必须注册 v-popup 指令
富文本工具栏等组件内部使用了 `timi-web` 的 `v-popup` 指令,且用在组件模板里,宿主的应用入口需要注册一次:
```ts
import TimiWebUI, { VPopup } from "timi-web";
app.use(TimiWebUI);
app.directive("popup", VPopup);
```
漏掉这一步不会报错,但工具栏的悬停提示会静默失效。
## 引入样式
```ts
import "timi-tdesign-mobile/style.css";
```
该样式入口已包含 TDesign 定制与编辑器正文样式,`timi-web` 的样式也会一并带入。
**另外还需要引入 TDesign Mobile 自己的样式**,注意要引完整包而不是 `es/style/index.css`——后者只含全局 token,不含任何组件类:
```ts
import "tdesign-mobile-vue/dist/tdesign.css";
```
## 数据与回调
文章、通知类组件只约束对象的最小字段,业务对象上的其他字段会原样保留。上传与附件地址通过回调注入,库本身不绑定任何接口实现:
```vue
<article-editor
:model-value="article"
:upload-image="uploadImage"
:upload-video="uploadVideo"
:resolve-attachment-url="resolveAttachmentUrl"
@update:model-value="article = $event"
/>
```
没有附件上传需求时,可以不传上传回调。
## AI 对话
对话能力由 `useAiChat` 组合式函数提供,组件只负责渲染:
```ts
import { useAiChat } from "timi-tdesign-mobile";
const { messages, isGenerating, sendMessage } = useAiChat({
request: ({ message, signal }) => fetchSseReply(message, signal)
});
```
详见 [AiChatPanel](/components/ai-chat-panel) 与 [AI 对话场景](/scenarios/ai)。
## 下一步
- 在[组件总览](/components/)中浏览全部组件
- 看[编辑器能力总览](/scenarios/editor)等场景,了解多个组件如何协作
- 了解本站的[演示与文档约定](/guide/demo),向文档站补充新组件
+23
View File
@@ -0,0 +1,23 @@
---
layout: home
hero:
name: timi-tdesign-mobile
text: 移动端 TDesign 组件库
tagline: 15 个业务组件,配套可交互演示与由源码自动生成的 API 文档
actions:
- theme: brand
text: 快速开始
link: /guide/
- theme: alt
text: 浏览组件
link: /components/
features:
- title: 真实可交互
details: 演示直接运行 src 源码而非静态截图,改动组件源码后文档即时热更新。
- title: 手机视口呈现
details: 演示统一放在移动端宽度的视口里,避免移动组件被桌面宽度拉伸失真。
- title: API 自动生成
details: 由 vue-docgen-api 在构建期解析组件源码提取 props、events、slots 与 expose,文档与代码不会脱节。
---
+27
View File
@@ -0,0 +1,27 @@
{
"name": "timi-tdesign-mobile-docs",
"private": true,
"version": "0.0.1",
"type": "module",
"description": "timi-tdesign-mobile 组件演示与 API 文档站",
"scripts": {
"dev": "vitepress dev .",
"build": "vitepress build .",
"preview": "vitepress preview ."
},
"dependencies": {
"tdesign-mobile-vue": "^1.13.2",
"timi-web": "link:E:/WebProject/timi-web",
"vue": "^3.5.26"
},
"devDependencies": {
"@types/node": "^25.0.8",
"@vitepress-demo-preview/plugin": "^1.4.1",
"less": "^4.5.1",
"markdown-it-container": "^3.0.0",
"typescript": "^5.9.3",
"unplugin-auto-import": "^21.0.0",
"vitepress": "^1.6.4",
"vue-docgen-api": "^4.79.2"
}
}
+2845
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
# AI 对话
把对话面板嵌进带标题栏的页面骨架,并自定义欢迎语与角色名。
## 组合演示
:::preview 完整对话 || 回答由本地假流逐块吐出,可随时点「停止」或用「清空」重开
demo-preview=../demos/scenarios/ai.vue
:::
## 这个场景覆盖了什么
- **会话接线**:`useAiChat` 提供 `messages` 与生成状态,面板只负责渲染
- **页面骨架**:标题栏与面板组合成完整页面,`清空` 调用 `resetConversation`
- **自定义文案**:`roleNames` 把「助手」换成「分析助手」,`welcome` 插槽替换空态
- **流式呈现**:思考过程、工具状态、回答与引用按事件依次出现
## 换成真实后端
示例里的 `request` 返回的是本地构造的假流。接真实接口时只要返回一个 SSE 流式的 `Response` 即可,其余不用改:
```ts
const chat = useAiChat({
request: ({ message, signal }) => fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
signal
})
});
```
事件名与载荷字段见 [AiChatPanel](/components/ai-chat-panel) 的协议表;后端用的是另一套事件名时,用 `parseEvent` 转换。
## 相关组件
- [AiChatPanel](/components/ai-chat-panel)
- [AiChatMessageList](/components/ai-chat-message-list)
- [AiChatComposer](/components/ai-chat-composer)
- [AiChatThinking](/components/ai-chat-thinking)
+27
View File
@@ -0,0 +1,27 @@
# 文章编辑与预览
编辑与预览共用同一个 `Article` 对象,用标签在两种视图之间切换。
## 组合演示
:::preview 编辑与预览联动 || 切换正文类型时两个标签页同时切换渲染分支
demo-preview=../demos/scenarios/article.vue
:::
## 这个场景覆盖了什么
- **正文类型切换**:纯文本、Markdown、富文本三种形态共用一份数据
- **数据联动**:`ArticleEditor` 抛出的更新直接喂给 `ArticleView`
- **字段保留**:`title` 等业务字段由外层维护,编辑器不会丢字段
- **提交语义**:`submit()` 等待未完成的媒体上传后再返回最终数据
## 为什么用标签而不是分栏
PC 端可以左右并排,移动端宽度不够。用标签切换既保证了对比的便利,又不牺牲编辑区的可用宽度。
## 相关组件
- [ArticleEditor](/components/article-editor)
- [ArticleView](/components/article-view)
+24
View File
@@ -0,0 +1,24 @@
# 编辑器能力总览
把富文本编辑器、只读视图与代码编辑器放在一个页面里,验证它们共用同一套内容模型与检索能力。
## 组合演示
:::preview 组合用法 || 标签切换而不是左右分栏——移动端宽度放不下分栏
demo-preview=../demos/scenarios/editor.vue
:::
## 这个场景覆盖了什么
- **富文本编辑**:工具栏、媒体上传、提交前的占位内容处理
- **只读渲染**:同一份 HTML 交给 `TiptapView`,展示效果与编辑态一致
- **代码编辑**:行号、语法高亮与查找替换
- **统一检索**:`openSearch()` 在两类编辑器上语义一致,便于封装统一的快捷键
## 相关组件
- [TiptapEditor](/components/tiptap-editor)
- [TiptapView](/components/tiptap-view)
- [CodeEditor](/components/code-editor)
+27
View File
@@ -0,0 +1,27 @@
# 移动端组件
移动端最典型的两种浮现方式:整块选择面板与操作确认,都从底部升起。
## 组合演示
:::preview 组合用法 || 点击「缓存有效期」行打开 Picker,点按钮打开底部弹层
demo-preview=../demos/scenarios/mobile.vue
:::
## 这个场景覆盖了什么
- **列表触发的选择面板**:`t-cell` 当前值 + 箭头,点击后用 `t-popup` 从底部升起 `DurationPicker`
- **操作确认**:`BottomPopup` 承载标题与左右按钮,按钮只抛事件、关闭由业务决定
- **间距**:`Space` 统一控制一组元素之间的间距
## 关于 destroy-on-close
示例里的 Picker 弹层加了 `destroy-on-close`,每次打开都重建,避免上次的选择残留。选择面板这类有内部滚动状态的组件建议都带上。
## 相关组件
- [DurationPicker](/components/duration-picker)
- [BottomPopup](/components/bottom-popup)
- [Space](/components/space)
+22
View File
@@ -0,0 +1,22 @@
# 通知编辑与预览
通知复用文章编辑器的正文能力,同时保留主题、状态等自有字段。
## 组合演示
:::preview 编辑与预览联动 || 状态字段由外层维护,切状态不影响正文
demo-preview=../demos/scenarios/notify.vue
:::
## 这个场景覆盖了什么
- **能力复用**:`NotifyEditor` 内部就是 `ArticleEditor`,正文体验一致
- **元数据独立**:`subject`、`status` 等字段与正文互不干扰
- **字段透传**:业务字段在编辑过程中原样保留
## 相关组件
- [NotifyEditor](/components/notify-editor)
- [NotifyView](/components/notify-view)
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"jsx": "preserve",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": [
"node"
],
"baseUrl": ".",
"paths": {
"timi-tdesign-mobile": [
"../src/index.ts"
],
"timi-web/editor": [
"../../timi-web/src/editor/index.ts"
]
}
},
"include": [
"./**/*.ts",
"./**/*.d.ts",
"./**/*.vue"
],
"exclude": [
"node_modules",
".vitepress/cache",
".vitepress/dist"
]
}
-102
View File
@@ -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>
-177
View File
@@ -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>
-88
View File
@@ -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>
-186
View File
@@ -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>
-137
View File
@@ -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>
-59
View File
@@ -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)
};
}
-12
View 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");
-5
View File
@@ -1,5 +0,0 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
-19
View File
@@ -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>
+10 -7
View File
@@ -8,10 +8,10 @@
"version": "0.0.10", "version": "0.0.10",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite", "dev": "pnpm run -C docs dev",
"dev:doc": "pnpm run -C docs dev",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"build:doc": "pnpm run -C docs 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/" "pub": "pnpm run build && npm publish --registry=https://nexus.imyeyu.com/repository/npm-timi/"
}, },
"publishConfig": { "publishConfig": {
@@ -20,7 +20,6 @@
"files": [ "files": [
"dist/**", "dist/**",
"src/**", "src/**",
"examples/**",
"README.md", "README.md",
"package.json" "package.json"
], ],
@@ -37,9 +36,11 @@
}, },
"dependencies": { "dependencies": {
"tdesign-mobile-vue": "^1.13.2", "tdesign-mobile-vue": "^1.13.2",
"timi-web": "link:E:/WebProject/timi-web", "timi-web": "link:E:/WebProject/timi-web"
"vue": "3.5.26", },
"vue-router": "4.5.0" "peerDependencies": {
"vue": "^3.5.0",
"vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
@@ -64,7 +65,9 @@
"vite-plugin-dts": "^4.5.4", "vite-plugin-dts": "^4.5.4",
"vite-plugin-vue-setup-extend": "^0.4.0", "vite-plugin-vue-setup-extend": "^0.4.0",
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.7.0",
"vue": "3.5.26",
"vue-router": "4.5.0",
"vite": "7.1.11", "vite": "7.1.11",
"vue-tsc": "^2.2.10" "vue-tsc": "^2.2.12"
} }
} }
+38 -17
View File
@@ -14,12 +14,6 @@ importers:
timi-web: timi-web:
specifier: link:E:/WebProject/timi-web specifier: link:E:/WebProject/timi-web
version: link:../timi-web version: link:../timi-web
vue:
specifier: 3.5.26
version: 3.5.26(typescript@5.8.3)
vue-router:
specifier: 4.5.0
version: 4.5.0(vue@3.5.26(typescript@5.8.3))
devDependencies: devDependencies:
'@types/node': '@types/node':
specifier: ^22.13.5 specifier: ^22.13.5
@@ -90,9 +84,15 @@ importers:
vite-plugin-vue-setup-extend: vite-plugin-vue-setup-extend:
specifier: ^0.4.0 specifier: ^0.4.0
version: 0.4.0(vite@7.1.11(@types/node@22.13.9)(less@4.3.0)(terser@5.46.0)) version: 0.4.0(vite@7.1.11(@types/node@22.13.9)(less@4.3.0)(terser@5.46.0))
vue:
specifier: 3.5.26
version: 3.5.26(typescript@5.8.3)
vue-router:
specifier: 4.5.0
version: 4.5.0(vue@3.5.26(typescript@5.8.3))
vue-tsc: vue-tsc:
specifier: ^2.2.10 specifier: ^2.2.12
version: 2.2.10(typescript@5.8.3) version: 2.2.12(typescript@5.8.3)
packages: packages:
@@ -1309,12 +1309,21 @@ packages:
'@volar/language-core@2.4.12': '@volar/language-core@2.4.12':
resolution: {integrity: sha512-RLrFdXEaQBWfSnYGVxvR2WrO6Bub0unkdHYIdC31HzIEqATIuuhRRzYu76iGPZ6OtA4Au1SnW0ZwIqPP217YhA==} resolution: {integrity: sha512-RLrFdXEaQBWfSnYGVxvR2WrO6Bub0unkdHYIdC31HzIEqATIuuhRRzYu76iGPZ6OtA4Au1SnW0ZwIqPP217YhA==}
'@volar/language-core@2.4.15':
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
'@volar/source-map@2.4.12': '@volar/source-map@2.4.12':
resolution: {integrity: sha512-bUFIKvn2U0AWojOaqf63ER0N/iHIBYZPpNGogfLPQ68F5Eet6FnLlyho7BS0y2HJ1jFhSif7AcuTx1TqsCzRzw==} resolution: {integrity: sha512-bUFIKvn2U0AWojOaqf63ER0N/iHIBYZPpNGogfLPQ68F5Eet6FnLlyho7BS0y2HJ1jFhSif7AcuTx1TqsCzRzw==}
'@volar/source-map@2.4.15':
resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
'@volar/typescript@2.4.12': '@volar/typescript@2.4.12':
resolution: {integrity: sha512-HJB73OTJDgPc80K30wxi3if4fSsZZAOScbj2fcicMuOPoOkcf9NNAINb33o+DzhBdF9xTKC1gnPmIRDous5S0g==} resolution: {integrity: sha512-HJB73OTJDgPc80K30wxi3if4fSsZZAOScbj2fcicMuOPoOkcf9NNAINb33o+DzhBdF9xTKC1gnPmIRDous5S0g==}
'@volar/typescript@2.4.15':
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
'@vue/babel-helper-vue-jsx-merge-props@1.4.0': '@vue/babel-helper-vue-jsx-merge-props@1.4.0':
resolution: {integrity: sha512-JkqXfCkUDp4PIlFdDQ0TdXoIejMtTHP67/pvxlgeY+u5k3LEdKuWZ3LK6xkxo52uDoABIVyRwqVkfLQJhk7VBA==} resolution: {integrity: sha512-JkqXfCkUDp4PIlFdDQ0TdXoIejMtTHP67/pvxlgeY+u5k3LEdKuWZ3LK6xkxo52uDoABIVyRwqVkfLQJhk7VBA==}
@@ -1512,8 +1521,8 @@ packages:
typescript: typescript:
optional: true optional: true
'@vue/language-core@2.2.10': '@vue/language-core@2.2.12':
resolution: {integrity: sha512-+yNoYx6XIKuAO8Mqh1vGytu8jkFEOH5C8iOv3i8Z/65A7x9iAOXA97Q+PqZ3nlm2lxf5rOJuIGI/wDtx/riNYw==} resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
peerDependencies: peerDependencies:
typescript: '*' typescript: '*'
peerDependenciesMeta: peerDependenciesMeta:
@@ -4772,8 +4781,8 @@ packages:
vue-template-es2015-compiler@1.9.1: vue-template-es2015-compiler@1.9.1:
resolution: {integrity: sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==} resolution: {integrity: sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==}
vue-tsc@2.2.10: vue-tsc@2.2.12:
resolution: {integrity: sha512-jWZ1xSaNbabEV3whpIDMbjVSVawjAyW+x1n3JeGQo7S0uv2n9F/JMgWW90tGWNFRKya4YwKMZgCtr0vRAM7DeQ==} resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
typescript: '>=5.0.0' typescript: '>=5.0.0'
@@ -6272,14 +6281,26 @@ snapshots:
dependencies: dependencies:
'@volar/source-map': 2.4.12 '@volar/source-map': 2.4.12
'@volar/language-core@2.4.15':
dependencies:
'@volar/source-map': 2.4.15
'@volar/source-map@2.4.12': {} '@volar/source-map@2.4.12': {}
'@volar/source-map@2.4.15': {}
'@volar/typescript@2.4.12': '@volar/typescript@2.4.12':
dependencies: dependencies:
'@volar/language-core': 2.4.12 '@volar/language-core': 2.4.12
path-browserify: 1.0.1 path-browserify: 1.0.1
vscode-uri: 3.1.0 vscode-uri: 3.1.0
'@volar/typescript@2.4.15':
dependencies:
'@volar/language-core': 2.4.15
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue/babel-helper-vue-jsx-merge-props@1.4.0': {} '@vue/babel-helper-vue-jsx-merge-props@1.4.0': {}
'@vue/babel-helper-vue-transform-on@1.3.0': {} '@vue/babel-helper-vue-transform-on@1.3.0': {}
@@ -6779,9 +6800,9 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.8.3 typescript: 5.8.3
'@vue/language-core@2.2.10(typescript@5.8.3)': '@vue/language-core@2.2.12(typescript@5.8.3)':
dependencies: dependencies:
'@volar/language-core': 2.4.12 '@volar/language-core': 2.4.15
'@vue/compiler-dom': 3.5.41 '@vue/compiler-dom': 3.5.41
'@vue/compiler-vue2': 2.7.16 '@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.41 '@vue/shared': 3.5.41
@@ -10075,10 +10096,10 @@ snapshots:
vue-template-es2015-compiler@1.9.1: {} vue-template-es2015-compiler@1.9.1: {}
vue-tsc@2.2.10(typescript@5.8.3): vue-tsc@2.2.12(typescript@5.8.3):
dependencies: dependencies:
'@volar/typescript': 2.4.12 '@volar/typescript': 2.4.15
'@vue/language-core': 2.2.10(typescript@5.8.3) '@vue/language-core': 2.2.12(typescript@5.8.3)
typescript: 5.8.3 typescript: 5.8.3
vue@3.5.26(typescript@5.8.3): vue@3.5.26(typescript@5.8.3):
+14
View File
@@ -0,0 +1,14 @@
.entrance-fade-up-enter-active {
transition: opacity .4s ease, transform .4s ease;
transition-delay: var(--entrance-delay, 0ms);
}
.entrance-fade-up-enter-from {
opacity: 0;
transform: translate3d(0, 1rem, 0);
}
.entrance-fade-up-enter-to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
+46
View File
@@ -8,6 +8,25 @@
-webkit-transform: none; -webkit-transform: none;
} }
.cell-button {
--td-button-border-radius: 1rem;
width: calc(100% - 2rem);
margin: 0 1rem;
}
.t-swipe-cell {
&__right {
.t-button {
height: 100%;
padding: 0 1rem;
border-radius: 0;
}
}
}
.t-image-viewer { .t-image-viewer {
.t-swiper-item { .t-swiper-item {
@@ -18,18 +37,36 @@
.t-form { .t-form {
.cellStyle(); .cellStyle();
&.card {
border-radius: 1rem;
}
.t-form__item { .t-form__item {
padding: @cellPadding; padding: @cellPadding;
&:not(:last-child)::after { &:not(:last-child)::after {
.cellSplit(); .cellSplit();
} }
.t-form__label {
color: var(--td-gray-color-9);
flex: none;
min-width: unset;
white-space: nowrap;
text-overflow: unset;
}
} }
} }
.t-cell { .t-cell {
padding: @cellPadding; padding: @cellPadding;
.t-cell__title,
.t-cell__title-text {
overflow: hidden;
min-width: 0;
}
&:after { &:after {
.cellSplit(); .cellSplit();
} }
@@ -39,6 +76,15 @@
} }
} }
.cell-title-clip {
.t-cell__title {
flex: 1 1 0;
overflow: hidden;
min-width: 0;
}
}
.t-divider { .t-divider {
&::before, &::before,
+231
View File
@@ -0,0 +1,231 @@
<template>
<div class="composer">
<div v-if="props.lastError" class="error-bar">
<slot name="error" :error="props.lastError" :can-retry="props.canRetry">
<span class="error-text" v-text="props.lastError" />
<t-button
v-if="props.canRetry"
variant="text"
size="small"
:content="props.retryText"
@click="emit('retry')"
/>
</slot>
</div>
<t-form class="form glass-white" @submit="submitMessage">
<t-textarea
v-model="draft"
class="input"
:autosize="{ minRows: 3, maxRows: 6 }"
:disabled="props.isGenerating || props.disabled"
:maxlength="props.maxLength"
:placeholder="props.placeholder"
/>
<div class="actions">
<div class="tools">
<t-button
class="tool"
type="button"
size="extra-small"
variant="outline"
aria-label="新会话"
content="新会话"
:disabled="props.isGenerating || props.disabled"
@click="startNewConversation"
>
<template #icon>
<t-icon name="chat-add" size="small" />
</template>
</t-button>
<t-button
class="tool"
type="button"
size="extra-small"
variant="outline"
aria-label="上传附件"
content="上传附件"
:disabled="props.isGenerating || props.disabled"
@click="addAttachment"
>
<template #icon>
<t-icon name="file-add" size="small" />
</template>
</t-button>
</div>
<slot
v-if="props.isGenerating"
name="stop"
:disabled="props.disabled"
:on-stop="() => emit('stop')"
>
<t-button
class="send"
type="button"
size="extra-small"
theme="danger"
:disabled="props.disabled"
:content="props.stopText"
@click="emit('stop')"
/>
</slot>
<slot
v-else
name="send"
:disabled="props.disabled"
:on-send="submitMessage"
>
<t-button
class="send"
type="submit"
size="extra-small"
theme="primary"
:disabled="props.disabled"
:content="props.sendText"
/>
</slot>
</div>
</t-form>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: "AiChatComposer"
});
export interface Props {
isGenerating: boolean;
lastError?: string;
canRetry?: boolean;
disabled?: boolean;
placeholder?: string;
sendText?: string;
stopText?: string;
retryText?: string;
maxLength?: number;
}
const props = withDefaults(defineProps<Props>(), {
lastError: "",
canRetry: false,
disabled: false,
placeholder: "输入消息…",
sendText: "发送",
stopText: "停止",
retryText: "重试",
maxLength: 2000
});
const draft = defineModel<string>({ required: true });
const emit = defineEmits<{
send: [message: string];
stop: [];
retry: [];
"new-conversation": [];
"add-attachment": [];
}>();
function startNewConversation(): void {
if (props.disabled || props.isGenerating) {
return;
}
draft.value = "";
emit("new-conversation");
}
function addAttachment(): void {
if (props.disabled || props.isGenerating) {
return;
}
emit("add-attachment");
}
function submitMessage(): void {
if (props.disabled || props.isGenerating) {
return;
}
const message = draft.value.trim();
if (!message) {
return;
}
draft.value = "";
emit("send", message);
}
</script>
<style scoped lang="less">
.composer {
width: 100%;
.error-bar {
gap: .375rem;
display: flex;
padding: .5rem .75rem;
border-radius: .75rem;
color: var(--ai-chat-error-color, #b42318);
font-size: .75rem;
background: var(--ai-chat-error-background, #fef3f2);
align-items: center;
justify-content: space-between;
.error-text {
min-width: 0;
word-break: break-word;
}
.t-button {
color: inherit;
flex: none;
}
}
.form {
--td-textarea-background-color: transparent;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border: 1px solid var(--td-component-border);
margin: .5rem 0 0;
display: flex;
padding: .5rem;
box-shadow: 0 .5rem 1.5rem rgba(15, 23, 42, .12);
align-items: stretch;
border-radius: 1.25rem;
flex-direction: column;
.input {
flex: 1;
width: 100%;
padding: 0 .25rem;
min-width: 0;
}
.actions {
--td-button-border-radius: .75rem;
gap: .5rem;
margin-top: .375rem;
display: flex;
align-items: center;
justify-content: space-between;
.tools {
gap: .5rem;
display: flex;
align-items: center;
}
.send {
padding: 0 1rem;
}
}
}
}
:global(html.theme-dark) {
.error-bar {
color: var(--ai-chat-error-color-dark, #fda29b);
background: var(--ai-chat-error-background-dark, rgba(180, 35, 24, .18));
}
}
</style>
+360
View File
@@ -0,0 +1,360 @@
<template>
<div ref="messageList" class="message-list" role="log" :aria-label="props.ariaLabel" aria-live="polite">
<div v-if="props.loading" class="list-state loading-state">
<t-loading size="1.5rem" />
<span v-text="props.loadingText" />
</div>
<slot
v-else-if="!props.messages.length"
name="welcome"
/>
<template v-for="(message, index) in props.messages" v-else :key="message.id">
<slot
name="message"
:message="message"
:index="index"
:is-generating="props.isGenerating"
>
<article class="message-row" :class="message.role">
<div class="message-header">
<div class="avatar">
<slot
:name="message.role === 'assistant' ? 'assistant-icon' : 'user-icon'"
:message="message"
:role="message.role"
>
<t-icon :name="message.role === 'assistant' ? 'robot-2' : 'user'" />
</slot>
</div>
<slot name="role-name" :message="message" :role="message.role">
<div class="role-name" v-text="getRoleName(message)" />
</slot>
</div>
<div class="message-main">
<div class="bubble" :class="message.role">
<template v-if="message.role === 'assistant'">
<slot
v-for="thinking in message.thinkingList"
:key="thinking.id"
name="thinking"
:message="message"
:thinking="thinking"
:is-generating="props.isGenerating && thinking.id === message.thinkingList[message.thinkingList.length - 1]?.id"
>
<ai-chat-thinking
:content="thinking.content"
:title="props.thinkingTitle"
:is-generating="props.isGenerating && thinking.id === message.thinkingList[message.thinkingList.length - 1]?.id"
/>
</slot>
</template>
<div v-if="message.content && message.role === 'assistant'" class="content markdown-content">
<markdown-view :content="message.content" />
</div>
<div v-else-if="message.content" class="content" v-text="message.content" />
<div v-else-if="message.role === 'assistant' && props.isGenerating" class="typing">
<span /><span /><span />
</div>
<div v-if="$slots.citation && message.role === 'assistant' && message.citationList.length" class="citation-list">
<template v-for="(citation, citationIndex) in message.citationList" :key="`${message.id}-${citationIndex}`">
<slot
name="citation"
:citation="citation"
:message="message"
:index="citationIndex"
/>
</template>
</div>
<div
v-if="message.role === 'assistant' && (message.statusText || (!props.isGenerating && message.content))"
class="message-status"
>
<slot name="message-status" :message="message" :text="getMessageStatus(message)">
<span v-text="getMessageStatus(message)" />
</slot>
</div>
</div>
</div>
</article>
</slot>
</template>
</div>
</template>
<script setup lang="ts">
import { nextTick, useTemplateRef } from "vue";
import { MarkdownView } from "timi-web";
import AiChatThinking from "./AiChatThinking.vue";
import type {
AiChatMessage,
AiChatMessageStatusFormatter,
AiChatRoleNameFormatter,
AiChatRoleNames
} from "./types";
defineOptions({
name: "AiChatMessageList"
});
export interface Props {
messages: readonly AiChatMessage[];
isGenerating: boolean;
loading?: boolean;
loadingText?: string;
ariaLabel?: string;
roleNames?: AiChatRoleNames;
roleNameFormatter?: AiChatRoleNameFormatter;
thinkingTitle?: string;
createdAtLabel?: string;
durationLabel?: string;
formatMessageStatus?: AiChatMessageStatusFormatter;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
loadingText: "正在加载…",
ariaLabel: "聊天消息",
roleNames: () => ({ assistant: "助手", user: "我" }),
thinkingTitle: "正在思考…",
createdAtLabel: "生成时间:",
durationLabel: "耗时:"
});
const messageListRef = useTemplateRef<HTMLElement>("messageList");
async function scrollToBottom(): Promise<void> {
await nextTick();
const element = messageListRef.value;
if (element) {
element.scrollTop = element.scrollHeight;
}
}
function getRoleName(message: AiChatMessage): string {
return props.roleNameFormatter?.(message.role, message)
|| props.roleNames[message.role]
|| message.role;
}
function getMessageStatus(message: AiChatMessage): string {
if (props.formatMessageStatus) {
return props.formatMessageStatus(message);
}
if (message.statusText) {
return message.statusText;
}
const timeText = `${props.createdAtLabel}${formatDateTime(message.createdAt)}`;
if (message.duration !== undefined) {
return `${timeText} · ${props.durationLabel}${formatDuration(message.duration)}`;
}
return timeText;
}
function formatDateTime(timestamp: number): string {
return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
}
function formatDuration(duration: number): string {
if (duration < 1000) {
return `${duration} 毫秒`;
}
return `${(duration / 1000).toFixed(1)} 秒`;
}
defineExpose({
scrollToBottom
});
</script>
<style scoped lang="less">
.message-list {
gap: 1rem;
flex: none;
height: 100%;
display: flex;
padding: var(--ai-msg-padding-top, .5rem) 1rem calc(var(--ai-chat-bottom-area-height, 4.5rem) + 1.5rem + env(safe-area-inset-bottom, 0px));
min-width: 0;
min-height: 0;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
flex-direction: column;
.list-state {
gap: .5rem;
margin: auto 0;
padding: 1rem;
color: var(--td-text-color-secondary);
display: flex;
font-size: .8125rem;
text-align: center;
align-items: center;
justify-content: center;
}
.message-row {
gap: .5rem;
display: flex;
width: 100%;
min-width: 0;
align-items: stretch;
flex-direction: column;
&.user {
.message-header {
justify-content: flex-end;
}
.role-name {
order: 1;
}
.avatar {
order: 2;
}
}
.message-header {
gap: .5rem;
width: 100%;
display: flex;
align-items: center;
}
.avatar {
flex: none;
color: var(--ai-chat-accent, #0f766e);
width: 2rem;
height: 2rem;
display: flex;
background: var(--ai-chat-accent-soft, #ccfbf1);
align-items: center;
justify-content: center;
}
&.user .avatar {
color: var(--ai-chat-user-accent, #2563eb);
background: var(--ai-chat-user-accent-soft, #dbeafe);
}
.message-main {
width: 100%;
min-width: 0;
max-width: none;
}
&.user .message-main {
text-align: right;
}
.role-name {
color: var(--td-text-color-secondary);
margin: 0;
font-size: .6875rem;
}
.bubble {
color: var(--td-text-color-primary);
width: 100%;
padding: .75rem;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
border-radius: .25rem 1rem 1rem 1rem;
background: var(--ai-chat-bubble, #f5f6f8);
text-align: left;
box-shadow: 0 .25rem 1rem rgba(15, 23, 42, .05);
.thinking + .thinking {
margin-top: .75rem;
}
&.user {
color: white;
border-radius: 1rem .25rem 1rem 1rem;
background: var(--ai-chat-accent, #0f766e);
}
.content {
font-size: .875rem;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
.markdown-content {
white-space: normal;
:deep(.tui-markdown-view) {
width: auto;
max-width: 100%;
font-size: .875rem;
line-height: 1.65;
word-break: break-word;
overflow-wrap: anywhere;
overflow-x: hidden;
}
:deep(.tui-markdown-view pre),
:deep(.tui-markdown-view code) {
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
}
}
}
.typing {
gap: .25rem;
display: flex;
padding: .25rem .125rem;
align-items: center;
span {
width: .375rem;
height: .375rem;
border-radius: 50%;
background: var(--ai-chat-accent, #0f766e);
animation: typing 1.2s infinite ease-in-out;
&:nth-child(2) {
animation-delay: .16s;
}
&:nth-child(3) {
animation-delay: .32s;
}
}
}
.citation-list {
gap: .375rem;
margin-top: .75rem;
padding-top: .625rem;
display: flex;
border-top: 1px solid var(--td-component-border);
flex-direction: column;
}
.message-status {
color: var(--td-text-color-secondary);
margin-top: .5rem;
font-size: .6875rem;
line-height: 1.4;
}
}
@keyframes typing {
0%, 60%, 100% {
opacity: .35;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-.1875rem);
}
}
</style>
+260
View File
@@ -0,0 +1,260 @@
<template>
<section class="ai-chat-panel" :aria-label="props.ariaLabel">
<ai-chat-message-list
ref="messageList"
:style="messageListStyle"
:messages="props.messages"
:is-generating="props.isGenerating"
:loading="props.isLoading"
:loading-text="props.loadingText"
:aria-label="props.messageListAriaLabel"
:role-names="props.roleNames"
:role-name-formatter="props.roleNameFormatter"
:thinking-title="props.thinkingTitle"
:created-at-label="props.createdAtLabel"
:duration-label="props.durationLabel"
:format-message-status="props.formatMessageStatus"
>
<template v-if="$slots.welcome" #welcome="slotProps">
<slot name="welcome" v-bind="slotProps" />
</template>
<template v-if="$slots['assistant-icon']" #assistant-icon="slotProps">
<slot name="assistant-icon" v-bind="slotProps" />
</template>
<template v-if="$slots['user-icon']" #user-icon="slotProps">
<slot name="user-icon" v-bind="slotProps" />
</template>
<template v-if="$slots.message" #message="slotProps">
<slot name="message" v-bind="slotProps" />
</template>
<template v-if="$slots['role-name']" #role-name="slotProps">
<slot name="role-name" v-bind="slotProps" />
</template>
<template v-if="$slots.thinking" #thinking="slotProps">
<slot name="thinking" v-bind="slotProps" />
</template>
<template v-if="$slots.citation" #citation="slotProps">
<slot name="citation" v-bind="slotProps" />
</template>
<template v-if="$slots['message-status']" #message-status="slotProps">
<slot name="message-status" v-bind="slotProps" />
</template>
</ai-chat-message-list>
<div ref="bottomArea" class="bottom-area">
<slot name="tool-status" :status="props.activeToolStatus">
<div v-if="props.activeToolStatus" class="tool-status">
<t-icon name="loading" size="1rem" />
<span v-text="props.activeToolStatus" />
</div>
</slot>
<slot
name="composer"
:draft="draft"
:is-generating="props.isGenerating"
:is-loading="props.isLoading"
:last-error="props.lastError"
:can-retry="props.canRetry"
:on-send="submitMessage"
:on-stop="() => emit('stop')"
:on-retry="() => emit('retry')"
:on-new-conversation="() => emit('new-conversation')"
:on-add-attachment="() => emit('add-attachment')"
>
<ai-chat-composer
v-model="draft"
:is-generating="props.isGenerating"
:disabled="props.disabled || props.isLoading"
:last-error="props.lastError"
:can-retry="props.canRetry"
:placeholder="props.placeholder"
:send-text="props.sendText"
:stop-text="props.stopText"
:retry-text="props.retryText"
:max-length="props.maxLength"
@send="submitMessage"
@stop="emit('stop')"
@retry="emit('retry')"
@new-conversation="emit('new-conversation')"
@add-attachment="emit('add-attachment')"
/>
</slot>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef } from "vue";
import type {
AiChatMessage,
AiChatMessageStatusFormatter,
AiChatRoleNameFormatter,
AiChatRoleNames
} from "./types";
import AiChatMessageList from "./AiChatMessageList.vue";
import AiChatComposer from "./AiChatComposer.vue";
defineOptions({
name: "AiChatPanel"
});
export interface Props {
messages: readonly AiChatMessage[];
isGenerating: boolean;
isLoading?: boolean;
activeToolStatus?: string;
lastError?: string;
canRetry?: boolean;
disabled?: boolean;
loadingText?: string;
ariaLabel?: string;
messageListAriaLabel?: string;
roleNames?: AiChatRoleNames;
roleNameFormatter?: AiChatRoleNameFormatter;
thinkingTitle?: string;
createdAtLabel?: string;
durationLabel?: string;
formatMessageStatus?: AiChatMessageStatusFormatter;
placeholder?: string;
sendText?: string;
stopText?: string;
retryText?: string;
maxLength?: number;
}
const props = withDefaults(defineProps<Props>(), {
isLoading: false,
activeToolStatus: "",
lastError: "",
canRetry: false,
disabled: false,
loadingText: "正在加载…",
ariaLabel: "AI 对话",
messageListAriaLabel: "聊天消息",
roleNames: () => ({ assistant: "助手", user: "我" }),
thinkingTitle: "正在思考…",
createdAtLabel: "生成时间:",
durationLabel: "耗时:",
placeholder: "输入消息…",
sendText: "发送",
stopText: "停止",
retryText: "重试",
maxLength: 2000
});
const emit = defineEmits<{
send: [message: string];
stop: [];
retry: [];
"new-conversation": [];
"add-attachment": [];
}>();
const messageListRef = useTemplateRef<{ scrollToBottom: () => Promise<void> }>("messageList");
const bottomAreaRef = useTemplateRef<HTMLElement>("bottomArea");
const bottomAreaHeight = ref<number>();
const draft = shallowRef("");
let bottomAreaObserver: ResizeObserver | undefined;
const messageListStyle = computed(() => {
if (bottomAreaHeight.value === undefined) {
return {};
}
return {
"--ai-chat-bottom-area-height": `${bottomAreaHeight.value}px`
};
});
function submitMessage(message: string): void {
emit("send", message);
}
async function scrollToBottom(): Promise<void> {
await messageListRef.value?.scrollToBottom();
}
onMounted(() => {
const bottomArea = bottomAreaRef.value;
if (!bottomArea) {
return;
}
const updateBottomAreaHeight = () => {
bottomAreaHeight.value = Math.ceil(bottomArea.getBoundingClientRect().height);
};
updateBottomAreaHeight();
bottomAreaObserver = new ResizeObserver(updateBottomAreaHeight);
bottomAreaObserver.observe(bottomArea);
});
onBeforeUnmount(() => {
bottomAreaObserver?.disconnect();
});
defineExpose({
scrollToBottom
});
</script>
<style scoped lang="less">
.ai-chat-panel {
--ai-chat-background: var(--td-bg-color-page, #fff);
--ai-chat-bubble: var(--td-bg-color-container, #f5f6f8);
--ai-chat-accent: var(--td-brand-color, #0f766e);
--ai-chat-accent-soft: #ccfbf1;
--ai-chat-soft: #f0fdfa;
--ai-chat-user-accent: #2563eb;
--ai-chat-user-accent-soft: #dbeafe;
height: 100%;
display: flex;
position: relative;
overflow: hidden;
min-width: 0;
min-height: 0;
background: var(--ai-chat-background);
flex-direction: column;
.bottom-area {
inset: auto 1rem calc(1rem + env(safe-area-inset-bottom, 0px));
width: auto;
margin: 0 auto;
z-index: 2;
position: absolute;
max-width: 40rem;
display: flex;
flex-direction: column;
.tool-status {
gap: .375rem;
margin-bottom: .5rem;
color: var(--ai-chat-accent);
padding: .5rem .75rem;
border-radius: .75rem;
display: flex;
font-size: .75rem;
background: var(--ai-chat-soft);
align-items: center;
.t-icon {
animation: rotate 1s linear infinite;
}
}
}
}
@keyframes rotate {
to {
transform: rotate(360deg);
}
}
:global(html.theme-dark) {
.ai-chat-panel {
--ai-chat-background: var(--td-bg-color-page, #0f172a);
--ai-chat-bubble: var(--td-bg-color-container, #1e293b);
--ai-chat-accent-soft: rgba(20, 184, 166, .12);
--ai-chat-soft: rgba(20, 184, 166, .12);
--ai-chat-user-accent-soft: rgba(37, 99, 235, .18);
}
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<template>
<section class="thinking" :aria-label="props.ariaLabel">
<div class="thinking-header">
<slot name="header">
<span v-text="props.title" />
</slot>
</div>
<div
ref="thinkingContent"
class="thinking-content"
role="log"
aria-live="polite"
>
<span v-text="props.content" />
</div>
</section>
</template>
<script setup lang="ts">
import { nextTick, useTemplateRef, watch } from "vue";
defineOptions({
name: "AiChatThinking"
});
export interface Props {
content: string;
isGenerating: boolean;
title?: string;
ariaLabel?: string;
}
const props = withDefaults(defineProps<Props>(), {
title: "正在思考…",
ariaLabel: "思考过程"
});
const thinkingContentRef = useTemplateRef<HTMLElement>("thinkingContent");
async function scrollToBottom(): Promise<void> {
await nextTick();
const element = thinkingContentRef.value;
if (element) {
element.scrollTop = element.scrollHeight;
}
}
watch(
[() => props.content, () => props.isGenerating],
async ([content, isGenerating]) => {
if (content && isGenerating) {
await scrollToBottom();
}
},
{ flush: "post" }
);
</script>
<style scoped lang="less">
.thinking {
gap: .5rem;
height: clamp(2rem, 24vh, 10rem);
display: flex;
padding: .625rem .75rem;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
background: var(--ai-chat-background, var(--td-bg-color-container, #fff));
flex-direction: column;
min-height: 2rem;
max-height: 10rem;
overflow: hidden;
.thinking-header {
gap: .375rem;
display: flex;
color: var(--td-text-color-secondary);
font-size: .75rem;
line-height: 1rem;
align-items: center;
}
.thinking-content {
flex: 1;
padding-right: .25rem;
color: var(--td-text-color-secondary);
font-size: .75rem;
line-height: 1.5;
min-height: 0;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
}
</style>
+31
View File
@@ -0,0 +1,31 @@
import { WebToolkit } from "timi-web";
import AiChatComposerView from "./AiChatComposer.vue";
import AiChatMessageListView from "./AiChatMessageList.vue";
import AiChatPanelView from "./AiChatPanel.vue";
import AiChatThinkingView from "./AiChatThinking.vue";
export const AiChatComposer = WebToolkit.withInstall(AiChatComposerView);
export const AiChatMessageList = WebToolkit.withInstall(AiChatMessageListView);
export const AiChatPanel = WebToolkit.withInstall(AiChatPanelView);
export const AiChatThinking = WebToolkit.withInstall(AiChatThinkingView);
export default AiChatPanel;
export { useAiChat, AiChatHttpError, AiChatStreamError, ensureChatResponse, parseSseData, readSseStream } from "./useAiChat";
export { parseAssistantResponse } from "./useAiChat";
export type {
AiChatCitationFormatter,
AiChatErrorMessages,
AiChatEventAction,
AiChatMessage,
AiChatMessageStatusFormatter,
AiChatRequestContext,
AiChatRoleNameFormatter,
AiChatRoleNames,
AiChatStreamEvent,
AiCitation,
AiMessageRole,
AiThinking,
UseAiChatOptions
} from "./types";
+106
View File
@@ -0,0 +1,106 @@
import type { MaybeRefOrGetter } from "vue";
/** AI 消息角色。 */
export type AiMessageRole = "user" | "assistant";
/** 消息引用信息。业务方可以通过格式化函数显示自定义字段。 */
export interface AiCitation {
source?: string;
dataDomain?: string;
timeRange?: string;
caliber?: string;
generatedAt?: string;
}
/** 思考过程分段。 */
export interface AiThinking {
id: number;
content: string;
}
/** 聊天消息。 */
export interface AiChatMessage {
id: number;
role: AiMessageRole;
content: string;
thinkingList: AiThinking[];
citationList: AiCitation[];
createdAt: number;
statusText?: string;
duration?: number;
}
/** SSE 事件。 */
export interface AiChatStreamEvent {
event: string;
data: string;
}
/** 发起聊天请求时传给业务适配器的上下文。 */
export interface AiChatRequestContext<TContext = unknown> {
message: string;
conversationId?: string;
context?: TContext;
signal: AbortSignal;
}
/** SSE 事件转换后的通用动作。 */
export type AiChatEventAction =
| { type: "thinking"; content: string; newSegment?: boolean }
| { type: "answer"; content: string }
| { type: "tool-status"; text: string }
| { type: "citation"; citation: AiCitation }
| { type: "done"; conversationId?: string; duration?: number }
| { type: "error"; error: unknown };
/** 可覆盖的聊天错误文案。 */
export interface AiChatErrorMessages {
request: string;
stream: string;
unauthorized: string;
forbidden: string;
tooManyRequests: string;
timeout: string;
aborted: string;
connection: string;
unknown: string;
}
/** 聊天请求和历史会话的业务适配器配置。 */
export interface UseAiChatOptions<TConversationResponse = unknown, TContext = unknown> {
/** 发起一个返回 SSE 响应的请求。 */
request: (context: AiChatRequestContext<TContext>) => Promise<Response>;
/** 读取历史会话,可选。 */
readConversation?: (conversationId: string, signal: AbortSignal) => Promise<TConversationResponse>;
/** 把业务历史响应转换成通用消息。 */
mapConversationMessages?: (response: TConversationResponse) => AiChatMessage[];
/** 用于切换业务上下文时自动清空当前会话。 */
sessionKey?: MaybeRefOrGetter<unknown>;
/** 传给 request 的业务上下文。 */
context?: MaybeRefOrGetter<TContext | undefined>;
/** 消息流式更新后的滚动回调。 */
scrollToBottom?: () => Promise<void>;
/** 自定义 SSE 事件转换器,未转换的事件继续使用内置协议。 */
parseEvent?: (event: AiChatStreamEvent, payload: unknown) => AiChatEventAction | undefined;
/** 判断当前错误是否表示会话已失效。命中后会自动清空会话并重试一次。 */
isConversationExpired?: (error: unknown) => boolean;
/** 自定义错误文案转换。 */
getErrorMessage?: (error: unknown) => string | undefined;
/** 响应文本中的思考标记。 */
thinkingOpenMarkers?: readonly string[];
thinkingCloseMarkers?: readonly string[];
/** 覆盖默认错误文案。 */
errorMessages?: Partial<AiChatErrorMessages>;
}
/** 消息列表的角色文案。 */
export type AiChatRoleNames = Partial<Record<AiMessageRole, string>>;
/** 消息状态格式化函数。 */
export type AiChatMessageStatusFormatter = (message: AiChatMessage) => string;
/** 引用格式化函数。 */
export type AiChatCitationFormatter = (citation: AiCitation) => string;
/** 角色名称格式化函数。 */
export type AiChatRoleNameFormatter = (role: AiMessageRole, message: AiChatMessage) => string;
+784
View File
@@ -0,0 +1,784 @@
import { computed, onUnmounted, shallowRef, toValue, watch } from "vue";
import type {
AiChatErrorMessages,
AiChatEventAction,
AiChatMessage,
AiChatRequestContext,
AiChatStreamEvent,
AiCitation,
AiThinking,
UseAiChatOptions
} from "./types";
export type {
AiChatMessage,
AiChatErrorMessages,
AiChatEventAction,
AiChatRequestContext,
AiChatStreamEvent,
AiCitation,
AiMessageRole,
AiThinking,
UseAiChatOptions
} from "./types";
export { parseAssistantResponse };
/** 通用聊天 HTTP 错误。 */
export class AiChatHttpError extends Error {
readonly status: number;
readonly code?: string;
constructor(status: number, message: string, code?: string) {
super(message);
this.name = "AiChatHttpError";
this.status = status;
this.code = code;
}
}
/** 通用聊天流错误。 */
export class AiChatStreamError extends Error {
readonly code?: string;
constructor(message: string, code?: string) {
super(message);
this.name = "AiChatStreamError";
this.code = code;
}
}
const DEFAULT_ERROR_MESSAGES: AiChatErrorMessages = {
request: "聊天请求失败,请稍后重试",
stream: "聊天连接中断,请重试",
unauthorized: "登录状态已失效,请重新登录",
forbidden: "没有权限使用聊天服务",
tooManyRequests: "请求过于频繁,请稍后再试",
timeout: "聊天请求超时,请稍后重试",
aborted: "已停止生成,可重新提问",
connection: "连接中断,可重新提问",
unknown: "聊天服务暂时不可用,请稍后重试"
};
const DEFAULT_THINKING_OPEN_MARKERS = ["<thinking>", "<think>"];
const DEFAULT_THINKING_CLOSE_MARKERS = ["</thinking>", "</think>"];
interface AssistantResponseState {
rawText: string;
eventThinkingList: AiThinking[];
taggedThinkingList: AiThinking[];
}
/**
* 管理通用 AI 聊天的会话、SSE 流式消息、取消和重试。
*
* 业务 API 通过 request 和可选的历史会话回调注入,composable 本身不依赖具体
* 登录 Store、HTTP 客户端或业务数据模型。
*/
export function useAiChat<TConversationResponse = unknown, TContext = unknown>(
options: UseAiChatOptions<TConversationResponse, TContext>
) {
const messages = shallowRef<AiChatMessage[]>([]);
const conversationId = shallowRef("");
const isGenerating = shallowRef(false);
const isLoadingConversation = shallowRef(false);
const activeToolStatus = shallowRef("");
const lastError = shallowRef("");
const lastQuestion = shallowRef("");
const canRetry = computed(() => Boolean(
lastError.value
&& lastQuestion.value
&& !isGenerating.value
&& !isLoadingConversation.value
));
let activeController: AbortController | undefined;
let messageSequence = 0;
let thinkingSequence = 0;
const responseStateMap = new Map<number, AssistantResponseState>();
const errorMessages = { ...DEFAULT_ERROR_MESSAGES, ...options.errorMessages };
async function sendMessage(message: string): Promise<void> {
const normalizedMessage = message.trim();
if (!normalizedMessage || isGenerating.value || isLoadingConversation.value) {
return;
}
const userMessage = createMessage("user", normalizedMessage);
const assistantMessage = createMessage("assistant", "");
messages.value = [...messages.value, userMessage, assistantMessage];
lastQuestion.value = normalizedMessage;
lastError.value = "";
isGenerating.value = true;
activeToolStatus.value = "";
const controller = new AbortController();
activeController = controller;
let hasRetriedExpiredConversation = false;
try {
while (true) {
resetAssistantMessage(assistantMessage.id);
try {
await readChatStream(normalizedMessage, assistantMessage.id, controller);
break;
} catch (error) {
if (
!hasRetriedExpiredConversation
&& conversationId.value
&& options.isConversationExpired?.(error)
) {
hasRetriedExpiredConversation = true;
conversationId.value = "";
continue;
}
handleChatError(error, assistantMessage.id, controller.signal.aborted);
break;
}
}
} finally {
if (activeController === controller) {
activeController = undefined;
}
isGenerating.value = false;
activeToolStatus.value = "";
responseStateMap.delete(assistantMessage.id);
await scrollToBottom();
}
}
function stopGeneration(): void {
if (!isGenerating.value) {
return;
}
activeController?.abort();
activeToolStatus.value = "";
const assistantMessage = [...messages.value].reverse().find(item => item.role === "assistant");
if (assistantMessage) {
updateMessage(assistantMessage.id, { statusText: errorMessages.aborted });
}
}
function resetConversation(): void {
activeController?.abort();
activeController = undefined;
messages.value = [];
responseStateMap.clear();
conversationId.value = "";
isGenerating.value = false;
isLoadingConversation.value = false;
activeToolStatus.value = "";
lastError.value = "";
lastQuestion.value = "";
}
async function loadConversation(id: string): Promise<void> {
const normalizedId = id.trim();
if (
!normalizedId
|| isGenerating.value
|| isLoadingConversation.value
|| !options.readConversation
) {
return;
}
isLoadingConversation.value = true;
messages.value = [];
responseStateMap.clear();
conversationId.value = normalizedId;
lastError.value = "";
lastQuestion.value = "";
const controller = new AbortController();
try {
const response = await options.readConversation(normalizedId, controller.signal);
if (conversationId.value !== normalizedId) {
return;
}
messages.value = options.mapConversationMessages
? options.mapConversationMessages(response)
: normalizeConversationMessages(response);
lastQuestion.value = [...messages.value].reverse().find(item => item.role === "user")?.content || "";
await scrollToBottom();
} catch (error) {
if (conversationId.value === normalizedId) {
resetConversation();
}
throw error;
} finally {
isLoadingConversation.value = false;
}
}
async function retryLastMessage(): Promise<void> {
if (canRetry.value) {
await sendMessage(lastQuestion.value);
}
}
async function readChatStream(
message: string,
assistantId: number,
controller: AbortController
): Promise<void> {
const requestContext: AiChatRequestContext<TContext> = {
message,
conversationId: conversationId.value || undefined,
context: options.context ? toValue(options.context) : undefined,
signal: controller.signal
};
const response = await options.request(requestContext);
await ensureChatResponse(response, errorMessages.request);
let hasDoneEvent = false;
await readSseStream(response, async event => {
hasDoneEvent = handleStreamEvent(event, assistantId, hasDoneEvent);
await options.scrollToBottom?.();
});
if (!hasDoneEvent && !controller.signal.aborted) {
throw new AiChatStreamError(errorMessages.stream, "STREAM_INCOMPLETE");
}
}
function handleStreamEvent(event: AiChatStreamEvent, assistantId: number, hasDoneEvent: boolean): boolean {
const payload = parseSseData(event.data);
const action = options.parseEvent?.(event, payload) || parseDefaultEvent(event, payload);
if (!action) {
return hasDoneEvent;
}
switch (action.type) {
case "thinking":
appendAssistantThinking(assistantId, action.content, action.newSegment === true);
return hasDoneEvent;
case "answer":
appendAssistantResponse(assistantId, action.content);
return hasDoneEvent;
case "tool-status":
activeToolStatus.value = action.text;
return hasDoneEvent;
case "citation":
appendCitation(assistantId, action.citation);
return hasDoneEvent;
case "done":
applyDoneAction(assistantId, action);
return true;
case "error":
throw action.error;
}
}
function applyDoneAction(assistantId: number, action: Extract<AiChatEventAction, { type: "done" }>): void {
if (action.conversationId) {
conversationId.value = action.conversationId;
}
updateMessage(assistantId, {
createdAt: Date.now(),
duration: action.duration
});
}
function appendAssistantResponse(assistantId: number, text: string): void {
if (!text) {
return;
}
const state = getAssistantResponseState(assistantId);
state.rawText = mergeResponseText(state.rawText, text);
const parsedResponse = parseAssistantResponse(
state.rawText,
options.thinkingOpenMarkers || DEFAULT_THINKING_OPEN_MARKERS,
options.thinkingCloseMarkers || DEFAULT_THINKING_CLOSE_MARKERS
);
state.taggedThinkingList = parsedResponse.thinkingList.map((content, index) => ({
id: state.taggedThinkingList[index]?.id || nextThinkingId(),
content
}));
updateMessage(assistantId, {
content: parsedResponse.answer,
thinkingList: [...state.eventThinkingList, ...state.taggedThinkingList],
statusText: undefined
});
}
function appendAssistantThinking(assistantId: number, text: string, createSegment = false): void {
if (!text) {
return;
}
const state = getAssistantResponseState(assistantId);
const thinkingList = [...state.eventThinkingList];
const lastThinking = thinkingList[thinkingList.length - 1];
if (createSegment || !lastThinking) {
thinkingList.push({ id: nextThinkingId(), content: text });
} else {
thinkingList[thinkingList.length - 1] = {
...lastThinking,
content: lastThinking.content + text
};
}
state.eventThinkingList = thinkingList;
updateMessage(assistantId, {
thinkingList: [...state.eventThinkingList, ...state.taggedThinkingList],
statusText: undefined
});
}
function appendCitation(assistantId: number, citation: AiCitation): void {
const message = messages.value.find(item => item.id === assistantId);
if (!message) {
return;
}
updateMessage(assistantId, { citationList: [...message.citationList, citation] });
}
function handleChatError(error: unknown, assistantId: number, isAborted: boolean): void {
if (isAborted) {
return;
}
const message = options.getErrorMessage?.(error) || getDefaultErrorMessage(error, errorMessages);
lastError.value = message;
updateMessage(assistantId, { statusText: message });
}
function getAssistantResponseState(assistantId: number): AssistantResponseState {
const currentState = responseStateMap.get(assistantId);
if (currentState) {
return currentState;
}
const nextState: AssistantResponseState = {
rawText: "",
eventThinkingList: [],
taggedThinkingList: []
};
responseStateMap.set(assistantId, nextState);
return nextState;
}
function updateMessage(id: number, patch: Partial<AiChatMessage>): void {
messages.value = messages.value.map(item => item.id === id ? { ...item, ...patch } : item);
}
function resetAssistantMessage(id: number): void {
responseStateMap.delete(id);
updateMessage(id, {
content: "",
thinkingList: [],
citationList: [],
statusText: undefined,
duration: undefined,
createdAt: Date.now()
});
}
function createMessage(role: AiChatMessage["role"], content: string): AiChatMessage {
return {
id: nextMessageId(),
role,
content,
thinkingList: [],
citationList: [],
createdAt: Date.now()
};
}
function nextMessageId(): number {
messageSequence += 1;
return messageSequence;
}
function nextThinkingId(): number {
thinkingSequence += 1;
return thinkingSequence;
}
async function scrollToBottom(): Promise<void> {
await options.scrollToBottom?.();
}
if (options.sessionKey !== undefined) {
watch(() => toValue(options.sessionKey), () => {
resetConversation();
}, { flush: "sync" });
}
onUnmounted(() => {
activeController?.abort();
messages.value = [];
responseStateMap.clear();
});
return {
messages,
conversationId: computed(() => conversationId.value),
isGenerating,
isLoadingConversation,
activeToolStatus,
lastError,
canRetry,
sendMessage,
loadConversation,
stopGeneration,
resetConversation,
retryLastMessage
};
}
/** 读取 SSE 响应并按事件块回调。 */
export async function readSseStream(
response: Response,
onEvent: (event: AiChatStreamEvent) => Promise<void>
): Promise<void> {
if (!response.body) {
throw new AiChatStreamError("聊天响应没有可读取的数据流", "EMPTY_STREAM");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
buffer = await consumeSseBuffer(buffer, onEvent, false);
}
buffer += decoder.decode();
await consumeSseBuffer(buffer, onEvent, true);
} finally {
reader.releaseLock();
}
}
/** 将非 2xx 响应转换成通用错误,不暴露原始响应正文。 */
export async function ensureChatResponse(response: Response, fallbackMessage = DEFAULT_ERROR_MESSAGES.request): Promise<void> {
if (response.ok) {
return;
}
let code: string | undefined;
let message = fallbackMessage;
try {
const payload = await response.json() as {
code?: unknown;
message?: unknown;
error?: unknown;
};
code = getString(payload.code);
message = getString(payload.message) || getString(payload.error) || message;
} catch {
// 非 JSON 响应使用安全默认文案。
}
throw new AiChatHttpError(response.status, message, code);
}
/** 解析 SSE data,解析失败时返回原始文本。 */
export function parseSseData(data: string): unknown {
const normalizedData = data.trim();
if (normalizedData === "[DONE]") {
return normalizedData;
}
try {
return JSON.parse(normalizedData) as unknown;
} catch {
return data;
}
}
/** 解析包含思考标记的完整响应文本。 */
function parseAssistantResponse(
value: string,
openMarkers: readonly string[] = DEFAULT_THINKING_OPEN_MARKERS,
closeMarkers: readonly string[] = DEFAULT_THINKING_CLOSE_MARKERS
): { answer: string; thinkingList: string[] } {
let answer = "";
let thinking = "";
let mode: "answer" | "thinking" = "answer";
let cursor = 0;
const thinkingList: string[] = [];
while (cursor < value.length) {
const marker = findMarker(value.slice(cursor), [...openMarkers, ...closeMarkers]);
if (!marker) {
const rest = value.slice(cursor);
if (mode === "thinking") {
thinking += rest;
} else {
answer += rest;
}
break;
}
const content = value.slice(cursor, cursor + marker.index);
if (mode === "thinking") {
thinking += content;
if (thinking) {
thinkingList.push(thinking);
}
thinking = "";
mode = "answer";
} else if (openMarkers.some(item => item.toLowerCase() === marker.value.toLowerCase())) {
answer += content;
mode = "thinking";
} else {
if (content) {
thinkingList.push(content);
}
mode = "answer";
}
cursor += marker.index + marker.value.length;
}
if (mode === "thinking" && thinking) {
thinkingList.push(thinking);
}
return { answer, thinkingList };
}
async function consumeSseBuffer(
buffer: string,
onEvent: (event: AiChatStreamEvent) => Promise<void>,
isFinal: boolean
): Promise<string> {
const normalizedBuffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
const blockList = normalizedBuffer.split("\n\n");
const remain = isFinal ? "" : blockList.pop() || "";
for (const block of blockList) {
const event = parseSseBlock(block);
if (event) {
await onEvent(event);
}
}
if (isFinal && remain.trim()) {
const event = parseSseBlock(remain);
if (event) {
await onEvent(event);
}
}
return remain;
}
function parseSseBlock(block: string): AiChatStreamEvent | undefined {
let event = "message";
const dataLineList: string[] = [];
for (const line of block.split("\n")) {
if (line.startsWith("event:")) {
event = line.slice(6).trim() || "message";
} else if (line.startsWith("data:")) {
const data = line.slice(5);
dataLineList.push(data.startsWith(" ") ? data.slice(1) : data);
}
}
if (!dataLineList.length) {
return undefined;
}
return { event, data: dataLineList.join("\n") };
}
function parseDefaultEvent(event: AiChatStreamEvent, payload: unknown): AiChatEventAction | undefined {
switch (event.event.toLowerCase()) {
case "think":
case "thinking":
return { type: "thinking", content: extractThinkingText(payload) };
case "agent_thought":
return { type: "thinking", content: extractThinkingText(payload), newSegment: true };
case "message":
case "answer":
case "text":
return { type: "answer", content: extractText(payload) };
case "tool_status":
case "tool-status":
return { type: "tool-status", text: extractToolStatus(payload) };
case "citation":
case "source": {
const citation = normalizeCitation(payload);
return citation ? { type: "citation", citation } : undefined;
}
case "done":
case "finish":
case "completed":
return { type: "done", ...normalizeDonePayload(payload) };
case "error":
return { type: "error", error: createStreamError(payload) };
default:
return undefined;
}
}
function normalizeDonePayload(payload: unknown): { conversationId?: string; duration?: number } {
if (!isRecord(payload)) {
return {};
}
return {
conversationId: getString(payload.conversationId) || getString(payload.conversation_id),
duration: getNumber(payload.duration)
};
}
function extractText(payload: unknown): string {
if (typeof payload === "string") {
return payload === "[DONE]" ? "" : payload;
}
if (!isRecord(payload)) {
return "";
}
return getRawString(payload.answer)
|| getRawString(payload.text)
|| getRawString(payload.content)
|| "";
}
function extractThinkingText(payload: unknown): string {
if (typeof payload === "string") {
return payload === "[DONE]" ? "" : payload;
}
if (!isRecord(payload)) {
return "";
}
return getRawString(payload.thought)
|| getRawString(payload.thinking)
|| getRawString(payload.reasoning)
|| getRawString(payload.observation)
|| getRawString(payload.text)
|| getRawString(payload.content)
|| "";
}
function extractToolStatus(payload: unknown): string {
if (typeof payload === "string") {
return payload === "[DONE]" ? "" : payload;
}
if (!isRecord(payload)) {
return "";
}
return getString(payload.text)
|| getString(payload.message)
|| getString(payload.statusText)
|| getString(payload.status)
|| "";
}
function normalizeCitation(payload: unknown): AiCitation | undefined {
if (typeof payload === "string") {
return payload ? { source: payload } : undefined;
}
if (!isRecord(payload)) {
return undefined;
}
const citation: AiCitation = {
source: getString(payload.source),
dataDomain: getString(payload.dataDomain) || getString(payload.domain),
timeRange: getString(payload.timeRange) || formatScope(payload.scope),
caliber: getString(payload.caliber) || getString(payload.metric),
generatedAt: getDisplayText(payload.generatedAt) || getDisplayText(payload.generated_at)
};
return Object.values(citation).some(Boolean) ? citation : undefined;
}
function createStreamError(payload: unknown): AiChatStreamError {
if (!isRecord(payload)) {
return new AiChatStreamError(DEFAULT_ERROR_MESSAGES.unknown, "STREAM_ERROR");
}
return new AiChatStreamError(
getString(payload.message) || DEFAULT_ERROR_MESSAGES.unknown,
getString(payload.code)
);
}
function getDefaultErrorMessage(error: unknown, messages: AiChatErrorMessages): string {
if (error instanceof AiChatHttpError) {
if (error.status === 401) {
return messages.unauthorized;
}
if (error.status === 403) {
return messages.forbidden;
}
if (error.status === 429) {
return messages.tooManyRequests;
}
return error.message;
}
if (error instanceof AiChatStreamError) {
if (error.code?.toLowerCase().includes("timeout")) {
return messages.timeout;
}
return error.message;
}
if (error instanceof DOMException && error.name === "AbortError") {
return messages.aborted;
}
if (error instanceof TypeError) {
return messages.connection;
}
return messages.unknown;
}
function normalizeConversationMessages(response: unknown): AiChatMessage[] {
if (Array.isArray(response)) {
return response as AiChatMessage[];
}
if (isRecord(response) && Array.isArray(response.data)) {
return response.data as AiChatMessage[];
}
return [];
}
function mergeResponseText(currentText: string, nextText: string): string {
if (currentText && nextText.startsWith(currentText)) {
return nextText;
}
return currentText + nextText;
}
function findMarker(value: string, markerList: string[]): { index: number; value: string } | undefined {
const lowerValue = value.toLowerCase();
return markerList.reduce<{ index: number; value: string } | undefined>((current, marker) => {
const index = lowerValue.indexOf(marker.toLowerCase());
if (index < 0 || (current && current.index <= index)) {
return current;
}
return { index, value: marker };
}, undefined);
}
function formatScope(value: unknown): string | undefined {
const text = getString(value);
if (text) {
return text;
}
if (!isRecord(value)) {
return undefined;
}
const beginAt = getDisplayText(value.beginAt) || getDisplayText(value.begin);
const endAt = getDisplayText(value.endAt) || getDisplayText(value.end);
if (beginAt && endAt) {
return `${beginAt} 至 ${endAt}`;
}
return beginAt || endAt;
}
function getDisplayText(value: unknown): string | undefined {
const text = getString(value);
if (text) {
return text;
}
const timestamp = getNumber(value);
if (timestamp === undefined) {
return undefined;
}
const milliseconds = timestamp < 100000000000 ? timestamp * 1000 : timestamp;
return new Date(milliseconds).toLocaleString("zh-CN", { hour12: false });
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function getString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function getRawString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function getNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
+8 -4
View File
@@ -1,5 +1,5 @@
<template> <template>
<t-popup v-model="visible" placement="bottom"> <t-popup v-model="visible" placement="bottom" :z-index="props.zIndex" :overlay-props="props.overlayProps">
<div class="bottom-popup" :style="{ height: props.height }"> <div class="bottom-popup" :style="{ height: props.height }">
<div v-if="hasHeader" class="header"> <div v-if="hasHeader" class="header">
<div class="button left"> <div class="button left">
@@ -40,6 +40,8 @@ export interface BottomPopupProps {
rightButtonText?: string; rightButtonText?: string;
height?: string | number; height?: string | number;
title?: string; title?: string;
zIndex?: number;
overlayProps?: Record<string, unknown>;
} }
export interface BottomPopupSlots { export interface BottomPopupSlots {
@@ -54,7 +56,9 @@ const props = withDefaults(defineProps<BottomPopupProps>(), {
leftButtonText: "取消", leftButtonText: "取消",
rightButtonText: "确定", rightButtonText: "确定",
height: "auto", height: "auto",
title: "" title: "",
zIndex: undefined,
overlayProps: undefined
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -115,8 +119,8 @@ defineSlots<BottomPopupSlots>();
} }
.title { .title {
margin: 0;
overflow: hidden; overflow: hidden;
margin-top: -.25rem;
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -124,7 +128,7 @@ defineSlots<BottomPopupSlots>();
.body { .body {
flex: 1; flex: 1;
padding: .75rem 0 env(safe-area-inset-bottom, 0); padding: 0 0 env(safe-area-inset-bottom, 0);
overflow: auto; overflow: auto;
min-height: 0; min-height: 0;
} }
+7
View File
@@ -0,0 +1,7 @@
import view from "./index.vue";
import { WebToolkit } from "timi-web";
export const CardContainer = WebToolkit.withInstall(view);
export default CardContainer;
export type { CardContainerProps } from "./type";
+91
View File
@@ -0,0 +1,91 @@
<template>
<div class="card-container">
<div class="card-title">
<h3 v-if="title || $slots.title" class="title">
<slot name="title">
<span v-text="title" />
</slot>
</h3>
<p v-if="description || $slots.description" class="description">
<slot name="description">
<span v-text="description" />
</slot>
</p>
</div>
<div v-if="$slots.default" class="content">
<slot />
</div>
<div v-if="tips || $slots.tips" class="tips">
<slot name="tips">
<span v-text="tips" />
</slot>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
title: String,
description: String,
tips: String
});
defineSlots<{
default?: () => unknown;
title?: () => unknown;
description?: () => unknown;
tips?: () => unknown;
}>();
defineOptions({
name: "CardContainer"
});
</script>
<style scoped lang="less">
.card-container {
margin: 0 1rem;
:deep(.t-cell-group--card) {
margin: 0;
}
.card-title {
.title {
margin: 0 .875rem;
}
.description {
color: var(--td-text-color-secondary, #52607a);
margin: .5rem .875rem;
font-size: .875rem;
}
}
.content {
overflow: hidden;
background: var(--td-bg-color-container, #fff);
border-radius: 1rem;
}
.tips {
color: var(--td-text-color-secondary, #52607a);
padding: .25rem 1rem;
font-size: .8125rem;
line-height: 1.5;
}
}
:global(html.theme-dark) {
.card-container {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
.tips {
color: #94a3b8;
background: rgba(148, 163, 184, .14);
}
}
}
</style>
+6
View File
@@ -0,0 +1,6 @@
/** CardContainer 组件属性。 */
export interface CardContainerProps {
title?: string;
description?: string;
tips?: string;
}
+1 -5
View File
@@ -5,7 +5,7 @@
display: flex; display: flex;
overflow: hidden; overflow: hidden;
background: transparent; background: transparent;
border-radius: var(--td-radius-medium, 0); border-radius: var(--tui-radius-small, 0);
.gutter { .gutter {
flex: none; flex: none;
@@ -19,7 +19,6 @@
width: 3.25rem; width: 3.25rem;
height: 1.5rem; height: 1.5rem;
display: block; display: block;
font-size: 1rem;
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace; font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
line-height: 1.5rem; line-height: 1.5rem;
padding-right: .25rem; padding-right: .25rem;
@@ -83,7 +82,6 @@
.search-status { .search-status {
color: var(--td-text-color-secondary, #666); color: var(--td-text-color-secondary, #666);
white-space: nowrap; white-space: nowrap;
font-size: .875rem;
} }
.preview, .preview,
@@ -97,7 +95,6 @@
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
line-height: 1.5rem; line-height: 1.5rem;
font-size: 1rem;
box-sizing: border-box; box-sizing: border-box;
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace; font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
} }
@@ -123,7 +120,6 @@
color: inherit; color: inherit;
background: transparent; background: transparent;
text-shadow: none; text-shadow: none;
font-size: inherit;
line-height: inherit; line-height: inherit;
font-family: inherit; font-family: inherit;
} }
+63 -2
View File
@@ -1,5 +1,6 @@
/** 导出所有组件 */ /** 导出所有组件 */
import RootLayout from "./root-layout"; import RootLayout from "./root-layout";
import PageTransition from "./page-transition";
import DurationPicker from "./duration-picker"; import DurationPicker from "./duration-picker";
import CodeEditor from "./code-editor"; import CodeEditor from "./code-editor";
import TiptapEditor from "./tiptap-editor"; import TiptapEditor from "./tiptap-editor";
@@ -10,9 +11,17 @@ import NotifyEditor from "./notify-editor";
import NotifyView from "./notify-view"; import NotifyView from "./notify-view";
import BottomPopup from "./bottom-popup"; import BottomPopup from "./bottom-popup";
import Space from "./space"; import Space from "./space";
import CardContainer from "./card-container";
import {
AiChatComposer,
AiChatMessageList,
AiChatPanel,
AiChatThinking
} from "./ai";
export default [ export default [
RootLayout, RootLayout,
PageTransition,
DurationPicker, DurationPicker,
CodeEditor, CodeEditor,
TiptapEditor, TiptapEditor,
@@ -22,11 +31,17 @@ export default [
NotifyEditor, NotifyEditor,
NotifyView, NotifyView,
BottomPopup, BottomPopup,
Space Space,
CardContainer,
AiChatComposer,
AiChatMessageList,
AiChatPanel,
AiChatThinking
]; ];
export { export {
RootLayout, RootLayout,
PageTransition,
DurationPicker, DurationPicker,
CodeEditor, CodeEditor,
TiptapEditor, TiptapEditor,
@@ -36,9 +51,40 @@ export {
NotifyEditor, NotifyEditor,
NotifyView, NotifyView,
BottomPopup, BottomPopup,
Space Space,
CardContainer,
AiChatComposer,
AiChatMessageList,
AiChatPanel,
AiChatThinking
}; };
export {
AiChatHttpError,
AiChatStreamError,
ensureChatResponse,
parseAssistantResponse,
parseSseData,
readSseStream,
useAiChat
} from "./ai";
export type {
AiChatCitationFormatter,
AiChatErrorMessages,
AiChatEventAction,
AiChatMessage,
AiChatMessageStatusFormatter,
AiChatRequestContext,
AiChatRoleNameFormatter,
AiChatRoleNames,
AiChatStreamEvent,
AiCitation,
AiMessageRole,
AiThinking,
UseAiChatOptions
} from "./ai";
export type { export type {
SpaceAlign, SpaceAlign,
SpaceDirection, SpaceDirection,
@@ -48,6 +94,21 @@ export type {
SpaceSlots SpaceSlots
} from "./space"; } from "./space";
export type {
CardContainerProps
} from "./card-container";
export type {
PageTransitionBackground,
PageTransitionClass,
PageTransitionKeepAliveMatcher,
PageTransitionNameResolver,
PageTransitionProps,
PageTransitionRoute,
PageTransitionRouteDepthResolver,
PageTransitionRouteKeyResolver
} from "./page-transition";
export type { export type {
CodeEditorExpose CodeEditorExpose
} from "./code-editor"; } from "./code-editor";
+16
View File
@@ -0,0 +1,16 @@
import view from "./index.vue";
import { WebToolkit } from "timi-web";
export type {
PageTransitionBackground,
PageTransitionClass,
PageTransitionKeepAliveMatcher,
PageTransitionNameResolver,
PageTransitionProps,
PageTransitionRoute,
PageTransitionRouteDepthResolver,
PageTransitionRouteKeyResolver
} from "./type";
export const PageTransition = WebToolkit.withInstall(view);
export default PageTransition;
+320
View File
@@ -0,0 +1,320 @@
<template>
<div
class="page-transition"
:class="{ 'page-transition-reduced-motion': props.respectReducedMotion }"
>
<RouterView v-slot="{ Component, route }">
<Transition
:name="transitionName"
:mode="props.transitionMode"
:appear="props.appear"
>
<KeepAlive :include="keepAlivePageNames" :max="props.keepAliveMax">
<component
:is="isKeepAlive(route) ? CachedPage : RoutePage"
:key="resolveRouteKey(route)"
:component="Component"
:background="getPageBackground(route)"
:class="props.pageClass"
:style="props.pageStyle"
/>
</KeepAlive>
</Transition>
</RouterView>
</div>
</template>
<script lang="ts" setup>
import { defineComponent, h, onUnmounted, ref, watch } from "vue";
import type { Component, PropType, StyleValue } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import type { RouteLocationNormalized, RouteLocationNormalizedLoaded } from "vue-router";
import type {
PageTransitionProps,
PageTransitionRoute
} from "./type";
defineOptions({
name: "PageTransition"
});
interface RoutePageProps {
component: Component;
background?: string;
}
const routePageProps = {
component: {
type: null as unknown as PropType<Component>,
required: true
},
background: String
};
function createRoutePage(name: string) {
return defineComponent({
name,
inheritAttrs: false,
props: routePageProps,
setup(pageProps: RoutePageProps, { attrs }) {
return () => h(
"div",
{
...attrs,
class: ["page-transition-page", attrs.class],
style: [attrs.style as StyleValue, pageProps.background === undefined ? undefined : { background: pageProps.background }]
},
[h(pageProps.component)]
);
}
});
}
const RoutePage = createRoutePage("PageTransitionPage");
const CachedPage = createRoutePage("PageTransitionKeepAlivePage");
const keepAlivePageNames = ["PageTransitionKeepAlivePage"];
const props = defineProps({
transition: {
type: [String, Function, Boolean] as PropType<PageTransitionProps["transition"]>,
default: "auto"
},
transitionMode: String as PropType<PageTransitionProps["transitionMode"]>,
appear: Boolean,
respectReducedMotion: {
type: Boolean,
default: true
},
keepAlive: {
type: [Boolean, Function] as PropType<PageTransitionProps["keepAlive"]>,
default: false
},
keepAliveMax: Number,
getRouteDepth: Function as PropType<PageTransitionProps["getRouteDepth"]>,
routeDepthQueryKey: {
type: String,
default: "transitionDepth"
},
routeDepthMetaKey: {
type: String,
default: "depth"
},
routeDepthBaseMetaKey: {
type: String,
default: "baseDepth"
},
routeDepthDynamicMetaKey: {
type: String,
default: "dynamicDepth"
},
getRouteKey: Function as PropType<PageTransitionProps["getRouteKey"]>,
pageBackground: [String, Function] as PropType<PageTransitionProps["pageBackground"]>,
pageClass: [String, Array, Object] as PropType<PageTransitionProps["pageClass"]>,
pageStyle: [String, Array, Object] as PropType<PageTransitionProps["pageStyle"]>
});
const router = useRouter();
const route = useRoute();
const transitionName = ref(resolveTransitionName(route, route));
function resolveTransitionName(
to: PageTransitionRoute,
from: PageTransitionRoute
): string {
const transition = props.transition ?? "auto";
if (transition === false) {
return "";
}
if (typeof transition === "function") {
const name = transition(to, from);
return name === false ? "" : name;
}
if (transition !== "auto") {
return transition;
}
const toDepth = resolveRouteDepth(to);
const fromDepth = resolveRouteDepth(from);
if (toDepth === undefined || fromDepth === undefined || toDepth === fromDepth) {
return "";
}
return fromDepth < toDepth ? "push-left" : "push-right";
}
function resolveRouteDepth(sourceRoute: PageTransitionRoute): number | undefined {
if (props.getRouteDepth) {
return props.getRouteDepth(sourceRoute);
}
const queryDepth = parseDepth(sourceRoute.query[props.routeDepthQueryKey ?? "transitionDepth"]);
if (queryDepth !== undefined) {
return queryDepth;
}
const meta = sourceRoute.meta as Record<string, unknown>;
const depthKey = props.routeDepthMetaKey ?? "depth";
const depth = parseDepth(meta[depthKey]);
const dynamicKey = props.routeDepthDynamicMetaKey ?? "dynamicDepth";
if (meta[dynamicKey] === true) {
const baseDepth = parseDepth(meta[props.routeDepthBaseMetaKey ?? "baseDepth"]) ?? depth ?? 0;
const pathSegments = sourceRoute.path.split("/").filter(Boolean);
return baseDepth + Math.max(0, pathSegments.length - 1);
}
if (depth !== undefined) {
return depth;
}
return sourceRoute.matched.length;
}
function parseDepth(value: unknown): number | undefined {
if (Array.isArray(value)) {
return parseDepth(value[0]);
}
if (typeof value !== "string" && typeof value !== "number") {
return undefined;
}
if (typeof value === "string" && !value.trim()) {
return undefined;
}
const depth = typeof value === "number" ? value : Number(value.trim());
if (!Number.isInteger(depth) || depth < 0) {
return undefined;
}
return depth;
}
function isKeepAlive(sourceRoute: RouteLocationNormalizedLoaded): boolean {
const matcher = props.keepAlive;
if (typeof matcher === "function") {
return matcher(sourceRoute);
}
return matcher === true;
}
function resolveRouteKey(sourceRoute: RouteLocationNormalizedLoaded): PropertyKey {
const resolver = props.getRouteKey;
return resolver ? resolver(sourceRoute) : sourceRoute.path;
}
function getPageBackground(sourceRoute: RouteLocationNormalizedLoaded): string {
const background = props.pageBackground;
if (typeof background === "function") {
return background(sourceRoute) ?? "var(--td-bg-color-page, transparent)";
}
return background ?? "var(--td-bg-color-page, transparent)";
}
const unregisterGuard = router.beforeEach((to: RouteLocationNormalized, from: RouteLocationNormalized) => {
transitionName.value = resolveTransitionName(to, from);
});
watch(
() => props.transition,
() => {
transitionName.value = resolveTransitionName(route, route);
}
);
onUnmounted(() => {
unregisterGuard();
});
</script>
<style scoped lang="less">
@easing: cubic-bezier(.19, 1, .22, 1);
@duration: 520ms;
@brightness: .98;
@shadow-intensity: .15;
@page-offset: 25%;
.page-transition {
position: relative;
z-index: 0;
width: 100%;
height: 100%;
overflow-x: clip;
isolation: isolate;
:deep(.page-transition-page) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: auto;
overflow-x: clip;
background: var(--td-bg-color-page, transparent);
}
:deep(.push-left-enter-active),
:deep(.push-left-leave-active),
:deep(.push-right-enter-active),
:deep(.push-right-leave-active) {
width: 100%;
height: 100%;
position: fixed;
transition: transform @duration @easing, opacity .2s linear;
backface-visibility: hidden;
}
:deep(.push-left-enter-from) {
box-shadow: -4px 0 16px rgba(0, 0, 0, @shadow-intensity);
transform: translate3d(100%, 0, 0);
}
:deep(.push-left-enter-to) {
transform: translate3d(0, 0, 0);
}
:deep(.push-left-leave-active) {
transition: all @duration @easing, filter .3s;
}
:deep(.push-left-leave-to) {
filter: brightness(@brightness);
transform: translate3d(-@page-offset, 0, 0);
}
:deep(.push-right-enter-active) {
z-index: 0;
transition: transform @duration @easing, opacity .3s;
}
:deep(.push-right-enter-from) {
transform: translate3d(-@page-offset, 0, 0);
}
:deep(.push-right-enter-to) {
transform: translate3d(0, 0, 0);
}
:deep(.push-right-leave-active) {
z-index: 1;
transition: transform @duration @easing, box-shadow .3s;
}
:deep(.push-right-leave-from) {
transform: translate3d(0, 0, 0);
}
:deep(.push-right-leave-to) {
box-shadow: 4px 0 16px rgba(0, 0, 0, @shadow-intensity);
transform: translate3d(105%, 0, 0);
}
:deep(.fade-enter-active),
:deep(.fade-leave-active) {
transition: opacity .2s ease;
}
:deep(.fade-enter-from),
:deep(.fade-leave-to) {
opacity: 0;
}
}
</style>
+61
View File
@@ -0,0 +1,61 @@
import type { StyleValue } from "vue";
import type { RouteLocationNormalized, RouteLocationNormalizedLoaded } from "vue-router";
/** PageTransition 可接收的路由对象。 */
export type PageTransitionRoute = RouteLocationNormalized | RouteLocationNormalizedLoaded;
/** 根据路由计算页面层级,用于决定前进或后退动画。 */
export type PageTransitionRouteDepthResolver = (route: PageTransitionRoute) => number | undefined;
/** 根据路由生成页面 key。 */
export type PageTransitionRouteKeyResolver = (route: PageTransitionRoute) => PropertyKey;
/** 决定当前路由是否进入 keep-alive。 */
export type PageTransitionKeepAliveMatcher = boolean | ((route: PageTransitionRoute) => boolean);
/** 自定义过渡名称;返回 false 或传入 false 时不使用过渡。 */
export type PageTransitionNameResolver =
| "auto"
| string
| false
| ((to: PageTransitionRoute, from: PageTransitionRoute) => string | false);
/** 页面容器 class 的类型。 */
export type PageTransitionClass = string | string[] | Record<string, boolean>;
/** 页面容器的背景色,可以按路由动态计算。 */
export type PageTransitionBackground = string | ((route: PageTransitionRoute) => string | undefined);
/** PageTransition 的公开属性。 */
export interface PageTransitionProps {
/** 过渡名称;auto 会根据路由层级使用 push-left / push-right。 */
transition?: PageTransitionNameResolver;
/** Vue Transition 的模式。 */
transitionMode?: "out-in" | "in-out";
/** 是否在首次渲染时播放过渡。 */
appear?: boolean;
/** 是否尊重系统的减少动态效果设置,默认开启。 */
respectReducedMotion?: boolean;
/** 是否缓存页面,也可以按路由决定。默认不缓存。 */
keepAlive?: PageTransitionKeepAliveMatcher;
/** keep-alive 最多保留的页面数量。 */
keepAliveMax?: number;
/** 自定义路由层级解析器。 */
getRouteDepth?: PageTransitionRouteDepthResolver;
/** 路由 query 中的层级字段名,默认是 transitionDepth。 */
routeDepthQueryKey?: string;
/** 路由 meta 中的固定层级字段名,默认是 depth。 */
routeDepthMetaKey?: string;
/** 动态层级的起始层级字段名,默认是 baseDepth。 */
routeDepthBaseMetaKey?: string;
/** 标记动态层级的 meta 字段名,默认是 dynamicDepth。 */
routeDepthDynamicMetaKey?: string;
/** 自定义路由 key,默认使用 path,避免普通 query 变化重建页面。 */
getRouteKey?: PageTransitionRouteKeyResolver;
/** 页面容器的背景色,默认使用 TDesign 页面背景变量。 */
pageBackground?: PageTransitionBackground;
/** 页面容器 class。 */
pageClass?: PageTransitionClass;
/** 页面容器 style。 */
pageStyle?: StyleValue;
}
+4 -10
View File
@@ -15,7 +15,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Comment, Fragment, Teleport, defineComponent, h, isVNode, onBeforeUpdate, useSlots } from "vue"; import { Comment, Fragment, Teleport, defineComponent, h, isVNode, useSlots } from "vue";
import type { CSSProperties, PropType, VNode, VNodeChild } from "vue"; import type { CSSProperties, PropType, VNode, VNodeChild } from "vue";
import type { SpaceProps, SpaceSlots, SpaceSize } from "./type"; import type { SpaceProps, SpaceSlots, SpaceSize } from "./type";
@@ -76,15 +76,9 @@ const props = defineProps({
const slots = useSlots() as SpaceSlots; const slots = useSlots() as SpaceSlots;
let children = flattenChildren(slots.default?.() ?? []); const children = computed(() => flattenChildren(slots.default?.() ?? []));
let separatorContent = resolveSeparatorContent(); const separatorContent = computed(() => resolveSeparatorContent());
let hasSeparator = hasSeparatorContent(separatorContent); const hasSeparator = computed(() => hasSeparatorContent(separatorContent.value));
onBeforeUpdate(() => {
children = flattenChildren(slots.default?.() ?? []);
separatorContent = resolveSeparatorContent();
hasSeparator = hasSeparatorContent(separatorContent);
});
function resolveSeparatorContent(): VNodeChild | undefined { function resolveSeparatorContent(): VNodeChild | undefined {
if (props.separator !== undefined) { if (props.separator !== undefined) {
@@ -23,7 +23,6 @@
import type { PickerValue } from "tdesign-mobile-vue"; import type { PickerValue } from "tdesign-mobile-vue";
import { supportedCodeLanguages } from "timi-web/editor"; import { supportedCodeLanguages } from "timi-web/editor";
import type { TiptapEditorInstance } from "timi-web/editor"; import type { TiptapEditorInstance } from "timi-web/editor";
import BottomPopup from "../bottom-popup/index.vue";
defineOptions({ defineOptions({
name: "TiptapCodeBlockButton" name: "TiptapCodeBlockButton"
+173
View File
@@ -0,0 +1,173 @@
import type { ObjectDirective } from "vue";
const ENTRANCE_DURATION = 400;
const ENTRANCE_DELAY_STEP = 70;
const ACTIVE_CLASS = "entrance-fade-up-enter-active";
const FROM_CLASS = "entrance-fade-up-enter-from";
const TO_CLASS = "entrance-fade-up-enter-to";
const STATE_KEY = Symbol("entrance-transition-state");
const AUTO_ELEMENTS = new WeakSet<HTMLElement>();
/** 入场动画指令支持的延迟配置;省略时按同级元素顺序自动错开。 */
export type EntranceTransitionValue =
| number
| string
| {
/** 固定延迟;设置 step 时启用自动步进,step 优先。 */
delay?: number | string;
/** 自动步进间隔,单位为毫秒。 */
step?: number | string;
}
| undefined;
interface EntranceTransitionState {
frameId?: number;
timerId?: number;
delay: number;
step: number;
auto: boolean;
}
type EntranceTransitionElement = HTMLElement & {
[STATE_KEY]?: EntranceTransitionState;
};
function getDelay(value: EntranceTransitionValue): number {
const rawDelay = typeof value === "object" && value !== null ? value.delay : value;
const delay = Number(rawDelay ?? 0);
return Number.isFinite(delay) ? Math.max(0, delay) : 0;
}
function setDelay(element: EntranceTransitionElement, delay: number): void {
element.style.setProperty("--entrance-delay", `${delay}ms`);
}
function isAuto(value: EntranceTransitionValue): boolean {
return (
value === undefined ||
(typeof value === "object" && value !== null && value.step !== undefined)
);
}
function getAutoStep(value: EntranceTransitionValue): number {
const rawStep =
typeof value === "object" && value !== null
? Number(value.step ?? ENTRANCE_DELAY_STEP)
: ENTRANCE_DELAY_STEP;
return Number.isFinite(rawStep) ? Math.max(0, rawStep) : ENTRANCE_DELAY_STEP;
}
function getAutoDelay(element: EntranceTransitionElement, step: number): number {
const parent = element.parentElement;
if (!parent) {
return 0;
}
const siblings = Array.from(parent.children).filter(
(sibling): sibling is HTMLElement => sibling instanceof HTMLElement && AUTO_ELEMENTS.has(sibling)
);
return Math.max(0, siblings.indexOf(element)) * step;
}
function cleanup(element: EntranceTransitionElement, isUnmounting = false): void {
const state = element[STATE_KEY];
if (state) {
if (state.frameId !== undefined) {
window.cancelAnimationFrame(state.frameId);
}
if (state.timerId !== undefined) {
window.clearTimeout(state.timerId);
}
element.classList.remove(ACTIVE_CLASS, FROM_CLASS, TO_CLASS);
element.style.removeProperty("--entrance-delay");
delete element[STATE_KEY];
}
if (isUnmounting) {
AUTO_ELEMENTS.delete(element);
}
}
/** 为元素添加一次性的渐入上移动画。 */
export const VEntranceTransition: ObjectDirective<HTMLElement, EntranceTransitionValue> = {
beforeMount(element, binding) {
if (!(element instanceof HTMLElement)) {
return;
}
const target = element as EntranceTransitionElement;
const auto = isAuto(binding.value);
const step = auto ? getAutoStep(binding.value) : ENTRANCE_DELAY_STEP;
const delay = auto ? 0 : getDelay(binding.value);
target[STATE_KEY] = { delay, step, auto };
if (auto) {
AUTO_ELEMENTS.add(target);
} else {
AUTO_ELEMENTS.delete(target);
}
setDelay(target, delay);
target.classList.add(ACTIVE_CLASS, FROM_CLASS);
},
mounted(element) {
if (!(element instanceof HTMLElement)) {
return;
}
const target = element as EntranceTransitionElement;
const state = target[STATE_KEY];
if (!state) {
return;
}
state.frameId = window.requestAnimationFrame(() => {
state.frameId = window.requestAnimationFrame(() => {
state.frameId = undefined;
if (state.auto) {
state.delay = getAutoDelay(target, state.step);
setDelay(target, state.delay);
}
target.classList.remove(FROM_CLASS);
target.classList.add(TO_CLASS);
state.timerId = window.setTimeout(() => cleanup(target), ENTRANCE_DURATION + state.delay);
});
});
},
updated(element, binding) {
if (!(element instanceof HTMLElement) || binding.value === binding.oldValue) {
return;
}
const target = element as EntranceTransitionElement;
const auto = isAuto(binding.value);
if (auto) {
AUTO_ELEMENTS.add(target);
} else {
AUTO_ELEMENTS.delete(target);
}
const state = target[STATE_KEY];
if (!state) {
return;
}
state.auto = auto;
state.step = auto ? getAutoStep(binding.value) : ENTRANCE_DELAY_STEP;
if (state.auto) {
state.delay = getAutoDelay(target, state.step);
} else {
state.delay = getDelay(binding.value);
}
setDelay(target, state.delay);
},
beforeUnmount(element) {
if (element instanceof HTMLElement) {
cleanup(element as EntranceTransitionElement, true);
}
}
};
export default VEntranceTransition;
+7
View File
@@ -0,0 +1,7 @@
export {
default as VEntranceTransition
} from "./entrance-transition";
export type {
EntranceTransitionValue
} from "./entrance-transition";
+2
View File
@@ -1,9 +1,11 @@
import "./assets/style/tdesign-custom.less"; import "./assets/style/tdesign-custom.less";
import "./assets/style/entrance-transition.less";
import "./components/tiptap-content.less"; import "./components/tiptap-content.less";
import "timi-web/style.css"; import "timi-web/style.css";
import "../../timi-web/src/components/prism-code-block.less"; import "../../timi-web/src/components/prism-code-block.less";
export * from "./components"; export * from "./components";
export * from "./directives";
export { export {
}; };
+10
View File
@@ -0,0 +1,10 @@
import type { Directive } from "vue";
import type { EntranceTransitionValue } from "../directives/entrance-transition";
declare module "vue" {
export interface GlobalDirectives {
entranceTransition: Directive<HTMLElement, EntranceTransitionValue>;
}
}
export {};

Some files were not shown because too many files have changed in this diff Show More