chore: offline temporary mcp function demonstration demo (#3553)
* chore: 下线临时mcp功能演示demo * chore: 添加 @opentiny/tiny-vue-mcp 依赖版本 ^0.0.2
This commit is contained in:
parent
bfce947877
commit
b963e65b46
|
@ -1,17 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<tiny-grid
|
<tiny-grid :data="tableData" :edit-config="{ trigger: 'click', mode: 'cell', showStatus: true }" height="420px">
|
||||||
:data="tableData"
|
|
||||||
:edit-config="{ trigger: 'click', mode: 'cell', showStatus: true }"
|
|
||||||
height="420px"
|
|
||||||
:tiny_mcp_config="{
|
|
||||||
server,
|
|
||||||
business: {
|
|
||||||
id: 'company-information',
|
|
||||||
description: '公司人员信息表'
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<tiny-grid-column type="index" width="60"></tiny-grid-column>
|
<tiny-grid-column type="index" width="60"></tiny-grid-column>
|
||||||
<tiny-grid-column type="selection" width="60"></tiny-grid-column>
|
<tiny-grid-column type="selection" width="60"></tiny-grid-column>
|
||||||
<tiny-grid-column field="company" title="公司名称"></tiny-grid-column>
|
<tiny-grid-column field="company" title="公司名称"></tiny-grid-column>
|
||||||
|
@ -25,7 +14,6 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { TinyGrid, TinyGridColumn } from '@opentiny/vue'
|
import { TinyGrid, TinyGridColumn } from '@opentiny/vue'
|
||||||
import { useNextServer } from '@opentiny/next-vue'
|
|
||||||
|
|
||||||
// 初始化表格数据
|
// 初始化表格数据
|
||||||
const _table = [
|
const _table = [
|
||||||
|
@ -48,8 +36,4 @@ const _table = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const tableData = ref(_table)
|
const tableData = ref(_table)
|
||||||
|
|
||||||
const { server } = useNextServer({
|
|
||||||
serverInfo: { name: 'company-information', version: '1.0.0' }
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -27,10 +27,6 @@
|
||||||
"@docsearch/css": "^3.8.0",
|
"@docsearch/css": "^3.8.0",
|
||||||
"@docsearch/js": "^3.8.0",
|
"@docsearch/js": "^3.8.0",
|
||||||
"@docsearch/react": "npm:@docsearch/css",
|
"@docsearch/react": "npm:@docsearch/css",
|
||||||
"@opentiny/next-vue": "^0.0.1",
|
|
||||||
"@opentiny/tiny-robot": "0.2.1",
|
|
||||||
"@opentiny/tiny-robot-kit": "0.2.1",
|
|
||||||
"@opentiny/tiny-robot-svgs": "0.2.1",
|
|
||||||
"@opentiny/tiny-vue-mcp": "^0.0.2",
|
"@opentiny/tiny-vue-mcp": "^0.0.2",
|
||||||
"@opentiny/utils": "workspace:~",
|
"@opentiny/utils": "workspace:~",
|
||||||
"@opentiny/vue": "workspace:~",
|
"@opentiny/vue": "workspace:~",
|
||||||
|
|
|
@ -11,13 +11,11 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { defineComponent, onMounted, provide, ref, watch } from 'vue'
|
import { defineComponent, onMounted, provide, ref } from 'vue'
|
||||||
import { ConfigProvider, Modal } from '@opentiny/vue'
|
import { ConfigProvider, Modal } from '@opentiny/vue'
|
||||||
import { iconClose } from '@opentiny/vue-icon'
|
import { iconClose } from '@opentiny/vue-icon'
|
||||||
import { appData } from './tools'
|
import { appData } from './tools'
|
||||||
import useTheme from './tools/useTheme'
|
import useTheme from './tools/useTheme'
|
||||||
import { useNextClient } from '@opentiny/next-vue'
|
|
||||||
import { globalConversation, $session } from './composable/utils'
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'AppVue',
|
name: 'AppVue',
|
||||||
|
@ -31,21 +29,6 @@ export default defineComponent({
|
||||||
const previewUrl = ref(import.meta.env.VITE_PLAYGROUND_URL)
|
const previewUrl = ref(import.meta.env.VITE_PLAYGROUND_URL)
|
||||||
const modalSHow = ref(false)
|
const modalSHow = ref(false)
|
||||||
|
|
||||||
const { sessionId } = useNextClient({
|
|
||||||
clientInfo: { name: 'tiny-vue-website', version: '1.0.0' },
|
|
||||||
proxyOptions: { url: 'https://agent.icjs.ink/sse', token: '', sessionId: $session.sessionId }
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => sessionId.value,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
$session.sessionId = newVal
|
|
||||||
globalConversation.sessionId = newVal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 加载header
|
// 加载header
|
||||||
const common = new window.TDCommon(['#header'], {
|
const common = new window.TDCommon(['#header'], {
|
||||||
|
|
|
@ -1,117 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="message-card" :class="role">
|
|
||||||
<div class="avatar">
|
|
||||||
<component :is="avatarIcon" class="avatar-icon" />
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="role-name">{{ roleName }}</div>
|
|
||||||
<div class="message">{{ message }}</div>
|
|
||||||
<div class="time">{{ formatTime }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { IconAi, IconUser } from '@opentiny/tiny-robot-svgs'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
role: 'user' | 'assistant'
|
|
||||||
message: string
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const roleName = computed(() => (props.role === 'assistant' ? '智能助手' : '我'))
|
|
||||||
|
|
||||||
const avatarIcon = computed(() => (props.role === 'assistant' ? IconAi : IconUser))
|
|
||||||
|
|
||||||
const formatTime = computed(() => {
|
|
||||||
const date = new Date(props.timestamp)
|
|
||||||
return date.toLocaleTimeString('zh-CN', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.message-card {
|
|
||||||
display: flex;
|
|
||||||
margin: 16px;
|
|
||||||
gap: 12px;
|
|
||||||
max-width: 80%;
|
|
||||||
|
|
||||||
&.assistant {
|
|
||||||
margin-right: auto;
|
|
||||||
|
|
||||||
.content {
|
|
||||||
background-color: #f0f2f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-icon {
|
|
||||||
color: #2080f0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.user {
|
|
||||||
margin-left: auto;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
|
|
||||||
.content {
|
|
||||||
background-color: #e8f5e9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-icon {
|
|
||||||
color: #18a058;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: #f5f7fa;
|
|
||||||
|
|
||||||
.avatar-icon {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
position: relative;
|
|
||||||
min-width: 120px;
|
|
||||||
max-width: calc(100% - 60px);
|
|
||||||
|
|
||||||
.role-name {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.5;
|
|
||||||
word-break: break-word;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
margin-top: 4px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -60,13 +60,6 @@
|
||||||
</div>
|
</div>
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<div>
|
<div>
|
||||||
<div
|
|
||||||
v-if="appData.hasFloatRobot"
|
|
||||||
class="settings-btn style-settings-btn"
|
|
||||||
@click="appData.showTinyRobot = true"
|
|
||||||
>
|
|
||||||
<IconAi class="settings-icon style-settings-icon"></IconAi>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="settings-btn style-settings-btn"
|
class="settings-btn style-settings-btn"
|
||||||
@click="demoStyleVisible = !demoStyleVisible"
|
@click="demoStyleVisible = !demoStyleVisible"
|
||||||
|
@ -104,7 +97,6 @@ import useTheme from '@/tools/useTheme'
|
||||||
import { appData } from '@/tools/appData.js'
|
import { appData } from '@/tools/appData.js'
|
||||||
import { router } from '@/router'
|
import { router } from '@/router'
|
||||||
import useStyleSettings from '@/tools/useStyleSettings'
|
import useStyleSettings from '@/tools/useStyleSettings'
|
||||||
import { IconAi } from '@opentiny/tiny-robot-svgs'
|
|
||||||
|
|
||||||
// import ThemeSettingsIcon from '@/assets/images/theme-settings.svg'
|
// import ThemeSettingsIcon from '@/assets/images/theme-settings.svg'
|
||||||
import StyleSettingsIcon from '@/assets/images/style-settings.svg'
|
import StyleSettingsIcon from '@/assets/images/style-settings.svg'
|
||||||
|
@ -117,7 +109,6 @@ export default defineComponent({
|
||||||
TinyRadioGroup: RadioGroup,
|
TinyRadioGroup: RadioGroup,
|
||||||
IconUpWard: iconUpWard(),
|
IconUpWard: iconUpWard(),
|
||||||
TinyPopover: Popover,
|
TinyPopover: Popover,
|
||||||
IconAi,
|
|
||||||
// ThemeSettingsIcon,
|
// ThemeSettingsIcon,
|
||||||
StyleSettingsIcon
|
StyleSettingsIcon
|
||||||
},
|
},
|
||||||
|
@ -269,10 +260,6 @@ export default defineComponent({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less">
|
<style lang="less">
|
||||||
.docs-on-robot-show .float-settings {
|
|
||||||
right: 680px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.float-settings {
|
.float-settings {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 200px;
|
right: 200px;
|
||||||
|
|
|
@ -1,128 +0,0 @@
|
||||||
<template>
|
|
||||||
<!-- mcp-robot弹窗 -->
|
|
||||||
<tr-container v-model:show="appData.showTinyRobot" v-model:fullscreen="fullscreen">
|
|
||||||
<div v-if="messages.length === 0">
|
|
||||||
<tr-welcome title="智能助手" description="您好,我是OpenTiny AI智能助手" :icon="welcomeIcon">
|
|
||||||
<template #footer>
|
|
||||||
<div class="welcome-footer"></div>
|
|
||||||
</template>
|
|
||||||
</tr-welcome>
|
|
||||||
<tr-prompts
|
|
||||||
:items="customPromptItems"
|
|
||||||
:wrap="true"
|
|
||||||
item-class="prompt-item"
|
|
||||||
class="tiny-prompts"
|
|
||||||
@item-click="handlePromptItemClick"
|
|
||||||
></tr-prompts>
|
|
||||||
</div>
|
|
||||||
<tr-bubble-list v-else :items="messages" :roles="roles" auto-scroll></tr-bubble-list>
|
|
||||||
<template #footer>
|
|
||||||
<div class="chat-input">
|
|
||||||
<TrSuggestionPills :items="customSuggestionPillItems" @item-click="handleSuggestionPillItemClick" /><br />
|
|
||||||
<tr-sender
|
|
||||||
ref="senderRef"
|
|
||||||
mode="single"
|
|
||||||
v-model="inputMessage"
|
|
||||||
:placeholder="GeneratingStatus.includes(messageState.status) ? '正在思考中...' : '请输入您的问题'"
|
|
||||||
:clearable="true"
|
|
||||||
:loading="GeneratingStatus.includes(messageState.status)"
|
|
||||||
:showWordLimit="true"
|
|
||||||
:maxLength="1000"
|
|
||||||
:template="currentTemplate"
|
|
||||||
@submit="handleSendMessage"
|
|
||||||
@cancel="abortRequest"
|
|
||||||
@keydown="handleMessageKeydown($event, onTrigger, onKeyDown)"
|
|
||||||
@reset-template="clearTemplate"
|
|
||||||
></tr-sender>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</tr-container>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { TrBubbleList, TrContainer, TrPrompts, TrSender, TrWelcome, TrSuggestionPills } from '@opentiny/tiny-robot'
|
|
||||||
import { GeneratingStatus } from '@opentiny/tiny-robot-kit'
|
|
||||||
import { useTinyRobot } from '../composable/useTinyRobot'
|
|
||||||
import { appData } from '../tools/appData'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
promptItems: any[]
|
|
||||||
suggestionPillItems: any[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const {
|
|
||||||
fullscreen,
|
|
||||||
welcomeIcon,
|
|
||||||
promptItems: defaultPromptItems,
|
|
||||||
messages,
|
|
||||||
messageState,
|
|
||||||
inputMessage,
|
|
||||||
abortRequest,
|
|
||||||
roles,
|
|
||||||
handlePromptItemClick,
|
|
||||||
senderRef,
|
|
||||||
currentTemplate,
|
|
||||||
clearTemplate,
|
|
||||||
handleSendMessage,
|
|
||||||
handleMessageKeydown,
|
|
||||||
suggestionPillItems: defaultSuggestionPillItems,
|
|
||||||
handleSuggestionPillItemClick
|
|
||||||
} = useTinyRobot()
|
|
||||||
|
|
||||||
const customPromptItems = props.promptItems || defaultPromptItems
|
|
||||||
const customSuggestionPillItems = props.suggestionPillItems || defaultSuggestionPillItems
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.chat-input {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 10px 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tr-container {
|
|
||||||
top: 64px !important;
|
|
||||||
|
|
||||||
container-type: inline-size;
|
|
||||||
|
|
||||||
:deep(.tr-welcome__title-wrapper) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome-footer {
|
|
||||||
margin-top: 12px;
|
|
||||||
color: rgb(128, 128, 128);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiny-prompts {
|
|
||||||
padding: 16px 24px;
|
|
||||||
|
|
||||||
:deep(.prompt-item) {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
@container (width >=64rem) {
|
|
||||||
width: calc(50% - 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tr-prompt__content-label {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tr-history-demo {
|
|
||||||
position: absolute;
|
|
||||||
right: 100%;
|
|
||||||
top: 100%;
|
|
||||||
z-index: 100;
|
|
||||||
width: 300px;
|
|
||||||
height: 600px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,65 +0,0 @@
|
||||||
import type { ChatCompletionRequest } from '@opentiny/tiny-robot-kit'
|
|
||||||
import type { AIModelConfig } from '@opentiny/tiny-robot-kit'
|
|
||||||
import type { ChatCompletionResponse } from '@opentiny/tiny-robot-kit'
|
|
||||||
import type { StreamHandler } from '@opentiny/tiny-robot-kit'
|
|
||||||
import { BaseModelProvider } from '@opentiny/tiny-robot-kit'
|
|
||||||
import { globalConversation, handleSSEStream } from './utils.js'
|
|
||||||
import type { Ref } from 'vue'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 对接AIClient的自定义 Dify 大模型服务
|
|
||||||
*
|
|
||||||
* const client = new AIClient({
|
|
||||||
* provider: 'custom',
|
|
||||||
* providerImplementation: new CustomModelProvider( config )
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
export class DifyModelProvider extends BaseModelProvider {
|
|
||||||
_messages: Ref<ChatCompletionRequest['messages']> = []
|
|
||||||
|
|
||||||
constructor(config: AIModelConfig) {
|
|
||||||
super(config)
|
|
||||||
}
|
|
||||||
/** 同步请示不需要实现 */
|
|
||||||
chat(request: ChatCompletionRequest): Promise<ChatCompletionResponse> {
|
|
||||||
throw new Error('Method not implemented.')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 异步流式请求 */
|
|
||||||
async chatStream(request: ChatCompletionRequest, handler: StreamHandler): Promise<void> {
|
|
||||||
const { signal } = request
|
|
||||||
this.validateRequest(request)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 验证请求的messages属性,必须是数组,且每个消息必须有role\content属性
|
|
||||||
const lastMessage = request.messages[request.messages.length - 1].content
|
|
||||||
// 模拟异步流式响应
|
|
||||||
const response = await fetch(`${this.config.apiUrl}/chat-messages`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${this.config.apiKey}`,
|
|
||||||
'Accept': 'text/event-stream'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: lastMessage,
|
|
||||||
user: 'user',
|
|
||||||
response_mode: 'streaming',
|
|
||||||
inputs: {
|
|
||||||
sessionId: globalConversation.sessionId
|
|
||||||
},
|
|
||||||
conversation_id: globalConversation.id
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
await handleSSEStream(response, handler, this._messages, signal)
|
|
||||||
} catch (error) {
|
|
||||||
if (signal && signal.aborted) {
|
|
||||||
console.warn('Request was aborted:', error)
|
|
||||||
} else {
|
|
||||||
console.error('Error in chatStream:', error)
|
|
||||||
// handler.onError(handleRequestError(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,167 +0,0 @@
|
||||||
import type { AIModelConfig } from '@opentiny/tiny-robot-kit'
|
|
||||||
import { AIClient, useConversation } from '@opentiny/tiny-robot-kit'
|
|
||||||
import { IconAi, IconUser } from '@opentiny/tiny-robot-svgs'
|
|
||||||
import { h, nextTick, onMounted, ref, watch } from 'vue'
|
|
||||||
import { DifyModelProvider } from './DifyModelProvider.js'
|
|
||||||
import type { SuggestionItem } from '@opentiny/tiny-robot'
|
|
||||||
|
|
||||||
const difyConfig: AIModelConfig = {
|
|
||||||
provider: 'custom',
|
|
||||||
apiUrl: 'https://api.dify.ai/v1',
|
|
||||||
apiKey: 'app-H0VJI4LqZ4KskdcA5a07pjXf'
|
|
||||||
}
|
|
||||||
export function useTinyRobot() {
|
|
||||||
const difyModelProvider = new DifyModelProvider(difyConfig)
|
|
||||||
const client = new AIClient({
|
|
||||||
providerImplementation: difyModelProvider,
|
|
||||||
...difyConfig
|
|
||||||
})
|
|
||||||
|
|
||||||
const fullscreen = ref(false)
|
|
||||||
const show = ref(true)
|
|
||||||
|
|
||||||
const aiAvatar = h(IconAi, { style: { fontSize: '32px' } })
|
|
||||||
const userAvatar = h(IconUser, { style: { fontSize: '32px' } })
|
|
||||||
const welcomeIcon = h(IconAi, { style: { fontSize: '48px' } })
|
|
||||||
|
|
||||||
const promptItems = [
|
|
||||||
{
|
|
||||||
label: '识别网页的内容',
|
|
||||||
description: '公司人员表中员工最多和最少的公司,帮我统计一下!',
|
|
||||||
icon: h('span', { style: { fontSize: '18px' } }, '💡')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '智能操作网页',
|
|
||||||
description: '请帮我选中公司人员表中员工最多的公司',
|
|
||||||
icon: h('span', { style: { fontSize: '18px' } }, '🕹')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
const handlePromptItemClick = (ev, item) => {
|
|
||||||
sendMessage(item.description)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { messageManager } = useConversation({ client })
|
|
||||||
const { messages, messageState, inputMessage, sendMessage, abortRequest } = messageManager
|
|
||||||
difyModelProvider._messages = messages
|
|
||||||
|
|
||||||
const roles = {
|
|
||||||
assistant: {
|
|
||||||
type: 'markdown',
|
|
||||||
placement: 'start',
|
|
||||||
avatar: aiAvatar,
|
|
||||||
maxWidth: '80%'
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
placement: 'end',
|
|
||||||
avatar: userAvatar,
|
|
||||||
maxWidth: '80%'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 建议按钮组,设置对话的模板
|
|
||||||
const suggestionPillItems = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
text: '公司人员表',
|
|
||||||
icon: h('span', { style: { fontSize: '18px' } }, '🏢')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
function handleSuggestionPillItemClick(item: SuggestionItem) {
|
|
||||||
let templateText = `请对 [目标组件] ,执行 [操作]`
|
|
||||||
let currentInitialValue = { 目标组件: item.text, 操作: '' }
|
|
||||||
|
|
||||||
if (senderRef.value) {
|
|
||||||
senderRef.value.setTemplate(templateText, currentInitialValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const senderRef = ref(null)
|
|
||||||
const currentTemplate = ref('')
|
|
||||||
const suggestionOpen = ref(false)
|
|
||||||
|
|
||||||
// 清除当前指令
|
|
||||||
const clearTemplate = () => {
|
|
||||||
// 清空指令相关状态
|
|
||||||
currentTemplate.value = ''
|
|
||||||
|
|
||||||
// 确保重新聚焦到输入框
|
|
||||||
nextTick(() => {
|
|
||||||
senderRef.value?.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送消息
|
|
||||||
const handleSendMessage = () => {
|
|
||||||
sendMessage(inputMessage.value)
|
|
||||||
|
|
||||||
clearTemplate()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMessageKeydown = (event, triggerFn, suggestionKeyDown) => {
|
|
||||||
// 如果指令面板已打开,交给 suggestion 组件处理键盘事件
|
|
||||||
if (suggestionOpen.value) {
|
|
||||||
suggestionKeyDown(event)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果按下斜杠键并且不在指令编辑模式,触发指令面板
|
|
||||||
if (event.key === '/' && !currentTemplate.value) {
|
|
||||||
triggerFn({
|
|
||||||
text: '',
|
|
||||||
position: 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ESC 键清除当前指令
|
|
||||||
if (event.key === 'Escape' && currentTemplate.value) {
|
|
||||||
event.preventDefault()
|
|
||||||
clearTemplate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => inputMessage.value,
|
|
||||||
(value) => {
|
|
||||||
// 如果指令面板已打开,并且指令为空,关闭指令面板
|
|
||||||
if (suggestionOpen.value && value === '') {
|
|
||||||
suggestionOpen.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 页面加载完成后自动聚焦输入框
|
|
||||||
onMounted(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
senderRef.value?.focus()
|
|
||||||
}, 500)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
client,
|
|
||||||
fullscreen,
|
|
||||||
show,
|
|
||||||
aiAvatar,
|
|
||||||
userAvatar,
|
|
||||||
welcomeIcon,
|
|
||||||
promptItems,
|
|
||||||
|
|
||||||
messageManager,
|
|
||||||
messages,
|
|
||||||
messageState,
|
|
||||||
inputMessage,
|
|
||||||
sendMessage,
|
|
||||||
abortRequest,
|
|
||||||
roles,
|
|
||||||
handlePromptItemClick,
|
|
||||||
|
|
||||||
senderRef,
|
|
||||||
currentTemplate,
|
|
||||||
suggestionOpen,
|
|
||||||
clearTemplate,
|
|
||||||
handleSendMessage,
|
|
||||||
handleMessageKeydown,
|
|
||||||
suggestionPillItems,
|
|
||||||
handleSuggestionPillItemClick
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,170 +3,10 @@
|
||||||
* 提供一些实用的辅助函数
|
* 提供一些实用的辅助函数
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ChatMessage, ChatCompletionResponse, StreamHandler } from '@opentiny/tiny-robot-kit'
|
import { reactive } from 'vue'
|
||||||
import type { ChatCompletionRequest } from '@opentiny/tiny-robot-kit'
|
|
||||||
import { ref, reactive, type Ref } from 'vue'
|
|
||||||
|
|
||||||
export { $local, $session } from './storage'
|
export { $local, $session } from './storage'
|
||||||
|
|
||||||
export const showTinyRobot = ref(true)
|
|
||||||
|
|
||||||
export const globalConversation = reactive({
|
export const globalConversation = reactive({
|
||||||
id: '',
|
id: ''
|
||||||
sessionId: ''
|
|
||||||
})
|
})
|
||||||
/**
|
|
||||||
* 处理SSE流式响应
|
|
||||||
* @param response fetch响应对象
|
|
||||||
* @param handler 流处理器
|
|
||||||
*/
|
|
||||||
export async function handleSSEStream(
|
|
||||||
response: Response,
|
|
||||||
handler: StreamHandler,
|
|
||||||
message: Ref<ChatCompletionRequest['messages']>,
|
|
||||||
signal?: AbortSignal
|
|
||||||
): Promise<void> {
|
|
||||||
// 获取ReadableStream
|
|
||||||
const reader = response.body?.getReader()
|
|
||||||
if (!reader) {
|
|
||||||
throw new Error('Response body is null')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理流式数据
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
|
|
||||||
if (signal) {
|
|
||||||
signal.addEventListener(
|
|
||||||
'abort',
|
|
||||||
() => {
|
|
||||||
reader.cancel().catch((err) => console.error('Error cancelling reader:', err))
|
|
||||||
},
|
|
||||||
{ once: true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageIndex = 0
|
|
||||||
function printMessage(data, str: string, endln = false) {
|
|
||||||
handler.onData({
|
|
||||||
id: '',
|
|
||||||
created: data.created_at,
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
index: messageIndex++,
|
|
||||||
delta: {
|
|
||||||
role: 'assistant',
|
|
||||||
content: str + (endln ? '\n\n' : '')
|
|
||||||
},
|
|
||||||
finish_reason: null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
object: '',
|
|
||||||
model: ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
await reader.cancel()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
// 解码二进制数据
|
|
||||||
const chunk = decoder.decode(value, { stream: true })
|
|
||||||
buffer += chunk
|
|
||||||
|
|
||||||
// 处理完整的SSE消息
|
|
||||||
const lines = buffer.split('\n\n')
|
|
||||||
buffer = lines.pop() || ''
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.trim() === '') continue
|
|
||||||
if (line.trim() === 'data: [DONE]') {
|
|
||||||
handler.onDone()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 解析SSE消息
|
|
||||||
const dataMatch = line.match(/^data: (.+)$/m)
|
|
||||||
if (!dataMatch) continue
|
|
||||||
|
|
||||||
const data = JSON.parse(dataMatch[1])
|
|
||||||
// console.log('SSE data:', data)
|
|
||||||
if (data?.event === 'node_started') {
|
|
||||||
printMessage(data, `${data.data.title} 节点运行...`, true)
|
|
||||||
}
|
|
||||||
if (data?.event === 'node_finished') {
|
|
||||||
printMessage(
|
|
||||||
data,
|
|
||||||
`${data.data.title} 节点结束\n\n` +
|
|
||||||
(data.data.node_type === 'answer' ? `${data.data.outputs.answer}` : '')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (data?.event === 'agent_log' && data.data.status === 'success' && data.data.label.startsWith('CALL')) {
|
|
||||||
printMessage(data, `--${data.data.label}(${JSON.stringify(data.data.data.output.tool_call_input)})`, true)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing SSE message:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buffer.trim() === 'data: [DONE]' || signal?.aborted) {
|
|
||||||
handler.onDone()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (signal?.aborted) return
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化消息
|
|
||||||
* 将各种格式的消息转换为标准的ChatMessage格式
|
|
||||||
* @param messages 消息数组
|
|
||||||
* @returns 标准格式的消息数组
|
|
||||||
*/
|
|
||||||
export function formatMessages(messages: Array<ChatMessage | string>): ChatMessage[] {
|
|
||||||
return messages.map((msg) => {
|
|
||||||
// 如果已经是标准格式,直接返回
|
|
||||||
if (typeof msg === 'object' && 'role' in msg && 'content' in msg) {
|
|
||||||
return {
|
|
||||||
role: msg.role,
|
|
||||||
content: String(msg.content),
|
|
||||||
...(msg.name ? { name: msg.name } : {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是字符串,默认为用户消息
|
|
||||||
if (typeof msg === 'string') {
|
|
||||||
return {
|
|
||||||
role: 'user',
|
|
||||||
content: msg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他情况,尝试转换为字符串
|
|
||||||
return {
|
|
||||||
role: 'user',
|
|
||||||
content: String(msg)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从响应中提取文本内容
|
|
||||||
* @param response 聊天完成响应
|
|
||||||
* @returns 文本内容
|
|
||||||
*/
|
|
||||||
export function extractTextFromResponse(response: ChatCompletionResponse): string {
|
|
||||||
if (!response.choices || !response.choices.length) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.choices[0].message?.content || ''
|
|
||||||
}
|
|
||||||
|
|
|
@ -3,9 +3,8 @@ import { initI18n, t } from '@opentiny/vue-locale'
|
||||||
import { $local } from '../tools'
|
import { $local } from '../tools'
|
||||||
import zh from './zh.json'
|
import zh from './zh.json'
|
||||||
import en from './en.json'
|
import en from './en.json'
|
||||||
import { zhCN, enUS } from '@opentiny/tiny-vue-mcp'
|
|
||||||
|
|
||||||
const messages = { enUS: { ...en, ...enUS }, zhCN: { ...zh, ...zhCN } }
|
const messages = { enUS: { ...en }, zhCN: { ...zh } }
|
||||||
// $local._lang = $local._lang !== 'zhCN' && $local._lang !== 'enUS' ? 'zhCN' : $local._lang
|
// $local._lang = $local._lang !== 'zhCN' && $local._lang !== 'enUS' ? 'zhCN' : $local._lang
|
||||||
$local._lang = 'zhCN'
|
$local._lang = 'zhCN'
|
||||||
const customCreateI18n = ({ locale, messages }) =>
|
const customCreateI18n = ({ locale, messages }) =>
|
||||||
|
|
|
@ -2,9 +2,6 @@ import { createHead } from '@vueuse/head'
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import '@unocss/reset/eric-meyer.css'
|
import '@unocss/reset/eric-meyer.css'
|
||||||
|
|
||||||
// tiny-robot 对话框
|
|
||||||
import '@opentiny/tiny-robot/dist/style.css'
|
|
||||||
|
|
||||||
// markdown文件内代码高亮
|
// markdown文件内代码高亮
|
||||||
import 'prismjs/themes/prism.css'
|
import 'prismjs/themes/prism.css'
|
||||||
import 'uno.css'
|
import 'uno.css'
|
||||||
|
@ -19,7 +16,7 @@ import './assets/custom-markdown.css'
|
||||||
import './assets/custom-block.less'
|
import './assets/custom-block.less'
|
||||||
import './assets/md-preview.less'
|
import './assets/md-preview.less'
|
||||||
|
|
||||||
import { i18n, t } from './i18n/index'
|
import { i18n } from './i18n/index'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { appData } from './tools'
|
import { appData } from './tools'
|
||||||
|
@ -36,12 +33,6 @@ import '@docsearch/css'
|
||||||
import { doSearchEverySite } from './tools/docsearch'
|
import { doSearchEverySite } from './tools/docsearch'
|
||||||
import '@opentiny/vue-theme/dark-theme-index.css'
|
import '@opentiny/vue-theme/dark-theme-index.css'
|
||||||
|
|
||||||
import { registerMcpConfig } from '@opentiny/vue-common'
|
|
||||||
import { createMcpTools, getTinyVueMcpConfig } from '@opentiny/tiny-vue-mcp'
|
|
||||||
|
|
||||||
// 注册TinyVue组件mcp配置
|
|
||||||
registerMcpConfig(getTinyVueMcpConfig({ t }), createMcpTools)
|
|
||||||
|
|
||||||
const envTarget = import.meta.env.VITE_BUILD_TARGET || 'open'
|
const envTarget = import.meta.env.VITE_BUILD_TARGET || 'open'
|
||||||
|
|
||||||
hljs.registerLanguage('javascript', javascript)
|
hljs.registerLanguage('javascript', javascript)
|
||||||
|
|
|
@ -7,8 +7,6 @@ const Components = () => import('@/views/components-doc/index.vue')
|
||||||
const Docs = () => import('@/views/docs/docs.vue')
|
const Docs = () => import('@/views/docs/docs.vue')
|
||||||
const Overview = () => import('@/views/overview.vue')
|
const Overview = () => import('@/views/overview.vue')
|
||||||
const Features = () => import('@/views/features.vue')
|
const Features = () => import('@/views/features.vue')
|
||||||
const Comprehensive = () => import('@/views/comprehensive/index.vue')
|
|
||||||
const Remoter = () => import('@/views/remoter/index.vue')
|
|
||||||
|
|
||||||
const context = import.meta.env.VITE_CONTEXT
|
const context = import.meta.env.VITE_CONTEXT
|
||||||
|
|
||||||
|
@ -20,16 +18,6 @@ let routes = [
|
||||||
name: 'overview',
|
name: 'overview',
|
||||||
children: [{ name: 'Overview', path: '', component: Overview, meta: { title: '组件总览 | TinyVue' } }]
|
children: [{ name: 'Overview', path: '', component: Overview, meta: { title: '组件总览 | TinyVue' } }]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: `${context}:all?/zh-CN/:theme/comprehensive`,
|
|
||||||
component: Comprehensive,
|
|
||||||
name: 'comprehensive'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `${context}:all?/zh-CN/:theme/remoter`,
|
|
||||||
component: Remoter,
|
|
||||||
name: 'remoter'
|
|
||||||
},
|
|
||||||
// 文档
|
// 文档
|
||||||
{
|
{
|
||||||
path: `${context}:all?/:lang/:theme/docs/:docId`,
|
path: `${context}:all?/:lang/:theme/docs/:docId`,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { reactive, computed, watch } from 'vue'
|
import { reactive, computed } from 'vue'
|
||||||
import { useAutoStore } from './storage'
|
import { useAutoStore } from './storage'
|
||||||
import { useMediaQuery } from './useMediaQuery'
|
import { useMediaQuery } from './useMediaQuery'
|
||||||
import { ZH_CN_LANG, EN_US_LANG, LANG_KEY, LANG_PATH_MAP } from '../const'
|
import { ZH_CN_LANG, EN_US_LANG, LANG_KEY, LANG_PATH_MAP } from '../const'
|
||||||
|
@ -8,9 +8,7 @@ const enPath = LANG_PATH_MAP[EN_US_LANG]
|
||||||
const appData = reactive({
|
const appData = reactive({
|
||||||
lang: useAutoStore('local', LANG_KEY, ZH_CN_LANG),
|
lang: useAutoStore('local', LANG_KEY, ZH_CN_LANG),
|
||||||
theme: useAutoStore('local', '_theme', 'light'),
|
theme: useAutoStore('local', '_theme', 'light'),
|
||||||
bpState: useMediaQuery([640, 1024, 1280]).matches, // 3点4区间, bp0,bp1,bp2,bp3
|
bpState: useMediaQuery([640, 1024, 1280]).matches // 3点4区间, bp0,bp1,bp2,bp3
|
||||||
showTinyRobot: false,
|
|
||||||
hasFloatRobot: false
|
|
||||||
})
|
})
|
||||||
const isZhCn = computed(() => appData.lang === ZH_CN_LANG)
|
const isZhCn = computed(() => appData.lang === ZH_CN_LANG)
|
||||||
const appFn = {
|
const appFn = {
|
||||||
|
@ -30,11 +28,4 @@ const appFn = {
|
||||||
// 为了和tiny-vue共享同一个响应变量
|
// 为了和tiny-vue共享同一个响应变量
|
||||||
window.appData = appData
|
window.appData = appData
|
||||||
|
|
||||||
watch(
|
|
||||||
() => appData.showTinyRobot,
|
|
||||||
(value) => {
|
|
||||||
document.body.classList.toggle('docs-on-robot-show', value)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export { appData, appFn, isZhCn }
|
export { appData, appFn, isZhCn }
|
||||||
|
|
|
@ -88,7 +88,6 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="footer"></div>
|
<div id="footer"></div>
|
||||||
</div>
|
</div>
|
||||||
<robotChat v-if="appData.showTinyRobot && appData.hasFloatRobot"></robotChat>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
@ -108,8 +107,6 @@ import McpDocs from '../../components/mcp-docs.vue'
|
||||||
import useTasksFinish from '../../composable/useTasksFinish'
|
import useTasksFinish from '../../composable/useTasksFinish'
|
||||||
import { appData } from '../../tools/appData'
|
import { appData } from '../../tools/appData'
|
||||||
|
|
||||||
import robotChat from '../../components/tiny-robot-chat.vue'
|
|
||||||
|
|
||||||
const props = defineProps({ loadData: {}, appMode: {}, demoKey: {} })
|
const props = defineProps({ loadData: {}, appMode: {}, demoKey: {} })
|
||||||
|
|
||||||
const emit = defineEmits(['single-demo-change', 'load-page'])
|
const emit = defineEmits(['single-demo-change', 'load-page'])
|
||||||
|
@ -449,9 +446,6 @@ defineExpose({ loadPage })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
:global(.docs-on-robot-show .docs-content) {
|
|
||||||
margin-right: 480px;
|
|
||||||
}
|
|
||||||
.docs-content {
|
.docs-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
|
|
|
@ -1,211 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="products-page">
|
|
||||||
<div class="page-header">
|
|
||||||
<h3>商品管理</h3>
|
|
||||||
</div>
|
|
||||||
<div class="page-content">
|
|
||||||
<div class="button-box">
|
|
||||||
<tiny-button type="info" @click="addProductToEdit"> 添加商品 </tiny-button>
|
|
||||||
<tiny-button type="danger" @click="removeProduct"> 删除商品 </tiny-button>
|
|
||||||
<tiny-button type="success" @click="saveProduct"> 保存 </tiny-button>
|
|
||||||
</div>
|
|
||||||
<tiny-grid
|
|
||||||
auto-resize
|
|
||||||
ref="gridRef"
|
|
||||||
:data="products"
|
|
||||||
:height="500"
|
|
||||||
:edit-config="{ trigger: 'click', mode: 'cell', showStatus: true }"
|
|
||||||
:tiny_mcp_config="{
|
|
||||||
server,
|
|
||||||
business: {
|
|
||||||
id: 'product-list',
|
|
||||||
description: '商品列表'
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<tiny-grid-column type="index" width="50" />
|
|
||||||
<tiny-grid-column type="selection" width="50" />
|
|
||||||
<tiny-grid-column title="商品图片" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<tiny-image :src="row.image" :preview-src-list="[row.image]" class="product-image" />
|
|
||||||
</template>
|
|
||||||
</tiny-grid-column>
|
|
||||||
|
|
||||||
<tiny-grid-column field="name" title="商品名称" :editor="{ component: 'input' }" />
|
|
||||||
<tiny-grid-column
|
|
||||||
field="price"
|
|
||||||
:editor="{
|
|
||||||
component: 'input',
|
|
||||||
attrs: { type: 'number' }
|
|
||||||
}"
|
|
||||||
title="价格"
|
|
||||||
>
|
|
||||||
<template #default="{ row }"> ¥{{ row.price }} </template>
|
|
||||||
</tiny-grid-column>
|
|
||||||
<tiny-grid-column
|
|
||||||
field="stock"
|
|
||||||
:editor="{
|
|
||||||
component: 'input',
|
|
||||||
attrs: { type: 'number' }
|
|
||||||
}"
|
|
||||||
title="库存"
|
|
||||||
/>
|
|
||||||
<tiny-grid-column
|
|
||||||
field="category"
|
|
||||||
:editor="{
|
|
||||||
component: 'select',
|
|
||||||
options: [
|
|
||||||
{ label: '手机', value: 'phones' },
|
|
||||||
{ label: '笔记本', value: 'laptops' },
|
|
||||||
{ label: '平板', value: 'tablets' }
|
|
||||||
]
|
|
||||||
}"
|
|
||||||
title="分类"
|
|
||||||
>
|
|
||||||
<template #default="{ row }">
|
|
||||||
{{ categoryLabels[row.category] }}
|
|
||||||
</template>
|
|
||||||
</tiny-grid-column>
|
|
||||||
<tiny-grid-column
|
|
||||||
field="status"
|
|
||||||
:editor="{
|
|
||||||
component: 'select',
|
|
||||||
options: [
|
|
||||||
{ label: '上架', value: 'on' },
|
|
||||||
{ label: '下架', value: 'off' }
|
|
||||||
]
|
|
||||||
}"
|
|
||||||
title="状态"
|
|
||||||
>
|
|
||||||
<template #default="{ row }">
|
|
||||||
<tiny-tag :type="row.status === 'on' ? 'success' : 'warning'">
|
|
||||||
{{ row.status === 'on' ? '上架' : '下架' }}
|
|
||||||
</tiny-tag>
|
|
||||||
</template>
|
|
||||||
</tiny-grid-column>
|
|
||||||
</tiny-grid>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import productsData from './products.json'
|
|
||||||
import { $local } from '../../composable/utils'
|
|
||||||
import { useNextServer } from '@opentiny/next-vue'
|
|
||||||
import { TinyGrid, TinyGridColumn, TinyButton, TinyTag, TinyModal, TinyImage } from '@opentiny/vue'
|
|
||||||
|
|
||||||
if (!$local.products) {
|
|
||||||
$local.products = productsData
|
|
||||||
}
|
|
||||||
|
|
||||||
const products = ref($local.products)
|
|
||||||
const gridRef = ref(null)
|
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
|
||||||
phones: '手机',
|
|
||||||
laptops: '笔记本',
|
|
||||||
tablets: '平板'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增商品到编辑弹窗
|
|
||||||
const addProductToEdit = async () => {
|
|
||||||
gridRef?.value?.insert({
|
|
||||||
'image': 'https://img1.baidu.com/it/u=1559062020,1043707656&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
|
|
||||||
price: 10000,
|
|
||||||
stock: 100,
|
|
||||||
category: 'phones',
|
|
||||||
status: 'on'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeProduct = () => {
|
|
||||||
const selectedRows = gridRef?.value?.getSelectRecords()
|
|
||||||
if (selectedRows.length === 0) {
|
|
||||||
TinyModal.confirm({
|
|
||||||
message: '请选择要删除的商品',
|
|
||||||
title: '删除商品',
|
|
||||||
status: 'warning'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (selectedRows.length > 0) {
|
|
||||||
gridRef?.value?.removeSelecteds()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveProduct = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
const data = gridRef?.value?.getTableData()
|
|
||||||
$local.products = data.tableData
|
|
||||||
TinyModal.message({
|
|
||||||
message: '保存成功',
|
|
||||||
status: 'success'
|
|
||||||
})
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { server } = useNextServer({
|
|
||||||
serverInfo: { name: 'commodity-config', version: '1.0.0' }
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.products-page {
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 8px;
|
|
||||||
position: relative;
|
|
||||||
border: 1px solid #edf2f7;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 0;
|
|
||||||
width: 4px;
|
|
||||||
height: 24px;
|
|
||||||
background: #1677ff;
|
|
||||||
border-radius: 2px;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1f2937;
|
|
||||||
position: relative;
|
|
||||||
padding-left: 20px;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-box {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.loading-state {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-image {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.page-content {
|
|
||||||
padding: 20px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,380 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="header">
|
|
||||||
<div class="qr-code-trigger" @click="showQrCode = true">
|
|
||||||
<IconAi class="qr-icon" />
|
|
||||||
<div class="qr-text-wrapper">
|
|
||||||
<span class="qr-label">手机遥控</span>
|
|
||||||
<span class="qr-desc">扫码体验移动端操作</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="app-container">
|
|
||||||
<!-- 主体内容区域 -->
|
|
||||||
<div class="main-content">
|
|
||||||
<Demo />
|
|
||||||
</div>
|
|
||||||
<div class="right-panel" :class="{ collapsed: !appData.showTinyRobot }">
|
|
||||||
<tiny-robot-chat :prompt-items="promptItems" :suggestion-pill-items="suggestionPillItems" />
|
|
||||||
</div>
|
|
||||||
<IconAi @click="appData.showTinyRobot = !appData.showTinyRobot" class="style-settings-icon"></IconAi>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- QR Code Modal -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div class="qr-modal" v-if="showQrCode" @click.self="showQrCode = false">
|
|
||||||
<div class="qr-modal-content">
|
|
||||||
<div class="qr-modal-header">
|
|
||||||
<h3>手机遥控体验</h3>
|
|
||||||
</div>
|
|
||||||
<div class="qr-modal-body">
|
|
||||||
<div class="qr-wrapper">
|
|
||||||
<tiny-qr-code :value="sessionUrl" :size="200" color="#1677ff"></tiny-qr-code>
|
|
||||||
<div class="qr-status">
|
|
||||||
<div class="status-dot"></div>
|
|
||||||
<span>链接已生成</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="qr-instructions">
|
|
||||||
<p class="instruction-title">使用说明</p>
|
|
||||||
<ol class="instruction-steps">
|
|
||||||
<li>使用微信扫一扫,扫描上方二维码</li>
|
|
||||||
<li>然后点击微信右上角的 "..." 图标</li>
|
|
||||||
<li>选择在默认浏览器中打开</li>
|
|
||||||
<li>可以选择直接语音交互也可使用AI对话框进行交互</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { watch, ref, h } from 'vue'
|
|
||||||
import TinyRobotChat from '@/components/tiny-robot-chat.vue'
|
|
||||||
import { globalConversation } from '@/composable/utils'
|
|
||||||
import { IconAi } from '@opentiny/tiny-robot-svgs'
|
|
||||||
import CryptoJS from 'crypto-js'
|
|
||||||
import { TinyQrCode } from '@opentiny/vue'
|
|
||||||
import Demo from './Demo.vue'
|
|
||||||
import { appData } from '@/tools/appData'
|
|
||||||
|
|
||||||
appData.showTinyRobot = true
|
|
||||||
const showQrCode = ref(false)
|
|
||||||
const sessionUrl = ref('placeholder')
|
|
||||||
|
|
||||||
const promptItems = [
|
|
||||||
{
|
|
||||||
label: '识别网页的内容',
|
|
||||||
description: '帮我在商品列表中查询最贵的手机和最便宜的笔记本',
|
|
||||||
icon: h('span', { style: { fontSize: '18px' } }, '💡')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '智能操作网页',
|
|
||||||
description: '帮我在商品列表中删除最贵的且分类为手机的商品',
|
|
||||||
icon: h('span', { style: { fontSize: '18px' } }, '🕹')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '智能操作网页',
|
|
||||||
description: '帮我在商品列表中添加一个华为p60品牌的手机商品',
|
|
||||||
icon: h('span', { style: { fontSize: '18px' } }, '🕹')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const suggestionPillItems = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
text: '商品列表',
|
|
||||||
icon: h('span', { style: { fontSize: '18px' } }, '🏢')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => globalConversation.sessionId,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
const encryptedId = CryptoJS.AES.encrypt(newVal, 'secret-session-id').toString()
|
|
||||||
|
|
||||||
const secretId = encodeURIComponent(encryptedId)
|
|
||||||
sessionUrl.value = `https://agent.icjs.ink?id=${secretId}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.header {
|
|
||||||
width: calc(100% - 502px);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 20px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-code-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-radius: 12px;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
background: linear-gradient(135deg, #1677ff 0%, #06b6d4 100%);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-code-trigger::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0));
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-code-trigger:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 20px rgba(22, 119, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-code-trigger:hover::before {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
margin-right: 12px;
|
|
||||||
color: white;
|
|
||||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
|
|
||||||
animation: float 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-text-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-label {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: white;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-desc {
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes float {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-modal {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 1000;
|
|
||||||
animation: fadeIn 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-modal-content {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 32px;
|
|
||||||
width: 460px;
|
|
||||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-modal-header {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-modal-header h3 {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-icon {
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 20px;
|
|
||||||
color: #999;
|
|
||||||
transition: color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-icon:hover {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-modal-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-wrapper {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
position: relative;
|
|
||||||
animation: glow 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-wrapper::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -2px;
|
|
||||||
left: -2px;
|
|
||||||
right: -2px;
|
|
||||||
bottom: -2px;
|
|
||||||
background: linear-gradient(45deg, #1677ff, #10b981, #1677ff);
|
|
||||||
border-radius: 10px;
|
|
||||||
z-index: -1;
|
|
||||||
filter: blur(8px);
|
|
||||||
opacity: 0.5;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-wrapper:hover::before {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glow {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 5px rgba(22, 119, 255, 0.2), 0 0 10px rgba(22, 119, 255, 0.2), 0 0 15px rgba(22, 119, 255, 0.2);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 0 10px rgba(22, 119, 255, 0.3), 0 0 20px rgba(22, 119, 255, 0.3), 0 0 30px rgba(22, 119, 255, 0.3);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 5px rgba(22, 119, 255, 0.2), 0 0 10px rgba(22, 119, 255, 0.2), 0 0 15px rgba(22, 119, 255, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 16px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
background-color: #10b981;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 8px;
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-instructions {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instruction-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #1f2937;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instruction-steps {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instruction-steps li {
|
|
||||||
color: #4b5563;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.instruction-steps li:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-container {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
padding: 10px 10px;
|
|
||||||
height: 100%;
|
|
||||||
width: calc(100% - 502px);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-panel {
|
|
||||||
width: 480px;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
background: #fff;
|
|
||||||
border-left: 1px solid #e4e7ed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-panel.collapsed {
|
|
||||||
width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.style-settings-icon {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 100px;
|
|
||||||
right: 100px;
|
|
||||||
font-size: 24px;
|
|
||||||
z-index: 30;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,99 +0,0 @@
|
||||||
[
|
|
||||||
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "iPhone 16",
|
|
||||||
"price": 5999,
|
|
||||||
"description": "苹果手机",
|
|
||||||
"image": "https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.alicdn.com%2Fbao%2Fuploaded%2FO1CN011qHTQ81edxfMcnfNR_%21%216000000003895-0-yinhe.jpg_300x300.jpg&refer=http%3A%2F%2Fimg.alicdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1750986769&t=4570d81c9f0301ab1745c75dfff9f272",
|
|
||||||
"category": "phones",
|
|
||||||
"stock": 100,
|
|
||||||
"status": "on",
|
|
||||||
"createdAt": "2024-03-20",
|
|
||||||
"updatedAt": "2025-05-29T21:56:30.481Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"name": "MacBook Pro",
|
|
||||||
"price": 12999,
|
|
||||||
"description": "笔记本电脑",
|
|
||||||
"image": "https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fg-search1.alicdn.com%2Fimg%2Fbao%2Fuploaded%2Fi3%2F1831488473%2FO1CN01I8MRyC2CSgWGbhVjJ_%21%211831488473.jpg_300x300.jpg&refer=http%3A%2F%2Fg-search1.alicdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1750986810&t=9eb9c9e0765244130200dd0f935b9cea",
|
|
||||||
"category": "laptops",
|
|
||||||
"stock": 50,
|
|
||||||
"status": "off",
|
|
||||||
"createdAt": "2024-03-20",
|
|
||||||
"updatedAt": "2024-03-20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"name": "iPad",
|
|
||||||
"price": 2399,
|
|
||||||
"description": "平板电脑",
|
|
||||||
"image": "https://respic.3d66.com/coverimg/cache/f2d1/53893cd4d00efe87132f1e3371b72339.jpg%21medium-size-2?v=17611204&k=D41D8CD98F00B204E9800998ECF8427E",
|
|
||||||
"category": "tablets",
|
|
||||||
"stock": 12999,
|
|
||||||
"status": "on",
|
|
||||||
"createdAt": "2025-05-27T00:49:29.993Z",
|
|
||||||
"updatedAt": "2025-05-29T16:11:49.743Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"name": "Huawei Pura 70",
|
|
||||||
"price": 6499,
|
|
||||||
"description": "华为手机",
|
|
||||||
"image": "https://img1.baidu.com/it/u=1559062020,1043707656&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500",
|
|
||||||
"category": "phones",
|
|
||||||
"stock": 1999,
|
|
||||||
"status": "on",
|
|
||||||
"createdAt": "2025-05-27T00:54:38.771Z",
|
|
||||||
"updatedAt": "2025-05-29T22:29:40.124Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"name": "Huawei Mate XT ultimate",
|
|
||||||
"price": 23999,
|
|
||||||
"description": "华为手机 非凡大师",
|
|
||||||
"image": "https://img2.baidu.com/it/u=2892977680,910281472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500",
|
|
||||||
"category": "phones",
|
|
||||||
"stock": 100,
|
|
||||||
"status": "on",
|
|
||||||
"createdAt": "2025-05-27T00:54:38.771Z",
|
|
||||||
"updatedAt": "2025-05-30T17:40:59.199Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 6,
|
|
||||||
"name": "小米15",
|
|
||||||
"price": 4999,
|
|
||||||
"description": "小米手机",
|
|
||||||
"image": "https://img2.baidu.com/it/u=2892977680,910281472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500",
|
|
||||||
"category": "phones",
|
|
||||||
"stock": 222,
|
|
||||||
"status": "off",
|
|
||||||
"createdAt": "2025-05-29T00:30:02.226Z",
|
|
||||||
"updatedAt": "2025-05-29T16:14:35.150Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"name": "小米 13",
|
|
||||||
"price": 3999,
|
|
||||||
"description": "小米手机",
|
|
||||||
"image": "https://img1.baidu.com/it/u=809120544,2106407569&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500",
|
|
||||||
"category": "phones",
|
|
||||||
"stock": 2222,
|
|
||||||
"status": "on",
|
|
||||||
"createdAt": "2025-05-29T00:32:46.308Z",
|
|
||||||
"updatedAt": "2025-05-30T21:28:28.543Z"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 8,
|
|
||||||
"name": "Vivo X90",
|
|
||||||
"price": 2999,
|
|
||||||
"description": "Vivo手机",
|
|
||||||
"image": "https://img2.baidu.com/it/u=2892977680,910281472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500",
|
|
||||||
"category": "phones",
|
|
||||||
"stock": 3999,
|
|
||||||
"status": "on",
|
|
||||||
"createdAt": "2025-05-27T00:54:38.771Z",
|
|
||||||
"updatedAt": "2025-05-30T17:40:59.199Z"
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,37 +0,0 @@
|
||||||
// 用户类型
|
|
||||||
export interface User {
|
|
||||||
id: number
|
|
||||||
username: string
|
|
||||||
avatar?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 商品类型
|
|
||||||
export interface Product {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
price: number
|
|
||||||
description?: string
|
|
||||||
image: string
|
|
||||||
category: string
|
|
||||||
stock: number
|
|
||||||
status: 'on' | 'off' // on: 上架, off: 下架
|
|
||||||
createdAt?: string
|
|
||||||
updatedAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录表单类型
|
|
||||||
export interface LoginForm {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 商品表单类型
|
|
||||||
export interface ProductForm {
|
|
||||||
name: string
|
|
||||||
price: number
|
|
||||||
description: string
|
|
||||||
image: string
|
|
||||||
category: string
|
|
||||||
stock: number
|
|
||||||
status: 'on' | 'off'
|
|
||||||
}
|
|
|
@ -1,63 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
// 导入 CryptoJS 库
|
|
||||||
import CryptoJS from 'crypto-js'
|
|
||||||
import { reactive } from 'vue'
|
|
||||||
import { TinyButton, TinyDrawer } from '@opentiny/vue'
|
|
||||||
import RobotChat from '../../components/tiny-robot-chat.vue'
|
|
||||||
import Sound from './sound.vue'
|
|
||||||
import { globalConversation } from '../../composable/utils'
|
|
||||||
|
|
||||||
// 加密密钥,需要和生成二维码的页面保持一致
|
|
||||||
const secretKey = 'secret-session-id'
|
|
||||||
// 获取 URL 参数
|
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
|
||||||
const encryptedId = urlParams.get('id')
|
|
||||||
const state = reactive({ isShow: true, showChat: false, showSound: false })
|
|
||||||
|
|
||||||
if (encryptedId) {
|
|
||||||
// 解密 id
|
|
||||||
const bytes = CryptoJS.AES.decrypt(encryptedId, secretKey)
|
|
||||||
const originalText = bytes.toString(CryptoJS.enc.Utf8)
|
|
||||||
// 存储解密后的 id 到 window.sessionId
|
|
||||||
globalConversation.sessionId = originalText
|
|
||||||
}
|
|
||||||
const showMoter = (type) => {
|
|
||||||
state.isShow = false
|
|
||||||
state[type] = true
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<RobotChat v-if="state.showChat" />
|
|
||||||
<Sound v-if="state.showSound" />
|
|
||||||
<tiny-drawer
|
|
||||||
v-if="state.isShow"
|
|
||||||
title="请选择控制器"
|
|
||||||
placement="bottom"
|
|
||||||
:mask-closable="false"
|
|
||||||
:show-close="false"
|
|
||||||
v-model:visible="state.isShow"
|
|
||||||
height="400px"
|
|
||||||
>
|
|
||||||
<div class="link-box">
|
|
||||||
<tiny-button @click="showMoter('showSound')" type="info" size="large">语音控制器</tiny-button>
|
|
||||||
|
|
||||||
<tiny-button @click="showMoter('showChat')" type="info" size="large">综合控制器</tiny-button>
|
|
||||||
</div>
|
|
||||||
</tiny-drawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.link-box {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.tiny-button {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,349 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="sound-container">
|
|
||||||
<div class="messages-container" ref="messagesContainer">
|
|
||||||
<template v-if="messages && messages.length > 0">
|
|
||||||
<message-card
|
|
||||||
v-for="(msg, index) in messages"
|
|
||||||
:key="index"
|
|
||||||
:role="msg.role === 'system' ? 'assistant' : msg.role"
|
|
||||||
:message="msg.content"
|
|
||||||
:timestamp="messageTimestamps[index]"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<div v-else class="empty-message">
|
|
||||||
<p>暂无对话记录</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="sound-box">
|
|
||||||
<div class="recording-status" v-show="isTalk">
|
|
||||||
<div class="wave-animation"></div>
|
|
||||||
<span>{{ recordingTime }}s</span>
|
|
||||||
</div>
|
|
||||||
<tiny-button
|
|
||||||
@touchstart.prevent="handleStart"
|
|
||||||
@touchend.prevent="handleEnd"
|
|
||||||
@touchcancel.prevent="handleEnd"
|
|
||||||
:type="isTalk ? 'danger' : 'info'"
|
|
||||||
class="talk-button"
|
|
||||||
size="large"
|
|
||||||
:reset-time="0"
|
|
||||||
:disabled="!isSupported"
|
|
||||||
>
|
|
||||||
{{ buttonText }}
|
|
||||||
</tiny-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onUnmounted, watch, nextTick } from 'vue'
|
|
||||||
import { TinyButton, TinyNotify } from '@opentiny/vue'
|
|
||||||
import { useTinyRobot } from '../../composable/useTinyRobot'
|
|
||||||
import MessageCard from '../../components/MessageCard.vue'
|
|
||||||
|
|
||||||
// 类型定义
|
|
||||||
interface SpeechRecognitionResult {
|
|
||||||
[index: number]: {
|
|
||||||
transcript: string
|
|
||||||
confidence: number
|
|
||||||
}
|
|
||||||
isFinal?: boolean
|
|
||||||
length: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognitionResultList {
|
|
||||||
[index: number]: SpeechRecognitionResult
|
|
||||||
length: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognitionEvent extends Event {
|
|
||||||
results: SpeechRecognitionResultList
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpeechRecognitionErrorEvent extends Event {
|
|
||||||
error: string
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取SpeechRecognition构造函数
|
|
||||||
const getSpeechRecognition = () => {
|
|
||||||
const SpeechRecognition =
|
|
||||||
(window as any).SpeechRecognition ||
|
|
||||||
(window as any).webkitSpeechRecognition ||
|
|
||||||
(window as any).mozSpeechRecognition ||
|
|
||||||
(window as any).msSpeechRecognition
|
|
||||||
return SpeechRecognition
|
|
||||||
}
|
|
||||||
|
|
||||||
// 状态管理
|
|
||||||
const isTalk = ref(false)
|
|
||||||
const isSupported = ref(false)
|
|
||||||
const recordingTime = ref(0)
|
|
||||||
const maxRecordingTime = 60
|
|
||||||
let recognition: any = null
|
|
||||||
let recordingTimer: number | null = null
|
|
||||||
const messagesContainer = ref<HTMLElement | null>(null)
|
|
||||||
const messageTimestamps = ref<number[]>([])
|
|
||||||
const { sendMessage, messages } = useTinyRobot()
|
|
||||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const buttonText = computed(() => {
|
|
||||||
if (!isSupported.value) return '当前浏览器不支持语音识别'
|
|
||||||
if (isTalk.value) return `录音中 ${recordingTime.value}s`
|
|
||||||
return '长按说话'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听消息变化,更新时间戳和自动滚动
|
|
||||||
watch(
|
|
||||||
() => messages.value,
|
|
||||||
async (newMessages) => {
|
|
||||||
if (!newMessages) return
|
|
||||||
|
|
||||||
// 更新时间戳
|
|
||||||
if (messageTimestamps.value.length < newMessages.length) {
|
|
||||||
const timestamp = Date.now()
|
|
||||||
messageTimestamps.value = newMessages.map((_, index) => messageTimestamps.value[index] || timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自动滚动到底部
|
|
||||||
await nextTick()
|
|
||||||
if (messagesContainer.value) {
|
|
||||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 初始化语音识别
|
|
||||||
const initSpeechRecognition = () => {
|
|
||||||
const SpeechRecognition = getSpeechRecognition()
|
|
||||||
|
|
||||||
if (SpeechRecognition) {
|
|
||||||
isSupported.value = true
|
|
||||||
recognition = new SpeechRecognition()
|
|
||||||
recognition.lang = 'zh-CN'
|
|
||||||
recognition.continuous = false
|
|
||||||
recognition.interimResults = false
|
|
||||||
recognition.maxAlternatives = 1
|
|
||||||
|
|
||||||
// Safari 需要特殊处理
|
|
||||||
if (isSafari) {
|
|
||||||
recognition.interimResults = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalTranscript = ''
|
|
||||||
|
|
||||||
recognition.addEventListener('result', (event: SpeechRecognitionEvent) => {
|
|
||||||
const transcript = event.results[0][0].transcript
|
|
||||||
if (transcript.trim()) {
|
|
||||||
finalTranscript = transcript
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
recognition.addEventListener('end', () => {
|
|
||||||
stopRecording()
|
|
||||||
// 确保在录音结束时发送消息
|
|
||||||
if (finalTranscript.trim()) {
|
|
||||||
sendMessage(finalTranscript)
|
|
||||||
finalTranscript = ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
recognition.addEventListener('error', (event: SpeechRecognitionErrorEvent) => {
|
|
||||||
handleRecognitionError(event)
|
|
||||||
finalTranscript = ''
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
isSupported.value = false
|
|
||||||
TinyNotify({
|
|
||||||
type: 'error',
|
|
||||||
title: '提示',
|
|
||||||
message: '您的浏览器不支持语音识别,请使用Chrome、Safari或Edge浏览器',
|
|
||||||
position: 'top-right',
|
|
||||||
duration: 5000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 错误处理
|
|
||||||
const handleRecognitionError = (event: SpeechRecognitionErrorEvent) => {
|
|
||||||
stopRecording()
|
|
||||||
let errorMessage = '语音识别出错'
|
|
||||||
|
|
||||||
switch (event.error) {
|
|
||||||
case 'not-allowed':
|
|
||||||
errorMessage = '请允许浏览器使用麦克风'
|
|
||||||
break
|
|
||||||
case 'no-speech':
|
|
||||||
errorMessage = '未检测到语音,请重试'
|
|
||||||
break
|
|
||||||
case 'network':
|
|
||||||
errorMessage = '网络连接出错,请检查网络后重试'
|
|
||||||
break
|
|
||||||
case 'aborted':
|
|
||||||
return // 用户主动取消,不显示错误
|
|
||||||
default:
|
|
||||||
errorMessage = `语音识别失败: ${event.message || '未知错误'}`
|
|
||||||
}
|
|
||||||
|
|
||||||
TinyNotify({
|
|
||||||
type: 'error',
|
|
||||||
title: '语音识别出错',
|
|
||||||
message: errorMessage,
|
|
||||||
position: 'top-right',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始录音
|
|
||||||
const handleStart = async (event: TouchEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
|
|
||||||
if (!isSupported.value) {
|
|
||||||
TinyNotify({
|
|
||||||
type: 'error',
|
|
||||||
title: '提示',
|
|
||||||
message: '当前浏览器不支持语音识别',
|
|
||||||
position: 'top-right',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTalk.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
recognition.start()
|
|
||||||
isTalk.value = true
|
|
||||||
recordingTime.value = 0
|
|
||||||
startRecordingTimer()
|
|
||||||
} catch (error) {
|
|
||||||
handleRecognitionError({
|
|
||||||
error: 'start_error',
|
|
||||||
message: '启动语音识别失败,请重试'
|
|
||||||
} as SpeechRecognitionErrorEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 结束录音
|
|
||||||
const handleEnd = () => {
|
|
||||||
if (!isTalk.value) return
|
|
||||||
try {
|
|
||||||
recognition.stop()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('停止录音失败:', error)
|
|
||||||
}
|
|
||||||
stopRecording()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始计时
|
|
||||||
const startRecordingTimer = () => {
|
|
||||||
recordingTimer = window.setInterval(() => {
|
|
||||||
recordingTime.value++
|
|
||||||
if (recordingTime.value >= maxRecordingTime) {
|
|
||||||
handleEnd()
|
|
||||||
TinyNotify({
|
|
||||||
type: 'warning',
|
|
||||||
title: '提示',
|
|
||||||
message: '已达到最大录音时长',
|
|
||||||
position: 'top-right',
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止录音
|
|
||||||
const stopRecording = () => {
|
|
||||||
isTalk.value = false
|
|
||||||
if (recordingTimer) {
|
|
||||||
clearInterval(recordingTimer)
|
|
||||||
recordingTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
initSpeechRecognition()
|
|
||||||
|
|
||||||
// 组件卸载时清理
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (recordingTimer) {
|
|
||||||
clearInterval(recordingTimer)
|
|
||||||
}
|
|
||||||
if (recognition) {
|
|
||||||
try {
|
|
||||||
recognition.abort()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清理语音识别失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.sound-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.messages-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
margin-bottom: 120px;
|
|
||||||
|
|
||||||
.empty-message {
|
|
||||||
text-align: center;
|
|
||||||
color: #999;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sound-box {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
margin: 10px;
|
|
||||||
background: white;
|
|
||||||
padding: 10px;
|
|
||||||
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
.recording-status {
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
|
|
||||||
.wave-animation {
|
|
||||||
width: 100px;
|
|
||||||
height: 20px;
|
|
||||||
margin: 0 auto 5px;
|
|
||||||
background: linear-gradient(90deg, #ff4d4f 25%, #ff7875 50%, #ff4d4f 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: wave 2s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.talk-button {
|
|
||||||
width: 100%;
|
|
||||||
margin: 20px 0;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes wave {
|
|
||||||
0% {
|
|
||||||
background-position: 100% 50%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
Loading…
Reference in New Issue