add vitepress docs

This commit is contained in:
Timi
2026-09-21 16:05:14 +08:00
parent b435bf992e
commit 37cf38fe27
73 changed files with 7003 additions and 802 deletions
+125
View File
@@ -0,0 +1,125 @@
<template>
<div class="demo">
<div class="phone">
<header class="head">
<span class="title" v-text="'智能助手'" />
<button type="button" class="reset" @click="resetConversation" v-text="'清空'" />
</header>
<ai-chat-panel
class="panel"
:messages="messages"
:is-generating="isGenerating"
:active-tool-status="activeToolStatus"
:last-error="lastError"
:can-retry="canRetry"
:loading="isLoadingConversation"
:role-names="{ assistant: '分析助手', user: '我' }"
@send="sendMessage"
@stop="stopGeneration"
@retry="retryLastMessage"
>
<template #welcome>
<div class="welcome">
<p class="welcome-title" v-text="'可以问我关于数据的问题'" />
<p class="welcome-tip" v-text="'例如:上个月订单量怎么样?'" />
</div>
</template>
</ai-chat-panel>
</div>
<p class="note" v-text="'面板嵌在带标题栏的页面骨架里,欢迎语与角色名都做了自定义。'" />
</div>
</template>
<script lang="ts" setup>
import { AiChatPanel, useAiChat } from "timi-tdesign-mobile";
import { createMockReply } from "../ai/mock-sse";
/**
* 完整场景:标题栏 + 面板 + 自定义欢迎语。
* 会话状态全部来自 useAiChat,真实项目里把 request 换成后端 SSE 接口即可。
*/
const {
messages,
isGenerating,
isLoadingConversation,
activeToolStatus,
lastError,
canRetry,
sendMessage,
stopGeneration,
retryLastMessage,
resetConversation
} = useAiChat({
request: async ({ message, signal }) => createMockReply(message, signal)
});
</script>
<style lang="less" scoped>
.demo {
.phone {
height: 32rem;
display: flex;
overflow: hidden;
flex-direction: column;
border: 1px solid var(--vp-c-divider);
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.head {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: .625rem .75rem;
border-bottom: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
}
.title {
font-size: .875rem;
font-weight: 600;
}
.reset {
padding: .25rem .5rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
}
.panel {
flex: 1;
min-height: 0;
}
.welcome {
padding: 2rem 1rem;
text-align: center;
}
.welcome-title {
margin: 0 0 .375rem;
font-size: .875rem;
font-weight: 600;
color: var(--vp-c-text-1);
}
.welcome-tip {
margin: 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
.note {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<template>
<div class="demo">
<div class="tabs">
<button
type="button"
class="tab"
:class="{ active: tab === 'edit' }"
@click="tab = 'edit'"
v-text="'编辑'"
/>
<button
type="button"
class="tab"
:class="{ active: tab === 'view' }"
@click="tab = 'view'"
v-text="'预览'"
/>
</div>
<div class="switcher">
<button
v-for="item in contentTypeList"
:key="item.value"
type="button"
class="chip"
:class="{ active: article.contentType === item.value }"
@click="switchContentType(item.value)"
v-text="item.label"
/>
</div>
<section v-show="tab === 'edit'" class="pane">
<input v-model="article.title" class="field" placeholder="文章标题" />
<article-editor
ref="editorRef"
:model-value="article"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
@update:model-value="onChange"
/>
<div class="footer">
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
<span class="status" v-text="status" />
</div>
</section>
<section v-show="tab === 'view'" class="pane">
<article-view :article="article" />
</section>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { ArticleEditor, ArticleView } from "timi-tdesign-mobile";
import type {
Article,
ArticleContentType,
ArticleEditorExpose,
UploadResult
} from "timi-tdesign-mobile";
/** 本场景验证「同一份 Article 数据,编辑与预览共用」。 */
const RICH_CONTENT = `<h2>文章编辑与预览</h2>
<p>两侧共用同一个 <code>Article</code> 对象,切换标签即可对比。</p>
<blockquote><p>移动端用标签切换替代左右分栏。</p></blockquote>`;
const contentTypeList: Array<{ label: string; value: ArticleContentType }> = [
{ label: "纯文本", value: "TEXT" },
{ label: "Markdown", value: "MARKDOWN" },
{ label: "富文本", value: "TIP_TAP" }
];
const tab = ref("edit");
const article = ref<Article>({
title: "编辑器组件演示文章",
contentType: "TIP_TAP",
content: RICH_CONTENT,
attachmentList: []
});
const status = ref("等待提交");
const editorRef = ref<ArticleEditorExpose>();
function switchContentType(contentType: ArticleContentType) {
const content = contentType === "TEXT"
? "这是一段纯文本示例。"
: contentType === "MARKDOWN"
? "## Markdown 示例\n\n这是一段 Markdown 正文。"
: RICH_CONTENT;
article.value = {
...article.value,
contentType,
content
};
status.value = `已切换到 ${contentType}`;
}
function onChange(value: Article) {
article.value = value;
}
async function submit() {
const value = await editorRef.value?.submit();
if (value) {
article.value = value;
status.value = `已提交,${value.attachmentList?.length || 0} 个附件`;
}
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.tabs {
display: flex;
gap: .25rem;
padding: .25rem;
margin-bottom: .75rem;
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.tab {
flex: 1;
padding: .375rem;
font-size: .75rem;
cursor: pointer;
border: none;
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
&.active {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg);
}
}
.switcher {
display: flex;
flex-wrap: wrap;
gap: .375rem;
margin-bottom: .75rem;
}
.chip {
padding: .25rem .625rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: 1rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.active {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.footer {
display: flex;
gap: .75rem;
align-items: center;
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.primary {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.status {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<template>
<div class="demo">
<div class="tabs">
<button
v-for="item in tabList"
:key="item.value"
type="button"
class="tab"
:class="{ active: tab === item.value }"
@click="tab = item.value"
v-text="item.label"
/>
</div>
<section v-show="tab === 'rich'" class="pane">
<div class="bar">
<button type="button" class="btn" @click="submitRich" v-text="'提交富文本'" />
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
</div>
<div class="box tall">
<tiptap-editor
ref="tiptapRef"
v-model="rich"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onRichChange"
/>
</div>
<p class="status" v-text="status" />
</section>
<section v-show="tab === 'view'" class="pane">
<tiptap-view :content="rich" max-height="20rem" />
</section>
<section v-show="tab === 'code'" class="pane">
<div class="bar">
<button type="button" class="btn" @click="resetCode" v-text="'恢复示例'" />
</div>
<div class="box">
<code-editor v-model="code" language="typescript" placeholder="请输入代码" />
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { CodeEditor, TiptapEditor, TiptapView } from "timi-tdesign-mobile";
import type { TiptapEditorExpose, UploadResult } from "timi-tdesign-mobile";
/** 本场景把三类编辑器收在一个页面里,用标签切换而不是并排——移动端宽度放不下分栏。 */
const DEMO_CODE = `type DemoUser = {
id: string;
name: string;
};
const user: DemoUser = { id: "u1", name: "Timi" };`;
const tabList = [
{ label: "富文本", value: "rich" },
{ label: "只读视图", value: "view" },
{ label: "代码", value: "code" }
];
const tab = ref("rich");
const rich = ref(`<h2>跨平台编辑器</h2>
<p>这段内容同时验证标题、段落、引用与代码块的渲染。</p>
<blockquote><p>移动端和 PC 端只替换交互层,共享文档模型。</p></blockquote>`);
const code = ref(DEMO_CODE);
const status = ref("等待操作");
const tiptapRef = ref<TiptapEditorExpose>();
function onRichChange(value: string) {
status.value = `内容已更新,${value.length} 个字符`;
}
function resetCode() {
code.value = DEMO_CODE;
}
async function submitRich() {
const value = await tiptapRef.value?.submit();
if (value !== undefined) {
rich.value = value;
status.value = `已提交,${value.length} 个字符`;
}
}
function openSearch() {
tiptapRef.value?.openSearch(true);
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.tabs {
display: flex;
gap: .25rem;
padding: .25rem;
margin-bottom: .75rem;
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.tab {
flex: 1;
padding: .375rem;
font-size: .75rem;
cursor: pointer;
border: none;
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
&.active {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg);
}
}
.bar {
display: flex;
gap: .5rem;
margin-bottom: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&:hover {
color: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
}
}
.box {
height: 16rem;
}
.tall {
height: 22rem;
}
.status {
margin: .75rem 0 0;
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<template>
<div class="demo">
<div class="card">
<t-cell title="缓存有效期" arrow @click="pickerVisible = true">
<span v-text="humanized" />
</t-cell>
</div>
<div class="bar">
<button type="button" class="btn" @click="sheetVisible = true" v-text="'打开底部弹层'" />
</div>
<p class="status" v-text="`弹层最近一次操作:${lastAction}`" />
<space size="small" class="tags">
<span v-for="item in 3" :key="item" class="tag" v-text="`标签 ${item}`" />
</space>
<t-popup v-model="pickerVisible" placement="bottom" destroy-on-close>
<div class="picker-panel">
<duration-picker v-model="durationMs" @change="pickerVisible = false" />
</div>
</t-popup>
<bottom-popup
v-model="sheetVisible"
title="选择操作"
@left-button-click="onAction('取消')"
@right-button-click="onAction('确定')"
>
<div class="sheet-content">
<p v-text="'底部弹层适合承载一组操作或一段简短表单。'" />
</div>
</bottom-popup>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { BottomPopup, DurationPicker, Space } from "timi-tdesign-mobile";
/**
* 本场景是移动端最典型的两种浮现方式:整块选择面板用 `t-popup` 从底部升起,
* 操作确认用 BottomPopup。
*/
const durationMs = ref(1000);
const pickerVisible = ref(false);
const sheetVisible = ref(false);
const lastAction = ref("无");
/** 把毫秒换算成便于阅读的形式。 */
const humanized = computed(() => {
const value = durationMs.value;
if (value % (60 * 60 * 1000) === 0) {
return `${value / (60 * 60 * 1000)} 小时`;
}
if (value % (60 * 1000) === 0) {
return `${value / (60 * 1000)} 分钟`;
}
if (value % 1000 === 0) {
return `${value / 1000} 秒`;
}
return `${value} 毫秒`;
});
function onAction(action: string) {
lastAction.value = action;
sheetVisible.value = false;
}
</script>
<style lang="less" scoped>
.demo {
.card {
overflow: hidden;
margin-bottom: .75rem;
border: 1px solid var(--vp-c-divider);
border-radius: .5rem;
}
.bar {
margin-bottom: .75rem;
}
.btn {
width: 100%;
padding: .5rem;
font-size: .8125rem;
cursor: pointer;
border: none;
border-radius: .5rem;
color: #fff;
background: var(--vp-c-brand-1);
}
.status {
margin: 0 0 .75rem;
font-size: .75rem;
color: var(--vp-c-text-2);
}
.tags {
margin-bottom: 0;
}
.tag {
padding: .25rem .5rem;
font-size: .6875rem;
border-radius: .25rem;
color: var(--vp-c-brand-1);
background: var(--vp-c-default-soft);
}
.picker-panel {
padding-top: .5rem;
border-radius: 1rem 1rem 0 0;
background: var(--vp-c-bg);
}
.sheet-content {
padding: 1rem;
p {
margin: 0;
font-size: .8125rem;
line-height: 1.6;
color: var(--vp-c-text-2);
}
}
}
</style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<div class="demo">
<div class="tabs">
<button
type="button"
class="tab"
:class="{ active: tab === 'edit' }"
@click="tab = 'edit'"
v-text="'编辑'"
/>
<button
type="button"
class="tab"
:class="{ active: tab === 'view' }"
@click="tab = 'view'"
v-text="'预览'"
/>
</div>
<div class="status-bar">
<button
type="button"
class="chip"
@click="switchStatus"
v-text="'切换状态'"
/>
<span class="tag" v-text="detail.status" />
</div>
<section v-show="tab === 'edit'" class="pane">
<input v-model="detail.subject" class="field" placeholder="通知主题" />
<notify-editor
ref="editorRef"
:model-value="detail"
:upload-image="uploadMedia"
:upload-video="uploadMedia"
@change="onChange"
@update:model-value="onChange"
/>
<div class="footer">
<button type="button" class="btn primary" @click="submit" v-text="'提交通知'" />
<span class="status" v-text="status" />
</div>
</section>
<section v-show="tab === 'view'" class="pane">
<notify-view :detail="detail" />
</section>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { NotifyEditor, NotifyView } from "timi-tdesign-mobile";
import type { NotifyDetail, NotifyEditorExpose, UploadResult } from "timi-tdesign-mobile";
/** 本场景验证通知复用文章编辑能力,同时保留自己的元数据字段。 */
const RICH_CONTENT = `<h2>系统维护通知</h2>
<p>本周六 02:00 - 04:00 进行数据库升级,期间服务不可用。</p>
<ul><li>请提前保存工作内容</li><li>升级完成后会同步通知</li></ul>`;
const statusList = ["WAITING", "SENT", "FAILED"];
const tab = ref("edit");
const detail = ref<NotifyDetail>({
subject: "编辑器通知演示",
contentType: "TIP_TAP",
content: RICH_CONTENT,
status: "WAITING",
attachmentList: []
});
const status = ref("等待提交");
const editorRef = ref<NotifyEditorExpose>();
function switchStatus() {
const index = statusList.indexOf(detail.value.status);
const next = statusList[(index + 1) % statusList.length];
detail.value = {
...detail.value,
status: next
};
}
function onChange(value: NotifyDetail) {
detail.value = value;
}
async function submit() {
const value = await editorRef.value?.submit();
if (value) {
detail.value = value;
status.value = `已提交,正文 ${value.content?.length || 0} 个字符`;
}
}
async function uploadMedia(file: File): Promise<UploadResult> {
return {
id: `demo-${Date.now()}-${file.name}`,
name: file.name,
url: URL.createObjectURL(file)
};
}
</script>
<style lang="less" scoped>
.demo {
.tabs {
display: flex;
gap: .25rem;
padding: .25rem;
margin-bottom: .75rem;
border-radius: .5rem;
background: var(--vp-c-bg-soft);
}
.tab {
flex: 1;
padding: .375rem;
font-size: .75rem;
cursor: pointer;
border: none;
border-radius: .375rem;
color: var(--vp-c-text-2);
background: transparent;
&.active {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg);
}
}
.status-bar {
display: flex;
gap: .5rem;
align-items: center;
margin-bottom: .75rem;
}
.chip {
padding: .25rem .625rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: 1rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
}
.tag {
padding: .125rem .5rem;
font-size: .6875rem;
border-radius: .25rem;
color: var(--vp-c-brand-1);
background: var(--vp-c-default-soft);
}
.field {
width: 100%;
padding: .375rem .5rem;
margin-bottom: .75rem;
box-sizing: border-box;
font-size: .8125rem;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.footer {
display: flex;
gap: .75rem;
align-items: center;
margin-top: .75rem;
}
.btn {
padding: .25rem .75rem;
font-size: .75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: .375rem;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
&.primary {
color: #fff;
border-color: var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
}
}
.status {
font-size: .75rem;
color: var(--vp-c-text-2);
}
}
</style>