Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions docs/demos/sender/actions-enhanced.vue
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
<script setup lang="ts">
import { ref } from 'vue'
import { TrSender, UploadButton, VoiceButton } from '@opentiny/tiny-robot'
import { Tag as TinyTag } from '@opentiny/vue'

const content = ref('')
const message = ref('')
const selectedFiles = ref<File[]>([])

const handleSubmit = (text: string) => {
message.value = `已提交: ${text}`
const fileNames = selectedFiles.value.map((file) => file.name).join(', ')
message.value = fileNames ? `已提交: ${text || '(无文本)'},附件: ${fileNames}` : `已提交: ${text}`
content.value = ''
selectedFiles.value = []
setTimeout(() => (message.value = ''), 3000)
}

const handleFiles = (files: File[]) => {
message.value = `选择了 ${files.length} 个文件: ${files.map((f) => f.name).join(', ')}`
setTimeout(() => (message.value = ''), 3000)
selectedFiles.value = [...selectedFiles.value, ...files]
}
Comment thread
xuanlid marked this conversation as resolved.

const handleClear = () => {
selectedFiles.value = []
}

const removeFile = (index: number) => {
selectedFiles.value = selectedFiles.value.filter((_, fileIndex) => fileIndex !== index)
}

const handleVoiceFinal = (text: string) => {
Expand All @@ -27,8 +38,10 @@ const handleVoiceFinal = (text: string) => {
v-model="content"
placeholder="输入内容,或使用语音/上传文件..."
mode="multiple"
:has-external-content="selectedFiles.length > 0"
clearable
@submit="handleSubmit"
@clear="handleClear"
>
<template #footer-right>
<!-- 上传按钮 -->
Expand All @@ -45,6 +58,18 @@ const handleVoiceFinal = (text: string) => {
</template>
</tr-sender>

<div v-if="selectedFiles.length" class="file-list">
<tiny-tag
v-for="(file, index) in selectedFiles"
:key="`${file.name}-${file.lastModified}-${index}`"
:max-width="240"
closable
@close="removeFile(index)"
>
{{ file.name }}
</tiny-tag>
</div>

<div v-if="message" class="message">{{ message }}</div>
</div>
</template>
Expand All @@ -61,4 +86,11 @@ const handleVoiceFinal = (text: string) => {
border-radius: 6px;
color: #1476ff;
}

.file-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
</style>
3 changes: 3 additions & 0 deletions docs/src/components/sender.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ TrSender.Suggestion.configure({ items: suggestions, filterFn: customFilter })

<demo vue="../../demos/sender/actions-enhanced.vue" title="增强按钮" description="通过插槽添加 Upload、Voice 等增强按钮。" />

附件、图片等内容通常由独立组件维护,不会写入 Sender 的编辑器文本。当用户只上传附件或图片、不输入额外文本时,Sender 无法感知这部分外部内容,会因输入框为空而不显示默认发送按钮。此时可以通过 `hasExternalContent` 告诉 Sender 当前存在外部可提交内容,例如 `:has-external-content="files.length > 0"`。设置后,即使输入框为空,默认发送按钮也会显示,快捷键提交和 `submit()` 方法也会按有内容处理。默认清空按钮仍只根据编辑器文本显示;外部内容数据由业务侧维护,建议由附件或图片列表提供自己的删除入口。

**配置详见**:[UploadButton 属性](#uploadbutton)、[VoiceButton 属性](#voicebutton)

## 交互定制
Expand Down Expand Up @@ -281,6 +283,7 @@ Sender 提供了多个插槽位置,方便扩展功能:
| enterkeyhint @0.4 | 移动端虚拟键盘回车键提示 | `EnterKeyHint` | `'send'` |
| autoSize | 自动调整高度 | `boolean \| { minRows: number, maxRows: number }` | `{ minRows: 1, maxRows: 5 }` |
| clearable | 是否可清空 | `boolean` | `false` |
| hasExternalContent @0.5 | 是否存在外部可提交内容,适用于附件、图片等不写入编辑器文本的内容 | `boolean` | `false` |
| maxLength | 最大输入长度 | `number` | `Infinity` |
| showWordLimit | 是否显示字数统计 | `boolean` | `false` |
| submitType | 提交方式 | `'enter' \| 'ctrlEnter' \| 'shiftEnter'` | `'enter'` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { computed } from 'vue'
import { useSenderContext } from '../../sender/context'

export const useClearButtonState = () => {
const { hasContent, clearable, loading, defaultActions } = useSenderContext()
const { hasEditorContent, clearable, loading, defaultActions } = useSenderContext()

const isDisabled = computed<boolean>(() => {
if (defaultActions.value?.clear?.disabled !== undefined) {
Expand All @@ -17,7 +17,7 @@ export const useClearButtonState = () => {
const tooltipPlacement = computed(() => defaultActions.value?.clear?.tooltipPlacement ?? 'top')

const show = computed<boolean>(() => {
return clearable.value && hasContent.value && !loading.value && !isDisabled.value
return clearable.value && hasEditorContent.value && !loading.value && !isDisabled.value
})

return {
Expand Down
7 changes: 6 additions & 1 deletion packages/components/src/sender/composables/useSenderCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,16 @@ export function useSenderCore(props: SenderPropsWithDefaults, emit: SenderEmits)
// 2. 基础状态计算(依赖 editor)
// ========================================

const hasContent = computed(() => {
const hasEditorContent = computed(() => {
if (!editor.value) return false
const text = getTextWithTemplates(editor.value)
return text.trim().length > 0
})

const hasContent = computed(() => {
return hasEditorContent.value || Boolean(props.hasExternalContent)
})

const characterCount = computed(() => {
if (!editor.value) return 0
return countGraphemes(getTextWithTemplates(editor.value))
Expand Down Expand Up @@ -271,6 +275,7 @@ export function useSenderCore(props: SenderPropsWithDefaults, emit: SenderEmits)
loading: computed(() => props.loading ?? false),
disabled: computed(() => props.disabled ?? false),
hasContent,
hasEditorContent,
canSubmit,
isOverLimit,
characterCount,
Expand Down
10 changes: 10 additions & 0 deletions packages/components/src/sender/index.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ export interface SenderProps {
*/
clearable?: boolean

/**
* 是否存在外部可提交内容
*
* 用于附件、图片、文件列表等不写入编辑器文本的内容场景。
* 当编辑器文本为空但该值为 true 时,Sender 仍会认为存在可提交内容。
*
* @default false
*/
hasExternalContent?: boolean

// ===== 扩展配置 =====

/**
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/sender/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const props = withDefaults(defineProps<SenderProps>(), {
size: 'normal',
submitType: 'enter',
enterkeyhint: 'send',
hasExternalContent: false,
extensions: () => [],
autoSize: () => ({ minRows: 1, maxRows: 5 }),
})
Expand Down
7 changes: 7 additions & 0 deletions packages/components/src/sender/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ export interface SenderContext {
*/
hasContent: Ref<boolean>

/**
* 编辑器内是否有文本内容
*
* 不包含附件、图片等外部内容,主要用于清空按钮等只作用于编辑器文本的场景。
*/
hasEditorContent: Ref<boolean>

/**
* 是否可以提交
*
Expand Down
13 changes: 13 additions & 0 deletions packages/test/src/sender/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export function createSenderTestHelper(page: Page) {
await page.locator(selectors.toggleSizeBtn).click()
},

async toggleExternalContent() {
await page.locator(selectors.toggleExternalContentBtn).click()
},

async setSubmitType(type: 'enter' | 'ctrlEnter' | 'shiftEnter') {
await page.locator(selectors.submitTypeSelect).selectOption(type)
},
Expand Down Expand Up @@ -144,6 +148,15 @@ export function createSenderTestHelper(page: Page) {
}
},

async expectSubmitButtonVisible(visible: boolean) {
const submitBtn = page.locator(selectors.submitButton)
if (visible) {
await expect(submitBtn).toBeVisible()
} else {
await expect(submitBtn).toHaveCount(0)
}
},

async expectWordCounter(text: string) {
await expect(page.locator(selectors.wordCounter)).toContainText(text)
},
Expand Down
6 changes: 6 additions & 0 deletions packages/test/src/sender/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const loading = ref(false)
const disabled = ref(false)
const showWordLimit = ref(true)
const isSmallSize = ref(false)
const hasExternalContent = ref(false)
const submitType = ref<'enter' | 'ctrlEnter' | 'shiftEnter'>('enter')
const maxLength = ref(100)
const placeholder = ref('请输入内容...')
Expand Down Expand Up @@ -258,6 +259,10 @@ onBeforeUnmount(() => {
<tiny-switch data-testid="toggle-size-btn" v-model="isSmallSize" @change="handleSizeChange"></tiny-switch>
<span>{{ size }}</span>
</div>
<div class="control-item">
<label>hasExternalContent:</label>
<tiny-switch data-testid="toggle-external-content-btn" v-model="hasExternalContent"></tiny-switch>
</div>
<div class="control-item">
<label>submitType:</label>
<select data-testid="submit-type-select" v-model="submitType">
Expand Down Expand Up @@ -337,6 +342,7 @@ onBeforeUnmount(() => {
:show-word-limit="showWordLimit"
:placeholder="placeholder"
:submit-type="submitType"
:has-external-content="hasExternalContent"
@submit="handleSubmit"
@cancel="handleCancel"
@clear="handleClear"
Expand Down
1 change: 1 addition & 0 deletions packages/test/src/sender/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const SENDER_SELECTORS = {
toggleDisabledBtn: '[data-testid="toggle-disabled-btn"]',
toggleWordLimitBtn: '[data-testid="toggle-word-limit-btn"]',
toggleSizeBtn: '[data-testid="toggle-size-btn"]',
toggleExternalContentBtn: '[data-testid="toggle-external-content-btn"]',
submitTypeSelect: '[data-testid="submit-type-select"]',
maxLengthInput: '[data-testid="max-length-input"]',
placeholderInput: '[data-testid="placeholder-input"]',
Expand Down
16 changes: 16 additions & 0 deletions packages/test/src/sender/specs/basic.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ test.describe('Sender 组件测试', () => {
await helper.expectLoadingButtonVisible(true)
})

test('Props: hasExternalContent - 应该支持仅外部内容提交', async () => {
await helper.expectEditorEmpty()
await helper.expectSubmitButtonVisible(false)

await helper.toggleClearable()
await helper.expectClearButtonVisible(false)

await helper.toggleExternalContent()
await helper.expectSubmitButtonVisible(true)
await helper.expectClearButtonVisible(false)
await helper.expectSubmitButtonDisabled(false)

await helper.clickSubmit()
await helper.expectResult('提交内容:')
})

test('Props: disabled - 应该正确控制禁用状态', async () => {
await helper.typeContent('测试内容')
await helper.expectEditorContent('测试内容')
Expand Down
Loading