作者:最後一個農民工@掘金,轉載需注明出處。
文章中示例的原始碼地址:github.com/fffmoon/doubao-template-input
目前 AI 浪潮如火如荼,相信各位同行都接過不少 AI 項目。今天和大家分享一個實用場景:如何用 Vue3 實現類似豆包的模板式智能輸入框。
踩坑經驗:第一眼以為是富文本,細看才發現不簡單。傳統富文本編輯器(如 Quill、wangEditor)處理這種動態模板交互簡直是噩夢——DOM 操作複雜、狀態管理困難、複製粘貼全是坑。
豆包輸入框的主要功能點:
還是先看看市面上有沒有現成的輪子吧。搜索開源方案時,發現幾種常見實現:
都不合適。經驗告訴我豆包的模板輸入框是極大可能是使用了 成熟的組件庫。
<xml>
<span data-slate-node="text">
<span data-slate-leaf="true"><span data-slate-string="true">你好,我是XXX</span></span>
</span>
</xml>
果然,可以看到這邊的 HTML 結構是有一定規則的,並且在很多節點上,有統一的 data-slate-xxx 類型的資料,這命名風格,太有辨識度了。
這是 slate.js 庫,是 React 的 親兒子,完蛋。
豆包的回答開始牛頭不對馬嘴,估計是這個庫在國內用的人少
slate-vue3 專業對口!感謝大佬又捞了我一把。這就是為 Vue3 量身定制的 Slate 適配層。
pnpm create vite@latest doubao-template-input
選擇 vue3 => 選擇 ts
pnpm i sass
# 注意:如果不想用 unocss 的同學,不需要安裝下面的,將有 unocss 的程式碼丟到 ai 工具裡面轉一下就行,我這裡是為了快速開發。
pnpm i -D unocss @unocss/preset-uno @unocss/eslint-config @unocss/preset-icons @iconify-json/mdi @iconify/vue @iconify-json/ion @iconify/utils
依賴版本如下:
{
"name": "doubao-template-input",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@iconify-json/mdi": "^1.2.3",
"@iconify/vue": "^5.0.0",
"sass": "^1.92.1",
"vue": "^3.5.18"
},
"devDependencies": {
"@iconify-json/ion": "^1.2.2",
"@iconify/utils": "^2.3.0",
"@types/node": "^24.3.1",
"@unocss/eslint-config": "^0.65.4",
"@unocss/preset-icons": "0.65.4",
"@unocss/preset-uno": "^0.65.4",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"unocss": "^0.65.4",
"vite": "^7.1.2",
"vue-tsc": "^3.0.5"
}
}
項目根目錄新建 uno.config.ts
import { resolve } from "node:path";
import { FileSystemIconLoader } from "@iconify/utils/lib/loader/node-loaders";
import { presetIcons } from "@unocss/preset-icons";
import {
defineConfig,
presetAttributify,
presetUno,
transformerDirectives,
} from "unocss";
export default defineConfig({
presets: [
presetUno(),
presetAttributify(),
presetIcons({
// 圖標集合配置
collections: {
// 使用已安裝的圖標集
ion: () =>
import("@iconify-json/ion/icons.json").then((i) => i.default),
mdi: () =>
import("@iconify-json/mdi/icons.json").then((i) => i.default),
// 自定義圖標集合
custom: FileSystemIconLoader(
resolve(process.cwd(), "src/assets/svg"),
),
},
// 圖標樣式
extraProperties: {
display: "inline-block",
"vertical-align": "middle",
},
scale: 1,
// i-{collection}-{icon}
prefix: "i-",
}),
],
transformers: [transformerDirectives()],
// 定義組合
shortcuts: {
// 定義單個樣式組合
// 寬高100%
"wh-full": "w-full h-full",
// 一行顯示
"text-truncate":
"overflow-hidden text-ellipsis whitespace-nowrap break-words",
// 居中
"flex-center": "flex items-center justify-center",
},
// 定義自定義規則
rules: [],
});
還需要複製包括豆包的一些變數,方便快速開發,由於程式碼過多不一一展示,可以參考我的倉庫,也可以去豆包網站的 root 節點上複製。
/* reset.scss */
/* Reset box sizing */
*, *::before, *::after {
box-sizing: border-box;
}
/* Remove default margin and padding */
html, body, div, h1, h2, h3, h4, h5, h6, p, ul, li, figure, figcaption {
margin: 0;
padding: 0;
}
/* Remove list styles */
ul, ol {
list-style: none;
}
/* Set a consistent style for buttons */
button {
cursor: pointer;
}
/* Set a consistent width for images */
img {
max-width: 100%;
height: auto;
}
.clearfix::after {
content: "";
display: table;
clear: both;
}
#app, body, html, main {
height: 100%;
}
因為演示的關係,我們直接在 APP.VUE 上操作。
APP.VUE
<script setup lang="ts">
import ChatInput from './components/ChatInput/index.vue'
</script>
<template>
<div class="container">
<div class="content-wrapper">
<h1>幫我寫作</h1>
<h2>多種體裁,潤色校對,一鍵成文</h2>
<ChatInput ref="chatInputRef" />
</div>
</div>
</template>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
.content-wrapper {
display: flex;
flex-direction: column;
justify-content: center;
max-width: 809px;
width: 100%;
h1 {
color: var(--s-color-text-secondary);
font: var(--s-font-h1);
margin: 28px 0 10px 0;
text-align: center;
}
h2 {
height: 52px;
font: var(--s-font-base);
text-align: center;
color: rgba(0, 0, 0, 0.3);
margin-bottom: 20px;
}
}
}
</style>
在 components 中新建 ChatInput/index.vue,完成基礎的結構搭建,直接貼程式碼:
<script lang='ts' setup>
</script>
<template>
<div class='input-container w-full h-128px w-full relative flex flex-col'>
<!-- 輸入框 -->
<div class="editor-container flex flex-col relative wh-full min-h-0 min-w-0 flex-1 p-[12px_12px_12px_16px]">
<!-- 輸入框主體 -->
<div class="wh-full flex-1 min-h-0 min-w-0"></div>
</div>
<!-- 技能 -->
<div class="skill-box flex items-center justify-between p-[0_12px_12px_12px]">
<div class="left-box flex flex-center gap-10px">
<div class="btn-box !p-x-7px">
<!-- 旋轉-45度 -->
<div class="icon i-mdi-attachment rotate-315 text-16px"></div>
</div>
<div class="btn-box">
<div class="icon i-custom-think"></div>
<div class="label">深度思考</div>
</div>
<div class="btn-box">
<div class="icon i-mdi-web"></div>
<div class="label">搜索資料</div>
</div>
</div>
<div class="right-box flex flex-center">
<div class="icon-btn ">
<div class="icon i-custom-microphone size-18px"></div>
</div>
<div class="split h-19px w-1px bg-[var(--s-color-border-tertiary)] m-l-4px m-r-12px"></div>
<div class="icon-btn send-btn">
<div class="icon i-custom-arrow-up size-16px color-[var(--s-color-text-inverse-tertiary)]"></div>
</div>
</div>
</div>
</div>
</template>
<style lang='scss' scoped>
.input-container {
--chat-input-skill-border-radius: 12px;
&::after {
border: 1px solid var(--s-color-border-tertiary);
border-radius: var(--chat-input-skill-border-radius);
bottom: 0;
content: "";
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
}
.editor-container {}
.skill-box {
.btn-box {
border: 1px solid var(--s-color-border-tertiary);
color: var(--s-color-text-secondary);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 12px;
}
}
}
</style>
效果圖預覽:
主體結構搭建好了
pnpm i slate-vue3
直接使用 slate.js 的 Placehold 案例。
<script lang='ts' setup>
import { Slate, Editable, type RenderPlaceholderProps } from "slate-vue3"
import { h, ref } from "vue"
import { createEditor } from "slate-vue3/core"
import { withDOM } from "slate-vue3/dom"
import { withHistory } from "slate-vue3/history"
// #region ➤ 初始化編輯器
// ================================================
const initialValue = [
{
type: 'paragraph', children: [
{ text: '' },
],
},
]
const renderPlaceholder = ({ children, attributes }: RenderPlaceholderProps) => {
return h('div', attributes,
[
h('p', null, children),
h('pre', null, 'Use the renderPlaceholder prop to customize rendering of the placeholder')
])
}
const editor = withHistory(withDOM(createEditor()));
editor.children = initialValue;
// #endregion 初始化編輯器
</script>
<template>
<div class='input-container w-full h-128px w-full relative flex flex-col'>
<!-- 輸入框 -->
<div class="editor-container flex flex-col relative wh-full min-h-0 min-w-0 flex-1 p-[12px_12px_12px_16px]">
<!-- 輸入框主體 -->
<div class="wh-full flex-1 min-h-0 min-w-0">
<Slate :editor="editor" :render-placeholder="renderPlaceholder">
<Editable style="padding: 10px;" placeholder="Type something" />
</Slate>
</div>
</div>
<!-- 技能 -->
<div class="skill-box flex items-center justify-between p-[0_12px_12px_12px]">
<div class="left-box flex flex-center gap-10px">
<div class="btn-box !p-x-7px">
<!-- 旋轉-45度 -->
<div class="icon i-mdi-attachment rotate-315 text-16px"></div>
</div>
<div class="btn-box">
<div class="icon i-custom-think"></div>
<div class="label">深度思考</div>
</div>
<div class="btn-box">
<div class="icon i-mdi-web"></div>
<div class="label">搜索資料</div>
</div>
</div>
<div class="right-box flex flex-center">
<div class="icon-btn ">
<div class="icon i-custom-microphone size-18px"></div>
</div>
<div class="split h-19px w-1px bg-[var(--s-color-border-tertiary)] m-l-4px m-r-12px"></div>
<div class="icon-btn send-btn">
<div class="icon i-custom-arrow-up size-16px color-[var(--s-color-text-inverse-tertiary)]"></div>
</div>
</div>
</div>
</div>
</template>
<style lang='scss' scoped>
.input-container {
--chat-input-skill-border-radius: 12px;
&::after {
border: 1px solid var(--s-color-border-tertiary);
border-radius: var(--chat-input-skill-border-radius);
bottom: 0;
content: "";
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
}
.editor-container {}
.skill-box {
.btn-box {
border: 1px solid var(--s-color-border-tertiary);
color: var(--s-color-text-secondary);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 12px;
}
}
}
</style>
現在可以正常輸入文字了,並且在沒有輸入的時候也會顯示 placeholder 內容。
在 components 目錄下新建 type.ts,類型聲明如下:
import type { BaseEditor, BaseElement } from "slate-vue3/core";
import type { DOMEditor } from "slate-vue3/dom";
export type CustomElement =
| ParagraphElement
| InputTagElement
| SelectTagElement;
// 段落元素
export interface ParagraphElement extends BaseElement {
type: "paragraph";
children: (CustomText | CustomElement)[];
}
// 輸入標籤元素
export interface InputTagElement extends BaseElement {
type: "input-tag";
label: string;
children: CustomText[];
}
// 選擇標籤元素
export interface SelectTagElement extends BaseElement {
type: "select-tag";
value: string;
options: { label: string; value: string }[];
children: CustomText[];
}
// 節點聯合類型
export type CustomNode = CustomElement | CustomText;
export interface CustomText {
text: string;
}
export interface selectTagOption {
label: string;
value: string;
}
export type CustomEditor = BaseEditor & DOMEditor;
其中 select-tag 就是下拉框;input-tag 就是輸入框。
先看豆包的效果如下:
<script lang='ts' setup>
</script>
<template>
<div class='base-container'>SelectTag.vue</div>
</template>
<style scoped>
</style>
在 Slate 組件上添加 render-element,自定義節點的渲染
<Slate ... :render-element="renderElement" ... >
在 ts 中添加的 renderElement 函數
const renderElement = ({ attributes, children, element }: RenderElementProps) => {
const customElement = element as CustomElement;
switch (customElement.type) {
case 'select-tag':
return h(SelectTag as unknown as Component, {
...useInheritRef(attributes),
element
}, () => children);
default:
return h('p', { ...attributes, class: 'slate-common-p' }, children);
}
}
const initialValue = [
{
type: 'paragraph', children: [
{ text: '' },
{ type: 'select-tag', children: [{ text: '' }], value: '選項1', options: [{ label: '選項1', value: '選項1' }, { label: '選項2', value: '選項2' }, { label: '選項3', value: '選項3' }] },
{ text: '' }, // 不要忘記用 text 結尾,不然會出現問題
],
},
]
完整程式碼如下:
<script lang='ts' setup>
import { Slate, Editable, type RenderPlaceholderProps, type RenderElementProps, useInheritRef } from "slate-vue3"
import { h, type Component } from "vue"
import { createEditor } from "slate-vue3/core"
import { withDOM } from "slate-vue3/dom"
import { withHistory } from "slate-vue3/history"
import { type CustomElement } from "../type"
import SelectTag from "./components/SelectTag.vue"
// #region ➤ 初始化編輯器
// ================================================
const initialValue = [
{
type: 'paragraph', children: [
{ text: '' },
{ type: 'select-tag', children: [{ text: '' }], value: '選項1', options: [{ label: '選項1', value: '選項1' }, { label: '選項2', value: '選項2' }, { label: '選項3', value: '選項3' }] },
{ text: '' }, // 不要忘記用 text 結尾,不然會出現問題
],
},
]
const editor = withHistory(withDOM(createEditor()));
editor.children = initialValue;
// #endregion 初始化編輯器
const renderElement = ({ attributes, children, element }: RenderElementProps) => {
const customElement = element as CustomElement;
switch (customElement.type) {
case 'select-tag':
return h(SelectTag as unknown as Component, {
...useInheritRef(attributes),
element
}, () => children);
default:
return h('p', { ...attributes, class: 'slate-common-p' }, children);
}
}
</script>
<template>
<div class='input-container w-full h-128px w-full relative flex flex-col'>
<!-- 輸入框 -->
<div class="editor-container flex flex-col relative wh-full min-h-0 min-w-0 flex-1 p-[12px_12px_12px_16px]">
<!-- 輸入框主體 -->
<div class="wh-full flex-1 min-h-0 min-w-0">
<Slate :editor="editor" :render-element="renderElement">
<Editable style="padding: 10px;" placeholder="Type something" />
</Slate>
</div>
</div>
<!-- 技能 -->
<div class="skill-box flex items-center justify-between p-[0_12px_12px_12px]">
<div class="left-box flex flex-center gap-10px">
<div class="btn-box !p-x-7px">
<!-- 旋轉-45度 -->
<div class="icon i-mdi-attachment rotate-315 text-16px"></div>
</div>
<div class="btn-box">
<div class="icon i-custom-think"></div>
<div class="label">深度思考</div>
</div>
<div class="btn-box">
<div class="icon i-mdi-web"></div>
<div class="label">搜索資料</div>
</div>
</div>
<div class="right-box flex flex-center">
<div class="icon-btn ">
<div class="icon i-custom-microphone size-18px"></div>
</div>
<div class="split h-19px w-1px bg-[var(--s-color-border-tertiary)] m-l-4px m-r-12px"></div>
<div class="icon-btn send-btn">
<div class="icon i-custom-arrow-up size-16px color-[var(--s-color-text-inverse-tertiary)]"></div>
</div>
</div>
</div>
</div>
</template>
<style lang='scss' scoped>
.input-container {
--chat-input-skill-border-radius: 12px;
&::after {
border: 1px solid var(--s-color-border-tertiary);
border-radius: var(--chat-input-skill-border-radius);
bottom: 0;
content: "";
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
}
.editor-container {}
.skill-box {
.btn-box {
border: 1px solid var(--s-color-border-tertiary);
color: var(--s-color-text-secondary);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 36px;
padding: 0 12px;
}
}
}
</style>
接下來將“計算機”改為輸入框組件。
最終的呈現效果如下:
已經完全一模一樣了。
現在實現拿到用戶輸入內容的邏輯,
具體邏輯是:點擊發送按鈕的時候,我們要從編輯器中拿到所有組件的值:
<div class="icon-btn send-btn" @click="send"></div>
// #region ➤ 獲取發送內容
// ================================================
// 自定義序列化函數,處理所有元素類型
const serializeToPlainText = (nodes: any[]): string => {
return nodes.map(node => {
// 處理文本節點
if (Text.isText(node)) {
return node.text;
}
// 處理自定義元素
const children = serializeToPlainText(node.children);
switch (node.type) {
case 'input-tag':
return children || node.label || '';
case 'select-tag':
return node.value || '';
case 'paragraph':
return children + '\n\n';
default:
return children;
}
}).join('');
};
function send() {
const content = serializeToPlainText(editor.children);
console.info('輸出內容', content);
alert(content);
}
// #endregion 獲取發送內容
點擊發送按鈕
輸出成功
接下來完成在外層選擇技能