| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334 |
- <template>
- <view class="ai-page">
- <view class="header">
- <text class="title">💊 AI 药典助手</text>
- <text class="subtitle">基于《中华人民共和国药典》& Qwen 大模型</text>
- </view>
- <scroll-view class="chat-list" scroll-y :scroll-top="scrollTop" :scroll-with-animation="true">
- <view v-if="messages.length === 0" class="welcome">
- <text class="welcome-icon">🔬</text>
- <text>输入药品名称、成分或用药问题</text>
- </view>
- <view v-for="(msg, idx) in messages" :key="idx" class="msg-wrapper">
- <!-- 用户消息 -->
- <view v-if="msg.role === 'user'" class="msg-user">
- <text class="msg-content">{{ msg.content }}</text>
- </view>
- <!-- AI 消息 -->
- <view v-else class="msg-ai">
- <view class="msg-tag" :class="tagClass(msg.intent)">
- {{ msg.intent ? intentLabel(msg.intent) : 'AI 药典助手' }}
- </view>
- <!-- 思考中 -->
- <view v-if="msg.thinking" class="thinking">
- <view class="think-dot"></view>
- <text>{{ msg.thinking }}</text>
- </view>
- <!-- 正文 -->
- <rich-text class="msg-body" :nodes="renderMarkdown(msg.content)"></rich-text>
- <!-- 来源 -->
- <view v-if="msg.sources && msg.sources.length" class="msg-sources">
- <text>📚 </text>
- <text v-for="(s, si) in msg.sources.slice(0,3)" :key="si" class="src-item">
- {{ s.section || s.name || '' }} ({{ (s.score || 0).toFixed(2) }})
- <text v-if="si < Math.min(2, msg.sources.length-1)"> · </text>
- </text>
- </view>
- </view>
- </view>
- </scroll-view>
- <!-- 队列提示 -->
- <view v-if="queueCount > 0" class="queue-hint">⏳ 还有 {{ queueCount }} 个问题等待回答</view>
- <!-- 媒体预览 -->
- <view v-if="pendingMedia" class="media-preview">
- <image v-if="pendingMedia.type==='image'" :src="pendingMedia.preview" mode="aspectFit" class="preview-thumb"/>
- <video v-if="pendingMedia.type==='video'" :src="pendingMedia.preview" class="preview-vid"/>
- <text class="preview-name">{{ pendingMedia.type==='video'?'🎬':'📷' }} {{ pendingMedia.name }}</text>
- <text class="preview-remove" @click="clearMedia">✕</text>
- </view>
- <view class="tags">
- <text class="tag-btn" @click="quickAsk('阿莫西林禁忌')">阿莫西林禁忌</text>
- <text class="tag-btn" @click="quickAsk('二甲双胍不良反应')">二甲双胍不良反应</text>
- <text class="tag-btn" @click="quickAsk('布洛芬用法用量')">布洛芬用法用量</text>
- <text class="tag-btn" @click="quickAsk('感冒发烧吃什么药')">感冒发烧吃什么药</text>
- </view>
- <view class="input-area">
- <text class="media-btn" @click="pickImage">📷</text>
- <text class="media-btn" @click="pickVideo">🎬</text>
- <input
- v-model="inputText"
- class="chat-input"
- :placeholder="pendingMedia?(pendingMedia.type==='video'?'视频已就绪,可输入补充问题...':'图片已就绪,可输入补充问题...'):'输入药品问题...'"
- :disabled="streaming"
- @confirm="sendMessage"
- confirm-type="send"
- />
- <button class="send-btn" :disabled="!inputText.trim() && !pendingMedia" @click="sendMessage">{{pendingMedia?'发送(含附件)':'发送'}}</button>
- <button v-if="streaming" class="stop-btn" @click="stopCurrent">停止</button>
- </view>
- </view>
- </template>
- <script>
- import { chatStream } from '@/api/ai.js'
- export default {
- data() {
- return {
- messages: [],
- inputText: '',
- conversationId: '',
- streaming: false,
- queueCount: 0,
- messageQueue: [],
- scrollTop: 0,
- currentIntent: '',
- abortFlag: false,
- pendingMedia: null // {type:'image'|'video', base64:'...', mime:'...', name:'...', preview:'...'}
- }
- },
- methods: {
- intentLabel(i) {
- const m = { drug_query:'药品查询', usage_guide:'用法用量', symptom_advice:'用药建议',
- regulation:'法规条款', exam_tutor:'考试辅导' }
- return m[i] || '综合查询'
- },
- tagClass(i) {
- const m = { drug_query:'tag-drug', usage_guide:'tag-usage', symptom_advice:'tag-symptom',
- regulation:'tag-regulation', exam_tutor:'tag-exam' }
- return m[i] || ''
- },
- renderMarkdown(content) {
- if (!content) return ''
- let h = content
- .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
- h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
- // 修复残缺括号
- var secs='(?:结论|详细说明|注意事项|来源明细|适应症|适应证|用法与用量|用法用量|禁忌|不良反应|副作用|药理|药物相互作用|贮藏|特殊人群|安全提醒|就医指征|非药物建议|用药建议|用药方案|通用药学知识|来源汇总|AI 声明|AI声明|来源说明|免责声明)';
- h = h.replace(new RegExp('(?<![【])】\\s*('+secs+')','g'),'【$1】');
- h = h.replace(new RegExp('(?<![【])('+secs+')】','g'),'【$1】');
- h = h.replace(/\n\n+/g, '</p><p>')
- h = h.replace(/\n/g, '<br>')
- // 段落标题:吞掉紧跟的 <br>
- h = h.replace(/【(.+?)】(?:<br>)?/g, '<p style="font-size:30rpx;color:#002FA7;font-weight:bold;margin:14rpx 0 2rpx">【$1】</p>')
- // 去掉标题 <p> 前后的 <br>(避免标题自带 margin + <br> = 多余空行)
- h = h.replace(/<br><p style="font-size:30rpx/g, '<p style="font-size:30rpx')
- h = h.replace(/<\/p><br>/g, '</p>')
- // markdown 链接 [文字](url) → <a>
- h = h.replace(/\[([^\]]+)\]\((https?:\/\/[^\s<>)]+)\)/g, '<a href="$2" style="color:#002FA7;text-decoration:underline">$1</a>')
- // 纯 URL 自动转可点击链接
- h = h.replace(/(?<!href=")(?<!href=')(https?:\/\/[^\s<>]+)/g, '<a href="$1" style="color:#002FA7;text-decoration:underline;word-break:break-all">$1</a>')
- return '<div style="font-size:28rpx;line-height:1.8;color:#1a1a2e;word-break:break-all">' + h + '</div>'
- },
- // ============================================================
- // 媒体选择(图片/视频)
- // ============================================================
- pickImage() {
- uni.chooseImage({
- count: 1, sizeType: ['compressed'],
- success: (res) => {
- const path = res.tempFilePaths[0]
- this.fileToBase64(path, 'image', (b64, info) => {
- this.pendingMedia = { type: 'image', base64: b64, mime: info.mime, name: info.name, preview: path }
- })
- }
- })
- },
- pickVideo() {
- uni.chooseVideo({
- maxDuration: 60, compressed: true,
- success: (res) => {
- const path = res.tempFilePath
- this.fileToBase64(path, 'video', (b64, info) => {
- this.pendingMedia = { type: 'video', base64: b64, mime: info.mime, name: info.name, preview: path }
- })
- }
- })
- },
- fileToBase64(path, type, cb) {
- // uni-app 文件转 base64
- const fs = uni.getFileSystemManager()
- try {
- const data = fs.readFileSync(path, 'base64')
- const name = path.split('/').pop() || (type === 'video' ? 'video.mp4' : 'photo.jpg')
- const mime = type === 'video' ? 'video/mp4' : 'image/jpeg'
- cb(data, { mime, name })
- } catch (e) {
- uni.showToast({ title: '读取文件失败', icon: 'none' })
- }
- },
- clearMedia() {
- this.pendingMedia = null
- },
- quickAsk(text) {
- this.inputText = text
- this.sendMessage()
- },
- sendMessage() {
- const text = this.inputText.trim()
- const hasMedia = !!this.pendingMedia
- if (!text && !hasMedia) return
- this.inputText = ''
- const payload = { text, media: this.pendingMedia }
- this.pendingMedia = null
- this.messageQueue.push(payload)
- this.queueCount = this.messageQueue.length
- if (!this.streaming) this.processQueue()
- },
- stopCurrent() {
- this.abortFlag = true
- this.messageQueue = []
- this.queueCount = 0
- // 最后一个 AI 消息追加停止标记
- if (this.messages.length) {
- const last = this.messages[this.messages.length - 1]
- if (last.role === 'assistant') {
- last.content += ' ⏹ 已停止'
- last.thinking = ''
- }
- }
- this.streaming = false
- },
- async processQueue() {
- if (this.streaming || this.messageQueue.length === 0) return
- this.streaming = true
- this.abortFlag = false
- const payload = this.messageQueue.shift()
- this.queueCount = this.messageQueue.length
- const text = typeof payload === 'string' ? payload : (payload.text || '')
- const media = (typeof payload === 'string') ? null : payload.media
- // 构造用户消息显示
- let userDisplay = text
- if (media) {
- const icon = media.type === 'video' ? '🎬 [视频] ' : '📷 [图片] '
- userDisplay = icon + (text || ('请分析此' + (media.type === 'video' ? '视频' : '图片')))
- }
- this.messages.push({ role: 'user', content: userDisplay })
- const aiIdx = this.messages.length
- this.messages.push({ role: 'assistant', content: '', intent: '', sources: [], thinking: '正在分析问题...' })
- this.scrollToBottom()
- // 构建请求体(支持多媒体)
- const reqBody = { message: text, conversation_id: this.conversationId }
- if (media) {
- reqBody.media_type = media.type
- reqBody.media_base64 = media.base64
- reqBody.media_mime = media.mime
- }
- await chatStream(
- reqBody,
- {
- onIntent: (d) => {
- if (this.abortFlag) return
- this.currentIntent = d
- this.messages[aiIdx].intent = d
- },
- onStatus: (d) => {
- if (this.abortFlag) return
- this.messages[aiIdx].thinking = d
- this.scrollToBottom()
- },
- onToken: (d) => {
- if (this.abortFlag) return
- this.messages[aiIdx].thinking = ''
- this.messages[aiIdx].content += d
- this.scrollToBottom()
- },
- onMeta: (meta) => {
- if (meta.cid) this.conversationId = meta.cid
- if (meta.intent) this.messages[aiIdx].intent = meta.intent
- if (meta.sources) this.messages[aiIdx].sources = meta.sources
- },
- onDone: () => {
- this.messages[aiIdx].thinking = ''
- this.scrollToBottom()
- },
- onError: (err) => {
- if (!this.abortFlag) this.messages[aiIdx].content = '请求失败:' + (err.message || '网络异常')
- }
- }
- )
- this.streaming = false
- this.scrollToBottom()
- if (this.messageQueue.length > 0 && !this.abortFlag) {
- setTimeout(() => this.processQueue(), 300)
- }
- },
- scrollToBottom() {
- this.$nextTick(() => {
- this.scrollTop = 999999
- })
- }
- }
- }
- </script>
- <style scoped>
- .ai-page { display: flex; flex-direction: column; height: 100vh; background: #F5F6FA; }
- .header { padding: 24rpx 32rpx 16rpx; background: linear-gradient(135deg, #002FA7, #1a3fbf); color: #fff; text-align: center; position: relative; overflow: hidden; }
- .header::after { content: ''; position: absolute; top: -40rpx; right: -30rpx; width: 120rpx; height: 120rpx; background: #C41E3A; border-radius: 50%; opacity: .15; }
- .title { font-size: 36rpx; font-weight: 700; position: relative; z-index: 1; }
- .subtitle { font-size: 24rpx; opacity: .85; margin-top: 8rpx; position: relative; z-index: 1; display: block; }
- .chat-list { flex: 1; padding: 16rpx 24rpx; }
- .msg-wrapper { margin-bottom: 24rpx; }
- .msg-user { display: flex; justify-content: flex-end; }
- .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; }
- .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); }
- .msg-tag { display: inline-block; font-size: 22rpx; padding: 2rpx 12rpx; border-radius: 10rpx; margin-bottom: 8rpx; font-weight: 600; }
- .tag-drug { background: #E8EDF8; color: #002FA7; }
- .tag-usage { background: #FDEAEE; color: #C41E3A; }
- .tag-symptom { background: #fff3e0; color: #e65100; }
- .tag-regulation { background: #f3e5f5; color: #6a1b9a; }
- .tag-exam { background: #e0f2f1; color: #00695c; }
- .thinking { color: #888; font-size: 26rpx; padding: 4rpx 0; display: flex; align-items: center; }
- .think-dot { width: 14rpx; height: 14rpx; border-radius: 50%; background: #002FA7; margin-right: 12rpx; animation: pulse 1s infinite; }
- @keyframes pulse { 0%,100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(.7); } }
- .msg-body { font-size: 28rpx; line-height: 1.8; color: #1a1a2e; word-break: break-all; }
- .msg-body h3 { font-size: 30rpx; color: #002FA7; margin: 16rpx 0 8rpx; }
- .msg-body strong { color: #111; }
- .msg-body p { margin: 0 0 12rpx 0; }
- .msg-sources { margin-top: 16rpx; padding-top: 12rpx; border-top: 1rpx solid #eee; font-size: 22rpx; color: #aaa; }
- .src-item { color: #888; }
- .queue-hint { text-align: center; padding: 8rpx; font-size: 24rpx; color: #888; background: #fafafa; }
- .tags { display: flex; gap: 12rpx; padding: 12rpx 24rpx 16rpx; flex-wrap: wrap; }
- .tag-btn { font-size: 24rpx; color: #002FA7; background: #E8EDF8; padding: 8rpx 20rpx; border-radius: 20rpx; border: 1rpx solid rgba(0,47,167,.2); }
- .media-preview { display: flex; align-items: center; padding: 12rpx 24rpx; background: #fafafa; border-top: 1rpx solid #eee; gap: 12rpx; }
- .preview-thumb { width: 80rpx; height: 80rpx; border-radius: 8rpx; flex-shrink: 0; }
- .preview-vid { width: 120rpx; height: 80rpx; border-radius: 8rpx; flex-shrink: 0; }
- .preview-name { font-size: 24rpx; color: #888; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .preview-remove { font-size: 28rpx; color: #C41E3A; padding: 8rpx; }
- .input-area { display: flex; align-items: center; padding: 16rpx 24rpx; background: #fff; border-top: 1rpx solid #e0e0e0; gap: 12rpx; }
- .media-btn { font-size: 36rpx; padding: 8rpx; min-width: 56rpx; text-align: center; }
- .chat-input { flex: 1; height: 72rpx; font-size: 28rpx; background: #F5F6FA; border-radius: 40rpx; padding: 0 24rpx; }
- .send-btn { background: #C41E3A; color: #fff; border: none; border-radius: 40rpx; padding: 12rpx 28rpx; font-size: 26rpx; font-weight: 700; }
- .send-btn[disabled] { opacity: .4; }
- .stop-btn { background: #999; color: #fff; border: none; border-radius: 40rpx; padding: 12rpx 28rpx; font-size: 26rpx; }
- .welcome { text-align: center; color: #888; padding: 80rpx 40rpx; }
- .welcome-icon { font-size: 80rpx; display: block; margin-bottom: 20rpx; }
- </style>
|