add vitepress docs
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<ai-chat-composer
|
||||
v-model="draft"
|
||||
:is-generating="generating"
|
||||
:last-error="error"
|
||||
:can-retry="Boolean(error)"
|
||||
@send="onSend"
|
||||
@stop="onStop"
|
||||
@retry="onRetry"
|
||||
/>
|
||||
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" @click="error = '网络中断,请重试'" v-text="'制造一个错误'" />
|
||||
</div>
|
||||
|
||||
<ol class="log">
|
||||
<li v-for="(item, index) in log" :key="index" v-text="item" />
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { AiChatComposer } from "timi-tdesign-mobile";
|
||||
|
||||
const draft = ref("");
|
||||
const generating = ref(false);
|
||||
const error = ref("");
|
||||
const log = ref<string[]>([]);
|
||||
|
||||
function push(message: string) {
|
||||
log.value = [`${new Date().toLocaleTimeString()} ${message}`, ...log.value].slice(0, 5);
|
||||
}
|
||||
|
||||
/** 发送后进入生成态,2 秒后自动恢复,便于观察按钮的三种形态。 */
|
||||
function onSend(message: string) {
|
||||
error.value = "";
|
||||
generating.value = true;
|
||||
push(`send("${message}")`);
|
||||
setTimeout(() => {
|
||||
generating.value = false;
|
||||
push("生成结束");
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function onStop() {
|
||||
generating.value = false;
|
||||
push("stop()");
|
||||
}
|
||||
|
||||
function onRetry() {
|
||||
error.value = "";
|
||||
push("retry()");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.bar {
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.log {
|
||||
margin: .75rem 0 0;
|
||||
padding-left: 1.25rem;
|
||||
font-size: .75rem;
|
||||
font-family: monospace;
|
||||
color: var(--vp-c-text-2);
|
||||
|
||||
li {
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="phone">
|
||||
<ai-chat-message-list :messages="messages" :is-generating="false" />
|
||||
</div>
|
||||
|
||||
<p class="note" v-text="'消息里的 thinkingList 与 citationList 会渲染为思考过程与引用。'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { AiChatMessageList } from "timi-tdesign-mobile";
|
||||
import { createMessage } from "../ai/mock-sse";
|
||||
|
||||
const messages = [
|
||||
createMessage(1, "user", "上个月订单量怎么样?"),
|
||||
createMessage(2, "assistant", "近 30 天订单量**环比上升 12%**,其中移动端贡献了主要增量。", {
|
||||
thinkingList: [
|
||||
{ id: 1, content: "先确认时间范围是自然月还是滚动 30 天。" },
|
||||
{ id: 2, content: "再按端类型拆分,确认增量来源。" }
|
||||
],
|
||||
citationList: [
|
||||
{ source: "分析中心", dataDomain: "订单域", timeRange: "近 30 天", caliber: "支付口径" }
|
||||
]
|
||||
})
|
||||
];
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.phone {
|
||||
height: 26rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="phone">
|
||||
<ai-chat-panel
|
||||
:messages="messages"
|
||||
:is-generating="isGenerating"
|
||||
:active-tool-status="activeToolStatus"
|
||||
:last-error="lastError"
|
||||
:can-retry="canRetry"
|
||||
@send="sendMessage"
|
||||
@stop="stopGeneration"
|
||||
@retry="retryLastMessage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="note" v-text="'输入任意问题:回答由本地假流逐块吐出,可随时点「停止」。'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { AiChatPanel, useAiChat } from "timi-tdesign-mobile";
|
||||
import { createMockReply } from "../ai/mock-sse";
|
||||
|
||||
/**
|
||||
* 面板本身是纯展示组件,会话状态全部由 `useAiChat` 提供。
|
||||
* 真实项目里把 `request` 换成调后端 SSE 接口即可,组件侧不用改。
|
||||
*/
|
||||
const {
|
||||
messages,
|
||||
isGenerating,
|
||||
activeToolStatus,
|
||||
lastError,
|
||||
canRetry,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
retryLastMessage
|
||||
} = useAiChat({
|
||||
request: async ({ message, signal }) => createMockReply(message, signal)
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.phone {
|
||||
height: 30rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<ai-chat-thinking :content="content" :is-generating="generating" />
|
||||
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" :disabled="generating" @click="start" v-text="'开始生成'" />
|
||||
<button type="button" class="btn" @click="reset" v-text="'重置'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, ref } from "vue";
|
||||
import { AiChatThinking } from "timi-tdesign-mobile";
|
||||
|
||||
const SEGMENTS = [
|
||||
"先理解问题:用户想知道上个月的订单情况。",
|
||||
"确认时间范围:按自然月还是滚动 30 天?这里取自然月。",
|
||||
"确认口径:使用支付口径而非下单口径。",
|
||||
"开始查询数据并汇总……"
|
||||
];
|
||||
|
||||
const content = ref("");
|
||||
const generating = ref(false);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** 逐段追加,模拟思考内容流式到达;生成中组件会自动滚到底部。 */
|
||||
function start() {
|
||||
reset();
|
||||
generating.value = true;
|
||||
let index = 0;
|
||||
timer = setInterval(() => {
|
||||
if (index >= SEGMENTS.length) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
content.value += (content.value ? "\n" : "") + SEGMENTS[index];
|
||||
index += 1;
|
||||
}, 700);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer !== null) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
generating.value = false;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
stop();
|
||||
content.value = "";
|
||||
}
|
||||
|
||||
onUnmounted(stop);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--vp-c-brand-1);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 演示专用的假 SSE 流。
|
||||
*
|
||||
* `useAiChat` 要求 `request` 返回一个 `Response`,真实的实现由后端提供。
|
||||
* 这里用 ReadableStream 按内置协议逐条吐出事件,让文档站无需后端也能跑通
|
||||
* 流式对话的完整链路。
|
||||
*
|
||||
* 内置协议的事件名与载荷字段(见 `parseDefaultEvent` / `extractText` 等):
|
||||
* - `thinking` 载荷取 `thought` / `thinking` / `reasoning`
|
||||
* - `answer` 载荷取 `answer` / `text` / `content`
|
||||
* - `tool-status` 载荷取 `text` / `message` / `status`
|
||||
* - `citation` 载荷为引用对象
|
||||
* - `done` 载荷可带 `conversationId` 与 `duration`
|
||||
*/
|
||||
|
||||
/** 单条 SSE 事件。 */
|
||||
interface MockEvent {
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 每个事件之间的间隔,模拟真实的流式节奏。 */
|
||||
const STEP_DELAY = 420;
|
||||
|
||||
function buildEvents(question: string): MockEvent[] {
|
||||
return [
|
||||
{ event: "thinking", data: { thought: `先理解问题:「${question}」` } },
|
||||
{ event: "thinking", data: { thought: "然后确认需要的数据口径与时间范围。" } },
|
||||
{ event: "tool-status", data: { text: "正在查询数据" } },
|
||||
{ event: "answer", data: { text: "根据当前口径," } },
|
||||
{ event: "answer", data: { text: `关于「${question}」的结论如下:` } },
|
||||
{ event: "answer", data: { text: "\n\n- 近 30 天整体**环比上升 12%**" } },
|
||||
{ event: "answer", data: { text: "\n- 其中移动端贡献了主要增量" } },
|
||||
{ event: "citation", data: { source: "分析中心", dataDomain: "订单域", timeRange: "近 30 天" } },
|
||||
{ event: "done", data: { conversationId: "demo-conversation", duration: STEP_DELAY * 9 } }
|
||||
];
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一段假的流式回复。
|
||||
*
|
||||
* @param question 用户提问,会回显在回答里
|
||||
* @param signal 取消信号,用户点「停止」时中断
|
||||
*/
|
||||
export function createMockReply(question: string, signal?: AbortSignal): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const events = buildEvents(question);
|
||||
let index = 0;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
if (signal?.aborted) {
|
||||
controller.error(new DOMException("已停止生成", "AbortError"));
|
||||
return;
|
||||
}
|
||||
if (index >= events.length) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
const item = events[index];
|
||||
index += 1;
|
||||
controller.enqueue(encoder.encode(`event: ${item.event}\ndata: ${JSON.stringify(item.data)}\n\n`));
|
||||
await sleep(STEP_DELAY);
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: { "Content-Type": "text/event-stream" }
|
||||
});
|
||||
}
|
||||
|
||||
/** 构造一条用于静态展示的消息。 */
|
||||
export function createMessage(
|
||||
id: number,
|
||||
role: "user" | "assistant",
|
||||
content: string,
|
||||
extra: Partial<{ thinkingList: Array<{ id: number; content: string }>; citationList: unknown[] }> = {}
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
thinkingList: extra.thinkingList ?? [],
|
||||
citationList: extra.citationList ?? [],
|
||||
createdAt: Date.now() - (10 - id) * 60_000,
|
||||
statusText: "",
|
||||
duration: role === "assistant" ? 2400 : undefined
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="bar">
|
||||
<button
|
||||
v-for="item in contentTypeList"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="{ active: value.contentType === item.value }"
|
||||
@click="switchContentType(item.value)"
|
||||
v-text="item.label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input v-model="value.title" class="field" placeholder="文章标题" />
|
||||
|
||||
<article-editor
|
||||
ref="editorRef"
|
||||
:model-value="value"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onChange"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
|
||||
<div class="footer">
|
||||
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
|
||||
<span class="status" v-text="status" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { ArticleEditor } from "timi-tdesign-mobile";
|
||||
import type {
|
||||
Article,
|
||||
ArticleContentType,
|
||||
ArticleEditorExpose,
|
||||
UploadResult
|
||||
} from "timi-tdesign-mobile";
|
||||
|
||||
const RICH_CONTENT = `<h2>正文类型可切换</h2>
|
||||
<p>同一份 <code>Article</code> 数据,按 <code>contentType</code> 切换三种编辑形态。</p>`;
|
||||
|
||||
const contentTypeList: Array<{ label: string; value: ArticleContentType }> = [
|
||||
{ label: "纯文本", value: "TEXT" },
|
||||
{ label: "Markdown", value: "MARKDOWN" },
|
||||
{ label: "富文本", value: "TIP_TAP" }
|
||||
];
|
||||
|
||||
const value = ref<Article>({
|
||||
title: "编辑器演示文章",
|
||||
contentType: "TIP_TAP",
|
||||
content: RICH_CONTENT,
|
||||
attachmentList: []
|
||||
});
|
||||
|
||||
const status = ref("等待提交");
|
||||
const editorRef = ref<ArticleEditorExpose>();
|
||||
|
||||
/** 切换正文类型时同步替换内容,避免不同格式互相污染。 */
|
||||
function switchContentType(contentType: ArticleContentType) {
|
||||
const content = contentType === "TEXT"
|
||||
? "这是一段纯文本正文。"
|
||||
: contentType === "MARKDOWN"
|
||||
? "## Markdown 标题\n\n这是一段 Markdown 正文。"
|
||||
: RICH_CONTENT;
|
||||
value.value = {
|
||||
...value.value,
|
||||
contentType,
|
||||
content
|
||||
};
|
||||
}
|
||||
|
||||
function onChange(article: Article) {
|
||||
value.value = article;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const article = await editorRef.value?.submit();
|
||||
if (article) {
|
||||
value.value = article;
|
||||
status.value = `已提交,标题为「${article.title}」`;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .375rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .625rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: .375rem .5rem;
|
||||
margin-bottom: .75rem;
|
||||
box-sizing: border-box;
|
||||
font-size: .8125rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
align-items: center;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<article-view :article="article" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ArticleView } from "timi-tdesign-mobile";
|
||||
import type { Article } from "timi-tdesign-mobile";
|
||||
|
||||
/** ArticleView 按 contentType 选择渲染方式,这里演示富文本形态。 */
|
||||
const article: Article = {
|
||||
title: "文章视图",
|
||||
contentType: "TIP_TAP",
|
||||
content: `<h2>文章视图</h2>
|
||||
<p>正文按 <code>Article</code> 结构渲染,空内容时显示占位文案。</p>
|
||||
<blockquote><p>把 <code>contentType</code> 换成 TEXT 或 MARKDOWN 会切换渲染分支。</p></blockquote>`,
|
||||
attachmentList: []
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<button type="button" class="btn" @click="visible = true" v-text="'打开底部弹层'" />
|
||||
|
||||
<p class="status" v-text="`最近一次操作:${lastAction}`" />
|
||||
|
||||
<bottom-popup
|
||||
v-model="visible"
|
||||
title="选择操作"
|
||||
left-button-text="取消"
|
||||
right-button-text="确定"
|
||||
@left-button-click="onCancel"
|
||||
@right-button-click="onConfirm"
|
||||
>
|
||||
<div class="content">
|
||||
<p v-text="'弹层从视口底部升起,默认带遮罩,内容区可滚动。'" />
|
||||
<p v-text="'标题栏在内容滚动时会吸顶。'" />
|
||||
</div>
|
||||
</bottom-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { BottomPopup } from "timi-tdesign-mobile";
|
||||
|
||||
const visible = ref(false);
|
||||
const lastAction = ref("无");
|
||||
|
||||
/** 左右按钮只抛事件,关闭与否由业务决定——这里两者都关闭。 */
|
||||
function onCancel() {
|
||||
visible.value = false;
|
||||
lastAction.value = "取消";
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
visible.value = false;
|
||||
lastAction.value = "确定";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: .625rem;
|
||||
font-size: .875rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .5rem;
|
||||
color: #fff;
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1rem;
|
||||
|
||||
p {
|
||||
margin: 0 0 .75rem;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-2);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" @click="readValue" v-text="'读取内容'" />
|
||||
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<code-editor
|
||||
ref="editorRef"
|
||||
v-model="code"
|
||||
language="typescript"
|
||||
placeholder="请输入代码"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="status" v-text="status" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { CodeEditor } from "timi-tdesign-mobile";
|
||||
import type { CodeEditorExpose } from "timi-tdesign-mobile";
|
||||
|
||||
const DEMO_CODE = `type DemoUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const user: DemoUser = {
|
||||
id: "demo-001",
|
||||
name: "Timi"
|
||||
};`;
|
||||
|
||||
const code = ref(DEMO_CODE);
|
||||
const status = ref("等待操作");
|
||||
const editorRef = ref<CodeEditorExpose>();
|
||||
|
||||
function onChange(value: string) {
|
||||
status.value = `内容已更新,${value.length} 个字符`;
|
||||
}
|
||||
|
||||
function readValue() {
|
||||
status.value = `当前共 ${code.value.length} 个字符`;
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
editorRef.value?.openSearch(true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
height: 18rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<duration-picker v-model="duration" @change="onChange" />
|
||||
|
||||
<div class="result">
|
||||
<p class="value" v-text="`${duration} ms`" />
|
||||
<p class="text" v-text="humanized" />
|
||||
<p class="text" v-text="`变更次数:${changeCount}`" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { DurationPicker } from "timi-tdesign-mobile";
|
||||
|
||||
const duration = ref(1000);
|
||||
const changeCount = ref(0);
|
||||
|
||||
/** 把毫秒换算成便于阅读的形式,仅用于演示展示。 */
|
||||
const humanized = computed(() => {
|
||||
const value = duration.value;
|
||||
if (value < 1000) {
|
||||
return `${value} 毫秒`;
|
||||
}
|
||||
if (value < 60_000) {
|
||||
return `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 2)} 秒`;
|
||||
}
|
||||
if (value < 3600_000) {
|
||||
return `${(value / 60_000).toFixed(2)} 分钟`;
|
||||
}
|
||||
if (value < 86400_000) {
|
||||
return `${(value / 3600_000).toFixed(2)} 小时`;
|
||||
}
|
||||
return `${(value / 86400_000).toFixed(2)} 天`;
|
||||
});
|
||||
|
||||
function onChange() {
|
||||
changeCount.value += 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.result {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin: 0 0 .25rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0 0 .25rem;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<input v-model="value.subject" class="field" placeholder="通知主题" />
|
||||
|
||||
<notify-editor
|
||||
ref="editorRef"
|
||||
:model-value="value"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onChange"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
|
||||
<div class="footer">
|
||||
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
|
||||
<span class="status" v-text="status" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { NotifyEditor } from "timi-tdesign-mobile";
|
||||
import type { NotifyDetail, NotifyEditorExpose, UploadResult } from "timi-tdesign-mobile";
|
||||
|
||||
const value = ref<NotifyDetail>({
|
||||
subject: "编辑器通知演示",
|
||||
contentType: "TIP_TAP",
|
||||
content: `<h2>通知内容</h2>
|
||||
<p>通知复用文章编辑器,同时保留自己的元数据字段(如 <code>subject</code>)。</p>`,
|
||||
attachmentList: []
|
||||
});
|
||||
|
||||
const status = ref("等待提交");
|
||||
const editorRef = ref<NotifyEditorExpose>();
|
||||
|
||||
function onChange(detail: NotifyDetail) {
|
||||
value.value = detail;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const detail = await editorRef.value?.submit();
|
||||
if (detail) {
|
||||
value.value = detail;
|
||||
status.value = `已提交,正文 ${detail.content?.length || 0} 个字符`;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: .375rem .5rem;
|
||||
margin-bottom: .75rem;
|
||||
box-sizing: border-box;
|
||||
font-size: .8125rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
align-items: center;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<notify-view :detail="detail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { NotifyView } from "timi-tdesign-mobile";
|
||||
import type { NotifyDetail } from "timi-tdesign-mobile";
|
||||
|
||||
/** NotifyView 只关心正文相关字段,主题与状态由业务页面自行呈现。 */
|
||||
const detail: NotifyDetail = {
|
||||
subject: "系统维护通知",
|
||||
contentType: "TIP_TAP",
|
||||
content: `<h2>系统维护通知</h2>
|
||||
<p>本周六 02:00 - 04:00 进行数据库升级,期间服务不可用。</p>
|
||||
<ul><li>请提前保存工作内容</li><li>升级完成后会同步通知</li></ul>`
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<root-layout
|
||||
icp="京ICP备 00000000 号"
|
||||
domain="example.com"
|
||||
author="Timi"
|
||||
>
|
||||
<div class="body">
|
||||
<h3 v-text="'RootLayout'" />
|
||||
<p v-text="'移动端页面骨架:插槽填充主体,底部自动渲染版权与备案信息。'" />
|
||||
</div>
|
||||
</root-layout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { RootLayout } from "timi-tdesign-mobile";
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.body {
|
||||
padding: 1.5rem 1rem;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 .5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="phone">
|
||||
<header class="head">
|
||||
<span class="title" v-text="'智能助手'" />
|
||||
<button type="button" class="reset" @click="resetConversation" v-text="'清空'" />
|
||||
</header>
|
||||
|
||||
<ai-chat-panel
|
||||
class="panel"
|
||||
:messages="messages"
|
||||
:is-generating="isGenerating"
|
||||
:active-tool-status="activeToolStatus"
|
||||
:last-error="lastError"
|
||||
:can-retry="canRetry"
|
||||
:loading="isLoadingConversation"
|
||||
:role-names="{ assistant: '分析助手', user: '我' }"
|
||||
@send="sendMessage"
|
||||
@stop="stopGeneration"
|
||||
@retry="retryLastMessage"
|
||||
>
|
||||
<template #welcome>
|
||||
<div class="welcome">
|
||||
<p class="welcome-title" v-text="'可以问我关于数据的问题'" />
|
||||
<p class="welcome-tip" v-text="'例如:上个月订单量怎么样?'" />
|
||||
</div>
|
||||
</template>
|
||||
</ai-chat-panel>
|
||||
</div>
|
||||
|
||||
<p class="note" v-text="'面板嵌在带标题栏的页面骨架里,欢迎语与角色名都做了自定义。'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { AiChatPanel, useAiChat } from "timi-tdesign-mobile";
|
||||
import { createMockReply } from "../ai/mock-sse";
|
||||
|
||||
/**
|
||||
* 完整场景:标题栏 + 面板 + 自定义欢迎语。
|
||||
* 会话状态全部来自 useAiChat,真实项目里把 request 换成后端 SSE 接口即可。
|
||||
*/
|
||||
const {
|
||||
messages,
|
||||
isGenerating,
|
||||
isLoadingConversation,
|
||||
activeToolStatus,
|
||||
lastError,
|
||||
canRetry,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
retryLastMessage,
|
||||
resetConversation
|
||||
} = useAiChat({
|
||||
request: async ({ message, signal }) => createMockReply(message, signal)
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.phone {
|
||||
height: 32rem;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: .625rem .75rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reset {
|
||||
padding: .25rem .5rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
margin: 0 0 .375rem;
|
||||
font-size: .875rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.welcome-tip {
|
||||
margin: 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: tab === 'edit' }"
|
||||
@click="tab = 'edit'"
|
||||
v-text="'编辑'"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: tab === 'view' }"
|
||||
@click="tab = 'view'"
|
||||
v-text="'预览'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="switcher">
|
||||
<button
|
||||
v-for="item in contentTypeList"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="chip"
|
||||
:class="{ active: article.contentType === item.value }"
|
||||
@click="switchContentType(item.value)"
|
||||
v-text="item.label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section v-show="tab === 'edit'" class="pane">
|
||||
<input v-model="article.title" class="field" placeholder="文章标题" />
|
||||
<article-editor
|
||||
ref="editorRef"
|
||||
:model-value="article"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onChange"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
<div class="footer">
|
||||
<button type="button" class="btn primary" @click="submit" v-text="'提交'" />
|
||||
<span class="status" v-text="status" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="tab === 'view'" class="pane">
|
||||
<article-view :article="article" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { ArticleEditor, ArticleView } from "timi-tdesign-mobile";
|
||||
import type {
|
||||
Article,
|
||||
ArticleContentType,
|
||||
ArticleEditorExpose,
|
||||
UploadResult
|
||||
} from "timi-tdesign-mobile";
|
||||
|
||||
/** 本场景验证「同一份 Article 数据,编辑与预览共用」。 */
|
||||
const RICH_CONTENT = `<h2>文章编辑与预览</h2>
|
||||
<p>两侧共用同一个 <code>Article</code> 对象,切换标签即可对比。</p>
|
||||
<blockquote><p>移动端用标签切换替代左右分栏。</p></blockquote>`;
|
||||
|
||||
const contentTypeList: Array<{ label: string; value: ArticleContentType }> = [
|
||||
{ label: "纯文本", value: "TEXT" },
|
||||
{ label: "Markdown", value: "MARKDOWN" },
|
||||
{ label: "富文本", value: "TIP_TAP" }
|
||||
];
|
||||
|
||||
const tab = ref("edit");
|
||||
const article = ref<Article>({
|
||||
title: "编辑器组件演示文章",
|
||||
contentType: "TIP_TAP",
|
||||
content: RICH_CONTENT,
|
||||
attachmentList: []
|
||||
});
|
||||
|
||||
const status = ref("等待提交");
|
||||
const editorRef = ref<ArticleEditorExpose>();
|
||||
|
||||
function switchContentType(contentType: ArticleContentType) {
|
||||
const content = contentType === "TEXT"
|
||||
? "这是一段纯文本示例。"
|
||||
: contentType === "MARKDOWN"
|
||||
? "## Markdown 示例\n\n这是一段 Markdown 正文。"
|
||||
: RICH_CONTENT;
|
||||
article.value = {
|
||||
...article.value,
|
||||
contentType,
|
||||
content
|
||||
};
|
||||
status.value = `已切换到 ${contentType}`;
|
||||
}
|
||||
|
||||
function onChange(value: Article) {
|
||||
article.value = value;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const value = await editorRef.value?.submit();
|
||||
if (value) {
|
||||
article.value = value;
|
||||
status.value = `已提交,${value.attachmentList?.length || 0} 个附件`;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
padding: .25rem;
|
||||
margin-bottom: .75rem;
|
||||
border-radius: .5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: .375rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: transparent;
|
||||
|
||||
&.active {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.switcher {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .375rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: .25rem .625rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: .375rem .5rem;
|
||||
margin-bottom: .75rem;
|
||||
box-sizing: border-box;
|
||||
font-size: .8125rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
align-items: center;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="item in tabList"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: tab === item.value }"
|
||||
@click="tab = item.value"
|
||||
v-text="item.label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section v-show="tab === 'rich'" class="pane">
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" @click="submitRich" v-text="'提交富文本'" />
|
||||
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
|
||||
</div>
|
||||
<div class="box tall">
|
||||
<tiptap-editor
|
||||
ref="tiptapRef"
|
||||
v-model="rich"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onRichChange"
|
||||
/>
|
||||
</div>
|
||||
<p class="status" v-text="status" />
|
||||
</section>
|
||||
|
||||
<section v-show="tab === 'view'" class="pane">
|
||||
<tiptap-view :content="rich" max-height="20rem" />
|
||||
</section>
|
||||
|
||||
<section v-show="tab === 'code'" class="pane">
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" @click="resetCode" v-text="'恢复示例'" />
|
||||
</div>
|
||||
<div class="box">
|
||||
<code-editor v-model="code" language="typescript" placeholder="请输入代码" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { CodeEditor, TiptapEditor, TiptapView } from "timi-tdesign-mobile";
|
||||
import type { TiptapEditorExpose, UploadResult } from "timi-tdesign-mobile";
|
||||
|
||||
/** 本场景把三类编辑器收在一个页面里,用标签切换而不是并排——移动端宽度放不下分栏。 */
|
||||
const DEMO_CODE = `type DemoUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const user: DemoUser = { id: "u1", name: "Timi" };`;
|
||||
|
||||
const tabList = [
|
||||
{ label: "富文本", value: "rich" },
|
||||
{ label: "只读视图", value: "view" },
|
||||
{ label: "代码", value: "code" }
|
||||
];
|
||||
|
||||
const tab = ref("rich");
|
||||
const rich = ref(`<h2>跨平台编辑器</h2>
|
||||
<p>这段内容同时验证标题、段落、引用与代码块的渲染。</p>
|
||||
<blockquote><p>移动端和 PC 端只替换交互层,共享文档模型。</p></blockquote>`);
|
||||
const code = ref(DEMO_CODE);
|
||||
const status = ref("等待操作");
|
||||
const tiptapRef = ref<TiptapEditorExpose>();
|
||||
|
||||
function onRichChange(value: string) {
|
||||
status.value = `内容已更新,${value.length} 个字符`;
|
||||
}
|
||||
|
||||
function resetCode() {
|
||||
code.value = DEMO_CODE;
|
||||
}
|
||||
|
||||
async function submitRich() {
|
||||
const value = await tiptapRef.value?.submit();
|
||||
if (value !== undefined) {
|
||||
rich.value = value;
|
||||
status.value = `已提交,${value.length} 个字符`;
|
||||
}
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
tiptapRef.value?.openSearch(true);
|
||||
}
|
||||
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
padding: .25rem;
|
||||
margin-bottom: .75rem;
|
||||
border-radius: .5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: .375rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: transparent;
|
||||
|
||||
&.active {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
height: 16rem;
|
||||
}
|
||||
|
||||
.tall {
|
||||
height: 22rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="card">
|
||||
<t-cell title="缓存有效期" arrow @click="pickerVisible = true">
|
||||
<span v-text="humanized" />
|
||||
</t-cell>
|
||||
</div>
|
||||
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" @click="sheetVisible = true" v-text="'打开底部弹层'" />
|
||||
</div>
|
||||
|
||||
<p class="status" v-text="`弹层最近一次操作:${lastAction}`" />
|
||||
|
||||
<space size="small" class="tags">
|
||||
<span v-for="item in 3" :key="item" class="tag" v-text="`标签 ${item}`" />
|
||||
</space>
|
||||
|
||||
<t-popup v-model="pickerVisible" placement="bottom" destroy-on-close>
|
||||
<div class="picker-panel">
|
||||
<duration-picker v-model="durationMs" @change="pickerVisible = false" />
|
||||
</div>
|
||||
</t-popup>
|
||||
|
||||
<bottom-popup
|
||||
v-model="sheetVisible"
|
||||
title="选择操作"
|
||||
@left-button-click="onAction('取消')"
|
||||
@right-button-click="onAction('确定')"
|
||||
>
|
||||
<div class="sheet-content">
|
||||
<p v-text="'底部弹层适合承载一组操作或一段简短表单。'" />
|
||||
</div>
|
||||
</bottom-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { BottomPopup, DurationPicker, Space } from "timi-tdesign-mobile";
|
||||
|
||||
/**
|
||||
* 本场景是移动端最典型的两种浮现方式:整块选择面板用 `t-popup` 从底部升起,
|
||||
* 操作确认用 BottomPopup。
|
||||
*/
|
||||
const durationMs = ref(1000);
|
||||
const pickerVisible = ref(false);
|
||||
const sheetVisible = ref(false);
|
||||
const lastAction = ref("无");
|
||||
|
||||
/** 把毫秒换算成便于阅读的形式。 */
|
||||
const humanized = computed(() => {
|
||||
const value = durationMs.value;
|
||||
if (value % (60 * 60 * 1000) === 0) {
|
||||
return `${value / (60 * 60 * 1000)} 小时`;
|
||||
}
|
||||
if (value % (60 * 1000) === 0) {
|
||||
return `${value / (60 * 1000)} 分钟`;
|
||||
}
|
||||
if (value % 1000 === 0) {
|
||||
return `${value / 1000} 秒`;
|
||||
}
|
||||
return `${value} 毫秒`;
|
||||
});
|
||||
|
||||
function onAction(action: string) {
|
||||
lastAction.value = action;
|
||||
sheetVisible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.card {
|
||||
overflow: hidden;
|
||||
margin-bottom: .75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
.bar {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: .5rem;
|
||||
font-size: .8125rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .5rem;
|
||||
color: #fff;
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 0 0 .75rem;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.tags {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: .25rem .5rem;
|
||||
font-size: .6875rem;
|
||||
border-radius: .25rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-default-soft);
|
||||
}
|
||||
|
||||
.picker-panel {
|
||||
padding-top: .5rem;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.sheet-content {
|
||||
padding: 1rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: .8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: tab === 'edit' }"
|
||||
@click="tab = 'edit'"
|
||||
v-text="'编辑'"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ active: tab === 'view' }"
|
||||
@click="tab = 'view'"
|
||||
v-text="'预览'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
@click="switchStatus"
|
||||
v-text="'切换状态'"
|
||||
/>
|
||||
<span class="tag" v-text="detail.status" />
|
||||
</div>
|
||||
|
||||
<section v-show="tab === 'edit'" class="pane">
|
||||
<input v-model="detail.subject" class="field" placeholder="通知主题" />
|
||||
<notify-editor
|
||||
ref="editorRef"
|
||||
:model-value="detail"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onChange"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
<div class="footer">
|
||||
<button type="button" class="btn primary" @click="submit" v-text="'提交通知'" />
|
||||
<span class="status" v-text="status" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="tab === 'view'" class="pane">
|
||||
<notify-view :detail="detail" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { NotifyEditor, NotifyView } from "timi-tdesign-mobile";
|
||||
import type { NotifyDetail, NotifyEditorExpose, UploadResult } from "timi-tdesign-mobile";
|
||||
|
||||
/** 本场景验证通知复用文章编辑能力,同时保留自己的元数据字段。 */
|
||||
const RICH_CONTENT = `<h2>系统维护通知</h2>
|
||||
<p>本周六 02:00 - 04:00 进行数据库升级,期间服务不可用。</p>
|
||||
<ul><li>请提前保存工作内容</li><li>升级完成后会同步通知</li></ul>`;
|
||||
|
||||
const statusList = ["WAITING", "SENT", "FAILED"];
|
||||
|
||||
const tab = ref("edit");
|
||||
const detail = ref<NotifyDetail>({
|
||||
subject: "编辑器通知演示",
|
||||
contentType: "TIP_TAP",
|
||||
content: RICH_CONTENT,
|
||||
status: "WAITING",
|
||||
attachmentList: []
|
||||
});
|
||||
|
||||
const status = ref("等待提交");
|
||||
const editorRef = ref<NotifyEditorExpose>();
|
||||
|
||||
function switchStatus() {
|
||||
const index = statusList.indexOf(detail.value.status);
|
||||
const next = statusList[(index + 1) % statusList.length];
|
||||
detail.value = {
|
||||
...detail.value,
|
||||
status: next
|
||||
};
|
||||
}
|
||||
|
||||
function onChange(value: NotifyDetail) {
|
||||
detail.value = value;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const value = await editorRef.value?.submit();
|
||||
if (value) {
|
||||
detail.value = value;
|
||||
status.value = `已提交,正文 ${value.content?.length || 0} 个字符`;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
padding: .25rem;
|
||||
margin-bottom: .75rem;
|
||||
border-radius: .5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: .375rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: transparent;
|
||||
|
||||
&.active {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: .25rem .625rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: .125rem .5rem;
|
||||
font-size: .6875rem;
|
||||
border-radius: .25rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-default-soft);
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: .375rem .5rem;
|
||||
margin-bottom: .75rem;
|
||||
box-sizing: border-box;
|
||||
font-size: .8125rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: .75rem;
|
||||
align-items: center;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<section class="block">
|
||||
<h4 v-text="'水平(默认 medium)'" />
|
||||
<space>
|
||||
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
|
||||
</space>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h4 v-text="'预设尺寸 small / large'" />
|
||||
<space size="small">
|
||||
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
|
||||
</space>
|
||||
<space size="large">
|
||||
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
|
||||
</space>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h4 v-text="'垂直排列'" />
|
||||
<space direction="vertical">
|
||||
<div v-for="item in 3" :key="item" class="box" v-text="`项 ${item}`" />
|
||||
</space>
|
||||
</section>
|
||||
|
||||
<section class="block">
|
||||
<h4 v-text="'分隔符'" />
|
||||
<space separator="|">
|
||||
<span v-for="item in 3" :key="item" v-text="`项 ${item}`" />
|
||||
</space>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Space } from "timi-tdesign-mobile";
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.block {
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 .5rem;
|
||||
font-size: .8125rem;
|
||||
}
|
||||
|
||||
.box {
|
||||
padding: .375rem .625rem;
|
||||
font-size: .75rem;
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-default-soft);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<div class="bar">
|
||||
<button type="button" class="btn" @click="submit" v-text="'提交'" />
|
||||
<button type="button" class="btn" @click="openSearch" v-text="'打开查找'" />
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<tiptap-editor
|
||||
ref="editorRef"
|
||||
v-model="content"
|
||||
:upload-image="uploadMedia"
|
||||
:upload-video="uploadMedia"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="status" v-text="status" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { TiptapEditor } from "timi-tdesign-mobile";
|
||||
import type { TiptapEditorExpose, UploadResult } from "timi-tdesign-mobile";
|
||||
|
||||
const content = ref(`<h2>富文本编辑器</h2>
|
||||
<p>工具栏覆盖标题、颜色、列表、表格、链接与代码块。</p>
|
||||
<blockquote><p>移动端工具栏会自动换行排列。</p></blockquote>`);
|
||||
|
||||
const status = ref("等待操作");
|
||||
const editorRef = ref<TiptapEditorExpose>();
|
||||
|
||||
function onChange(value: string) {
|
||||
status.value = `内容已更新,${value.length} 个字符`;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const value = await editorRef.value?.submit();
|
||||
if (value !== undefined) {
|
||||
content.value = value;
|
||||
status.value = `已提交,${value.length} 个字符`;
|
||||
}
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
editorRef.value?.openSearch(true);
|
||||
}
|
||||
|
||||
/** 演示用上传:直接把本地文件转成临时地址,无需后端。 */
|
||||
async function uploadMedia(file: File): Promise<UploadResult> {
|
||||
return {
|
||||
id: `demo-${Date.now()}-${file.name}`,
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(file)
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: .25rem .75rem;
|
||||
font-size: .75rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
height: 24rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: .75rem 0 0;
|
||||
font-size: .75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="demo">
|
||||
<input v-model="maxHeight" class="field" placeholder="最大高度,如 16rem" />
|
||||
<tiptap-view :content="content" :max-height="maxHeight" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from "vue";
|
||||
import { TiptapView } from "timi-tdesign-mobile";
|
||||
|
||||
const content = `<h2>只读视图</h2>
|
||||
<p>与编辑器共用同一套渲染规则,内容超出 <code>maxHeight</code> 时可滚动查看。</p>
|
||||
<blockquote><p>适合详情页、审批流等只需要阅读的场景。</p></blockquote>`;
|
||||
|
||||
const maxHeight = ref("16rem");
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.demo {
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: .375rem .5rem;
|
||||
margin-bottom: .75rem;
|
||||
box-sizing: border-box;
|
||||
font-size: .8125rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: .375rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user