Compare commits
14
Commits
f14ebcfc85
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72ed15ae34 | ||
|
|
393bc8aa1b | ||
|
|
b09011251a | ||
|
|
a20e051e8a | ||
|
|
2b92012c39 | ||
|
|
0a4425e11f | ||
|
|
e8b9a21b82 | ||
|
|
aeea93c0bb | ||
|
|
b54c07fe49 | ||
|
|
45f77d3168 | ||
|
|
37cf38fe27 | ||
|
|
b435bf992e | ||
|
|
3d2968cb3b | ||
|
|
fc0348aeae |
+6
-1
@@ -1,9 +1,14 @@
|
|||||||
/.eslintrc-auto-import.json
|
/.eslintrc-auto-import.json
|
||||||
/components.d.ts
|
/components.d.ts
|
||||||
/examples/auto-imports.d.ts
|
/auto-imports.d.ts
|
||||||
/AGENTS.md
|
/AGENTS.md
|
||||||
/.npmrc
|
/.npmrc
|
||||||
|
|
||||||
|
# 文档站
|
||||||
|
/docs/.vitepress/cache
|
||||||
|
/docs/.vitepress/dist
|
||||||
|
/docs/.npmrc
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -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` 回调。没有附件上传需求时,可以不传上传回调。
|
通知组件使用同样的 `uploadImage`、`uploadVideo` 和 `resolveAttachmentUrl` 回调。没有附件上传需求时,可以不传上传回调。
|
||||||
|
|
||||||
|
## 文档与演示
|
||||||
|
|
||||||
|
组件演示与 API 文档由 VitePress 驱动,源码位于 `docs/`。它作为独立子包维护,依赖版本与根项目隔离(根项目用 Vite 7,文档站需要 VitePress 自带的 Vite 5)。
|
||||||
|
|
||||||
|
首次使用先安装文档站依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -C docs install
|
||||||
|
```
|
||||||
|
|
||||||
|
启动演示站(默认 http://localhost:3004):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
构建静态站点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run build:doc
|
||||||
|
```
|
||||||
|
|
||||||
|
演示直接引用 `src` 源码,改动组件即可热更新;API 表格由 `vue-docgen-api` 在构建期解析组件源码生成,无需手写。演示统一渲染在手机宽度的视口里——这是移动端组件库,按桌面宽度铺开会失去真实形态。新增组件的完整步骤见文档站的「演示与文档约定」页面。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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",
|
"version": "0.0.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "pnpm run -C docs dev",
|
||||||
"dev:doc": "pnpm run -C docs dev",
|
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"build:doc": "pnpm run -C docs build",
|
"build:doc": "pnpm run -C docs build",
|
||||||
|
"preview:doc": "pnpm run -C docs preview",
|
||||||
"pub": "pnpm run build && npm publish --registry=https://nexus.imyeyu.com/repository/npm-timi/"
|
"pub": "pnpm run build && npm publish --registry=https://nexus.imyeyu.com/repository/npm-timi/"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist/**",
|
"dist/**",
|
||||||
"src/**",
|
"src/**",
|
||||||
"examples/**",
|
|
||||||
"README.md",
|
"README.md",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
@@ -37,9 +36,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tdesign-mobile-vue": "^1.13.2",
|
"tdesign-mobile-vue": "^1.13.2",
|
||||||
"timi-web": "link:E:/WebProject/timi-web",
|
"timi-web": "link:E:/WebProject/timi-web"
|
||||||
"vue": "3.5.26",
|
},
|
||||||
"vue-router": "4.5.0"
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.5",
|
"@types/node": "^22.13.5",
|
||||||
@@ -64,7 +65,9 @@
|
|||||||
"vite-plugin-dts": "^4.5.4",
|
"vite-plugin-dts": "^4.5.4",
|
||||||
"vite-plugin-vue-setup-extend": "^0.4.0",
|
"vite-plugin-vue-setup-extend": "^0.4.0",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"vue": "3.5.26",
|
||||||
|
"vue-router": "4.5.0",
|
||||||
"vite": "7.1.11",
|
"vite": "7.1.11",
|
||||||
"vue-tsc": "^2.2.10"
|
"vue-tsc": "^2.2.12"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+38
-17
@@ -14,12 +14,6 @@ importers:
|
|||||||
timi-web:
|
timi-web:
|
||||||
specifier: link:E:/WebProject/timi-web
|
specifier: link:E:/WebProject/timi-web
|
||||||
version: link:../timi-web
|
version: link:../timi-web
|
||||||
vue:
|
|
||||||
specifier: 3.5.26
|
|
||||||
version: 3.5.26(typescript@5.8.3)
|
|
||||||
vue-router:
|
|
||||||
specifier: 4.5.0
|
|
||||||
version: 4.5.0(vue@3.5.26(typescript@5.8.3))
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.13.5
|
specifier: ^22.13.5
|
||||||
@@ -90,9 +84,15 @@ importers:
|
|||||||
vite-plugin-vue-setup-extend:
|
vite-plugin-vue-setup-extend:
|
||||||
specifier: ^0.4.0
|
specifier: ^0.4.0
|
||||||
version: 0.4.0(vite@7.1.11(@types/node@22.13.9)(less@4.3.0)(terser@5.46.0))
|
version: 0.4.0(vite@7.1.11(@types/node@22.13.9)(less@4.3.0)(terser@5.46.0))
|
||||||
|
vue:
|
||||||
|
specifier: 3.5.26
|
||||||
|
version: 3.5.26(typescript@5.8.3)
|
||||||
|
vue-router:
|
||||||
|
specifier: 4.5.0
|
||||||
|
version: 4.5.0(vue@3.5.26(typescript@5.8.3))
|
||||||
vue-tsc:
|
vue-tsc:
|
||||||
specifier: ^2.2.10
|
specifier: ^2.2.12
|
||||||
version: 2.2.10(typescript@5.8.3)
|
version: 2.2.12(typescript@5.8.3)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -1309,12 +1309,21 @@ packages:
|
|||||||
'@volar/language-core@2.4.12':
|
'@volar/language-core@2.4.12':
|
||||||
resolution: {integrity: sha512-RLrFdXEaQBWfSnYGVxvR2WrO6Bub0unkdHYIdC31HzIEqATIuuhRRzYu76iGPZ6OtA4Au1SnW0ZwIqPP217YhA==}
|
resolution: {integrity: sha512-RLrFdXEaQBWfSnYGVxvR2WrO6Bub0unkdHYIdC31HzIEqATIuuhRRzYu76iGPZ6OtA4Au1SnW0ZwIqPP217YhA==}
|
||||||
|
|
||||||
|
'@volar/language-core@2.4.15':
|
||||||
|
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
||||||
|
|
||||||
'@volar/source-map@2.4.12':
|
'@volar/source-map@2.4.12':
|
||||||
resolution: {integrity: sha512-bUFIKvn2U0AWojOaqf63ER0N/iHIBYZPpNGogfLPQ68F5Eet6FnLlyho7BS0y2HJ1jFhSif7AcuTx1TqsCzRzw==}
|
resolution: {integrity: sha512-bUFIKvn2U0AWojOaqf63ER0N/iHIBYZPpNGogfLPQ68F5Eet6FnLlyho7BS0y2HJ1jFhSif7AcuTx1TqsCzRzw==}
|
||||||
|
|
||||||
|
'@volar/source-map@2.4.15':
|
||||||
|
resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
|
||||||
|
|
||||||
'@volar/typescript@2.4.12':
|
'@volar/typescript@2.4.12':
|
||||||
resolution: {integrity: sha512-HJB73OTJDgPc80K30wxi3if4fSsZZAOScbj2fcicMuOPoOkcf9NNAINb33o+DzhBdF9xTKC1gnPmIRDous5S0g==}
|
resolution: {integrity: sha512-HJB73OTJDgPc80K30wxi3if4fSsZZAOScbj2fcicMuOPoOkcf9NNAINb33o+DzhBdF9xTKC1gnPmIRDous5S0g==}
|
||||||
|
|
||||||
|
'@volar/typescript@2.4.15':
|
||||||
|
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
|
||||||
|
|
||||||
'@vue/babel-helper-vue-jsx-merge-props@1.4.0':
|
'@vue/babel-helper-vue-jsx-merge-props@1.4.0':
|
||||||
resolution: {integrity: sha512-JkqXfCkUDp4PIlFdDQ0TdXoIejMtTHP67/pvxlgeY+u5k3LEdKuWZ3LK6xkxo52uDoABIVyRwqVkfLQJhk7VBA==}
|
resolution: {integrity: sha512-JkqXfCkUDp4PIlFdDQ0TdXoIejMtTHP67/pvxlgeY+u5k3LEdKuWZ3LK6xkxo52uDoABIVyRwqVkfLQJhk7VBA==}
|
||||||
|
|
||||||
@@ -1512,8 +1521,8 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@vue/language-core@2.2.10':
|
'@vue/language-core@2.2.12':
|
||||||
resolution: {integrity: sha512-+yNoYx6XIKuAO8Mqh1vGytu8jkFEOH5C8iOv3i8Z/65A7x9iAOXA97Q+PqZ3nlm2lxf5rOJuIGI/wDtx/riNYw==}
|
resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '*'
|
typescript: '*'
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
@@ -4772,8 +4781,8 @@ packages:
|
|||||||
vue-template-es2015-compiler@1.9.1:
|
vue-template-es2015-compiler@1.9.1:
|
||||||
resolution: {integrity: sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==}
|
resolution: {integrity: sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==}
|
||||||
|
|
||||||
vue-tsc@2.2.10:
|
vue-tsc@2.2.12:
|
||||||
resolution: {integrity: sha512-jWZ1xSaNbabEV3whpIDMbjVSVawjAyW+x1n3JeGQo7S0uv2n9F/JMgWW90tGWNFRKya4YwKMZgCtr0vRAM7DeQ==}
|
resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=5.0.0'
|
typescript: '>=5.0.0'
|
||||||
@@ -6272,14 +6281,26 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@volar/source-map': 2.4.12
|
'@volar/source-map': 2.4.12
|
||||||
|
|
||||||
|
'@volar/language-core@2.4.15':
|
||||||
|
dependencies:
|
||||||
|
'@volar/source-map': 2.4.15
|
||||||
|
|
||||||
'@volar/source-map@2.4.12': {}
|
'@volar/source-map@2.4.12': {}
|
||||||
|
|
||||||
|
'@volar/source-map@2.4.15': {}
|
||||||
|
|
||||||
'@volar/typescript@2.4.12':
|
'@volar/typescript@2.4.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/language-core': 2.4.12
|
'@volar/language-core': 2.4.12
|
||||||
path-browserify: 1.0.1
|
path-browserify: 1.0.1
|
||||||
vscode-uri: 3.1.0
|
vscode-uri: 3.1.0
|
||||||
|
|
||||||
|
'@volar/typescript@2.4.15':
|
||||||
|
dependencies:
|
||||||
|
'@volar/language-core': 2.4.15
|
||||||
|
path-browserify: 1.0.1
|
||||||
|
vscode-uri: 3.1.0
|
||||||
|
|
||||||
'@vue/babel-helper-vue-jsx-merge-props@1.4.0': {}
|
'@vue/babel-helper-vue-jsx-merge-props@1.4.0': {}
|
||||||
|
|
||||||
'@vue/babel-helper-vue-transform-on@1.3.0': {}
|
'@vue/babel-helper-vue-transform-on@1.3.0': {}
|
||||||
@@ -6779,9 +6800,9 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
|
|
||||||
'@vue/language-core@2.2.10(typescript@5.8.3)':
|
'@vue/language-core@2.2.12(typescript@5.8.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/language-core': 2.4.12
|
'@volar/language-core': 2.4.15
|
||||||
'@vue/compiler-dom': 3.5.41
|
'@vue/compiler-dom': 3.5.41
|
||||||
'@vue/compiler-vue2': 2.7.16
|
'@vue/compiler-vue2': 2.7.16
|
||||||
'@vue/shared': 3.5.41
|
'@vue/shared': 3.5.41
|
||||||
@@ -10075,10 +10096,10 @@ snapshots:
|
|||||||
|
|
||||||
vue-template-es2015-compiler@1.9.1: {}
|
vue-template-es2015-compiler@1.9.1: {}
|
||||||
|
|
||||||
vue-tsc@2.2.10(typescript@5.8.3):
|
vue-tsc@2.2.12(typescript@5.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/typescript': 2.4.12
|
'@volar/typescript': 2.4.15
|
||||||
'@vue/language-core': 2.2.10(typescript@5.8.3)
|
'@vue/language-core': 2.2.12(typescript@5.8.3)
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
|
|
||||||
vue@3.5.26(typescript@5.8.3):
|
vue@3.5.26(typescript@5.8.3):
|
||||||
|
|||||||
@@ -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,10 +8,23 @@
|
|||||||
-webkit-transform: none;
|
-webkit-transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swipe-cell-button {
|
.cell-button {
|
||||||
height: 100%;
|
--td-button-border-radius: 1rem;
|
||||||
padding: 0 1rem;
|
|
||||||
border-radius: 0;
|
width: calc(100% - 2rem);
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-swipe-cell {
|
||||||
|
|
||||||
|
&__right {
|
||||||
|
|
||||||
|
.t-button {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.t-image-viewer {
|
.t-image-viewer {
|
||||||
@@ -24,18 +37,36 @@
|
|||||||
.t-form {
|
.t-form {
|
||||||
.cellStyle();
|
.cellStyle();
|
||||||
|
|
||||||
|
&.card {
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.t-form__item {
|
.t-form__item {
|
||||||
padding: @cellPadding;
|
padding: @cellPadding;
|
||||||
|
|
||||||
&:not(:last-child)::after {
|
&:not(:last-child)::after {
|
||||||
.cellSplit();
|
.cellSplit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.t-form__label {
|
||||||
|
color: var(--td-gray-color-9);
|
||||||
|
flex: none;
|
||||||
|
min-width: unset;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: unset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.t-cell {
|
.t-cell {
|
||||||
padding: @cellPadding;
|
padding: @cellPadding;
|
||||||
|
|
||||||
|
.t-cell__title,
|
||||||
|
.t-cell__title-text {
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&:after {
|
&:after {
|
||||||
.cellSplit();
|
.cellSplit();
|
||||||
}
|
}
|
||||||
@@ -45,6 +76,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-title-clip {
|
||||||
|
|
||||||
|
.t-cell__title {
|
||||||
|
flex: 1 1 0;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.t-divider {
|
.t-divider {
|
||||||
|
|
||||||
&::before,
|
&::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;
|
||||||
|
}
|
||||||
@@ -119,8 +119,8 @@ defineSlots<BottomPopupSlots>();
|
|||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin-top: -.25rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@@ -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;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: var(--td-radius-medium, 0);
|
border-radius: var(--tui-radius-small, 0);
|
||||||
|
|
||||||
.gutter {
|
.gutter {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
width: 3.25rem;
|
width: 3.25rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 1rem;
|
|
||||||
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
padding-right: .25rem;
|
padding-right: .25rem;
|
||||||
@@ -83,7 +82,6 @@
|
|||||||
.search-status {
|
.search-status {
|
||||||
color: var(--td-text-color-secondary, #666);
|
color: var(--td-text-color-secondary, #666);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: .875rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview,
|
.preview,
|
||||||
@@ -97,7 +95,6 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
font-size: 1rem;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
font-family: "JetBrains Mono", "Fira Code", Consolas, "Courier New", monospace;
|
||||||
}
|
}
|
||||||
@@ -123,7 +120,6 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
font-size: inherit;
|
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-2
@@ -1,5 +1,6 @@
|
|||||||
/** 导出所有组件 */
|
/** 导出所有组件 */
|
||||||
import RootLayout from "./root-layout";
|
import RootLayout from "./root-layout";
|
||||||
|
import PageTransition from "./page-transition";
|
||||||
import DurationPicker from "./duration-picker";
|
import DurationPicker from "./duration-picker";
|
||||||
import CodeEditor from "./code-editor";
|
import CodeEditor from "./code-editor";
|
||||||
import TiptapEditor from "./tiptap-editor";
|
import TiptapEditor from "./tiptap-editor";
|
||||||
@@ -10,9 +11,17 @@ import NotifyEditor from "./notify-editor";
|
|||||||
import NotifyView from "./notify-view";
|
import NotifyView from "./notify-view";
|
||||||
import BottomPopup from "./bottom-popup";
|
import BottomPopup from "./bottom-popup";
|
||||||
import Space from "./space";
|
import Space from "./space";
|
||||||
|
import CardContainer from "./card-container";
|
||||||
|
import {
|
||||||
|
AiChatComposer,
|
||||||
|
AiChatMessageList,
|
||||||
|
AiChatPanel,
|
||||||
|
AiChatThinking
|
||||||
|
} from "./ai";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
RootLayout,
|
RootLayout,
|
||||||
|
PageTransition,
|
||||||
DurationPicker,
|
DurationPicker,
|
||||||
CodeEditor,
|
CodeEditor,
|
||||||
TiptapEditor,
|
TiptapEditor,
|
||||||
@@ -22,11 +31,17 @@ export default [
|
|||||||
NotifyEditor,
|
NotifyEditor,
|
||||||
NotifyView,
|
NotifyView,
|
||||||
BottomPopup,
|
BottomPopup,
|
||||||
Space
|
Space,
|
||||||
|
CardContainer,
|
||||||
|
AiChatComposer,
|
||||||
|
AiChatMessageList,
|
||||||
|
AiChatPanel,
|
||||||
|
AiChatThinking
|
||||||
];
|
];
|
||||||
|
|
||||||
export {
|
export {
|
||||||
RootLayout,
|
RootLayout,
|
||||||
|
PageTransition,
|
||||||
DurationPicker,
|
DurationPicker,
|
||||||
CodeEditor,
|
CodeEditor,
|
||||||
TiptapEditor,
|
TiptapEditor,
|
||||||
@@ -36,9 +51,40 @@ export {
|
|||||||
NotifyEditor,
|
NotifyEditor,
|
||||||
NotifyView,
|
NotifyView,
|
||||||
BottomPopup,
|
BottomPopup,
|
||||||
Space
|
Space,
|
||||||
|
CardContainer,
|
||||||
|
AiChatComposer,
|
||||||
|
AiChatMessageList,
|
||||||
|
AiChatPanel,
|
||||||
|
AiChatThinking
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
AiChatHttpError,
|
||||||
|
AiChatStreamError,
|
||||||
|
ensureChatResponse,
|
||||||
|
parseAssistantResponse,
|
||||||
|
parseSseData,
|
||||||
|
readSseStream,
|
||||||
|
useAiChat
|
||||||
|
} from "./ai";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
AiChatCitationFormatter,
|
||||||
|
AiChatErrorMessages,
|
||||||
|
AiChatEventAction,
|
||||||
|
AiChatMessage,
|
||||||
|
AiChatMessageStatusFormatter,
|
||||||
|
AiChatRequestContext,
|
||||||
|
AiChatRoleNameFormatter,
|
||||||
|
AiChatRoleNames,
|
||||||
|
AiChatStreamEvent,
|
||||||
|
AiCitation,
|
||||||
|
AiMessageRole,
|
||||||
|
AiThinking,
|
||||||
|
UseAiChatOptions
|
||||||
|
} from "./ai";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
SpaceAlign,
|
SpaceAlign,
|
||||||
SpaceDirection,
|
SpaceDirection,
|
||||||
@@ -48,6 +94,21 @@ export type {
|
|||||||
SpaceSlots
|
SpaceSlots
|
||||||
} from "./space";
|
} from "./space";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CardContainerProps
|
||||||
|
} from "./card-container";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
PageTransitionBackground,
|
||||||
|
PageTransitionClass,
|
||||||
|
PageTransitionKeepAliveMatcher,
|
||||||
|
PageTransitionNameResolver,
|
||||||
|
PageTransitionProps,
|
||||||
|
PageTransitionRoute,
|
||||||
|
PageTransitionRouteDepthResolver,
|
||||||
|
PageTransitionRouteKeyResolver
|
||||||
|
} from "./page-transition";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
CodeEditorExpose
|
CodeEditorExpose
|
||||||
} from "./code-editor";
|
} from "./code-editor";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
import type { PickerValue } from "tdesign-mobile-vue";
|
import type { PickerValue } from "tdesign-mobile-vue";
|
||||||
import { supportedCodeLanguages } from "timi-web/editor";
|
import { supportedCodeLanguages } from "timi-web/editor";
|
||||||
import type { TiptapEditorInstance } from "timi-web/editor";
|
import type { TiptapEditorInstance } from "timi-web/editor";
|
||||||
import BottomPopup from "../bottom-popup/index.vue";
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: "TiptapCodeBlockButton"
|
name: "TiptapCodeBlockButton"
|
||||||
|
|||||||
@@ -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/tdesign-custom.less";
|
||||||
|
import "./assets/style/entrance-transition.less";
|
||||||
import "./components/tiptap-content.less";
|
import "./components/tiptap-content.less";
|
||||||
import "timi-web/style.css";
|
import "timi-web/style.css";
|
||||||
import "../../timi-web/src/components/prism-code-block.less";
|
import "../../timi-web/src/components/prism-code-block.less";
|
||||||
|
|
||||||
export * from "./components";
|
export * from "./components";
|
||||||
|
export * from "./directives";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
};
|
};
|
||||||
|
|||||||
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 {};
|
||||||
+1
-7
@@ -17,9 +17,6 @@
|
|||||||
"DOM.Iterable"
|
"DOM.Iterable"
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
|
||||||
"./examples/*"
|
|
||||||
],
|
|
||||||
"timi-web/editor": [
|
"timi-web/editor": [
|
||||||
"../timi-web/src/editor/index.ts"
|
"../timi-web/src/editor/index.ts"
|
||||||
],
|
],
|
||||||
@@ -32,10 +29,7 @@
|
|||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
"src/**/*.vue",
|
"src/**/*.vue",
|
||||||
"examples/**/*.ts",
|
"auto-imports.d.ts"
|
||||||
"examples/**/*.d.ts",
|
|
||||||
"examples/**/*.vue",
|
|
||||||
"node_modules/.pnpm/unplugin-auto-import@19.1.2/node_modules/unplugin-auto-import/auto-imports.d.ts"
|
|
||||||
],
|
],
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user