index.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. <template>
  2. <view class="ai-page">
  3. <view class="header">
  4. <text class="title">💊 AI 药典助手</text>
  5. <text class="subtitle">基于《中华人民共和国药典》& Qwen 大模型</text>
  6. </view>
  7. <scroll-view class="chat-list" scroll-y :scroll-top="scrollTop" :scroll-with-animation="true">
  8. <view v-if="messages.length === 0" class="welcome">
  9. <text class="welcome-icon">🔬</text>
  10. <text>输入药品名称、成分或用药问题</text>
  11. </view>
  12. <view v-for="(msg, idx) in messages" :key="idx" class="msg-wrapper">
  13. <!-- 用户消息 -->
  14. <view v-if="msg.role === 'user'" class="msg-user">
  15. <text class="msg-content">{{ msg.content }}</text>
  16. </view>
  17. <!-- AI 消息 -->
  18. <view v-else class="msg-ai">
  19. <view class="msg-tag" :class="tagClass(msg.intent)">
  20. {{ msg.intent ? intentLabel(msg.intent) : 'AI 药典助手' }}
  21. </view>
  22. <!-- 思考中 -->
  23. <view v-if="msg.thinking" class="thinking">
  24. <view class="think-dot"></view>
  25. <text>{{ msg.thinking }}</text>
  26. </view>
  27. <!-- 正文 -->
  28. <view class="msg-body" v-html="renderMarkdown(msg.content)"></view>
  29. <!-- 来源 -->
  30. <view v-if="msg.sources && msg.sources.length" class="msg-sources">
  31. <text>📚 </text>
  32. <text v-for="(s, si) in msg.sources.slice(0,3)" :key="si" class="src-item">
  33. {{ s.section || s.name || '' }} ({{ (s.score || 0).toFixed(2) }})
  34. <text v-if="si < Math.min(2, msg.sources.length-1)"> · </text>
  35. </text>
  36. </view>
  37. </view>
  38. </view>
  39. </scroll-view>
  40. <!-- 队列提示 -->
  41. <view v-if="queueCount > 0" class="queue-hint">⏳ 还有 {{ queueCount }} 个问题等待回答</view>
  42. <view class="tags">
  43. <text class="tag-btn" @click="quickAsk('阿莫西林禁忌')">阿莫西林禁忌</text>
  44. <text class="tag-btn" @click="quickAsk('二甲双胍不良反应')">二甲双胍不良反应</text>
  45. <text class="tag-btn" @click="quickAsk('布洛芬用法用量')">布洛芬用法用量</text>
  46. <text class="tag-btn" @click="quickAsk('感冒发烧吃什么药')">感冒发烧吃什么药</text>
  47. </view>
  48. <view class="input-area">
  49. <input
  50. v-model="inputText"
  51. class="chat-input"
  52. placeholder="输入药品问题..."
  53. :disabled="streaming"
  54. @confirm="sendMessage"
  55. confirm-type="send"
  56. />
  57. <button class="send-btn" :disabled="!inputText.trim()" @click="sendMessage">发送</button>
  58. <button v-if="streaming" class="stop-btn" @click="stopCurrent">停止</button>
  59. </view>
  60. </view>
  61. </template>
  62. <script>
  63. import { chatStream } from '@/api/ai.js'
  64. export default {
  65. data() {
  66. return {
  67. messages: [],
  68. inputText: '',
  69. conversationId: '',
  70. streaming: false,
  71. queueCount: 0,
  72. messageQueue: [],
  73. scrollTop: 0,
  74. currentIntent: '',
  75. abortFlag: false
  76. }
  77. },
  78. methods: {
  79. intentLabel(i) {
  80. const m = { drug_query:'药品查询', usage_guide:'用法用量', symptom_advice:'用药建议',
  81. regulation:'法规条款', exam_tutor:'考试辅导' }
  82. return m[i] || '综合查询'
  83. },
  84. tagClass(i) {
  85. const m = { drug_query:'tag-drug', usage_guide:'tag-usage', symptom_advice:'tag-symptom',
  86. regulation:'tag-regulation', exam_tutor:'tag-exam' }
  87. return m[i] || ''
  88. },
  89. renderMarkdown(content) {
  90. if (!content) return ''
  91. let h = content
  92. .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  93. h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
  94. // 修复残缺括号(匹配已知段落名,避开已有【的合法段)
  95. var secs='(?:结论|详细说明|注意事项|来源明细|适应症|适应证|用法与用量|用法用量|禁忌|不良反应|副作用|药理|药物相互作用|贮藏|特殊人群|安全提醒|就医指征|非药物建议|用药建议|用药方案|通用药学知识|来源汇总)';
  96. h = h.replace(new RegExp('(?<![【])】\\s*('+secs+')','g'),'【$1】');
  97. h = h.replace(new RegExp('(?<![【])('+secs+')】','g'),'【$1】');
  98. h = h.replace(/\n\n+/g, '</p><p>')
  99. h = h.replace(/\n/g, '<br>')
  100. h = h.replace(/【(.+?)】/g, '<h3>【$1】</h3>')
  101. return '<p>' + h + '</p>'
  102. },
  103. quickAsk(text) {
  104. this.inputText = text
  105. this.sendMessage()
  106. },
  107. sendMessage() {
  108. const text = this.inputText.trim()
  109. if (!text) return
  110. this.inputText = ''
  111. this.messageQueue.push(text)
  112. this.queueCount = this.messageQueue.length
  113. if (!this.streaming) this.processQueue()
  114. },
  115. stopCurrent() {
  116. this.abortFlag = true
  117. this.messageQueue = []
  118. this.queueCount = 0
  119. // 最后一个 AI 消息追加停止标记
  120. if (this.messages.length) {
  121. const last = this.messages[this.messages.length - 1]
  122. if (last.role === 'assistant') {
  123. last.content += ' ⏹ 已停止'
  124. last.thinking = ''
  125. }
  126. }
  127. this.streaming = false
  128. },
  129. async processQueue() {
  130. if (this.streaming || this.messageQueue.length === 0) return
  131. this.streaming = true
  132. this.abortFlag = false
  133. const text = this.messageQueue.shift()
  134. this.queueCount = this.messageQueue.length
  135. this.messages.push({ role: 'user', content: text })
  136. const aiIdx = this.messages.length
  137. this.messages.push({ role: 'assistant', content: '', intent: '', sources: [], thinking: '正在分析问题...' })
  138. this.scrollToBottom()
  139. await chatStream(
  140. { message: text, conversation_id: this.conversationId },
  141. {
  142. onIntent: (d) => {
  143. if (this.abortFlag) return
  144. this.currentIntent = d
  145. this.messages[aiIdx].intent = d
  146. },
  147. onStatus: (d) => {
  148. if (this.abortFlag) return
  149. this.messages[aiIdx].thinking = d
  150. this.scrollToBottom()
  151. },
  152. onToken: (d) => {
  153. if (this.abortFlag) return
  154. this.messages[aiIdx].thinking = ''
  155. this.messages[aiIdx].content += d
  156. this.scrollToBottom()
  157. },
  158. onMeta: (meta) => {
  159. if (meta.cid) this.conversationId = meta.cid
  160. if (meta.intent) this.messages[aiIdx].intent = meta.intent
  161. if (meta.sources) this.messages[aiIdx].sources = meta.sources
  162. },
  163. onDone: () => {
  164. this.messages[aiIdx].thinking = ''
  165. this.scrollToBottom()
  166. },
  167. onError: (err) => {
  168. if (!this.abortFlag) this.messages[aiIdx].content = '请求失败:' + (err.message || '网络异常')
  169. }
  170. }
  171. )
  172. this.streaming = false
  173. this.scrollToBottom()
  174. if (this.messageQueue.length > 0 && !this.abortFlag) {
  175. setTimeout(() => this.processQueue(), 300)
  176. }
  177. },
  178. scrollToBottom() {
  179. this.$nextTick(() => {
  180. this.scrollTop = 999999
  181. })
  182. }
  183. }
  184. }
  185. </script>
  186. <style scoped>
  187. .ai-page { display: flex; flex-direction: column; height: 100vh; background: #F5F6FA; }
  188. .header { padding: 24rpx 32rpx 16rpx; background: linear-gradient(135deg, #002FA7, #1a3fbf); color: #fff; text-align: center; position: relative; overflow: hidden; }
  189. .header::after { content: ''; position: absolute; top: -40rpx; right: -30rpx; width: 120rpx; height: 120rpx; background: #C41E3A; border-radius: 50%; opacity: .15; }
  190. .title { font-size: 36rpx; font-weight: 700; position: relative; z-index: 1; }
  191. .subtitle { font-size: 24rpx; opacity: .85; margin-top: 8rpx; position: relative; z-index: 1; display: block; }
  192. .chat-list { flex: 1; padding: 16rpx 24rpx; }
  193. .msg-wrapper { margin-bottom: 24rpx; }
  194. .msg-user { display: flex; justify-content: flex-end; }
  195. .msg-user .msg-content { background: #002FA7; color: #fff; padding: 16rpx 24rpx; border-radius: 16rpx 16rpx 4rpx 16rpx; max-width: 80%; font-size: 28rpx; line-height: 1.6; }
  196. .msg-ai { background: #fff; padding: 20rpx 24rpx; border-radius: 0 16rpx 16rpx 16rpx; border-left: 6rpx solid #002FA7; box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06); }
  197. .msg-tag { display: inline-block; font-size: 22rpx; padding: 2rpx 12rpx; border-radius: 10rpx; margin-bottom: 8rpx; font-weight: 600; }
  198. .tag-drug { background: #E8EDF8; color: #002FA7; }
  199. .tag-usage { background: #FDEAEE; color: #C41E3A; }
  200. .tag-symptom { background: #fff3e0; color: #e65100; }
  201. .tag-regulation { background: #f3e5f5; color: #6a1b9a; }
  202. .tag-exam { background: #e0f2f1; color: #00695c; }
  203. .thinking { color: #888; font-size: 26rpx; padding: 4rpx 0; display: flex; align-items: center; }
  204. .think-dot { width: 14rpx; height: 14rpx; border-radius: 50%; background: #002FA7; margin-right: 12rpx; animation: pulse 1s infinite; }
  205. @keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(.7); } }
  206. .msg-body { font-size: 28rpx; line-height: 1.8; color: #1a1a2e; word-break: break-all; }
  207. .msg-body h3 { font-size: 30rpx; color: #002FA7; margin: 16rpx 0 8rpx; }
  208. .msg-body strong { color: #111; }
  209. .msg-body p { margin: 0 0 12rpx 0; }
  210. .msg-sources { margin-top: 16rpx; padding-top: 12rpx; border-top: 1rpx solid #eee; font-size: 22rpx; color: #aaa; }
  211. .src-item { color: #888; }
  212. .queue-hint { text-align: center; padding: 8rpx; font-size: 24rpx; color: #888; background: #fafafa; }
  213. .tags { display: flex; gap: 12rpx; padding: 12rpx 24rpx 16rpx; flex-wrap: wrap; }
  214. .tag-btn { font-size: 24rpx; color: #002FA7; background: #E8EDF8; padding: 8rpx 20rpx; border-radius: 20rpx; border: 1rpx solid rgba(0,47,167,.2); }
  215. .input-area { display: flex; align-items: center; padding: 16rpx 24rpx; background: #fff; border-top: 1rpx solid #e0e0e0; gap: 12rpx; }
  216. .chat-input { flex: 1; height: 72rpx; font-size: 28rpx; background: #F5F6FA; border-radius: 40rpx; padding: 0 24rpx; }
  217. .send-btn { background: #C41E3A; color: #fff; border: none; border-radius: 40rpx; padding: 12rpx 28rpx; font-size: 26rpx; font-weight: 700; }
  218. .send-btn[disabled] { opacity: .4; }
  219. .stop-btn { background: #999; color: #fff; border: none; border-radius: 40rpx; padding: 12rpx 28rpx; font-size: 26rpx; }
  220. .welcome { text-align: center; color: #888; padding: 80rpx 40rpx; }
  221. .welcome-icon { font-size: 80rpx; display: block; margin-bottom: 20rpx; }
  222. </style>