Compare commits
18
Commits
9b61376830
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72ed15ae34 | ||
|
|
393bc8aa1b | ||
|
|
b09011251a | ||
|
|
a20e051e8a | ||
|
|
2b92012c39 | ||
|
|
0a4425e11f | ||
|
|
e8b9a21b82 | ||
|
|
aeea93c0bb | ||
|
|
b54c07fe49 | ||
|
|
45f77d3168 | ||
|
|
37cf38fe27 | ||
|
|
b435bf992e | ||
|
|
3d2968cb3b | ||
|
|
fc0348aeae | ||
|
|
f14ebcfc85 | ||
|
|
aadc8fa8fb | ||
|
|
b3c324653b | ||
|
|
5b5a5190e3 |
+6
-1
@@ -1,9 +1,14 @@
|
||||
/.eslintrc-auto-import.json
|
||||
/components.d.ts
|
||||
/examples/auto-imports.d.ts
|
||||
/auto-imports.d.ts
|
||||
/AGENTS.md
|
||||
/.npmrc
|
||||
|
||||
# 文档站
|
||||
/docs/.vitepress/cache
|
||||
/docs/.vitepress/dist
|
||||
/docs/.npmrc
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -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'` 后再运行。
|
||||
- 不随意重命名公共类型,不无故增加依赖;不添加测试,除非用户明确要求。
|
||||
@@ -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`。
|
||||
@@ -0,0 +1,3 @@
|
||||
# 完成任务时
|
||||
|
||||
先查看 `git status --short`,保留用户已有改动;遵循 `.eslintrc.js` 和 `AGENTS.md`。通常针对改动文件检查 ESLint 与 TypeScript 语法;与声明生成/打包相关的问题可运行 `pnpm build`。除非用户明确要求,不添加测试或运行测试。
|
||||
@@ -26,6 +26,30 @@ Vue 3 移动端通用组件库,依赖 `timi-web` 提供基础组件和编辑
|
||||
|
||||
通知组件使用同样的 `uploadImage`、`uploadVideo` 和 `resolveAttachmentUrl` 回调。没有附件上传需求时,可以不传上传回调。
|
||||
|
||||
## 文档与演示
|
||||
|
||||
组件演示与 API 文档由 VitePress 驱动,源码位于 `docs/`。它作为独立子包维护,依赖版本与根项目隔离(根项目用 Vite 7,文档站需要 VitePress 自带的 Vite 5)。
|
||||
|
||||
首次使用先安装文档站依赖:
|
||||
|
||||
```bash
|
||||
pnpm -C docs install
|
||||
```
|
||||
|
||||
启动演示站(默认 http://localhost:3004):
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
构建静态站点:
|
||||
|
||||
```bash
|
||||
pnpm run build:doc
|
||||
```
|
||||
|
||||
演示直接引用 `src` 源码,改动组件即可热更新;API 表格由 `vue-docgen-api` 在构建期解析组件源码生成,无需手写。演示统一渲染在手机宽度的视口里——这是移动端组件库,按桌面宽度铺开会失去真实形态。新增组件的完整步骤见文档站的「演示与文档约定」页面。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* API 文档数据类型。
|
||||
*
|
||||
* 这些结构由 Node 侧的 `plugins/docgen.ts` 产出,通过虚拟模块交给浏览器渲染,
|
||||
* 因此刻意保持为不含第三方类型的纯数据,避免 `vue-docgen-api` 被打进客户端产物。
|
||||
*/
|
||||
|
||||
/** 单个 prop 的表格行数据。 */
|
||||
export interface ApiPropRow {
|
||||
/** prop 名称。 */
|
||||
name: string;
|
||||
/** 类型签名,如 `string`、`Article`。 */
|
||||
type: string;
|
||||
/** 默认值,未声明时为空字符串。 */
|
||||
defaultValue: string;
|
||||
/** 是否必填。 */
|
||||
required: boolean;
|
||||
/** 注释说明,取自源码 JSDoc。 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 单个事件的表格行数据。 */
|
||||
export interface ApiEventRow {
|
||||
/** 事件名,如 `update:modelValue`。 */
|
||||
name: string;
|
||||
/** 回调参数签名。 */
|
||||
type: string;
|
||||
/** 注释说明。 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 单个插槽的表格行数据。 */
|
||||
export interface ApiSlotRow {
|
||||
/** 插槽名。 */
|
||||
name: string;
|
||||
/** 注释说明或作用域参数。 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 单个暴露方法或属性的表格行数据。 */
|
||||
export interface ApiExposeRow {
|
||||
/** 暴露的名称。 */
|
||||
name: string;
|
||||
/** 类型签名。 */
|
||||
type: string;
|
||||
/** 注释说明。 */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 一个组件解析完成后的完整 API 数据。 */
|
||||
export interface ComponentApi {
|
||||
/** 组件名。 */
|
||||
name: string;
|
||||
/** 组件自身的注释说明。 */
|
||||
description: string;
|
||||
/**
|
||||
* 源码是否解析成功。
|
||||
*
|
||||
* 用来区分「解析失败」与「组件本身就没有 props/events/slots/expose」,
|
||||
* 两者都会得到空数组,但提示语完全不同。
|
||||
*/
|
||||
parsed: boolean;
|
||||
props: ApiPropRow[];
|
||||
events: ApiEventRow[];
|
||||
slots: ApiSlotRow[];
|
||||
exposes: ApiExposeRow[];
|
||||
}
|
||||
|
||||
/** 全部组件的 API 数据,键为组件名。 */
|
||||
export type ComponentApiMap = Record<string, ComponentApi>;
|
||||
|
||||
/** 生成一个空的 API 数据,用于解析失败时兜底,保证文档站不因单个组件而白屏。 */
|
||||
export function createEmptyApi(name: string): ComponentApi {
|
||||
return {
|
||||
name,
|
||||
description: "",
|
||||
parsed: false,
|
||||
props: [],
|
||||
events: [],
|
||||
slots: [],
|
||||
exposes: []
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig } from "vitepress";
|
||||
import {
|
||||
componentPreview,
|
||||
containerPreview
|
||||
} from "@vitepress-demo-preview/plugin";
|
||||
import AutoImport from "unplugin-auto-import/vite";
|
||||
import { groupByCategory, toDocLink } from "./components";
|
||||
import { docgenPlugin } from "./plugins/docgen";
|
||||
import { timiWebSsrStub } from "./plugins/ssr-stub";
|
||||
|
||||
/** `__dirname` 由 VitePress 打包配置时注入,指向 `docs/.vitepress`。 */
|
||||
const docsDir = resolve(__dirname, "..");
|
||||
/** 仓库根目录,组件源码位于它的 `src` 下。 */
|
||||
const repoRoot = resolve(docsDir, "..");
|
||||
/** timi-web 仓库目录,docs 通过 link 依赖它。 */
|
||||
const timiWebRoot = resolve(repoRoot, "../timi-web");
|
||||
|
||||
/**
|
||||
* 源码引用别名。
|
||||
*
|
||||
* demo 直接引用 `src` 源码而非构建产物,改组件即可热更新文档。
|
||||
* 组件源码自身的传递依赖(tiptap、prismjs 等)由 timi-web 的 node_modules 解析,
|
||||
* docs 无需重复声明。
|
||||
*/
|
||||
const alias = {
|
||||
"timi-tdesign-mobile": resolve(repoRoot, "src/index.ts"),
|
||||
"timi-web/editor": resolve(timiWebRoot, "src/editor/index.ts")
|
||||
};
|
||||
|
||||
/** 组件文档侧边栏,按 components.ts 的分类与顺序生成。 */
|
||||
const componentSidebar = groupByCategory().map(group => ({
|
||||
text: group.category,
|
||||
collapsed: false,
|
||||
items: group.items.map(item => ({
|
||||
text: item.title,
|
||||
link: toDocLink(item)
|
||||
}))
|
||||
}));
|
||||
|
||||
export default defineConfig({
|
||||
title: "timi-tdesign-mobile",
|
||||
description: "移动端 TDesign 组件库的演示与 API 文档",
|
||||
lang: "zh-CN",
|
||||
cleanUrls: true,
|
||||
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: "指南", link: "/guide/" },
|
||||
{ text: "场景", link: "/scenarios/editor" },
|
||||
{ text: "组件", link: "/components/" }
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: "指南",
|
||||
items: [
|
||||
{ text: "快速开始", link: "/guide/" },
|
||||
{ text: "演示与文档约定", link: "/guide/demo" }
|
||||
]
|
||||
},
|
||||
{
|
||||
text: "场景示例",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "编辑器能力总览", link: "/scenarios/editor" },
|
||||
{ text: "文章编辑与预览", link: "/scenarios/article" },
|
||||
{ text: "通知编辑与预览", link: "/scenarios/notify" },
|
||||
{ text: "移动端组件", link: "/scenarios/mobile" },
|
||||
{ text: "AI 对话", link: "/scenarios/ai" }
|
||||
]
|
||||
},
|
||||
...componentSidebar
|
||||
],
|
||||
outline: {
|
||||
level: [2, 3],
|
||||
label: "本页目录"
|
||||
},
|
||||
search: {
|
||||
provider: "local"
|
||||
},
|
||||
docFooter: {
|
||||
prev: "上一篇",
|
||||
next: "下一篇"
|
||||
},
|
||||
darkModeSwitchLabel: "主题",
|
||||
sidebarMenuLabel: "目录",
|
||||
returnToTopLabel: "回到顶部"
|
||||
},
|
||||
|
||||
markdown: {
|
||||
theme: {
|
||||
light: "vitesse-light",
|
||||
dark: "vitesse-dark"
|
||||
},
|
||||
lineNumbers: true,
|
||||
config(md) {
|
||||
// 组件依赖浏览器 API(Tiptap、Prism、ResizeObserver、SSE 等),
|
||||
// 统一开启 clientOnly 交给浏览器渲染,避免 SSR 阶段报错。
|
||||
md.use(componentPreview, { clientOnly: true, alias });
|
||||
md.use(containerPreview, { clientOnly: true, alias });
|
||||
}
|
||||
},
|
||||
|
||||
vite: {
|
||||
resolve: {
|
||||
alias
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// 必须最先执行:SSR 阶段拦截 timi-web,否则整个构建无法通过。
|
||||
timiWebSsrStub(docsDir),
|
||||
docgenPlugin(repoRoot),
|
||||
// src 源码依赖自动导入(内部没有显式 import vue 的文件不在少数),
|
||||
// 这里仅为其提供支持;docs 自身的代码一律显式导入。
|
||||
AutoImport({
|
||||
imports: ["vue"],
|
||||
dts: false
|
||||
})
|
||||
],
|
||||
|
||||
server: {
|
||||
port: 3004,
|
||||
host: true,
|
||||
fs: {
|
||||
// demo 需要读取 docs 之外的 src 与 timi-web 源码。
|
||||
allow: [repoRoot, timiWebRoot]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { parse } from "vue-docgen-api";
|
||||
import type { Plugin } from "vite";
|
||||
import { COMPONENT_LIST } from "../components";
|
||||
import {
|
||||
createEmptyApi,
|
||||
type ApiEventRow,
|
||||
type ApiExposeRow,
|
||||
type ApiPropRow,
|
||||
type ApiSlotRow,
|
||||
type ComponentApi,
|
||||
type ComponentApiMap
|
||||
} from "../api-types";
|
||||
|
||||
/** 虚拟模块的引用标识,业务代码与类型声明共用。 */
|
||||
export const API_MODULE_ID = "virtual:timi-api";
|
||||
|
||||
/** Vite 内部使用的解析后标识,前置 `\0` 避免被其他插件处理。 */
|
||||
const RESOLVED_ID = `\0${API_MODULE_ID}`;
|
||||
|
||||
/**
|
||||
* 把 vue-docgen-api 的联合类型描述压平成可读文本。
|
||||
* 不同版本对 `defineEmits` 的产出结构不一致,这里统一兜底。
|
||||
*/
|
||||
function normalizeType(type: unknown): string {
|
||||
if (!type || typeof type !== "object") {
|
||||
return "-";
|
||||
}
|
||||
const candidate = type as { name?: unknown; names?: unknown };
|
||||
if (Array.isArray(candidate.names)) {
|
||||
return candidate.names.length > 0 ? candidate.names.join(" | ") : "-";
|
||||
}
|
||||
if (typeof candidate.name === "string" && candidate.name.length > 0) {
|
||||
return candidate.name;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
/** 转换 prop 描述符。 */
|
||||
function toPropRow(descriptor: any): ApiPropRow {
|
||||
return {
|
||||
name: String(descriptor.name ?? ""),
|
||||
type: normalizeType(descriptor.type),
|
||||
defaultValue: descriptor.defaultValue?.value === undefined ? "-" : String(descriptor.defaultValue.value),
|
||||
required: descriptor.required === true,
|
||||
description: String(descriptor.description ?? "")
|
||||
};
|
||||
}
|
||||
|
||||
/** 转换事件描述符。 */
|
||||
function toEventRow(descriptor: any): ApiEventRow {
|
||||
return {
|
||||
name: String(descriptor.name ?? ""),
|
||||
type: normalizeType(descriptor.type),
|
||||
description: String(descriptor.description ?? "")
|
||||
};
|
||||
}
|
||||
|
||||
/** 转换插槽描述符,把作用域参数并入说明列。 */
|
||||
function toSlotRow(descriptor: any): ApiSlotRow {
|
||||
const bindings = Array.isArray(descriptor.bindings)
|
||||
? descriptor.bindings.map((item: any) => String(item.name ?? "")).filter(Boolean)
|
||||
: [];
|
||||
const description = String(descriptor.description ?? "");
|
||||
const scope = bindings.length > 0 ? `作用域参数:${bindings.join("、")}` : "";
|
||||
return {
|
||||
name: String(descriptor.name ?? ""),
|
||||
description: [description, scope].filter(Boolean).join(";")
|
||||
};
|
||||
}
|
||||
|
||||
/** 转换暴露描述符。 */
|
||||
function toExposeRow(descriptor: any): ApiExposeRow {
|
||||
return {
|
||||
name: String(descriptor.name ?? ""),
|
||||
type: normalizeType(descriptor.type),
|
||||
description: String(descriptor.description ?? "")
|
||||
};
|
||||
}
|
||||
|
||||
/** 把单个组件文档规范化为纯数据。 */
|
||||
function toComponentApi(name: string, doc: any): ComponentApi {
|
||||
return {
|
||||
name,
|
||||
description: String(doc?.description ?? ""),
|
||||
parsed: true,
|
||||
props: Array.isArray(doc?.props) ? doc.props.map(toPropRow) : [],
|
||||
events: Array.isArray(doc?.events) ? doc.events.map(toEventRow) : [],
|
||||
slots: Array.isArray(doc?.slots) ? doc.slots.map(toSlotRow) : [],
|
||||
exposes: Array.isArray(doc?.expose) ? doc.expose.map(toExposeRow) : []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐个解析组件源码。
|
||||
* 单个组件解析失败只降级为空 API,不让整个文档站因一处语法而无法构建。
|
||||
*/
|
||||
async function parseAll(repoRoot: string): Promise<ComponentApiMap> {
|
||||
const result: ComponentApiMap = {};
|
||||
|
||||
for (const meta of COMPONENT_LIST) {
|
||||
const filePath = resolve(repoRoot, meta.entry);
|
||||
if (!existsSync(filePath)) {
|
||||
console.warn(`[timi-docgen] 未找到组件入口,已跳过:${meta.entry}`);
|
||||
result[meta.name] = createEmptyApi(meta.name);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const doc = await parse(filePath);
|
||||
result[meta.name] = toComponentApi(meta.name, doc);
|
||||
} catch (error) {
|
||||
console.warn(`[timi-docgen] 解析 ${meta.name} 失败,已降级为空 API:`, error);
|
||||
result[meta.name] = createEmptyApi(meta.name);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提供 `virtual:timi-api` 虚拟模块,内容为全部组件的 API 数据。
|
||||
*
|
||||
* 解析结果按需缓存,仅在组件源码变更时失效,避免每次页面请求都重复解析。
|
||||
* @param repoRoot 仓库根目录的绝对路径
|
||||
*/
|
||||
export function docgenPlugin(repoRoot: string): Plugin {
|
||||
const entryPathSet = new Set(COMPONENT_LIST.map(meta => resolve(repoRoot, meta.entry)));
|
||||
let cache: ComponentApiMap | null = null;
|
||||
|
||||
return {
|
||||
name: "timi-docgen",
|
||||
|
||||
resolveId(id: string) {
|
||||
return id === API_MODULE_ID ? RESOLVED_ID : null;
|
||||
},
|
||||
|
||||
async load(id: string) {
|
||||
if (id !== RESOLVED_ID) {
|
||||
return null;
|
||||
}
|
||||
if (cache === null) {
|
||||
cache = await parseAll(repoRoot);
|
||||
}
|
||||
return `export default ${JSON.stringify(cache)};`;
|
||||
},
|
||||
|
||||
handleHotUpdate({ file, server }) {
|
||||
if (!entryPathSet.has(file)) {
|
||||
return;
|
||||
}
|
||||
cache = null;
|
||||
const invalidated = server.moduleGraph.getModuleById(RESOLVED_ID);
|
||||
if (invalidated) {
|
||||
server.moduleGraph.invalidateModule(invalidated);
|
||||
server.ws.send({ type: "full-reload" });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
/** 虚拟 stub 模块标识。 */
|
||||
const STUB_ID = "\0timi-web-ssr-stub";
|
||||
|
||||
/** 被 stub 的模块说明符。 */
|
||||
const STUBBED_MODULES = new Set(["timi-web", "timi-web/editor"]);
|
||||
|
||||
/**
|
||||
* SSR 阶段必须存在的具名导出。
|
||||
*
|
||||
* 取自 `src` 与 `docs` 中所有 `timi-web` / `timi-web/editor` 值导入的并集,
|
||||
* 是 stub 的保底名单;若构建产物可读,`collectExportNames` 会在此基础上补全全部导出。
|
||||
*/
|
||||
const REQUIRED_EXPORTS = [
|
||||
// ---------- timi-web ----------
|
||||
"VPopup",
|
||||
"Popup",
|
||||
"PopupType",
|
||||
"Copyright",
|
||||
"MarkdownView",
|
||||
"MarkdownEditor",
|
||||
"Toolkit",
|
||||
"WebToolkit",
|
||||
|
||||
// ---------- timi-web/editor ----------
|
||||
"findTextMatches",
|
||||
"replaceAllText",
|
||||
"replaceMatchText",
|
||||
"buildSearchRegex",
|
||||
"highlightCode",
|
||||
"resolveCodeEditorKeydown",
|
||||
"resolvePrismLanguage",
|
||||
"useTiptapEditor",
|
||||
"useTiptapView",
|
||||
"headingMeta",
|
||||
"ATTACHMENT_ID_ATTR",
|
||||
"TEMP_FILE_ID_ATTR",
|
||||
"LOCAL_MEDIA_ID_PREFIX",
|
||||
"isLocalMediaId",
|
||||
"normalizeContent",
|
||||
"supportedCodeLanguages",
|
||||
"TABLE_PICKER_SIZE"
|
||||
];
|
||||
|
||||
/** 从构建产物中提取具名导出,让 stub 自动跟上上游变化。 */
|
||||
function collectExportNames(distFile: string): Set<string> {
|
||||
const names = new Set<string>(REQUIRED_EXPORTS);
|
||||
if (!existsSync(distFile)) {
|
||||
return names;
|
||||
}
|
||||
try {
|
||||
const source = readFileSync(distFile, "utf-8");
|
||||
for (const match of source.matchAll(/export\s*\{([^}]*)\}/g)) {
|
||||
for (const part of match[1].split(",")) {
|
||||
const bits = part.trim().split(/\s+as\s+/);
|
||||
const name = (bits[1] ?? bits[0]).trim();
|
||||
if (/^[A-Za-z_$][\w$]*$/.test(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 读取失败时退回保底名单,不影响构建。
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 生成 stub 模块源码:每个导出都是可调用、可解构、可当插件安装的空实现。 */
|
||||
function buildStubSource(names: Set<string>): string {
|
||||
const named = [...names]
|
||||
.filter(name => name !== "default")
|
||||
.map(name => `export const ${name} = stub;`)
|
||||
.join("\n");
|
||||
return `const stub = new Proxy(function () {}, {
|
||||
get(target, key) {
|
||||
if (key === Symbol.toPrimitive || key === "valueOf" || key === "toString") {
|
||||
return () => "";
|
||||
}
|
||||
return stub;
|
||||
},
|
||||
apply() {
|
||||
return stub;
|
||||
},
|
||||
construct() {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
export default stub;
|
||||
${named}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 SSR 阶段把 timi-web 替换为空实现。
|
||||
*
|
||||
* timi-web 的构建产物在模块顶层就访问 `window` 并启动 Prism 自动高亮定时器,
|
||||
* 在 Node 中 import 会直接抛错,导致 VitePress 的 SSR 构建必然失败。
|
||||
*
|
||||
* 文档站里真正需要 timi-web 的只有演示组件,它们全部通过 `clientOnly` 延迟到浏览器渲染,
|
||||
* SSR 阶段只需要「能 import 且不报错」,因此这里返回一个 Proxy 空实现。
|
||||
* 客户端构建不受影响,`options.ssr` 为 false 时照常解析到真实模块。
|
||||
* @param docsDir docs 目录绝对路径,用于定位 timi-web 构建产物
|
||||
*/
|
||||
export function timiWebSsrStub(docsDir: string): Plugin {
|
||||
const distFile = resolve(docsDir, "node_modules/timi-web/dist/timi-web.mjs");
|
||||
let stubSource: string | null = null;
|
||||
|
||||
return {
|
||||
name: "timi-web-ssr-stub",
|
||||
enforce: "pre",
|
||||
|
||||
resolveId(source, importer, options) {
|
||||
// 只拦截精确的包名,`timi-web/style.css` 等子路径照常交给 Vite 处理。
|
||||
if (options?.ssr && STUBBED_MODULES.has(source)) {
|
||||
return STUB_ID;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id !== STUB_ID) {
|
||||
return null;
|
||||
}
|
||||
if (stubSource === null) {
|
||||
stubSource = buildStubSource(collectExportNames(distFile));
|
||||
}
|
||||
return stubSource;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div v-if="api" class="tui-api">
|
||||
<section v-if="api.props.length > 0">
|
||||
<h3>Props</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>默认值</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in api.props" :key="row.name">
|
||||
<td class="name">
|
||||
<code v-text="row.name" />
|
||||
<span v-if="row.required" class="required" title="必填">*</span>
|
||||
</td>
|
||||
<td><code v-text="row.type" /></td>
|
||||
<td><code v-text="row.defaultValue" /></td>
|
||||
<td v-text="row.description" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section v-if="api.events.length > 0">
|
||||
<h3>Events</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>参数</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in api.events" :key="row.name">
|
||||
<td><code v-text="row.name" /></td>
|
||||
<td><code v-text="row.type" /></td>
|
||||
<td v-text="row.description" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section v-if="api.slots.length > 0">
|
||||
<h3>Slots</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in api.slots" :key="row.name">
|
||||
<td><code v-text="row.name" /></td>
|
||||
<td v-text="row.description" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section v-if="api.exposes.length > 0">
|
||||
<h3>Expose</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>类型</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in api.exposes" :key="row.name">
|
||||
<td><code v-text="row.name" /></td>
|
||||
<td><code v-text="row.type" /></td>
|
||||
<td v-text="row.description" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<p v-if="isEmpty && !api.parsed" class="notice warn">
|
||||
未能从源码中解析出 API 信息,请检查组件是否使用了 defineProps / defineEmits / defineExpose。
|
||||
</p>
|
||||
<p v-else-if="isEmpty" class="notice">
|
||||
该组件不需要任何 props、events、slots 或实例方法。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-else class="notice warn" v-text="`未找到组件 ${name} 的 API 数据,请检查 components.ts 中的 name 与 entry 是否正确`" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import apiMap from "virtual:timi-api";
|
||||
|
||||
/**
|
||||
* 组件 API 表格。
|
||||
*
|
||||
* 数据来自构建期 `vue-docgen-api` 对组件源码的解析结果,
|
||||
* 无需在 markdown 中手写 props / events / slots 表格。
|
||||
* 某个分区没有内容时整段不渲染,避免出现空表。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 组件名,需与 components.ts 中的 name 一致。 */
|
||||
name: string;
|
||||
}>();
|
||||
|
||||
const api = computed(() => apiMap[props.name]);
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
if (!api.value) {
|
||||
return false;
|
||||
}
|
||||
const { props: propRows, events, slots, exposes } = api.value;
|
||||
return propRows.length === 0 && events.length === 0 && slots.length === 0 && exposes.length === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tui-api {
|
||||
section {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 1.5rem 0 .75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.required {
|
||||
margin-left: .125rem;
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 1rem 0;
|
||||
padding: .75rem 1rem;
|
||||
font-size: .875rem;
|
||||
border-radius: .5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.warn {
|
||||
color: var(--vp-c-warning-1);
|
||||
background: var(--vp-c-warning-soft);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="tui-demo">
|
||||
<section class="stage">
|
||||
<div class="viewport">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="meta">
|
||||
<div class="info">
|
||||
<p v-if="title" class="title" v-text="title" />
|
||||
<p v-if="description" class="desc" v-text="description" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="action"
|
||||
type="button"
|
||||
@click="handleCopy"
|
||||
v-text="copied ? '已复制' : '复制代码'"
|
||||
/>
|
||||
<button
|
||||
class="action"
|
||||
type="button"
|
||||
@click="toggleExpanded"
|
||||
v-text="expanded ? '收起代码' : '展开代码'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="expanded" class="source">
|
||||
<div class="language-vue" v-html="sourceHtml" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
|
||||
/**
|
||||
* 预览容器。
|
||||
*
|
||||
* props 契约由 `@vitepress-demo-preview/plugin` 决定,它把 markdown 里的
|
||||
* `:::preview` 容器编译成 `<demo-preview>` 标签并传入下列属性:
|
||||
* - `code`:`encodeURI` 处理过的组件源码
|
||||
* - `showCode`:`encodeURIComponent` 处理过的高亮 HTML
|
||||
*
|
||||
* 这里刻意不依赖官方容器,也不引入额外 UI 库,只用原生元素配合 VitePress 的
|
||||
* CSS 变量,深色模式随之自动适配。
|
||||
*
|
||||
* 演示统一放在手机宽度的视口里:这些是移动端组件,按桌面宽度铺开会失去真实形态。
|
||||
* 需要调整宽度时改 `--tui-demo-viewport` 即可。
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 源码原文,经 encodeURI 编码。 */
|
||||
code?: string;
|
||||
/** 高亮后的源码 HTML,经 encodeURIComponent 编码。 */
|
||||
showCode?: string;
|
||||
/** 演示标题,来自 `:::preview` 后方第一段文字。 */
|
||||
title?: string;
|
||||
/** 演示描述,来自 `:::preview` 中 `||` 之后的内容。 */
|
||||
description?: string;
|
||||
}>(), {
|
||||
code: "",
|
||||
showCode: "",
|
||||
title: "",
|
||||
description: ""
|
||||
});
|
||||
|
||||
/** 源码区是否展开,默认收起以保持文档阅读节奏。 */
|
||||
const expanded = ref(false);
|
||||
/** 复制成功后的短暂反馈。 */
|
||||
const copied = ref(false);
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const sourceHtml = computed(() => decodeURIComponent(props.showCode));
|
||||
const sourceText = computed(() => decodeURIComponent(props.code));
|
||||
|
||||
/** 复制源码到剪贴板,clipboard 不可用时保持原按钮文案。 */
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(sourceText.value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
copied.value = true;
|
||||
if (copiedTimer !== null) {
|
||||
clearTimeout(copiedTimer);
|
||||
}
|
||||
copiedTimer = setTimeout(() => {
|
||||
copied.value = false;
|
||||
copiedTimer = null;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded.value = !expanded.value;
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (copiedTimer !== null) {
|
||||
clearTimeout(copiedTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tui-demo {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .75rem;
|
||||
background: var(--vp-c-bg);
|
||||
|
||||
.stage {
|
||||
display: flex;
|
||||
padding: 1.5rem;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
// 手机视口:移动组件在桌面宽度下会被拉伸,这里统一收到移动端宽度
|
||||
.viewport {
|
||||
width: 100%;
|
||||
max-width: var(--tui-demo-viewport, 26rem);
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .75rem;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: .75rem 1.5rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: .25rem 0 0;
|
||||
font-size: .75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: .25rem;
|
||||
}
|
||||
|
||||
.action {
|
||||
padding: .25rem .5rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .25rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-default-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.source {
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
|
||||
:deep(div[class*="language-"]) {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.tui-demo {
|
||||
.stage {
|
||||
padding: .75rem;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vitepress/client" />
|
||||
|
||||
/** 构建期由 vue-docgen-api 解析生成的组件 API 数据。 */
|
||||
declare module "virtual:timi-api" {
|
||||
import type { ComponentApiMap } from "../api-types";
|
||||
|
||||
const apiMap: ComponentApiMap;
|
||||
export default apiMap;
|
||||
}
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import { watch } from "vue";
|
||||
import { useData } from "vitepress";
|
||||
import type { App } from "vue";
|
||||
import TDesignMobile from "tdesign-mobile-vue";
|
||||
import TimiWebUI, { VPopup } from "timi-web";
|
||||
|
||||
// 必须引完整样式:`tdesign-mobile-vue/es/style/index.css` 只含全局 token,
|
||||
// 不含任何组件类,全量注册时会导致所有组件无样式渲染。
|
||||
import "tdesign-mobile-vue/dist/tdesign.css";
|
||||
import "./styles/index.less";
|
||||
|
||||
import DemoPreview from "./containers/DemoPreview.vue";
|
||||
import ApiTable from "./components/ApiTable.vue";
|
||||
|
||||
/**
|
||||
* 同步深色模式。
|
||||
*
|
||||
* VitePress 通过 `html.dark` 切换深色,而 TDesign 依赖 `html[theme-mode="dark"]`,
|
||||
* 两者不是同一套机制,不同步会导致深色模式下 TDesign 组件仍是亮色。
|
||||
*/
|
||||
function syncThemeMode(dark: boolean) {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
document.documentElement.setAttribute("theme-mode", dark ? "dark" : "light");
|
||||
}
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
|
||||
enhanceApp({ app }: { app: App }) {
|
||||
// 全量注册 TDesign Mobile,演示区内所有 t-* 组件直接可用。
|
||||
app.use(TDesignMobile);
|
||||
// 组件内部大量使用 timi-web 的 v-popup 指令,且用在组件模板里,
|
||||
// 无法由演示局部注入,只能在主题里全局注册。
|
||||
app.use(TimiWebUI);
|
||||
app.directive("popup", VPopup);
|
||||
|
||||
app.component("demo-preview", DemoPreview);
|
||||
app.component("api-table", ApiTable);
|
||||
},
|
||||
|
||||
setup() {
|
||||
const { isDark } = useData();
|
||||
watch(isDark, syncThemeMode, { immediate: true });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 文档站全局样式。
|
||||
*
|
||||
* 只处理 VitePress 正文与演示容器、API 表格的交界处;
|
||||
* 组件自身的样式一律各自 scoped,避免相互污染。
|
||||
*/
|
||||
|
||||
.vp-doc {
|
||||
// 演示容器与正文的呼吸感
|
||||
.tui-demo {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
// API 表格里的类型与默认值不换行,避免长签名把列挤变形
|
||||
table code {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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" />
|
||||
@@ -0,0 +1,42 @@
|
||||
# AiChatMessageList 消息列表
|
||||
|
||||
渲染对话消息,含思考过程、引用与流式光标。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 消息里的 thinkingList 与 citationList 会渲染为思考过程与引用
|
||||
|
||||
demo-preview=../demos/ai-chat-message-list/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要单独控制消息区域的布局时使用。多数情况下直接用 [AiChatPanel](/components/ai-chat-panel) 更省事。
|
||||
|
||||
## 消息结构
|
||||
|
||||
每条消息是 `AiChatMessage`:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `id` | 唯一标识。 |
|
||||
| `role` | `"user"` 或 `"assistant"`。 |
|
||||
| `content` | 正文,按 Markdown 渲染。 |
|
||||
| `thinkingList` | 思考分段,传给 `AiChatThinking` 展示。 |
|
||||
| `citationList` | 引用信息。 |
|
||||
| `createdAt` | 生成时间戳。 |
|
||||
| `statusText` | 状态文案,如工具调用的当前步骤。 |
|
||||
| `duration` | 本次生成耗时(毫秒)。 |
|
||||
|
||||
## 角色文案
|
||||
|
||||
`roleNames` 覆盖默认的「助手 / 我」,需要更复杂的逻辑时用 `roleNameFormatter`。
|
||||
|
||||
## 插槽
|
||||
|
||||
`welcome`、`message`、`role-name`、`thinking`、`citation`、`message-status`、`assistant-icon`、`user-icon` 可以替换对应区域,都带作用域参数。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="AiChatMessageList" />
|
||||
@@ -0,0 +1,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" />
|
||||
@@ -0,0 +1,29 @@
|
||||
# AiChatThinking 思考过程
|
||||
|
||||
折叠展示模型的思考分段,生成中自动展开。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 逐段追加内容,生成中会自动滚到底部
|
||||
|
||||
demo-preview=../demos/ai-chat-thinking/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要把模型的推理过程单独呈现时使用,通常由 [AiChatMessageList](/components/ai-chat-message-list) 内部调用,也可以单独摆放。
|
||||
|
||||
## 行为
|
||||
|
||||
- `isGenerating` 为真时展开并跟随滚动到底部,结束后收起
|
||||
- 内容受 `max-height` 限制,超出时内部滚动
|
||||
- 容器带 `role="log"` 与 `aria-live="polite"`,屏幕阅读器会播报增量
|
||||
|
||||
## 与消息的关系
|
||||
|
||||
消息的 `thinkingList` 是一组分段(`{ id, content }`),[AiChatMessageList](/components/ai-chat-message-list) 会把它们拼成完整文本后传进来。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="AiChatThinking" />
|
||||
@@ -0,0 +1,41 @@
|
||||
# ArticleEditor 文章编辑器
|
||||
|
||||
以 `Article` 为数据模型,按正文类型切换纯文本、Markdown 与富文本三种编辑形态。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 切换正文类型会同时替换内容,提交时读取完整文章
|
||||
|
||||
demo-preview=../demos/article-editor/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要在一套表单里同时支持多种正文格式时使用。切换只改 `contentType`,上层不需要关心具体编辑器。
|
||||
|
||||
## 数据模型
|
||||
|
||||
组件只约束三个字段,其他字段会原样保留:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `contentType` | 正文类型,支持 `TEXT`、`MARKDOWN`、`TIP_TAP`(`TIPTAP` 为兼容写法)。 |
|
||||
| `content` | 正文内容,随类型分别是纯文本、Markdown 源码或 HTML。 |
|
||||
| `attachmentList` | 附件列表,元素至少包含 `id`、`name`、`url`。 |
|
||||
|
||||
## 附件与媒体
|
||||
|
||||
`uploadImage`、`uploadVideo` 负责上传,`resolveAttachmentUrl`、`resolveTempUrl` 负责把附件标识换成可访问地址。四个回调都可选,不传时对应能力自动降级。
|
||||
|
||||
## 提交内容
|
||||
|
||||
编辑过程中抛出的是实时值,提交时建议调用 `submit()`,它会等待富文本里未完成的上传再返回最终数据:
|
||||
|
||||
```ts
|
||||
const article = await editorRef.value?.submit();
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="ArticleEditor" />
|
||||
@@ -0,0 +1,29 @@
|
||||
# ArticleView 文章视图
|
||||
|
||||
按 `Article` 的正文类型渲染正文内容。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 把 contentType 换成 TEXT 或 MARKDOWN 会切换渲染分支
|
||||
|
||||
demo-preview=../demos/article-view/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要在详情页只读展示文章正文时使用,与 `ArticleEditor` 共用同一套数据类型。
|
||||
|
||||
## 渲染分支
|
||||
|
||||
| contentType | 渲染方式 |
|
||||
| --- | --- |
|
||||
| `TEXT` | 只读文本域。 |
|
||||
| `MARKDOWN` | 由 timi-web 的 MarkdownView 渲染。 |
|
||||
| `TIP_TAP` | 由 [TiptapView](/components/tiptap-view) 渲染。 |
|
||||
|
||||
内容为空时统一显示 `emptyText`。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="ArticleView" />
|
||||
@@ -0,0 +1,37 @@
|
||||
# BottomPopup 底部弹层
|
||||
|
||||
从底部升起的操作面板,可带标题与左右按钮。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 弹层从视口底部升起,标题栏在内容滚动时吸顶
|
||||
|
||||
demo-preview=../demos/bottom-popup/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要在移动端承载一组操作或一段表单时使用。它基于 TDesign Mobile 的 `Popup`,因此遮罩、层级与动画都沿用框架行为。
|
||||
|
||||
## 按钮只抛事件
|
||||
|
||||
左右按钮**不会自动关闭弹层**,只抛出 `leftButtonClick` / `rightButtonClick`,关闭与否由业务决定:
|
||||
|
||||
```vue
|
||||
<bottom-popup
|
||||
v-model="visible"
|
||||
title="选择操作"
|
||||
@right-button-click="handleConfirm"
|
||||
/>
|
||||
```
|
||||
|
||||
按钮文案传空字符串即可隐藏对应按钮;也可以分别用 `leftButton` / `rightButton` 插槽完全接管,插槽会收到 `onClick`。
|
||||
|
||||
## 高度
|
||||
|
||||
`height` 默认 `"auto"`,按内容撑开;传具体值(如 `"60vh"`)时内容区滚动、标题栏吸顶。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="BottomPopup" />
|
||||
@@ -0,0 +1,36 @@
|
||||
# CodeEditor 代码编辑器
|
||||
|
||||
带行号、语法高亮与查找替换的代码输入框。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 通过 v-model 绑定内容,实例暴露 openSearch 等方法
|
||||
|
||||
demo-preview=../demos/code-editor/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要让用户输入或查看结构化代码时使用。它不做代码校验,只负责输入、高亮与检索。
|
||||
|
||||
## 内容绑定
|
||||
|
||||
`modelValue` 与 `value` 都可作为输入源,`value` 优先级更高。输入时同步抛出 `update:modelValue` 与 `change`。
|
||||
|
||||
## 实例方法
|
||||
|
||||
通过模板引用调用暴露的方法,例如打开查找栏:
|
||||
|
||||
```ts
|
||||
import type { CodeEditorExpose } from "timi-tdesign-mobile";
|
||||
|
||||
const editorRef = ref<CodeEditorExpose>();
|
||||
editorRef.value?.openSearch(true);
|
||||
```
|
||||
|
||||
移动端受限于宽度,代码行会横向滚动而不是折行。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="CodeEditor" />
|
||||
@@ -0,0 +1,42 @@
|
||||
# DurationPicker 时长选择
|
||||
|
||||
按时长值选择并回填,单位与候选项由组件内部推导。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 选择后回填的是毫秒数
|
||||
|
||||
demo-preview=../demos/duration-picker/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要让用户选择一个时间跨度(缓存有效期、超时时长等)时使用。对外只暴露毫秒值,单位换算是内部细节。
|
||||
|
||||
## 取值与单位
|
||||
|
||||
`modelValue` 是**毫秒数**。组件内置了年 / 天 / 小时 / 分钟 / 秒 / 毫秒六个单位,各自带有取值范围与步长:
|
||||
|
||||
| 单位 | 范围 | 步长 |
|
||||
| --- | --- | --- |
|
||||
| 年 | 1 - 100 | 1 |
|
||||
| 天 | 1 - 364 | 1 |
|
||||
| 小时 | 1 - 23 | 1 |
|
||||
| 分钟 | 1 - 59 | 1 |
|
||||
| 秒 | 1 - 59 | 1 |
|
||||
| 毫秒 | 50 - 950 | 50 |
|
||||
|
||||
默认值为 1000 毫秒(1 秒)。
|
||||
|
||||
## 两个事件
|
||||
|
||||
`update:modelValue` 在滚动选择时触发,`change` 在确认选择时触发:
|
||||
|
||||
```vue
|
||||
<duration-picker v-model="duration" @change="onConfirm" />
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="DurationPicker" />
|
||||
@@ -0,0 +1,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) | 折叠展示模型的思考分段,生成中自动展开。 |
|
||||
@@ -0,0 +1,37 @@
|
||||
# NotifyEditor 通知编辑器
|
||||
|
||||
以 `NotifyDetail` 为数据模型的通知编辑表单,正文部分复用文章编辑器。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 通知复用文章编辑器,同时保留主题等自有字段
|
||||
|
||||
demo-preview=../demos/notify-editor/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要编辑系统通知、站内信等内容时使用。通知与文章的差别只在元数据,正文能力完全一致。
|
||||
|
||||
## 数据模型
|
||||
|
||||
`NotifyDetail` 只约束正文相关字段,主题、状态等业务字段由外层维护并原样保留:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `content` | 正文内容。 |
|
||||
| `contentType` | 正文类型,支持 `TEXT`、`TIP_TAP`。 |
|
||||
| `attachmentList` | 附件列表。 |
|
||||
|
||||
## 提交内容
|
||||
|
||||
```ts
|
||||
const detail = await editorRef.value?.submit();
|
||||
```
|
||||
|
||||
与文章编辑器一致,`submit()` 会等待未完成的媒体上传。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="NotifyEditor" />
|
||||
@@ -0,0 +1,19 @@
|
||||
# NotifyView 通知视图
|
||||
|
||||
渲染通知的正文内容,是 `ArticleView` 在通知场景下的薄封装。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 传入 NotifyDetail 即可渲染正文
|
||||
|
||||
demo-preview=../demos/notify-view/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要在通知详情页只读展示正文时使用。主题、状态等元数据由业务页面自行呈现,组件只负责正文。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="NotifyView" />
|
||||
@@ -0,0 +1,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" />
|
||||
@@ -0,0 +1,23 @@
|
||||
# RootLayout 根布局
|
||||
|
||||
移动端页面骨架,主体内容之外自动渲染版权与备案信息。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 插槽填充主体内容,底部版权区在窄屏下自适应
|
||||
|
||||
demo-preview=../demos/root-layout/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要一套带页脚版权信息的整页容器时使用。它只负责骨架与页脚,不含导航栏,业务可以自行组合。
|
||||
|
||||
## 版权区
|
||||
|
||||
底部由 timi-web 的 `Copyright` 组件渲染,`icp` 会链接到工信部网站,`domain` 与 `author` 同时传入时才渲染版权行。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="RootLayout" />
|
||||
@@ -0,0 +1,33 @@
|
||||
# Space 间距
|
||||
|
||||
控制子元素之间的间距与排列方向,支持分隔符。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 尺寸可用预设名或具体数值,数组形式分别指定横向与纵向
|
||||
|
||||
demo-preview=../demos/space/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要给一组元素之间加统一间距时使用。相比手写 `margin`,它把间距收敛到一个属性上,调整时不必逐个改样式。
|
||||
|
||||
## 尺寸
|
||||
|
||||
`size` 接受三种写法:
|
||||
|
||||
| 写法 | 说明 |
|
||||
| --- | --- |
|
||||
| `"small"` / `"medium"` / `"large"` | 预设值,分别为 8 / 16 / 24 像素 |
|
||||
| 数字或带单位的字符串 | 自定义间距,如 `12`、`"1.5rem"` |
|
||||
| 数组 | 依次表示横向与纵向间距 |
|
||||
|
||||
## 分隔符
|
||||
|
||||
`separator` 或 `separator` 插槽会在相邻元素之间插入内容,注意**末尾元素之后不会插入**。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="Space" />
|
||||
@@ -0,0 +1,37 @@
|
||||
# TiptapEditor 富文本编辑器
|
||||
|
||||
基于 Tiptap 的富文本编辑器,工具栏覆盖媒体、表格、链接与代码块。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 工具栏按移动端宽度自动换行排列
|
||||
|
||||
demo-preview=../demos/tiptap-editor/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要所见即所得的正文编辑时使用。内容以 HTML 字符串双向绑定,可直接入库。
|
||||
|
||||
## 媒体上传
|
||||
|
||||
图片与视频通过 `uploadImage`、`uploadVideo` 回调上传,组件不绑定具体接口。拖拽或粘贴文件时会先插入占位内容,上传成功后替换为真实地址。
|
||||
|
||||
未完成的媒体会在提交时统一处理,`getPendingMediaCount()` 可查询待处理数量。
|
||||
|
||||
## 提交内容
|
||||
|
||||
编辑过程中的 `v-model` 是实时值,提交时建议调用 `submit()` 以等待未完成的上传并归一化内容:
|
||||
|
||||
```ts
|
||||
const html = await editorRef.value?.submit();
|
||||
```
|
||||
|
||||
## 移动端差异
|
||||
|
||||
工具栏按钮在窄屏下换行排列而不是收进折叠菜单,`v-popup` 提示在触屏下不可用,按钮已带文字或图标语义。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="TiptapEditor" />
|
||||
@@ -0,0 +1,23 @@
|
||||
# TiptapView 富文本视图
|
||||
|
||||
Tiptap 内容的只读渲染视图,与编辑器共用同一套样式与扩展。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 超出 maxHeight 时可滚动查看
|
||||
|
||||
demo-preview=../demos/tiptap-view/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## 何时使用
|
||||
|
||||
需要在详情页、审批流等只读场景渲染富文本时使用。相比直接 `v-html`,它复用了编辑器的扩展与样式,展示与编辑效果一致。
|
||||
|
||||
## 空内容
|
||||
|
||||
内容为空或规范化后为空字符串时,渲染 `emptyText` 占位文案而不是空白区域。
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="TiptapView" />
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="phone">
|
||||
<ai-chat-message-list :messages="messages" :is-generating="false" />
|
||||
</div>
|
||||
|
||||
<p class="note" v-text="'消息里的 thinkingList 与 citationList 会渲染为思考过程与引用。'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { AiChatMessageList } from "timi-tdesign-mobile";
|
||||
import { createMessage } from "../ai/mock-sse";
|
||||
|
||||
const messages = [
|
||||
createMessage(1, "user", "上个月订单量怎么样?"),
|
||||
createMessage(2, "assistant", "近 30 天订单量**环比上升 12%**,其中移动端贡献了主要增量。", {
|
||||
thinkingList: [
|
||||
{ id: 1, content: "先确认时间范围是自然月还是滚动 30 天。" },
|
||||
{ id: 2, content: "再按端类型拆分,确认增量来源。" }
|
||||
],
|
||||
citationList: [
|
||||
{ source: "分析中心", dataDomain: "订单域", timeRange: "近 30 天", caliber: "支付口径" }
|
||||
]
|
||||
})
|
||||
];
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.phone {
|
||||
height: 26rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<ai-chat-thinking :content="content" :is-generating="generating" />
|
||||
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" :disabled="generating" @click="start" v-text="'开始生成'" />
|
||||
<button type="button" class="btn" @click="reset" v-text="'重置'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, ref } from "vue";
|
||||
import { AiChatThinking } from "timi-tdesign-mobile";
|
||||
|
||||
const SEGMENTS = [
|
||||
"先理解问题:用户想知道上个月的订单情况。",
|
||||
"确认时间范围:按自然月还是滚动 30 天?这里取自然月。",
|
||||
"确认口径:使用支付口径而非下单口径。",
|
||||
"开始查询数据并汇总……"
|
||||
];
|
||||
|
||||
const content = ref("");
|
||||
const generating = ref(false);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** 逐段追加,模拟思考内容流式到达;生成中组件会自动滚到底部。 */
|
||||
function start() {
|
||||
reset();
|
||||
generating.value = true;
|
||||
let index = 0;
|
||||
timer = setInterval(() => {
|
||||
if (index >= SEGMENTS.length) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
content.value += (content.value ? "\n" : "") + SEGMENTS[index];
|
||||
index += 1;
|
||||
}, 700);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer !== null) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
generating.value = false;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
stop();
|
||||
content.value = "";
|
||||
}
|
||||
|
||||
onUnmounted(stop);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--vp-c-brand-1);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 演示专用的假 SSE 流。
|
||||
*
|
||||
* `useAiChat` 要求 `request` 返回一个 `Response`,真实的实现由后端提供。
|
||||
* 这里用 ReadableStream 按内置协议逐条吐出事件,让文档站无需后端也能跑通
|
||||
* 流式对话的完整链路。
|
||||
*
|
||||
* 内置协议的事件名与载荷字段(见 `parseDefaultEvent` / `extractText` 等):
|
||||
* - `thinking` 载荷取 `thought` / `thinking` / `reasoning`
|
||||
* - `answer` 载荷取 `answer` / `text` / `content`
|
||||
* - `tool-status` 载荷取 `text` / `message` / `status`
|
||||
* - `citation` 载荷为引用对象
|
||||
* - `done` 载荷可带 `conversationId` 与 `duration`
|
||||
*/
|
||||
|
||||
/** 单条 SSE 事件。 */
|
||||
interface MockEvent {
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 每个事件之间的间隔,模拟真实的流式节奏。 */
|
||||
const STEP_DELAY = 420;
|
||||
|
||||
function buildEvents(question: string): MockEvent[] {
|
||||
return [
|
||||
{ event: "thinking", data: { thought: `先理解问题:「${question}」` } },
|
||||
{ event: "thinking", data: { thought: "然后确认需要的数据口径与时间范围。" } },
|
||||
{ event: "tool-status", data: { text: "正在查询数据" } },
|
||||
{ event: "answer", data: { text: "根据当前口径," } },
|
||||
{ event: "answer", data: { text: `关于「${question}」的结论如下:` } },
|
||||
{ event: "answer", data: { text: "\n\n- 近 30 天整体**环比上升 12%**" } },
|
||||
{ event: "answer", data: { text: "\n- 其中移动端贡献了主要增量" } },
|
||||
{ event: "citation", data: { source: "分析中心", dataDomain: "订单域", timeRange: "近 30 天" } },
|
||||
{ event: "done", data: { conversationId: "demo-conversation", duration: STEP_DELAY * 9 } }
|
||||
];
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一段假的流式回复。
|
||||
*
|
||||
* @param question 用户提问,会回显在回答里
|
||||
* @param signal 取消信号,用户点「停止」时中断
|
||||
*/
|
||||
export function createMockReply(question: string, signal?: AbortSignal): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const events = buildEvents(question);
|
||||
let index = 0;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
if (signal?.aborted) {
|
||||
controller.error(new DOMException("已停止生成", "AbortError"));
|
||||
return;
|
||||
}
|
||||
if (index >= events.length) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const item = events[index];
|
||||
index += 1;
|
||||
controller.enqueue(encoder.encode(`event: ${item.event}\ndata: ${JSON.stringify(item.data)}\n\n`));
|
||||
await sleep(STEP_DELAY);
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: { "Content-Type": "text/event-stream" }
|
||||
});
|
||||
}
|
||||
|
||||
/** 构造一条用于静态展示的消息。 */
|
||||
export function createMessage(
|
||||
id: number,
|
||||
role: "user" | "assistant",
|
||||
content: string,
|
||||
extra: Partial<{ thinkingList: Array<{ id: number; content: string }>; citationList: unknown[] }> = {}
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
thinkingList: extra.thinkingList ?? [],
|
||||
citationList: extra.citationList ?? [],
|
||||
createdAt: Date.now() - (10 - id) * 60_000,
|
||||
statusText: "",
|
||||
duration: role === "assistant" ? 2400 : undefined
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="bar">
|
||||
<button
|
||||
v-for="item in contentTypeList"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="{ active: value.contentType === item.value }"
|
||||
@click="switchContentType(item.value)"
|
||||
v-text="item.label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input v-model="value.title" class="field" placeholder="文章标题" />
|
||||
|
||||
<article-editor
|
||||
ref="editorRef"
|
||||
:model-value="value"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onChange"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
|
||||
<div class="footer">
|
||||
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
|
||||
<span class="status" v-text="status" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { ArticleEditor } from "timi-tdesign-mobile";
|
||||
import type {
|
||||
Article,
|
||||
ArticleContentType,
|
||||
ArticleEditorExpose,
|
||||
UploadResult
|
||||
} from "timi-tdesign-mobile";
|
||||
|
||||
const RICH_CONTENT = `<h2>正文类型可切换</h2>
|
||||
<p>同一份 <code>Article</code> 数据,按 <code>contentType</code> 切换三种编辑形态。</p>`;
|
||||
|
||||
const contentTypeList: Array<{ label: string; value: ArticleContentType }> = [
|
||||
{ label: "纯文本", value: "TEXT" },
|
||||
{ label: "Markdown", value: "MARKDOWN" },
|
||||
{ label: "富文本", value: "TIP_TAP" }
|
||||
];
|
||||
|
||||
const value = ref<Article>({
|
||||
title: "编辑器演示文章",
|
||||
contentType: "TIP_TAP",
|
||||
content: RICH_CONTENT,
|
||||
attachmentList: []
|
||||
});
|
||||
|
||||
const status = ref("等待提交");
|
||||
const editorRef = ref<ArticleEditorExpose>();
|
||||
|
||||
/** 切换正文类型时同步替换内容,避免不同格式互相污染。 */
|
||||
function switchContentType(contentType: ArticleContentType) {
|
||||
const content = contentType === "TEXT"
|
||||
? "这是一段纯文本正文。"
|
||||
: contentType === "MARKDOWN"
|
||||
? "## Markdown 标题\n\n这是一段 Markdown 正文。"
|
||||
: RICH_CONTENT;
|
||||
value.value = {
|
||||
...value.value,
|
||||
contentType,
|
||||
content
|
||||
};
|
||||
}
|
||||
|
||||
function onChange(article: Article) {
|
||||
value.value = article;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const article = await editorRef.value?.submit();
|
||||
if (article) {
|
||||
value.value = article;
|
||||
status.value = `已提交,标题为「${article.title}」`;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .375rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .625rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: .375rem .5rem;
|
||||
margin-bottom: .75rem;
|
||||
box-sizing: border-box;
|
||||
font-size: .8125rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
align-items: center;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<article-view :article="article" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ArticleView } from "timi-tdesign-mobile";
|
||||
import type { Article } from "timi-tdesign-mobile";
|
||||
|
||||
/** ArticleView 按 contentType 选择渲染方式,这里演示富文本形态。 */
|
||||
const article: Article = {
|
||||
title: "文章视图",
|
||||
contentType: "TIP_TAP",
|
||||
content: `<h2>文章视图</h2>
|
||||
<p>正文按 <code>Article</code> 结构渲染,空内容时显示占位文案。</p>
|
||||
<blockquote><p>把 <code>contentType</code> 换成 TEXT 或 MARKDOWN 会切换渲染分支。</p></blockquote>`,
|
||||
attachmentList: []
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<button type="button" class="btn" @click="visible = true" v-text="'打开底部弹层'" />
|
||||
|
||||
<p class="status" v-text="`最近一次操作:${lastAction}`" />
|
||||
|
||||
<bottom-popup
|
||||
v-model="visible"
|
||||
title="选择操作"
|
||||
left-button-text="取消"
|
||||
right-button-text="确定"
|
||||
@left-button-click="onCancel"
|
||||
@right-button-click="onConfirm"
|
||||
>
|
||||
<div class="content">
|
||||
<p v-text="'弹层从视口底部升起,默认带遮罩,内容区可滚动。'" />
|
||||
<p v-text="'标题栏在内容滚动时会吸顶。'" />
|
||||
</div>
|
||||
</bottom-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { BottomPopup } from "timi-tdesign-mobile";
|
||||
|
||||
const visible = ref(false);
|
||||
const lastAction = ref("无");
|
||||
|
||||
/** 左右按钮只抛事件,关闭与否由业务决定——这里两者都关闭。 */
|
||||
function onCancel() {
|
||||
visible.value = false;
|
||||
lastAction.value = "取消";
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
visible.value = false;
|
||||
lastAction.value = "确定";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: .625rem;
|
||||
font-size: .875rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .5rem;
|
||||
color: #fff;
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem;
|
||||
|
||||
p {
|
||||
margin: 0 0 .75rem;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-2);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" @click="readValue" v-text="'读取内容'" />
|
||||
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<code-editor
|
||||
ref="editorRef"
|
||||
v-model="code"
|
||||
language="typescript"
|
||||
placeholder="请输入代码"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="status" v-text="status" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { CodeEditor } from "timi-tdesign-mobile";
|
||||
import type { CodeEditorExpose } from "timi-tdesign-mobile";
|
||||
|
||||
const DEMO_CODE = `type DemoUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const user: DemoUser = {
|
||||
id: "demo-001",
|
||||
name: "Timi"
|
||||
};`;
|
||||
|
||||
const code = ref(DEMO_CODE);
|
||||
const status = ref("等待操作");
|
||||
const editorRef = ref<CodeEditorExpose>();
|
||||
|
||||
function onChange(value: string) {
|
||||
status.value = `内容已更新,${value.length} 个字符`;
|
||||
}
|
||||
|
||||
function readValue() {
|
||||
status.value = `当前共 ${code.value.length} 个字符`;
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
editorRef.value?.openSearch(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
height: 18rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<duration-picker v-model="duration" @change="onChange" />
|
||||
|
||||
<div class="result">
|
||||
<p class="value" v-text="`${duration} ms`" />
|
||||
<p class="text" v-text="humanized" />
|
||||
<p class="text" v-text="`变更次数:${changeCount}`" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { DurationPicker } from "timi-tdesign-mobile";
|
||||
|
||||
const duration = ref(1000);
|
||||
const changeCount = ref(0);
|
||||
|
||||
/** 把毫秒换算成便于阅读的形式,仅用于演示展示。 */
|
||||
const humanized = computed(() => {
|
||||
const value = duration.value;
|
||||
if (value < 1000) {
|
||||
return `${value} 毫秒`;
|
||||
}
|
||||
if (value < 60_000) {
|
||||
return `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 2)} 秒`;
|
||||
}
|
||||
if (value < 3600_000) {
|
||||
return `${(value / 60_000).toFixed(2)} 分钟`;
|
||||
}
|
||||
if (value < 86400_000) {
|
||||
return `${(value / 3600_000).toFixed(2)} 小时`;
|
||||
}
|
||||
return `${(value / 86400_000).toFixed(2)} 天`;
|
||||
});
|
||||
|
||||
function onChange() {
|
||||
changeCount.value += 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.result {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin: 0 0 .25rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0 0 .25rem;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<input v-model="value.subject" class="field" placeholder="通知主题" />
|
||||
|
||||
<notify-editor
|
||||
ref="editorRef"
|
||||
:model-value="value"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onChange"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
|
||||
<div class="footer">
|
||||
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
|
||||
<span class="status" v-text="status" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { NotifyEditor } from "timi-tdesign-mobile";
|
||||
import type { NotifyDetail, NotifyEditorExpose, UploadResult } from "timi-tdesign-mobile";
|
||||
|
||||
const value = ref<NotifyDetail>({
|
||||
subject: "编辑器通知演示",
|
||||
contentType: "TIP_TAP",
|
||||
content: `<h2>通知内容</h2>
|
||||
<p>通知复用文章编辑器,同时保留自己的元数据字段(如 <code>subject</code>)。</p>`,
|
||||
attachmentList: []
|
||||
});
|
||||
|
||||
const status = ref("等待提交");
|
||||
const editorRef = ref<NotifyEditorExpose>();
|
||||
|
||||
function onChange(detail: NotifyDetail) {
|
||||
value.value = detail;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const detail = await editorRef.value?.submit();
|
||||
if (detail) {
|
||||
value.value = detail;
|
||||
status.value = `已提交,正文 ${detail.content?.length || 0} 个字符`;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: .375rem .5rem;
|
||||
margin-bottom: .75rem;
|
||||
box-sizing: border-box;
|
||||
font-size: .8125rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
align-items: center;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<notify-view :detail="detail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { NotifyView } from "timi-tdesign-mobile";
|
||||
import type { NotifyDetail } from "timi-tdesign-mobile";
|
||||
|
||||
/** NotifyView 只关心正文相关字段,主题与状态由业务页面自行呈现。 */
|
||||
const detail: NotifyDetail = {
|
||||
subject: "系统维护通知",
|
||||
contentType: "TIP_TAP",
|
||||
content: `<h2>系统维护通知</h2>
|
||||
<p>本周六 02:00 - 04:00 进行数据库升级,期间服务不可用。</p>
|
||||
<ul><li>请提前保存工作内容</li><li>升级完成后会同步通知</li></ul>`
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<root-layout
|
||||
icp="京ICP备 00000000 号"
|
||||
domain="example.com"
|
||||
author="Timi"
|
||||
>
|
||||
<div class="body">
|
||||
<h3 v-text="'RootLayout'" />
|
||||
<p v-text="'移动端页面骨架:插槽填充主体,底部自动渲染版权与备案信息。'" />
|
||||
</div>
|
||||
</root-layout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { RootLayout } from "timi-tdesign-mobile";
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.body {
|
||||
padding: 1.5rem 1rem;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 .5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="phone">
|
||||
<header class="head">
|
||||
<span class="title" v-text="'智能助手'" />
|
||||
<button type="button" class="reset" @click="resetConversation" v-text="'清空'" />
|
||||
</header>
|
||||
|
||||
<ai-chat-panel
|
||||
class="panel"
|
||||
:messages="messages"
|
||||
:is-generating="isGenerating"
|
||||
:active-tool-status="activeToolStatus"
|
||||
:last-error="lastError"
|
||||
:can-retry="canRetry"
|
||||
:loading="isLoadingConversation"
|
||||
:role-names="{ assistant: '分析助手', user: '我' }"
|
||||
@send="sendMessage"
|
||||
@stop="stopGeneration"
|
||||
@retry="retryLastMessage"
|
||||
>
|
||||
<template #welcome>
|
||||
<div class="welcome">
|
||||
<p class="welcome-title" v-text="'可以问我关于数据的问题'" />
|
||||
<p class="welcome-tip" v-text="'例如:上个月订单量怎么样?'" />
|
||||
</div>
|
||||
</template>
|
||||
</ai-chat-panel>
|
||||
</div>
|
||||
|
||||
<p class="note" v-text="'面板嵌在带标题栏的页面骨架里,欢迎语与角色名都做了自定义。'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { AiChatPanel, useAiChat } from "timi-tdesign-mobile";
|
||||
import { createMockReply } from "../ai/mock-sse";
|
||||
|
||||
/**
|
||||
* 完整场景:标题栏 + 面板 + 自定义欢迎语。
|
||||
* 会话状态全部来自 useAiChat,真实项目里把 request 换成后端 SSE 接口即可。
|
||||
*/
|
||||
const {
|
||||
messages,
|
||||
isGenerating,
|
||||
isLoadingConversation,
|
||||
activeToolStatus,
|
||||
lastError,
|
||||
canRetry,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
retryLastMessage,
|
||||
resetConversation
|
||||
} = useAiChat({
|
||||
request: async ({ message, signal }) => createMockReply(message, signal)
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.phone {
|
||||
height: 32rem;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: .625rem .75rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reset {
|
||||
padding: .25rem .5rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
margin: 0 0 .375rem;
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.welcome-tip {
|
||||
margin: 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: tab === 'edit' }"
|
||||
@click="tab = 'edit'"
|
||||
v-text="'编辑'"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: tab === 'view' }"
|
||||
@click="tab = 'view'"
|
||||
v-text="'预览'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="switcher">
|
||||
<button
|
||||
v-for="item in contentTypeList"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ active: article.contentType === item.value }"
|
||||
@click="switchContentType(item.value)"
|
||||
v-text="item.label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section v-show="tab === 'edit'" class="pane">
|
||||
<input v-model="article.title" class="field" placeholder="文章标题" />
|
||||
<article-editor
|
||||
ref="editorRef"
|
||||
:model-value="article"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onChange"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
<div class="footer">
|
||||
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
|
||||
<span class="status" v-text="status" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="tab === 'view'" class="pane">
|
||||
<article-view :article="article" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { ArticleEditor, ArticleView } from "timi-tdesign-mobile";
|
||||
import type {
|
||||
Article,
|
||||
ArticleContentType,
|
||||
ArticleEditorExpose,
|
||||
UploadResult
|
||||
} from "timi-tdesign-mobile";
|
||||
|
||||
/** 本场景验证「同一份 Article 数据,编辑与预览共用」。 */
|
||||
const RICH_CONTENT = `<h2>文章编辑与预览</h2>
|
||||
<p>两侧共用同一个 <code>Article</code> 对象,切换标签即可对比。</p>
|
||||
<blockquote><p>移动端用标签切换替代左右分栏。</p></blockquote>`;
|
||||
|
||||
const contentTypeList: Array<{ label: string; value: ArticleContentType }> = [
|
||||
{ label: "纯文本", value: "TEXT" },
|
||||
{ label: "Markdown", value: "MARKDOWN" },
|
||||
{ label: "富文本", value: "TIP_TAP" }
|
||||
];
|
||||
|
||||
const tab = ref("edit");
|
||||
const article = ref<Article>({
|
||||
title: "编辑器组件演示文章",
|
||||
contentType: "TIP_TAP",
|
||||
content: RICH_CONTENT,
|
||||
attachmentList: []
|
||||
});
|
||||
|
||||
const status = ref("等待提交");
|
||||
const editorRef = ref<ArticleEditorExpose>();
|
||||
|
||||
function switchContentType(contentType: ArticleContentType) {
|
||||
const content = contentType === "TEXT"
|
||||
? "这是一段纯文本示例。"
|
||||
: contentType === "MARKDOWN"
|
||||
? "## Markdown 示例\n\n这是一段 Markdown 正文。"
|
||||
: RICH_CONTENT;
|
||||
article.value = {
|
||||
...article.value,
|
||||
contentType,
|
||||
content
|
||||
};
|
||||
status.value = `已切换到 ${contentType}`;
|
||||
}
|
||||
|
||||
function onChange(value: Article) {
|
||||
article.value = value;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const value = await editorRef.value?.submit();
|
||||
if (value) {
|
||||
article.value = value;
|
||||
status.value = `已提交,${value.attachmentList?.length || 0} 个附件`;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
padding: .25rem;
|
||||
margin-bottom: .75rem;
|
||||
border-radius: .5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: .375rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: transparent;
|
||||
|
||||
&.active {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.switcher {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .375rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: .25rem .625rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: .375rem .5rem;
|
||||
margin-bottom: .75rem;
|
||||
box-sizing: border-box;
|
||||
font-size: .8125rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
align-items: center;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="item in tabList"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: tab === item.value }"
|
||||
@click="tab = item.value"
|
||||
v-text="item.label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section v-show="tab === 'rich'" class="pane">
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" @click="submitRich" v-text="'提交富文本'" />
|
||||
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
|
||||
</div>
|
||||
<div class="box tall">
|
||||
<tiptap-editor
|
||||
ref="tiptapRef"
|
||||
v-model="rich"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onRichChange"
|
||||
/>
|
||||
</div>
|
||||
<p class="status" v-text="status" />
|
||||
</section>
|
||||
|
||||
<section v-show="tab === 'view'" class="pane">
|
||||
<tiptap-view :content="rich" max-height="20rem" />
|
||||
</section>
|
||||
|
||||
<section v-show="tab === 'code'" class="pane">
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" @click="resetCode" v-text="'恢复示例'" />
|
||||
</div>
|
||||
<div class="box">
|
||||
<code-editor v-model="code" language="typescript" placeholder="请输入代码" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { CodeEditor, TiptapEditor, TiptapView } from "timi-tdesign-mobile";
|
||||
import type { TiptapEditorExpose, UploadResult } from "timi-tdesign-mobile";
|
||||
|
||||
/** 本场景把三类编辑器收在一个页面里,用标签切换而不是并排——移动端宽度放不下分栏。 */
|
||||
const DEMO_CODE = `type DemoUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const user: DemoUser = { id: "u1", name: "Timi" };`;
|
||||
|
||||
const tabList = [
|
||||
{ label: "富文本", value: "rich" },
|
||||
{ label: "只读视图", value: "view" },
|
||||
{ label: "代码", value: "code" }
|
||||
];
|
||||
|
||||
const tab = ref("rich");
|
||||
const rich = ref(`<h2>跨平台编辑器</h2>
|
||||
<p>这段内容同时验证标题、段落、引用与代码块的渲染。</p>
|
||||
<blockquote><p>移动端和 PC 端只替换交互层,共享文档模型。</p></blockquote>`);
|
||||
const code = ref(DEMO_CODE);
|
||||
const status = ref("等待操作");
|
||||
const tiptapRef = ref<TiptapEditorExpose>();
|
||||
|
||||
function onRichChange(value: string) {
|
||||
status.value = `内容已更新,${value.length} 个字符`;
|
||||
}
|
||||
|
||||
function resetCode() {
|
||||
code.value = DEMO_CODE;
|
||||
}
|
||||
|
||||
async function submitRich() {
|
||||
const value = await tiptapRef.value?.submit();
|
||||
if (value !== undefined) {
|
||||
rich.value = value;
|
||||
status.value = `已提交,${value.length} 个字符`;
|
||||
}
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
tiptapRef.value?.openSearch(true);
|
||||
}
|
||||
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
padding: .25rem;
|
||||
margin-bottom: .75rem;
|
||||
border-radius: .5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: .375rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: transparent;
|
||||
|
||||
&.active {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
height: 16rem;
|
||||
}
|
||||
|
||||
.tall {
|
||||
height: 22rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="card">
|
||||
<t-cell title="缓存有效期" arrow @click="pickerVisible = true">
|
||||
<span v-text="humanized" />
|
||||
</t-cell>
|
||||
</div>
|
||||
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" @click="sheetVisible = true" v-text="'打开底部弹层'" />
|
||||
</div>
|
||||
|
||||
<p class="status" v-text="`弹层最近一次操作:${lastAction}`" />
|
||||
|
||||
<space size="small" class="tags">
|
||||
<span v-for="item in 3" :key="item" class="tag" v-text="`标签 ${item}`" />
|
||||
</space>
|
||||
|
||||
<t-popup v-model="pickerVisible" placement="bottom" destroy-on-close>
|
||||
<div class="picker-panel">
|
||||
<duration-picker v-model="durationMs" @change="pickerVisible = false" />
|
||||
</div>
|
||||
</t-popup>
|
||||
|
||||
<bottom-popup
|
||||
v-model="sheetVisible"
|
||||
title="选择操作"
|
||||
@left-button-click="onAction('取消')"
|
||||
@right-button-click="onAction('确定')"
|
||||
>
|
||||
<div class="sheet-content">
|
||||
<p v-text="'底部弹层适合承载一组操作或一段简短表单。'" />
|
||||
</div>
|
||||
</bottom-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { BottomPopup, DurationPicker, Space } from "timi-tdesign-mobile";
|
||||
|
||||
/**
|
||||
* 本场景是移动端最典型的两种浮现方式:整块选择面板用 `t-popup` 从底部升起,
|
||||
* 操作确认用 BottomPopup。
|
||||
*/
|
||||
const durationMs = ref(1000);
|
||||
const pickerVisible = ref(false);
|
||||
const sheetVisible = ref(false);
|
||||
const lastAction = ref("无");
|
||||
|
||||
/** 把毫秒换算成便于阅读的形式。 */
|
||||
const humanized = computed(() => {
|
||||
const value = durationMs.value;
|
||||
if (value % (60 * 60 * 1000) === 0) {
|
||||
return `${value / (60 * 60 * 1000)} 小时`;
|
||||
}
|
||||
if (value % (60 * 1000) === 0) {
|
||||
return `${value / (60 * 1000)} 分钟`;
|
||||
}
|
||||
if (value % 1000 === 0) {
|
||||
return `${value / 1000} 秒`;
|
||||
}
|
||||
return `${value} 毫秒`;
|
||||
});
|
||||
|
||||
function onAction(action: string) {
|
||||
lastAction.value = action;
|
||||
sheetVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.card {
|
||||
overflow: hidden;
|
||||
margin-bottom: .75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
.bar {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: .5rem;
|
||||
font-size: .8125rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .5rem;
|
||||
color: #fff;
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 0 0 .75rem;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.tags {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: .25rem .5rem;
|
||||
font-size: .6875rem;
|
||||
border-radius: .25rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-default-soft);
|
||||
}
|
||||
|
||||
.picker-panel {
|
||||
padding-top: .5rem;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.sheet-content {
|
||||
padding: 1rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: tab === 'edit' }"
|
||||
@click="tab = 'edit'"
|
||||
v-text="'编辑'"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: tab === 'view' }"
|
||||
@click="tab = 'view'"
|
||||
v-text="'预览'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
@click="switchStatus"
|
||||
v-text="'切换状态'"
|
||||
/>
|
||||
<span class="tag" v-text="detail.status" />
|
||||
</div>
|
||||
|
||||
<section v-show="tab === 'edit'" class="pane">
|
||||
<input v-model="detail.subject" class="field" placeholder="通知主题" />
|
||||
<notify-editor
|
||||
ref="editorRef"
|
||||
:model-value="detail"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onChange"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
<div class="footer">
|
||||
<button type="button" class="btn primary" @click="submit" v-text="'提交通知'" />
|
||||
<span class="status" v-text="status" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="tab === 'view'" class="pane">
|
||||
<notify-view :detail="detail" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { NotifyEditor, NotifyView } from "timi-tdesign-mobile";
|
||||
import type { NotifyDetail, NotifyEditorExpose, UploadResult } from "timi-tdesign-mobile";
|
||||
|
||||
/** 本场景验证通知复用文章编辑能力,同时保留自己的元数据字段。 */
|
||||
const RICH_CONTENT = `<h2>系统维护通知</h2>
|
||||
<p>本周六 02:00 - 04:00 进行数据库升级,期间服务不可用。</p>
|
||||
<ul><li>请提前保存工作内容</li><li>升级完成后会同步通知</li></ul>`;
|
||||
|
||||
const statusList = ["WAITING", "SENT", "FAILED"];
|
||||
|
||||
const tab = ref("edit");
|
||||
const detail = ref<NotifyDetail>({
|
||||
subject: "编辑器通知演示",
|
||||
contentType: "TIP_TAP",
|
||||
content: RICH_CONTENT,
|
||||
status: "WAITING",
|
||||
attachmentList: []
|
||||
});
|
||||
|
||||
const status = ref("等待提交");
|
||||
const editorRef = ref<NotifyEditorExpose>();
|
||||
|
||||
function switchStatus() {
|
||||
const index = statusList.indexOf(detail.value.status);
|
||||
const next = statusList[(index + 1) % statusList.length];
|
||||
detail.value = {
|
||||
...detail.value,
|
||||
status: next
|
||||
};
|
||||
}
|
||||
|
||||
function onChange(value: NotifyDetail) {
|
||||
detail.value = value;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const value = await editorRef.value?.submit();
|
||||
if (value) {
|
||||
detail.value = value;
|
||||
status.value = `已提交,正文 ${value.content?.length || 0} 个字符`;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
padding: .25rem;
|
||||
margin-bottom: .75rem;
|
||||
border-radius: .5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: .375rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: transparent;
|
||||
|
||||
&.active {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: .25rem .625rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: .125rem .5rem;
|
||||
font-size: .6875rem;
|
||||
border-radius: .25rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-default-soft);
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: .375rem .5rem;
|
||||
margin-bottom: .75rem;
|
||||
box-sizing: border-box;
|
||||
font-size: .8125rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
align-items: center;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<section class="block">
|
||||
<h4 v-text="'水平(默认 medium)'" />
|
||||
<space>
|
||||
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
|
||||
</space>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h4 v-text="'预设尺寸 small / large'" />
|
||||
<space size="small">
|
||||
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
|
||||
</space>
|
||||
<space size="large">
|
||||
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
|
||||
</space>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h4 v-text="'垂直排列'" />
|
||||
<space direction="vertical">
|
||||
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
|
||||
</space>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h4 v-text="'分隔符'" />
|
||||
<space separator="|">
|
||||
<span v-for="item in 3" :key="item" v-text="`项 ${item}`" />
|
||||
</space>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Space } from "timi-tdesign-mobile";
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.block {
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 .5rem;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.box {
|
||||
padding: .375rem .625rem;
|
||||
font-size: .75rem;
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-default-soft);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" @click="submit" v-text="'提交'" />
|
||||
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<tiptap-editor
|
||||
ref="editorRef"
|
||||
v-model="content"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="status" v-text="status" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { TiptapEditor } from "timi-tdesign-mobile";
|
||||
import type { TiptapEditorExpose, UploadResult } from "timi-tdesign-mobile";
|
||||
|
||||
const content = ref(`<h2>富文本编辑器</h2>
|
||||
<p>工具栏覆盖标题、颜色、列表、表格、链接与代码块。</p>
|
||||
<blockquote><p>移动端工具栏会自动换行排列。</p></blockquote>`);
|
||||
|
||||
const status = ref("等待操作");
|
||||
const editorRef = ref<TiptapEditorExpose>();
|
||||
|
||||
function onChange(value: string) {
|
||||
status.value = `内容已更新,${value.length} 个字符`;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const value = await editorRef.value?.submit();
|
||||
if (value !== undefined) {
|
||||
content.value = value;
|
||||
status.value = `已提交,${value.length} 个字符`;
|
||||
}
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
editorRef.value?.openSearch(true);
|
||||
}
|
||||
|
||||
/** 演示用上传:直接把本地文件转成临时地址,无需后端。 */
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
height: 24rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<input v-model="maxHeight" class="field" placeholder="最大高度,如 16rem" />
|
||||
<tiptap-view :content="content" :max-height="maxHeight" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { TiptapView } from "timi-tdesign-mobile";
|
||||
|
||||
const content = `<h2>只读视图</h2>
|
||||
<p>与编辑器共用同一套渲染规则,内容超出 <code>maxHeight</code> 时可滚动查看。</p>
|
||||
<blockquote><p>适合详情页、审批流等只需要阅读的场景。</p></blockquote>`;
|
||||
|
||||
const maxHeight = ref("16rem");
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: .375rem .5rem;
|
||||
margin-bottom: .75rem;
|
||||
box-sizing: border-box;
|
||||
font-size: .8125rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
# 演示与文档约定
|
||||
|
||||
本站的演示与 API 文档都由源码驱动,新增组件时只需按下面的顺序补齐三处内容。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
docs/
|
||||
├── .vitepress/
|
||||
│ ├── components.ts # 组件元数据清单(唯一数据源)
|
||||
│ ├── config.ts # 站点配置、侧边栏、markdown 插件
|
||||
│ ├── plugins/
|
||||
│ │ ├── docgen.ts # vue-docgen-api 解析,产出虚拟模块
|
||||
│ │ └── ssr-stub.ts # SSR 阶段替换 timi-web
|
||||
│ └── theme/
|
||||
│ ├── index.ts # 主题入口,注册 TDesign Mobile 与自定义组件
|
||||
│ ├── containers/ # :::preview 的预览容器
|
||||
│ └── components/ # api-table 等文档组件
|
||||
├── demos/ # 演示组件,按组件名分目录
|
||||
│ ├── ai/ # AI 演示共用的假流
|
||||
│ └── scenarios/ # 跨组件的组合场景演示
|
||||
├── components/ # 组件文档页
|
||||
└── scenarios/ # 组合场景文档页
|
||||
```
|
||||
|
||||
单组件文档只回答「这个组件怎么用」,跨组件的协作方式放在 `scenarios/` 下。
|
||||
|
||||
## 新增一个组件
|
||||
|
||||
### 1. 登记元数据
|
||||
|
||||
在 `docs/.vitepress/components.ts` 的 `COMPONENT_LIST` 中追加一项。侧边栏导航、API 数据解析都从这里读取:
|
||||
|
||||
```ts
|
||||
{
|
||||
name: "CodeEditor", // 必须与组件 defineOptions 的 name 一致
|
||||
title: "CodeEditor 代码编辑器",
|
||||
description: "带行号、语法高亮与查找替换的代码输入框。",
|
||||
category: "编辑器组件",
|
||||
entry: "src/components/code-editor/index.vue"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 编写演示
|
||||
|
||||
演示组件放在 `docs/demos/<文档路径>/` 下。演示直接引用 `src` 源码,改组件会即时热更新:
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { CodeEditor } from "timi-tdesign-mobile";
|
||||
|
||||
const code = ref("");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<code-editor v-model="code" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### 3. 编写文档页
|
||||
|
||||
在 `docs/components/` 下建立同名 markdown,用 `:::preview` 容器引用演示:
|
||||
|
||||
````md
|
||||
# CodeEditor 代码编辑器
|
||||
|
||||
带行号、语法高亮与查找替换的代码输入框。
|
||||
|
||||
## 基础用法
|
||||
|
||||
:::preview 基础用法 || 通过 v-model 双向绑定内容
|
||||
|
||||
demo-preview=../demos/code-editor/basic.vue
|
||||
|
||||
:::
|
||||
|
||||
## API
|
||||
|
||||
<api-table name="CodeEditor" />
|
||||
````
|
||||
|
||||
路径以 markdown 文件所在位置为基准,因此 `docs/components/` 下的页面统一使用 `../demos/`。
|
||||
|
||||
## 文档路径的来历
|
||||
|
||||
`index.vue` 取所在**目录名**,具名文件取**文件名**——`ai` 目录下有多个组件,只靠目录名会全部撞成 `ai`。两者都会再转成短横线形式,`AiChatComposer` 得到 `ai-chat-composer`。需要特殊路径时用元数据里的 `slug` 覆盖。
|
||||
|
||||
## API 表从哪来
|
||||
|
||||
`<api-table>` 的数据由 `vue-docgen-api` 在构建期解析组件源码得到,通过虚拟模块 `virtual:timi-api` 交给页面渲染。因此不需要手写 props / events / slots 表格,改源码后文档自动更新。
|
||||
|
||||
想让说明出现在表里,就在 props 类型上写 JSDoc 注释:
|
||||
|
||||
```ts
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 距离底部小于等于该值时触发 */
|
||||
distance?: number;
|
||||
}>(), {
|
||||
distance: 120
|
||||
});
|
||||
```
|
||||
|
||||
某个组件解析失败时只会让该组件的 API 区降级为空,不会影响整站构建;构建日志里会打出对应的组件名。
|
||||
|
||||
## 演示统一放在手机视口里
|
||||
|
||||
这是移动端组件库,按桌面宽度铺开会失去真实形态。容器把演示收在 `26rem` 宽的视口内,宽度可以通过 CSS 变量调整:
|
||||
|
||||
```less
|
||||
.tui-demo {
|
||||
--tui-demo-viewport: 30rem;
|
||||
}
|
||||
```
|
||||
|
||||
需要全宽的演示(如整页骨架)可以在自己的样式里覆盖这个变量。
|
||||
|
||||
## 关于 SSR
|
||||
|
||||
`timi-web` 的构建产物在模块顶层访问 `window`,无法在 Node 中加载,而主题必须静态引入它来注册 `v-popup` 指令。因此 SSR 阶段由 `ssr-stub.ts` 提供空实现。
|
||||
|
||||
演示组件本身以 `clientOnly` 延迟渲染,不需要额外处理 SSR,但也别把它当作服务端渲染能力的验证。
|
||||
@@ -0,0 +1,102 @@
|
||||
# 快速开始
|
||||
|
||||
`timi-tdesign-mobile` 是面向移动端的 Vue 3 业务组件库,底层复用 `timi-web` 的编辑器与内容渲染能力,UI 基于 TDesign Mobile。
|
||||
|
||||
## 安装
|
||||
|
||||
```sh
|
||||
pnpm add timi-tdesign-mobile tdesign-mobile-vue
|
||||
```
|
||||
|
||||
## 按需引入
|
||||
|
||||
组件在 `<script setup>` 中导入后即可在模板里使用,这是推荐方式:
|
||||
|
||||
```vue
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { CodeEditor } from "timi-tdesign-mobile";
|
||||
|
||||
const code = ref("const answer = 42;");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<code-editor v-model="code" language="typescript" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## 全局注册
|
||||
|
||||
每个组件都带有 `install` 方法,可以单独注册为全局组件:
|
||||
|
||||
```ts
|
||||
import { createApp } from "vue";
|
||||
import { CodeEditor, TiptapEditor } from "timi-tdesign-mobile";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(CodeEditor);
|
||||
app.use(TiptapEditor);
|
||||
```
|
||||
|
||||
## 必须注册 v-popup 指令
|
||||
|
||||
富文本工具栏等组件内部使用了 `timi-web` 的 `v-popup` 指令,且用在组件模板里,宿主的应用入口需要注册一次:
|
||||
|
||||
```ts
|
||||
import TimiWebUI, { VPopup } from "timi-web";
|
||||
|
||||
app.use(TimiWebUI);
|
||||
app.directive("popup", VPopup);
|
||||
```
|
||||
|
||||
漏掉这一步不会报错,但工具栏的悬停提示会静默失效。
|
||||
|
||||
## 引入样式
|
||||
|
||||
```ts
|
||||
import "timi-tdesign-mobile/style.css";
|
||||
```
|
||||
|
||||
该样式入口已包含 TDesign 定制与编辑器正文样式,`timi-web` 的样式也会一并带入。
|
||||
|
||||
**另外还需要引入 TDesign Mobile 自己的样式**,注意要引完整包而不是 `es/style/index.css`——后者只含全局 token,不含任何组件类:
|
||||
|
||||
```ts
|
||||
import "tdesign-mobile-vue/dist/tdesign.css";
|
||||
```
|
||||
|
||||
## 数据与回调
|
||||
|
||||
文章、通知类组件只约束对象的最小字段,业务对象上的其他字段会原样保留。上传与附件地址通过回调注入,库本身不绑定任何接口实现:
|
||||
|
||||
```vue
|
||||
<article-editor
|
||||
:model-value="article"
|
||||
:upload-image="uploadImage"
|
||||
:upload-video="uploadVideo"
|
||||
:resolve-attachment-url="resolveAttachmentUrl"
|
||||
@update:model-value="article = $event"
|
||||
/>
|
||||
```
|
||||
|
||||
没有附件上传需求时,可以不传上传回调。
|
||||
|
||||
## AI 对话
|
||||
|
||||
对话能力由 `useAiChat` 组合式函数提供,组件只负责渲染:
|
||||
|
||||
```ts
|
||||
import { useAiChat } from "timi-tdesign-mobile";
|
||||
|
||||
const { messages, isGenerating, sendMessage } = useAiChat({
|
||||
request: ({ message, signal }) => fetchSseReply(message, signal)
|
||||
});
|
||||
```
|
||||
|
||||
详见 [AiChatPanel](/components/ai-chat-panel) 与 [AI 对话场景](/scenarios/ai)。
|
||||
|
||||
## 下一步
|
||||
|
||||
- 在[组件总览](/components/)中浏览全部组件
|
||||
- 看[编辑器能力总览](/scenarios/editor)等场景,了解多个组件如何协作
|
||||
- 了解本站的[演示与文档约定](/guide/demo),向文档站补充新组件
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: timi-tdesign-mobile
|
||||
text: 移动端 TDesign 组件库
|
||||
tagline: 15 个业务组件,配套可交互演示与由源码自动生成的 API 文档
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 快速开始
|
||||
link: /guide/
|
||||
- theme: alt
|
||||
text: 浏览组件
|
||||
link: /components/
|
||||
|
||||
features:
|
||||
- title: 真实可交互
|
||||
details: 演示直接运行 src 源码而非静态截图,改动组件源码后文档即时热更新。
|
||||
- title: 手机视口呈现
|
||||
details: 演示统一放在移动端宽度的视口里,避免移动组件被桌面宽度拉伸失真。
|
||||
- title: API 自动生成
|
||||
details: 由 vue-docgen-api 在构建期解析组件源码提取 props、events、slots 与 expose,文档与代码不会脱节。
|
||||
---
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "timi-tdesign-mobile-docs",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"description": "timi-tdesign-mobile 组件演示与 API 文档站",
|
||||
"scripts": {
|
||||
"dev": "vitepress dev .",
|
||||
"build": "vitepress build .",
|
||||
"preview": "vitepress preview ."
|
||||
},
|
||||
"dependencies": {
|
||||
"tdesign-mobile-vue": "^1.13.2",
|
||||
"timi-web": "link:E:/WebProject/timi-web",
|
||||
"vue": "^3.5.26"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.8",
|
||||
"@vitepress-demo-preview/plugin": "^1.4.1",
|
||||
"less": "^4.5.1",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"vitepress": "^1.6.4",
|
||||
"vue-docgen-api": "^4.79.2"
|
||||
}
|
||||
}
|
||||
Generated
+2845
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
# AI 对话
|
||||
|
||||
把对话面板嵌进带标题栏的页面骨架,并自定义欢迎语与角色名。
|
||||
|
||||
## 组合演示
|
||||
|
||||
:::preview 完整对话 || 回答由本地假流逐块吐出,可随时点「停止」或用「清空」重开
|
||||
|
||||
demo-preview=../demos/scenarios/ai.vue
|
||||
|
||||
:::
|
||||
|
||||
## 这个场景覆盖了什么
|
||||
|
||||
- **会话接线**:`useAiChat` 提供 `messages` 与生成状态,面板只负责渲染
|
||||
- **页面骨架**:标题栏与面板组合成完整页面,`清空` 调用 `resetConversation`
|
||||
- **自定义文案**:`roleNames` 把「助手」换成「分析助手」,`welcome` 插槽替换空态
|
||||
- **流式呈现**:思考过程、工具状态、回答与引用按事件依次出现
|
||||
|
||||
## 换成真实后端
|
||||
|
||||
示例里的 `request` 返回的是本地构造的假流。接真实接口时只要返回一个 SSE 流式的 `Response` 即可,其余不用改:
|
||||
|
||||
```ts
|
||||
const chat = useAiChat({
|
||||
request: ({ message, signal }) => fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message }),
|
||||
signal
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
事件名与载荷字段见 [AiChatPanel](/components/ai-chat-panel) 的协议表;后端用的是另一套事件名时,用 `parseEvent` 转换。
|
||||
|
||||
## 相关组件
|
||||
|
||||
- [AiChatPanel](/components/ai-chat-panel)
|
||||
- [AiChatMessageList](/components/ai-chat-message-list)
|
||||
- [AiChatComposer](/components/ai-chat-composer)
|
||||
- [AiChatThinking](/components/ai-chat-thinking)
|
||||
@@ -0,0 +1,27 @@
|
||||
# 文章编辑与预览
|
||||
|
||||
编辑与预览共用同一个 `Article` 对象,用标签在两种视图之间切换。
|
||||
|
||||
## 组合演示
|
||||
|
||||
:::preview 编辑与预览联动 || 切换正文类型时两个标签页同时切换渲染分支
|
||||
|
||||
demo-preview=../demos/scenarios/article.vue
|
||||
|
||||
:::
|
||||
|
||||
## 这个场景覆盖了什么
|
||||
|
||||
- **正文类型切换**:纯文本、Markdown、富文本三种形态共用一份数据
|
||||
- **数据联动**:`ArticleEditor` 抛出的更新直接喂给 `ArticleView`
|
||||
- **字段保留**:`title` 等业务字段由外层维护,编辑器不会丢字段
|
||||
- **提交语义**:`submit()` 等待未完成的媒体上传后再返回最终数据
|
||||
|
||||
## 为什么用标签而不是分栏
|
||||
|
||||
PC 端可以左右并排,移动端宽度不够。用标签切换既保证了对比的便利,又不牺牲编辑区的可用宽度。
|
||||
|
||||
## 相关组件
|
||||
|
||||
- [ArticleEditor](/components/article-editor)
|
||||
- [ArticleView](/components/article-view)
|
||||
@@ -0,0 +1,24 @@
|
||||
# 编辑器能力总览
|
||||
|
||||
把富文本编辑器、只读视图与代码编辑器放在一个页面里,验证它们共用同一套内容模型与检索能力。
|
||||
|
||||
## 组合演示
|
||||
|
||||
:::preview 组合用法 || 标签切换而不是左右分栏——移动端宽度放不下分栏
|
||||
|
||||
demo-preview=../demos/scenarios/editor.vue
|
||||
|
||||
:::
|
||||
|
||||
## 这个场景覆盖了什么
|
||||
|
||||
- **富文本编辑**:工具栏、媒体上传、提交前的占位内容处理
|
||||
- **只读渲染**:同一份 HTML 交给 `TiptapView`,展示效果与编辑态一致
|
||||
- **代码编辑**:行号、语法高亮与查找替换
|
||||
- **统一检索**:`openSearch()` 在两类编辑器上语义一致,便于封装统一的快捷键
|
||||
|
||||
## 相关组件
|
||||
|
||||
- [TiptapEditor](/components/tiptap-editor)
|
||||
- [TiptapView](/components/tiptap-view)
|
||||
- [CodeEditor](/components/code-editor)
|
||||
@@ -0,0 +1,27 @@
|
||||
# 移动端组件
|
||||
|
||||
移动端最典型的两种浮现方式:整块选择面板与操作确认,都从底部升起。
|
||||
|
||||
## 组合演示
|
||||
|
||||
:::preview 组合用法 || 点击「缓存有效期」行打开 Picker,点按钮打开底部弹层
|
||||
|
||||
demo-preview=../demos/scenarios/mobile.vue
|
||||
|
||||
:::
|
||||
|
||||
## 这个场景覆盖了什么
|
||||
|
||||
- **列表触发的选择面板**:`t-cell` 当前值 + 箭头,点击后用 `t-popup` 从底部升起 `DurationPicker`
|
||||
- **操作确认**:`BottomPopup` 承载标题与左右按钮,按钮只抛事件、关闭由业务决定
|
||||
- **间距**:`Space` 统一控制一组元素之间的间距
|
||||
|
||||
## 关于 destroy-on-close
|
||||
|
||||
示例里的 Picker 弹层加了 `destroy-on-close`,每次打开都重建,避免上次的选择残留。选择面板这类有内部滚动状态的组件建议都带上。
|
||||
|
||||
## 相关组件
|
||||
|
||||
- [DurationPicker](/components/duration-picker)
|
||||
- [BottomPopup](/components/bottom-popup)
|
||||
- [Space](/components/space)
|
||||
@@ -0,0 +1,22 @@
|
||||
# 通知编辑与预览
|
||||
|
||||
通知复用文章编辑器的正文能力,同时保留主题、状态等自有字段。
|
||||
|
||||
## 组合演示
|
||||
|
||||
:::preview 编辑与预览联动 || 状态字段由外层维护,切状态不影响正文
|
||||
|
||||
demo-preview=../demos/scenarios/notify.vue
|
||||
|
||||
:::
|
||||
|
||||
## 这个场景覆盖了什么
|
||||
|
||||
- **能力复用**:`NotifyEditor` 内部就是 `ArticleEditor`,正文体验一致
|
||||
- **元数据独立**:`subject`、`status` 等字段与正文互不干扰
|
||||
- **字段透传**:业务字段在编辑过程中原样保留
|
||||
|
||||
## 相关组件
|
||||
|
||||
- [NotifyEditor](/components/notify-editor)
|
||||
- [NotifyView](/components/notify-view)
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"timi-tdesign-mobile": [
|
||||
"../src/index.ts"
|
||||
],
|
||||
"timi-web/editor": [
|
||||
"../../timi-web/src/editor/index.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.d.ts",
|
||||
"./**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".vitepress/cache",
|
||||
".vitepress/dist"
|
||||
]
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
<template>
|
||||
<div class="demo-app">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">timi-tdesign-mobile</p>
|
||||
<h1>移动端组件演示台</h1>
|
||||
<p>每个页面都使用真实公开组件和可重复的本地演示数据。</p>
|
||||
</div>
|
||||
<t-tag theme="primary" variant="light">TDesign Mobile</t-tag>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" aria-label="演示分类">
|
||||
<t-button
|
||||
v-for="item in tabs"
|
||||
:key="item.value"
|
||||
size="small"
|
||||
:theme="activeTab === item.value ? 'primary' : 'default'"
|
||||
variant="outline"
|
||||
@click="activeTab = item.value"
|
||||
v-text="item.label"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<editor-showcase v-if="activeTab === 'editor'" />
|
||||
<article-showcase v-else-if="activeTab === 'article'" />
|
||||
<notify-showcase v-else-if="activeTab === 'notify'" />
|
||||
<duration-showcase v-else />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ArticleShowcase from "./components/ArticleShowcase.vue";
|
||||
import DurationShowcase from "./components/DurationShowcase.vue";
|
||||
import EditorShowcase from "./components/EditorShowcase.vue";
|
||||
import NotifyShowcase from "./components/NotifyShowcase.vue";
|
||||
|
||||
type TabValue = "editor" | "article" | "notify" | "duration";
|
||||
|
||||
const tabs: Array<{ label: string; value: TabValue }> = [
|
||||
{ label: "编辑器", value: "editor" },
|
||||
{ label: "文章", value: "article" },
|
||||
{ label: "通知", value: "notify" },
|
||||
{ label: "移动端组件", value: "duration" }
|
||||
];
|
||||
const activeTab = ref<TabValue>("editor");
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo-app {
|
||||
width: min(100% - 2rem, 80rem);
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 4rem;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h1 {
|
||||
margin: .25rem 0;
|
||||
font-size: clamp(1.75rem, 6vw, 3rem);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: .75rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: .25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.demo-app {
|
||||
width: min(100% - 1rem, 80rem);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,177 +0,0 @@
|
||||
<template>
|
||||
<section class="demo-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">文章场景</p>
|
||||
<h2>文章编辑与预览</h2>
|
||||
<p>切换正文类型,验证统一的 Article 数据结构。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
v-for="item in contentTypeOptions"
|
||||
:key="item.value"
|
||||
size="small"
|
||||
:theme="articleValue.contentType === item.value ? 'primary' : 'default'"
|
||||
variant="outline"
|
||||
@click="selectContentType(item.value)"
|
||||
v-text="item.label"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-grid">
|
||||
<article class="demo-card">
|
||||
<div class="card-heading">
|
||||
<h3>ArticleEditor</h3>
|
||||
<t-button size="small" theme="primary" @click="submitArticle">提交文章</t-button>
|
||||
</div>
|
||||
<t-input v-model="articleValue.title" class="title-input" placeholder="文章标题" />
|
||||
<article-editor
|
||||
ref="articleEditorRef"
|
||||
:model-value="articleValue"
|
||||
:upload-image="uploadDemoMedia"
|
||||
:upload-video="uploadDemoMedia"
|
||||
@change="onArticleChange"
|
||||
@update:model-value="onArticleChange"
|
||||
/>
|
||||
<p class="status" v-text="articleStatus" />
|
||||
</article>
|
||||
|
||||
<article class="demo-card">
|
||||
<div class="card-heading">
|
||||
<h3>ArticleView</h3>
|
||||
<span v-text="articleValue.contentType" />
|
||||
</div>
|
||||
<article-view :article="articleValue" />
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ArticleEditor,
|
||||
ArticleView
|
||||
} from "timi-tdesign-mobile";
|
||||
import type { ArticleEditorExpose } from "timi-tdesign-mobile";
|
||||
import type { Article, ArticleContentType } from "timi-tdesign-mobile";
|
||||
import { DEMO_ARTICLE_CONTENT, cloneArticle, uploadDemoMedia } from "../demo-data";
|
||||
|
||||
const contentTypeOptions: Array<{ label: string; value: ArticleContentType }> = [
|
||||
{ label: "纯文本", value: "TEXT" },
|
||||
{ label: "Markdown", value: "MARKDOWN" },
|
||||
{ label: "Tiptap", value: "TIP_TAP" }
|
||||
];
|
||||
const articleValue = ref<Article>(cloneArticle());
|
||||
const articleEditorRef = ref<ArticleEditorExpose>();
|
||||
const articleStatus = ref("等待提交");
|
||||
|
||||
function selectContentType(contentType: ArticleContentType) {
|
||||
const content = contentType === "TEXT"
|
||||
? "这是一段纯文本示例。"
|
||||
: contentType === "MARKDOWN"
|
||||
? "## Markdown 示例\n\n这是一段 Markdown 正文。"
|
||||
: DEMO_ARTICLE_CONTENT;
|
||||
articleValue.value = {
|
||||
...articleValue.value,
|
||||
contentType,
|
||||
content
|
||||
};
|
||||
}
|
||||
|
||||
function onArticleChange(value: Article) {
|
||||
articleValue.value = value;
|
||||
}
|
||||
|
||||
async function submitArticle() {
|
||||
const value = await articleEditorRef.value?.submit();
|
||||
if (value) {
|
||||
articleValue.value = value;
|
||||
articleStatus.value = `已提交,${value.attachmentList?.length || 0} 个附件`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo-section {
|
||||
.section-heading {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: .75rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.actions,
|
||||
.card-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .75rem;
|
||||
background: var(--td-bg-color-container);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span,
|
||||
.status {
|
||||
font-size: .75rem;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.title-input {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.section-heading,
|
||||
.card-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<section class="demo-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">移动端专属</p>
|
||||
<h2>DurationPicker</h2>
|
||||
<p>验证 Picker 交互和毫秒值双向转换。</p>
|
||||
</div>
|
||||
<span class="value" v-text="`${durationMs} ms`" />
|
||||
</div>
|
||||
<div class="demo-card">
|
||||
<t-cell title="当前毫秒值" arrow @click="visible = true">
|
||||
<span v-text="formatDuration(durationMs)" />
|
||||
</t-cell>
|
||||
</div>
|
||||
<t-popup v-model="visible" placement="bottom" destroy-on-close>
|
||||
<div class="picker-panel">
|
||||
<duration-picker v-model="durationMs" @change="visible = false" />
|
||||
</div>
|
||||
</t-popup>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { DurationPicker } from "timi-tdesign-mobile";
|
||||
|
||||
const visible = ref(false);
|
||||
const durationMs = ref(1000);
|
||||
|
||||
function formatDuration(value: number) {
|
||||
if (60 * 60 * 1000 <= value && value % (60 * 60 * 1000) === 0) {
|
||||
return `${value / (60 * 60 * 1000)} 小时`;
|
||||
}
|
||||
if (60 * 1000 <= value && value % (60 * 1000) === 0) {
|
||||
return `${value / (60 * 1000)} 分钟`;
|
||||
}
|
||||
if (1000 <= value && value % 1000 === 0) {
|
||||
return `${value / 1000} 秒`;
|
||||
}
|
||||
return `${value} 毫秒`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo-section {
|
||||
.section-heading {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: .75rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
padding: .25rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .75rem;
|
||||
background: var(--td-bg-color-container);
|
||||
}
|
||||
|
||||
.picker-panel {
|
||||
padding-top: .5rem;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
background: var(--td-bg-color-container);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,186 +0,0 @@
|
||||
<template>
|
||||
<section class="demo-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">编辑器基础能力</p>
|
||||
<h2>文本、富文本和代码</h2>
|
||||
<p>通过同一组公共数据规则验证移动端 TDesign 交互。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<t-button size="small" theme="primary" @click="openSearch">打开搜索</t-button>
|
||||
<t-button size="small" variant="outline" @click="submitEditor">提交富文本</t-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-grid">
|
||||
<article class="demo-card wide">
|
||||
<div class="card-heading">
|
||||
<h3>Tiptap 编辑器</h3>
|
||||
<span v-text="editorStatus" />
|
||||
</div>
|
||||
<div class="editor-box rich-editor">
|
||||
<tiptap-editor
|
||||
ref="tiptapEditorRef"
|
||||
v-model="richContent"
|
||||
:upload-image="uploadDemoMedia"
|
||||
:upload-video="uploadDemoMedia"
|
||||
@change="editorStatus = `内容已更新,${$event.length} 个字符`"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="demo-card wide">
|
||||
<div class="card-heading">
|
||||
<h3>Tiptap 只读视图</h3>
|
||||
<t-input v-model="maxHeight" class="height-input" placeholder="最大高度,如 20rem" />
|
||||
</div>
|
||||
<tiptap-view :content="richContent" :max-height="maxHeight" />
|
||||
</article>
|
||||
|
||||
<article class="demo-card wide">
|
||||
<div class="card-heading">
|
||||
<h3>代码编辑器</h3>
|
||||
<t-button size="small" variant="text" @click="resetCode">恢复示例</t-button>
|
||||
</div>
|
||||
<div class="editor-box code-editor">
|
||||
<code-editor
|
||||
ref="codeEditorRef"
|
||||
v-model="codeContent"
|
||||
language="typescript"
|
||||
placeholder="请输入代码"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
CodeEditor,
|
||||
TiptapEditor,
|
||||
TiptapView
|
||||
} from "timi-tdesign-mobile";
|
||||
import type {
|
||||
CodeEditorExpose,
|
||||
TiptapEditorExpose
|
||||
} from "timi-tdesign-mobile";
|
||||
import { DEMO_ARTICLE_CONTENT, DEMO_CODE, uploadDemoMedia } from "../demo-data";
|
||||
|
||||
const richContent = ref(DEMO_ARTICLE_CONTENT);
|
||||
const codeContent = ref(DEMO_CODE);
|
||||
const maxHeight = ref("20rem");
|
||||
const editorStatus = ref("等待编辑");
|
||||
const tiptapEditorRef = ref<TiptapEditorExpose>();
|
||||
const codeEditorRef = ref<CodeEditorExpose>();
|
||||
|
||||
function openSearch() {
|
||||
tiptapEditorRef.value?.openSearch(true);
|
||||
codeEditorRef.value?.openSearch(true);
|
||||
}
|
||||
|
||||
async function submitEditor() {
|
||||
const value = await tiptapEditorRef.value?.submit();
|
||||
if (value !== undefined) {
|
||||
editorStatus.value = `已提交,${value.length} 个字符`;
|
||||
}
|
||||
}
|
||||
|
||||
function resetCode() {
|
||||
codeContent.value = DEMO_CODE;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo-section {
|
||||
.section-heading {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: .75rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.actions,
|
||||
.card-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .75rem;
|
||||
background: var(--td-bg-color-container);
|
||||
box-shadow: 0 .25rem 1rem rgba(0, 0, 0, .04);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: .75rem;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 24rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
height: 28rem;
|
||||
}
|
||||
|
||||
.height-input {
|
||||
width: 9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.section-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.height-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,137 +0,0 @@
|
||||
<template>
|
||||
<section class="demo-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow">通知场景</p>
|
||||
<h2>通知编辑与预览</h2>
|
||||
<p>Notify 组件复用文章编辑器,同时保留通知自己的元数据。</p>
|
||||
</div>
|
||||
<t-button size="small" theme="primary" @click="submitNotify">提交通知</t-button>
|
||||
</div>
|
||||
|
||||
<div class="demo-grid">
|
||||
<article class="demo-card">
|
||||
<div class="card-heading">
|
||||
<h3>NotifyEditor</h3>
|
||||
<span v-text="notifyStatus" />
|
||||
</div>
|
||||
<t-input v-model="notifyValue.subject" class="subject-input" placeholder="通知标题" />
|
||||
<notify-editor
|
||||
ref="notifyEditorRef"
|
||||
:model-value="notifyValue"
|
||||
:upload-image="uploadDemoMedia"
|
||||
:upload-video="uploadDemoMedia"
|
||||
@change="onNotifyChange"
|
||||
@update:model-value="onNotifyChange"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="demo-card">
|
||||
<div class="card-heading">
|
||||
<h3>NotifyView</h3>
|
||||
<span v-text="notifyValue.status" />
|
||||
</div>
|
||||
<notify-view :detail="notifyValue" />
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
NotifyEditor,
|
||||
NotifyView
|
||||
} from "timi-tdesign-mobile";
|
||||
import type { NotifyEditorExpose } from "timi-tdesign-mobile";
|
||||
import type { NotifyDetail } from "timi-tdesign-mobile";
|
||||
import { cloneNotify, uploadDemoMedia } from "../demo-data";
|
||||
|
||||
const notifyValue = ref<NotifyDetail>(cloneNotify());
|
||||
const notifyEditorRef = ref<NotifyEditorExpose>();
|
||||
const notifyStatus = ref("等待提交");
|
||||
|
||||
function onNotifyChange(value: NotifyDetail) {
|
||||
notifyValue.value = value;
|
||||
}
|
||||
|
||||
async function submitNotify() {
|
||||
const value = await notifyEditorRef.value?.submit();
|
||||
if (value) {
|
||||
notifyValue.value = value;
|
||||
notifyStatus.value = `已提交,${value.content?.length || 0} 个字符`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo-section {
|
||||
.section-heading {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h2 {
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: .75rem;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
justify-content: space-between;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: .75rem;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: .75rem;
|
||||
background: var(--td-bg-color-container);
|
||||
}
|
||||
|
||||
.subject-input {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.section-heading,
|
||||
.card-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { Article, NotifyDetail, UploadResult } from "timi-tdesign-mobile";
|
||||
|
||||
/** 编辑器演示使用的固定内容,避免每个页面各自维护一份 HTML。 */
|
||||
export const DEMO_ARTICLE_CONTENT = `<h2>跨平台编辑器</h2>
|
||||
<p>这段内容同时验证标题、段落、链接、表格和代码块的渲染。</p>
|
||||
<blockquote><p>移动端和 PC 端只替换交互层,共享文档模型。</p></blockquote>
|
||||
<pre><code class="language-typescript">const message: string = "timi editor";</code></pre>`;
|
||||
|
||||
export const DEMO_CODE = `type DemoUser = {
|
||||
\tid: string;
|
||||
\tname: string;
|
||||
};
|
||||
|
||||
const user: DemoUser = {
|
||||
\tid: "demo-001",
|
||||
\tname: "Timi"
|
||||
};
|
||||
|
||||
console.log(user.name);`;
|
||||
|
||||
export const DEMO_ARTICLE: Article = {
|
||||
title: "编辑器组件演示文章",
|
||||
description: "用于验证文章编辑、预览和附件处理。",
|
||||
contentType: "TIP_TAP",
|
||||
content: DEMO_ARTICLE_CONTENT,
|
||||
attachmentList: []
|
||||
};
|
||||
|
||||
export const DEMO_NOTIFY: NotifyDetail = {
|
||||
subject: "编辑器通知演示",
|
||||
msgType: "INTERNAL",
|
||||
contentType: "TIP_TAP",
|
||||
content: DEMO_ARTICLE_CONTENT,
|
||||
status: "WAITING",
|
||||
attachmentList: []
|
||||
};
|
||||
|
||||
export function cloneArticle(): Article {
|
||||
return {
|
||||
...DEMO_ARTICLE,
|
||||
attachmentList: [...(DEMO_ARTICLE.attachmentList || [])]
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneNotify(): NotifyDetail {
|
||||
return {
|
||||
...DEMO_NOTIFY,
|
||||
attachmentList: [...(DEMO_NOTIFY.attachmentList || [])]
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回本地预览地址,让示例无需依赖后端上传接口。 */
|
||||
export async function uploadDemoMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { createApp } from "vue";
|
||||
import Root from "./Root.vue";
|
||||
|
||||
import TimiWebUI from "timi-web";
|
||||
|
||||
import "timi-web/style.css";
|
||||
import "tdesign-mobile-vue/es/style/index.css";
|
||||
import "../src/assets/style/tdesign-custom.less";
|
||||
|
||||
const app = createApp(Root);
|
||||
app.use(TimiWebUI);
|
||||
app.mount("#root");
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f5f5f5"/>
|
||||
<meta name="color-scheme" content="light dark"/>
|
||||
<meta name="format-detection" content="telephone=no"/>
|
||||
<meta name="mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default"/>
|
||||
<meta name="apple-mobile-web-app-title" content="Timi Mobile"/>
|
||||
<title>Timi TDesign Mobile</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/examples/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+10
-7
@@ -8,10 +8,10 @@
|
||||
"version": "0.0.10",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:doc": "pnpm run -C docs dev",
|
||||
"dev": "pnpm run -C docs dev",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"build:doc": "pnpm run -C docs build",
|
||||
"preview:doc": "pnpm run -C docs preview",
|
||||
"pub": "pnpm run build && npm publish --registry=https://nexus.imyeyu.com/repository/npm-timi/"
|
||||
},
|
||||
"publishConfig": {
|
||||
@@ -20,7 +20,6 @@
|
||||
"files": [
|
||||
"dist/**",
|
||||
"src/**",
|
||||
"examples/**",
|
||||
"README.md",
|
||||
"package.json"
|
||||
],
|
||||
@@ -37,9 +36,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"tdesign-mobile-vue": "^1.13.2",
|
||||
"timi-web": "link:E:/WebProject/timi-web",
|
||||
"vue": "3.5.26",
|
||||
"vue-router": "4.5.0"
|
||||
"timi-web": "link:E:/WebProject/timi-web"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.5",
|
||||
@@ -64,7 +65,9 @@
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-plugin-vue-setup-extend": "^0.4.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"vue": "3.5.26",
|
||||
"vue-router": "4.5.0",
|
||||
"vite": "7.1.11",
|
||||
"vue-tsc": "^2.2.10"
|
||||
"vue-tsc": "^2.2.12"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+38
-17
@@ -14,12 +14,6 @@ importers:
|
||||
timi-web:
|
||||
specifier: link:E:/WebProject/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:
|
||||
'@types/node':
|
||||
specifier: ^22.13.5
|
||||
@@ -90,9 +84,15 @@ importers:
|
||||
vite-plugin-vue-setup-extend:
|
||||
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))
|
||||
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:
|
||||
specifier: ^2.2.10
|
||||
version: 2.2.10(typescript@5.8.3)
|
||||
specifier: ^2.2.12
|
||||
version: 2.2.12(typescript@5.8.3)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1309,12 +1309,21 @@ packages:
|
||||
'@volar/language-core@2.4.12':
|
||||
resolution: {integrity: sha512-RLrFdXEaQBWfSnYGVxvR2WrO6Bub0unkdHYIdC31HzIEqATIuuhRRzYu76iGPZ6OtA4Au1SnW0ZwIqPP217YhA==}
|
||||
|
||||
'@volar/language-core@2.4.15':
|
||||
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
||||
|
||||
'@volar/source-map@2.4.12':
|
||||
resolution: {integrity: sha512-bUFIKvn2U0AWojOaqf63ER0N/iHIBYZPpNGogfLPQ68F5Eet6FnLlyho7BS0y2HJ1jFhSif7AcuTx1TqsCzRzw==}
|
||||
|
||||
'@volar/source-map@2.4.15':
|
||||
resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
|
||||
|
||||
'@volar/typescript@2.4.12':
|
||||
resolution: {integrity: sha512-HJB73OTJDgPc80K30wxi3if4fSsZZAOScbj2fcicMuOPoOkcf9NNAINb33o+DzhBdF9xTKC1gnPmIRDous5S0g==}
|
||||
|
||||
'@volar/typescript@2.4.15':
|
||||
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
|
||||
|
||||
'@vue/babel-helper-vue-jsx-merge-props@1.4.0':
|
||||
resolution: {integrity: sha512-JkqXfCkUDp4PIlFdDQ0TdXoIejMtTHP67/pvxlgeY+u5k3LEdKuWZ3LK6xkxo52uDoABIVyRwqVkfLQJhk7VBA==}
|
||||
|
||||
@@ -1512,8 +1521,8 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@vue/language-core@2.2.10':
|
||||
resolution: {integrity: sha512-+yNoYx6XIKuAO8Mqh1vGytu8jkFEOH5C8iOv3i8Z/65A7x9iAOXA97Q+PqZ3nlm2lxf5rOJuIGI/wDtx/riNYw==}
|
||||
'@vue/language-core@2.2.12':
|
||||
resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
@@ -4772,8 +4781,8 @@ packages:
|
||||
vue-template-es2015-compiler@1.9.1:
|
||||
resolution: {integrity: sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==}
|
||||
|
||||
vue-tsc@2.2.10:
|
||||
resolution: {integrity: sha512-jWZ1xSaNbabEV3whpIDMbjVSVawjAyW+x1n3JeGQo7S0uv2n9F/JMgWW90tGWNFRKya4YwKMZgCtr0vRAM7DeQ==}
|
||||
vue-tsc@2.2.12:
|
||||
resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
@@ -6272,14 +6281,26 @@ snapshots:
|
||||
dependencies:
|
||||
'@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.15': {}
|
||||
|
||||
'@volar/typescript@2.4.12':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.12
|
||||
path-browserify: 1.0.1
|
||||
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-transform-on@1.3.0': {}
|
||||
@@ -6779,9 +6800,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
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:
|
||||
'@volar/language-core': 2.4.12
|
||||
'@volar/language-core': 2.4.15
|
||||
'@vue/compiler-dom': 3.5.41
|
||||
'@vue/compiler-vue2': 2.7.16
|
||||
'@vue/shared': 3.5.41
|
||||
@@ -10075,10 +10096,10 @@ snapshots:
|
||||
|
||||
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:
|
||||
'@volar/typescript': 2.4.12
|
||||
'@vue/language-core': 2.2.10(typescript@5.8.3)
|
||||
'@volar/typescript': 2.4.15
|
||||
'@vue/language-core': 2.2.12(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
|
||||
vue@3.5.26(typescript@5.8.3):
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -8,6 +8,25 @@
|
||||
-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-swiper-item {
|
||||
@@ -18,18 +37,36 @@
|
||||
.t-form {
|
||||
.cellStyle();
|
||||
|
||||
&.card {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.t-form__item {
|
||||
padding: @cellPadding;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
.cellSplit();
|
||||
}
|
||||
|
||||
.t-form__label {
|
||||
color: var(--td-gray-color-9);
|
||||
flex: none;
|
||||
min-width: unset;
|
||||
white-space: nowrap;
|
||||
text-overflow: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.t-cell {
|
||||
padding: @cellPadding;
|
||||
|
||||
.t-cell__title,
|
||||
.t-cell__title-text {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&:after {
|
||||
.cellSplit();
|
||||
}
|
||||
@@ -39,6 +76,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cell-title-clip {
|
||||
|
||||
.t-cell__title {
|
||||
flex: 1 1 0;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.t-divider {
|
||||
|
||||
&::before,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 v-if="hasHeader" class="header">
|
||||
<div class="button left">
|
||||
@@ -40,6 +40,8 @@ export interface BottomPopupProps {
|
||||
rightButtonText?: string;
|
||||
height?: string | number;
|
||||
title?: string;
|
||||
zIndex?: number;
|
||||
overlayProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BottomPopupSlots {
|
||||
@@ -54,7 +56,9 @@ const props = withDefaults(defineProps<BottomPopupProps>(), {
|
||||
leftButtonText: "取消",
|
||||
rightButtonText: "确定",
|
||||
height: "auto",
|
||||
title: ""
|
||||
title: "",
|
||||
zIndex: undefined,
|
||||
overlayProps: undefined
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -115,8 +119,8 @@ defineSlots<BottomPopupSlots>();
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
margin-top: -.25rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
@@ -124,7 +128,7 @@ defineSlots<BottomPopupSlots>();
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: .75rem 0 env(safe-area-inset-bottom, 0);
|
||||
padding: 0 0 env(safe-area-inset-bottom, 0);
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
/** CardContainer 组件属性。 */
|
||||
export interface CardContainerProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tips?: string;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
border-radius: var(--td-radius-medium, 0);
|
||||
border-radius: var(--tui-radius-small, 0);
|
||||
|
||||
.gutter {
|
||||
flex: none;
|
||||
@@ -19,7 +19,6 @@
|
||||
width: 3.25rem;
|
||||
height: 1.5rem;
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||
line-height: 1.5rem;
|
||||
padding-right: .25rem;
|
||||
@@ -83,7 +82,6 @@
|
||||
.search-status {
|
||||
color: var(--td-text-color-secondary, #666);
|
||||
white-space: nowrap;
|
||||
font-size: .875rem;
|
||||
}
|
||||
|
||||
.preview,
|
||||
@@ -97,7 +95,6 @@
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
line-height: 1.5rem;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||
}
|
||||
@@ -123,7 +120,6 @@
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
text-shadow: none;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
+63
-2
@@ -1,5 +1,6 @@
|
||||
/** 导出所有组件 */
|
||||
import RootLayout from "./root-layout";
|
||||
import PageTransition from "./page-transition";
|
||||
import DurationPicker from "./duration-picker";
|
||||
import CodeEditor from "./code-editor";
|
||||
import TiptapEditor from "./tiptap-editor";
|
||||
@@ -10,9 +11,17 @@ import NotifyEditor from "./notify-editor";
|
||||
import NotifyView from "./notify-view";
|
||||
import BottomPopup from "./bottom-popup";
|
||||
import Space from "./space";
|
||||
import CardContainer from "./card-container";
|
||||
import {
|
||||
AiChatComposer,
|
||||
AiChatMessageList,
|
||||
AiChatPanel,
|
||||
AiChatThinking
|
||||
} from "./ai";
|
||||
|
||||
export default [
|
||||
RootLayout,
|
||||
PageTransition,
|
||||
DurationPicker,
|
||||
CodeEditor,
|
||||
TiptapEditor,
|
||||
@@ -22,11 +31,17 @@ export default [
|
||||
NotifyEditor,
|
||||
NotifyView,
|
||||
BottomPopup,
|
||||
Space
|
||||
Space,
|
||||
CardContainer,
|
||||
AiChatComposer,
|
||||
AiChatMessageList,
|
||||
AiChatPanel,
|
||||
AiChatThinking
|
||||
];
|
||||
|
||||
export {
|
||||
RootLayout,
|
||||
PageTransition,
|
||||
DurationPicker,
|
||||
CodeEditor,
|
||||
TiptapEditor,
|
||||
@@ -36,9 +51,40 @@ export {
|
||||
NotifyEditor,
|
||||
NotifyView,
|
||||
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 {
|
||||
SpaceAlign,
|
||||
SpaceDirection,
|
||||
@@ -48,6 +94,21 @@ export type {
|
||||
SpaceSlots
|
||||
} from "./space";
|
||||
|
||||
export type {
|
||||
CardContainerProps
|
||||
} from "./card-container";
|
||||
|
||||
export type {
|
||||
PageTransitionBackground,
|
||||
PageTransitionClass,
|
||||
PageTransitionKeepAliveMatcher,
|
||||
PageTransitionNameResolver,
|
||||
PageTransitionProps,
|
||||
PageTransitionRoute,
|
||||
PageTransitionRouteDepthResolver,
|
||||
PageTransitionRouteKeyResolver
|
||||
} from "./page-transition";
|
||||
|
||||
export type {
|
||||
CodeEditorExpose
|
||||
} from "./code-editor";
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
</template>
|
||||
|
||||
<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 { SpaceProps, SpaceSlots, SpaceSize } from "./type";
|
||||
|
||||
@@ -76,15 +76,9 @@ const props = defineProps({
|
||||
|
||||
const slots = useSlots() as SpaceSlots;
|
||||
|
||||
let children = flattenChildren(slots.default?.() ?? []);
|
||||
let separatorContent = resolveSeparatorContent();
|
||||
let hasSeparator = hasSeparatorContent(separatorContent);
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
children = flattenChildren(slots.default?.() ?? []);
|
||||
separatorContent = resolveSeparatorContent();
|
||||
hasSeparator = hasSeparatorContent(separatorContent);
|
||||
});
|
||||
const children = computed(() => flattenChildren(slots.default?.() ?? []));
|
||||
const separatorContent = computed(() => resolveSeparatorContent());
|
||||
const hasSeparator = computed(() => hasSeparatorContent(separatorContent.value));
|
||||
|
||||
function resolveSeparatorContent(): VNodeChild | undefined {
|
||||
if (props.separator !== undefined) {
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
import type { PickerValue } from "tdesign-mobile-vue";
|
||||
import { supportedCodeLanguages } from "timi-web/editor";
|
||||
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||
import BottomPopup from "../bottom-popup/index.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "TiptapCodeBlockButton"
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
default as VEntranceTransition
|
||||
} from "./entrance-transition";
|
||||
|
||||
export type {
|
||||
EntranceTransitionValue
|
||||
} from "./entrance-transition";
|
||||
@@ -1,9 +1,11 @@
|
||||
import "./assets/style/tdesign-custom.less";
|
||||
import "./assets/style/entrance-transition.less";
|
||||
import "./components/tiptap-content.less";
|
||||
import "timi-web/style.css";
|
||||
import "../../timi-web/src/components/prism-code-block.less";
|
||||
|
||||
export * from "./components";
|
||||
export * from "./directives";
|
||||
|
||||
export {
|
||||
};
|
||||
|
||||
Vendored
+10
@@ -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
Reference in New Issue
Block a user