index.vue 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. <template>
  2. <view class="ai-chat-panel" v-if="visible">
  3. <view class="panel-header">
  4. <text class="panel-title">AI 药典助手</text>
  5. <button class="close-btn" size="mini" @click="close">✕</button>
  6. </view>
  7. <scroll-view class="panel-body" scroll-y :scroll-top="scrollTop">
  8. <view v-for="(msg, idx) in messages" :key="idx" class="chat-item">
  9. <view :class="msg.role === 'user' ? 'chat-user' : 'chat-ai'">
  10. <view v-if="msg.thinking" class="thinking">
  11. <view class="think-dot"></view>
  12. <text>{{ msg.thinking }}</text>
  13. </view>
  14. <text v-html="renderMarkdown(msg.content)"></text>
  15. </view>
  16. </view>
  17. </scroll-view>
  18. <view class="panel-input">
  19. <input
  20. v-model="input"
  21. class="mini-input"
  22. placeholder="问关于这个药品..."
  23. @confirm="send"
  24. />
  25. </view>
  26. </view>
  27. <view v-else class="ai-trigger" @click="visible = true">
  28. <text class="trigger-icon">💊</text>
  29. <text class="trigger-text">问 AI</text>
  30. </view>
  31. </template>
  32. <script>
  33. import { chatStream } from '@/api/ai.js'
  34. export default {
  35. name: 'AiChatPanel',
  36. props: {
  37. drugName: { type: String, default: '' }
  38. },
  39. data() {
  40. return {
  41. visible: false,
  42. messages: [],
  43. input: '',
  44. streaming: false,
  45. conversationId: '',
  46. scrollTop: 0
  47. }
  48. },
  49. watch: {
  50. visible(val) {
  51. if (val && this.drugName && this.messages.length === 0) {
  52. this.input = '请介绍' + this.drugName + '的适应症、用法用量和注意事项'
  53. }
  54. }
  55. },
  56. methods: {
  57. close() { this.visible = false },
  58. renderMarkdown(content) {
  59. if (!content) return ''
  60. let h = content.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  61. h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
  62. var secs = '结论|详细说明|注意事项|来源明细|适应症|适应证|用法与用量|禁忌|不良反应|副作用|药理|贮藏|特殊人群|安全提醒|通用药学知识|来源汇总'
  63. h = h.replace(new RegExp('(?<![【])】\\s*(' + secs + ')', 'g'), '【$1】')
  64. h = h.replace(new RegExp('(?<![【])(' + secs + ')】', 'g'), '【$1】')
  65. h = h.replace(/\n\n+/g, '</p><p>')
  66. h = h.replace(/\n/g, '<br>')
  67. h = h.replace(/【(.+?)】/g, '<h3>【$1】</h3>')
  68. return '<p>' + h + '</p>'
  69. },
  70. async send() {
  71. const text = this.input.trim()
  72. if (!text || this.streaming) return
  73. this.input = ''
  74. this.streaming = true
  75. this.messages.push({ role: 'user', content: text })
  76. const idx = this.messages.length
  77. this.messages.push({ role: 'assistant', content: '', thinking: '正在分析...' })
  78. this.scrollBottom()
  79. await chatStream(
  80. { message: text, conversation_id: this.conversationId },
  81. {
  82. onIntent: (d) => { this.messages[idx].intent = d },
  83. onStatus: (d) => { this.messages[idx].thinking = d; this.scrollBottom() },
  84. onToken: (d) => { this.messages[idx].thinking = ''; this.messages[idx].content += d; this.scrollBottom() },
  85. onMeta: (meta) => { if (meta.cid) this.conversationId = meta.cid },
  86. onDone: () => { this.messages[idx].thinking = '' },
  87. onError: () => { this.messages[idx].content = '服务暂不可用' }
  88. }
  89. )
  90. this.streaming = false
  91. },
  92. scrollBottom() {
  93. this.$nextTick(() => { this.scrollTop = 999999 })
  94. }
  95. }
  96. }
  97. </script>
  98. <style scoped>
  99. .ai-chat-panel {
  100. position: fixed; right: 0; bottom: 0; width: 90vw; max-width: 500rpx; height: 60vh;
  101. background: #fff; border-radius: 16rpx 16rpx 0 0; box-shadow: 0 -4rpx 20rpx rgba(0,0,0,.12);
  102. display: flex; flex-direction: column; z-index: 999;
  103. }
  104. .panel-header {
  105. display: flex; justify-content: space-between; align-items: center;
  106. padding: 16rpx 24rpx; background: #002FA7; color: #fff; border-radius: 16rpx 16rpx 0 0;
  107. }
  108. .panel-title { font-size: 28rpx; font-weight: 600; }
  109. .close-btn { background: rgba(255,255,255,.2); border: none; color: #fff; border-radius: 8rpx; }
  110. .panel-body { flex: 1; padding: 16rpx; overflow-y: auto; }
  111. .chat-item { margin-bottom: 16rpx; }
  112. .chat-user { text-align: right; color: #002FA7; background: #E8EDF8; padding: 10rpx 16rpx; border-radius: 12rpx 12rpx 4rpx 12rpx; font-size: 26rpx; display: inline-block; max-width: 80%; }
  113. .chat-ai { text-align: left; background: #F5F6FA; padding: 10rpx 16rpx; border-radius: 4rpx 12rpx 12rpx 12rpx; font-size: 26rpx; color: #1a1a2e; border-left: 4rpx solid #002FA7; word-break: break-all; }
  114. .thinking { color: #888; font-size: 22rpx; display: flex; align-items: center; margin-bottom: 6rpx; }
  115. .think-dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #002FA7; margin-right: 8rpx; animation: pulse 1s infinite; }
  116. @keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(.7); } }
  117. .panel-input { padding: 12rpx 16rpx; border-top: 1rpx solid #eee; }
  118. .mini-input { height: 64rpx; background: #F5F6FA; border-radius: 32rpx; padding: 0 20rpx; font-size: 26rpx; }
  119. .ai-trigger {
  120. position: fixed; right: 24rpx; bottom: 120rpx; width: 96rpx; height: 96rpx;
  121. background: linear-gradient(135deg, #002FA7, #1a3fbf); border-radius: 50%;
  122. display: flex; flex-direction: column; align-items: center; justify-content: center;
  123. box-shadow: 0 4rpx 16rpx rgba(0,47,167,.35); z-index: 99;
  124. }
  125. .trigger-icon { font-size: 36rpx; }
  126. .trigger-text { font-size: 20rpx; color: #fff; margin-top: 2rpx; }
  127. </style>