Commit 56f5feef041a98a311a8bd3a0d1f36bb86b0bf58

Authored by pengye
1 parent 84224915

aiBook页面

  1 +const {
  2 + mockReply
  3 +} = require('../../services/ai/mock')
  4 +
  5 +const HINTS = [
  6 + '今晚还有空桌吗?',
  7 + '热销菜品有哪些?',
  8 + '今天预订情况怎么样?',
  9 + '帮我查一下订单',
  10 +]
  11 +
  12 +let typingTimer = null
  13 +let recTimer = null
  14 +let recStart = 0
  15 +let touchStartY = 0
  16 +let stopped = false
  17 +let recordToken = 0
  18 +let recorderBound = false
  19 +const recorder = wx.getRecorderManager()
  20 +
  21 +function bindRecorder() {
  22 + if (recorderBound) return
  23 + recorderBound = true
  24 + recorder.onStop((res) => {
  25 + const pages = getCurrentPages()
  26 + const page = pages[pages.length - 1]
  27 + if (page && typeof page.handleRecordStop === 'function') {
  28 + page.handleRecordStop(res)
  29 + }
  30 + })
  31 + recorder.onError((err) => {
  32 + const pages = getCurrentPages()
  33 + const page = pages[pages.length - 1]
  34 + if (page && typeof page.handleRecordError === 'function') {
  35 + page.handleRecordError(err)
  36 + }
  37 + })
  38 +}
  39 +
  40 +function pad2(n) {
  41 + return n < 10 ? `0${n}` : `${n}`
  42 +}
  43 +
  44 +function timeStr(date) {
  45 + date = date || new Date()
  46 + return `${pad2(date.getHours())}:${pad2(date.getMinutes())}`
  47 +}
  48 +
  49 +function uid() {
  50 + return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
  51 +}
  52 +
  53 +function clearTimers() {
  54 + if (typingTimer) {
  55 + clearInterval(typingTimer)
  56 + typingTimer = null
  57 + }
  58 + if (recTimer) {
  59 + clearInterval(recTimer)
  60 + recTimer = null
  61 + }
  62 +}
  63 +
  64 +Page({
  65 + data: {
  66 + messages: [],
  67 + inputText: '',
  68 + scrollIntoView: '',
  69 + streaming: false,
  70 + recording: false,
  71 + cancelHint: false,
  72 + recTime: '0:00',
  73 + confirmVisible: false,
  74 + confirmText: '',
  75 + recognizing: false,
  76 + showHints: true,
  77 + hints: HINTS,
  78 + storeName: '高新店',
  79 + },
  80 +
  81 + onLoad() {
  82 + bindRecorder()
  83 + this._recorderOn = false
  84 + this._ignoreRecord = true
  85 + this.seedWelcome()
  86 + },
  87 +
  88 + onUnload() {
  89 + clearTimers()
  90 + },
  91 +
  92 + seedWelcome() {
  93 + const welcome = {
  94 + id: uid(),
  95 + role: 'agent',
  96 + text: '你好,我是经营助手。今天想问点什么?比如:"今天营业额多少?"(当前为本地演示数据)',
  97 + meta: `经营助手 · ${timeStr()}`,
  98 + }
  99 + this.setData({
  100 + messages: [welcome]
  101 + }, () => this.scrollBottom())
  102 + },
  103 +
  104 + scrollBottom() {
  105 + this.setData({
  106 + scrollIntoView: ''
  107 + }, () => {
  108 + this.setData({
  109 + scrollIntoView: 'msg-bottom'
  110 + })
  111 + })
  112 + },
  113 +
  114 + updateMessage(id, patch) {
  115 + const messages = this.data.messages.map((m) =>
  116 + m.id === id ? Object.assign({}, m, patch) : m
  117 + )
  118 + this.setData({
  119 + messages
  120 + })
  121 + },
  122 +
  123 + onInput(e) {
  124 + this.setData({
  125 + inputText: e.detail.value
  126 + })
  127 + },
  128 +
  129 + onHintTap(e) {
  130 + const text = e.currentTarget.dataset.text
  131 + this.setData({
  132 + inputText: text,
  133 + showHints: false
  134 + })
  135 + this.send(text)
  136 + },
  137 +
  138 + onSend() {
  139 + this.send(this.data.inputText)
  140 + },
  141 +
  142 + onStop() {
  143 + stopped = true
  144 + },
  145 +
  146 + send(raw) {
  147 + const q = (raw || '').trim()
  148 + if (!q || this.data.streaming || this.data.recording) return
  149 +
  150 + const userMsg = {
  151 + id: uid(),
  152 + role: 'user',
  153 + text: q,
  154 + meta: timeStr(),
  155 + }
  156 +
  157 + this.setData({
  158 + messages: this.data.messages.concat([userMsg]),
  159 + inputText: '',
  160 + showHints: false,
  161 + confirmVisible: false,
  162 + },
  163 + () => {
  164 + this.scrollBottom()
  165 + setTimeout(() => this.streamMock(mockReply(q)), 400)
  166 + }
  167 + )
  168 + },
  169 +
  170 + streamMock(reply) {
  171 + const id = uid()
  172 + const holder = {
  173 + id,
  174 + role: 'agent',
  175 + text: '',
  176 + meta: `经营助手 · ${timeStr()}`,
  177 + typing: true,
  178 + }
  179 +
  180 + stopped = false
  181 + this.setData({
  182 + messages: this.data.messages.concat([holder]),
  183 + streaming: true,
  184 + },
  185 + () => this.scrollBottom()
  186 + )
  187 +
  188 + let i = 0
  189 + const full = reply.text
  190 + if (typingTimer) clearInterval(typingTimer)
  191 +
  192 + typingTimer = setInterval(() => {
  193 + if (stopped) {
  194 + clearInterval(typingTimer)
  195 + typingTimer = null
  196 + const current = this.data.messages.find((m) => m.id === id)
  197 + this.updateMessage(id, {
  198 + typing: false,
  199 + showCaret: false,
  200 + stopped: true,
  201 + text: ((current && current.text) || '').replace(/\s+$/, '') || full.slice(0, Math.max(i, 2)),
  202 + })
  203 + this.setData({
  204 + streaming: false
  205 + })
  206 + this.scrollBottom()
  207 + return
  208 + }
  209 +
  210 + i += 2
  211 + this.updateMessage(id, {
  212 + typing: false,
  213 + showCaret: i < full.length,
  214 + text: full.slice(0, i),
  215 + })
  216 + this.scrollBottom()
  217 +
  218 + if (i >= full.length) {
  219 + clearInterval(typingTimer)
  220 + typingTimer = null
  221 + this.updateMessage(id, {
  222 + typing: false,
  223 + showCaret: false,
  224 + text: full,
  225 + card: reply.card,
  226 + })
  227 + this.setData({
  228 + streaming: false
  229 + })
  230 + this.scrollBottom()
  231 + }
  232 + }, 24)
  233 + },
  234 +
  235 + onVoiceStart(e) {
  236 + if (this.data.streaming || this.data.recognizing) return
  237 + const touch = e.touches[0]
  238 + touchStartY = touch ? touch.clientY : 0
  239 + recStart = Date.now()
  240 + recordToken += 1
  241 + this._recordToken = recordToken
  242 + this._ignoreRecord = false
  243 + this.setData({
  244 + recording: true,
  245 + cancelHint: false,
  246 + recTime: '0:00',
  247 + confirmVisible: false,
  248 + confirmText: '',
  249 + })
  250 +
  251 + if (recTimer) clearInterval(recTimer)
  252 + recTimer = setInterval(() => {
  253 + const s = Math.floor((Date.now() - recStart) / 1000)
  254 + this.setData({
  255 + recTime: `0:${pad2(s)}`
  256 + })
  257 + }, 200)
  258 +
  259 + try {
  260 + recorder.start({
  261 + duration: 60000,
  262 + sampleRate: 16000,
  263 + numberOfChannels: 1,
  264 + encodeBitRate: 48000,
  265 + format: 'mp3',
  266 + })
  267 + this._recorderOn = true
  268 + } catch (err) {
  269 + this._recorderOn = false
  270 + this._ignoreRecord = true
  271 + this.setData({
  272 + recording: false
  273 + })
  274 + wx.showToast({
  275 + title: '无法开始录音',
  276 + icon: 'none'
  277 + })
  278 + }
  279 + },
  280 +
  281 + onVoiceMove(e) {
  282 + if (!this.data.recording) return
  283 + const touch = e.touches[0]
  284 + if (!touch) return
  285 + const dy = touchStartY - touch.clientY
  286 + const cancelHint = dy > 60
  287 + if (cancelHint !== this.data.cancelHint) {
  288 + this.setData({
  289 + cancelHint
  290 + })
  291 + }
  292 + },
  293 +
  294 + onVoiceCancel() {
  295 + this.finishVoice(true)
  296 + },
  297 +
  298 + onVoiceEnd() {
  299 + this.finishVoice(this.data.cancelHint)
  300 + },
  301 +
  302 + finishVoice(cancelled) {
  303 + if (!this.data.recording) return
  304 + if (recTimer) {
  305 + clearInterval(recTimer)
  306 + recTimer = null
  307 + }
  308 + const dur = Math.round((Date.now() - recStart) / 1000)
  309 + const tooShort = !cancelled && dur < 1
  310 + this._ignoreRecord = cancelled || tooShort
  311 + this._pendingToken = this._ignoreRecord ? 0 : this._recordToken
  312 + this.setData({
  313 + recording: false,
  314 + cancelHint: false
  315 + })
  316 +
  317 + if (this._recorderOn) {
  318 + this._recorderOn = false
  319 + try {
  320 + recorder.stop()
  321 + } catch (err) {
  322 + // ignore
  323 + }
  324 + }
  325 +
  326 + if (cancelled) return
  327 + if (tooShort) {
  328 + wx.showToast({
  329 + title: '录音太短,请重说',
  330 + icon: 'none'
  331 + })
  332 + }
  333 + },
  334 +
  335 + handleRecordStop(res) {
  336 + if (this._ignoreRecord || !this._pendingToken || this._pendingToken !== this._recordToken) return
  337 + this._pendingToken = 0
  338 + if (!(res && res.tempFilePath)) {
  339 + wx.showToast({
  340 + title: '没有录到声音',
  341 + icon: 'none'
  342 + })
  343 + return
  344 + }
  345 + // 演示阶段不接语音识别,后续接后端接口
  346 + wx.showToast({
  347 + title: '演示模式暂不支持语音',
  348 + icon: 'none'
  349 + })
  350 + },
  351 +
  352 + handleRecordError(err) {
  353 + this._recorderOn = false
  354 + this._ignoreRecord = true
  355 + if (recTimer) {
  356 + clearInterval(recTimer)
  357 + recTimer = null
  358 + }
  359 + this.setData({
  360 + recording: false,
  361 + recognizing: false
  362 + })
  363 + const msg = (err && err.errMsg) || ''
  364 + if (/auth|deny|permission/i.test(msg)) {
  365 + wx.showModal({
  366 + title: '需要麦克风权限',
  367 + content: '请在设置中允许使用麦克风,再按住说话。',
  368 + confirmText: '去设置',
  369 + success(res) {
  370 + if (res.confirm) wx.openSetting()
  371 + },
  372 + })
  373 + return
  374 + }
  375 + if (this.data.recording || /start/i.test(msg)) {
  376 + wx.showToast({
  377 + title: '录音失败,请重试',
  378 + icon: 'none'
  379 + })
  380 + }
  381 + },
  382 +
  383 + onConfirmInput(e) {
  384 + this.setData({
  385 + confirmText: e.detail.value
  386 + })
  387 + },
  388 +
  389 + onCancelConfirm() {
  390 + this.setData({
  391 + confirmVisible: false,
  392 + confirmText: ''
  393 + })
  394 + },
  395 +
  396 + onOkConfirm() {
  397 + const t = this.data.confirmText.trim()
  398 + this.setData({
  399 + confirmVisible: false,
  400 + confirmText: ''
  401 + })
  402 + if (t) this.send(t)
  403 + },
  404 +})
  1 +{
  2 + "usingComponents": {
  3 + "nav": "/components/nav/nav"
  4 + },
  5 + "navigationStyle": "custom",
  6 + "disableScroll": true
  7 +}
  1 +<view class="page">
  2 + <nav backgroundColor="#FFFFFF" color="#1A1B1C">
  3 + <view class="nav-title">
  4 + <text class="nav-title-main">AI 经营助手</text>
  5 + <text class="nav-title-sub">{{storeName}}</text>
  6 + </view>
  7 + </nav>
  8 +
  9 + <scroll-view class="msg-list" scroll-y scroll-with-animation scroll-into-view="{{scrollIntoView}}" enhanced show-scrollbar="{{false}}">
  10 + <view class="msg-list-inner">
  11 + <block wx:for="{{messages}}" wx:key="id">
  12 + <view id="msg-{{item.id}}" class="msg {{item.role}}">
  13 + <view class="bubble">
  14 + <view wx:if="{{item.typing}}" class="typing">
  15 + <view class="dot"></view>
  16 + <view class="dot"></view>
  17 + <view class="dot"></view>
  18 + </view>
  19 + <block wx:else>
  20 + <view class="bubble-body">
  21 + <text class="bubble-text" user-select>{{item.text}}</text><text wx:if="{{item.showCaret}}" class="caret">|</text><text wx:if="{{item.stopped}}" class="stopped-tag"> (已停止)</text>
  22 + </view>
  23 + </block>
  24 +
  25 + <!-- KPI 卡片 -->
  26 + <view wx:if="{{item.card && item.card.type === 'kpi'}}" class="card">
  27 + <view class="c-title">{{item.card.title}}</view>
  28 + <view class="kpis">
  29 + <view class="kpi" wx:for="{{item.card.items}}" wx:for-item="kpi" wx:key="label">
  30 + <view class="k-label">{{kpi.label}}</view>
  31 + <view class="k-val">{{kpi.value}}</view>
  32 + <view class="k-delta {{kpi.trend}}">{{kpi.delta}}</view>
  33 + </view>
  34 + </view>
  35 + </view>
  36 +
  37 + <!-- 行列表卡片(桌位 / 预订 / 订单) -->
  38 + <view wx:if="{{item.card && item.card.type === 'rows'}}" class="card">
  39 + <view class="c-title">{{item.card.title}}</view>
  40 + <view class="row" wx:for="{{item.card.rows}}" wx:for-item="row" wx:key="label">
  41 + <text class="row-label">{{row.label}}</text>
  42 + <text class="row-status {{row.status}}">{{row.statusText}}</text>
  43 + </view>
  44 + </view>
  45 +
  46 + <!-- 排行卡片 -->
  47 + <view wx:if="{{item.card && item.card.type === 'rank'}}" class="card">
  48 + <view class="c-title">{{item.card.title}}</view>
  49 + <view class="rank" wx:for="{{item.card.items}}" wx:for-item="rank" wx:key="name">
  50 + <view class="no">{{rank.no}}</view>
  51 + <text class="rank-name">{{rank.name}}</text>
  52 + <view class="bar">
  53 + <view class="bar-fill" style="width: {{rank.percent}};"></view>
  54 + </view>
  55 + <text class="rank-val">{{rank.value}}</text>
  56 + </view>
  57 + </view>
  58 + </view>
  59 + <view class="meta">{{item.meta}}</view>
  60 + </view>
  61 + </block>
  62 + <view id="msg-bottom" class="msg-bottom"></view>
  63 + </view>
  64 + </scroll-view>
  65 +
  66 + <!-- 快捷提问 -->
  67 + <scroll-view wx:if="{{showHints}}" class="hints" scroll-x enable-flex show-scrollbar="{{false}}">
  68 + <view class="hint-btn" wx:for="{{hints}}" wx:key="*this" data-text="{{item}}" bindtap="onHintTap">{{item}}</view>
  69 + </scroll-view>
  70 +
  71 + <!-- 语音转写确认 -->
  72 + <view wx:if="{{confirmVisible}}" class="confirm-box">
  73 + <textarea class="confirm-text" value="{{confirmText}}" maxlength="-1" auto-height placeholder="{{recognizing ? '正在识别…' : '转写结果可在这里修改…'}}" disabled="{{recognizing}}" bindinput="onConfirmInput" />
  74 + <view class="confirm-bar">
  75 + <view class="cbtn cancel" bindtap="onCancelConfirm">取消</view>
  76 + <view class="cbtn ok" bindtap="onOkConfirm">确认发送</view>
  77 + </view>
  78 + </view>
  79 +
  80 + <!-- 输入区 -->
  81 + <view class="inputbar">
  82 + <input class="text-input" value="{{inputText}}" placeholder="问点什么…" confirm-type="send" bindinput="onInput" bindconfirm="onSend" disabled="{{recording}}" />
  83 + <view class="ibtn voice {{recording ? 'recording' : ''}}" catchtouchstart="onVoiceStart" catchtouchend="onVoiceEnd" catchtouchcancel="onVoiceCancel" catchtouchmove="onVoiceMove">🎤</view>
  84 + <view wx:if="{{streaming}}" class="stop-btn" bindtap="onStop">停止</view>
  85 + <view wx:else class="ibtn send" bindtap="onSend">➤</view>
  86 + </view>
  87 +
  88 + <!-- 录音浮层 -->
  89 + <view class="record-overlay {{recording ? 'show' : ''}} {{cancelHint ? 'cancel' : ''}}">
  90 + <view class="rec-time">{{recTime}}</view>
  91 + <view class="wave">
  92 + <view class="wave-bar"></view>
  93 + <view class="wave-bar"></view>
  94 + <view class="wave-bar"></view>
  95 + <view class="wave-bar"></view>
  96 + <view class="wave-bar"></view>
  97 + <view class="wave-bar"></view>
  98 + <view class="wave-bar"></view>
  99 + </view>
  100 + <view class="rec-hint">{{cancelHint ? '松开取消' : '松开发送 · 上滑取消'}}</view>
  101 + </view>
  102 +</view>
  1 +page {
  2 + height: 100%;
  3 + background: linear-gradient(160deg, #edf4f8 0%, #f4f3ee 100%);
  4 +}
  5 +
  6 +.page {
  7 + height: 100vh;
  8 + display: flex;
  9 + flex-direction: column;
  10 + box-sizing: border-box;
  11 + background: linear-gradient(160deg, #edf4f8 0%, #f4f3ee 100%);
  12 + position: relative;
  13 +}
  14 +
  15 +.nav-title {
  16 + display: flex;
  17 + flex-direction: column;
  18 + align-items: center;
  19 + justify-content: center;
  20 + height: 100%;
  21 + line-height: 1.15;
  22 + white-space: normal;
  23 +}
  24 +
  25 +.nav-title-main {
  26 + font-size: 30rpx;
  27 + font-weight: 600;
  28 + color: #1a1b1c;
  29 +}
  30 +
  31 +.nav-title-sub {
  32 + font-size: 20rpx;
  33 + color: #6b7280;
  34 + margin-top: 2rpx;
  35 + font-weight: 400;
  36 +}
  37 +
  38 +.msg-list {
  39 + flex: 1;
  40 + height: 0;
  41 + padding: 0 20rpx;
  42 + box-sizing: border-box;
  43 +}
  44 +
  45 +.msg-list-inner {
  46 + padding: 24rpx 0 16rpx;
  47 + display: flex;
  48 + flex-direction: column;
  49 + gap: 24rpx;
  50 +}
  51 +
  52 +.msg {
  53 + max-width: 88%;
  54 + display: flex;
  55 + flex-direction: column;
  56 + animation: msgIn 0.4s cubic-bezier(0.22, 0.9, 0.3, 1) both;
  57 +}
  58 +
  59 +.msg.user {
  60 + align-self: flex-end;
  61 +}
  62 +
  63 +.msg.agent {
  64 + align-self: flex-start;
  65 +}
  66 +
  67 +@keyframes msgIn {
  68 + from {
  69 + opacity: 0;
  70 + transform: translateY(14px) scale(0.96);
  71 + }
  72 +
  73 + to {
  74 + opacity: 1;
  75 + transform: none;
  76 + }
  77 +}
  78 +
  79 +.bubble {
  80 + padding: 18rpx 24rpx;
  81 + font-size: 28rpx;
  82 + line-height: 1.55;
  83 + color: #1a1b1c;
  84 + word-break: break-word;
  85 +}
  86 +
  87 +.msg.user .bubble {
  88 + background: #a3d5e8;
  89 + border-radius: 28rpx 28rpx 6rpx 28rpx;
  90 +}
  91 +
  92 +.msg.agent .bubble {
  93 + background: rgba(26, 27, 28, 0.05);
  94 + border-radius: 28rpx 28rpx 28rpx 6rpx;
  95 +}
  96 +
  97 +.bubble-body {
  98 + display: inline;
  99 +}
  100 +
  101 +.bubble-text {
  102 + white-space: pre-wrap;
  103 +}
  104 +
  105 +.meta {
  106 + font-size: 20rpx;
  107 + color: #9ca3af;
  108 + margin-top: 8rpx;
  109 + padding: 0 8rpx;
  110 +}
  111 +
  112 +.msg.user .meta {
  113 + text-align: right;
  114 +}
  115 +
  116 +.typing {
  117 + display: inline-flex;
  118 + gap: 8rpx;
  119 + align-items: center;
  120 + padding: 6rpx 4rpx;
  121 + height: 28rpx;
  122 +}
  123 +
  124 +.dot {
  125 + width: 10rpx;
  126 + height: 10rpx;
  127 + border-radius: 50%;
  128 + background: #6b7280;
  129 + animation: blink 1.2s infinite;
  130 +}
  131 +
  132 +.dot:nth-child(2) {
  133 + animation-delay: 0.2s;
  134 +}
  135 +
  136 +.dot:nth-child(3) {
  137 + animation-delay: 0.4s;
  138 +}
  139 +
  140 +@keyframes blink {
  141 +
  142 + 0%,
  143 + 60%,
  144 + 100% {
  145 + opacity: 0.25;
  146 + transform: translateY(0);
  147 + }
  148 +
  149 + 30% {
  150 + opacity: 1;
  151 + transform: translateY(-6rpx);
  152 + }
  153 +}
  154 +
  155 +.caret {
  156 + display: inline;
  157 + color: #1a1b1c;
  158 + font-weight: 400;
  159 + margin-left: 2rpx;
  160 + animation: caretBlink 0.8s step-end infinite;
  161 +}
  162 +
  163 +@keyframes caretBlink {
  164 + 50% {
  165 + opacity: 0;
  166 + }
  167 +}
  168 +
  169 +.stopped-tag {
  170 + color: #9ca3af;
  171 + font-size: 20rpx;
  172 +}
  173 +
  174 +.card {
  175 + margin-top: 16rpx;
  176 + background: #fff;
  177 + border-radius: 20rpx;
  178 + padding: 20rpx;
  179 + border: 1rpx solid rgba(0, 0, 0, 0.06);
  180 + animation: cardIn 0.5s cubic-bezier(0.22, 0.9, 0.3, 1) both;
  181 +}
  182 +
  183 +@keyframes cardIn {
  184 + from {
  185 + opacity: 0;
  186 + transform: translateY(8px) scale(0.97);
  187 + }
  188 +
  189 + to {
  190 + opacity: 1;
  191 + transform: none;
  192 + }
  193 +}
  194 +
  195 +.c-title {
  196 + font-size: 22rpx;
  197 + color: #6b7280;
  198 + margin-bottom: 16rpx;
  199 +}
  200 +
  201 +.kpis {
  202 + display: flex;
  203 + gap: 12rpx;
  204 + flex-wrap: wrap;
  205 +}
  206 +
  207 +.kpi {
  208 + flex: 1 1 140rpx;
  209 + min-width: 0;
  210 +}
  211 +
  212 +.k-label {
  213 + font-size: 20rpx;
  214 + color: #6b7280;
  215 +}
  216 +
  217 +.k-val {
  218 + font-size: 28rpx;
  219 + font-weight: 700;
  220 + color: #1a1b1c;
  221 + margin-top: 4rpx;
  222 +}
  223 +
  224 +.k-delta {
  225 + font-size: 20rpx;
  226 + margin-top: 2rpx;
  227 +}
  228 +
  229 +.k-delta.up {
  230 + color: #52c41a;
  231 +}
  232 +
  233 +.k-delta.down {
  234 + color: #ea6668;
  235 +}
  236 +
  237 +.k-delta.flat {
  238 + color: #6b7280;
  239 +}
  240 +
  241 +.row {
  242 + display: flex;
  243 + justify-content: space-between;
  244 + align-items: center;
  245 + font-size: 24rpx;
  246 + color: #3d4a5c;
  247 + padding: 10rpx 0;
  248 + border-bottom: 1rpx dashed rgba(0, 0, 0, 0.06);
  249 +}
  250 +
  251 +.row:last-child {
  252 + border-bottom: none;
  253 +}
  254 +
  255 +.row-label {
  256 + flex: 1;
  257 + padding-right: 16rpx;
  258 +}
  259 +
  260 +.row-status.ok {
  261 + color: #52c41a;
  262 +}
  263 +
  264 +.row-status.warn {
  265 + color: #faad14;
  266 +}
  267 +
  268 +.row-status.bad {
  269 + color: #ea6668;
  270 +}
  271 +
  272 +.rank {
  273 + display: flex;
  274 + align-items: center;
  275 + gap: 12rpx;
  276 + font-size: 24rpx;
  277 + color: #3d4a5c;
  278 + padding: 10rpx 0;
  279 +}
  280 +
  281 +.no {
  282 + width: 36rpx;
  283 + height: 36rpx;
  284 + border-radius: 50%;
  285 + background: rgba(163, 213, 232, 0.35);
  286 + color: #1a1b1c;
  287 + font-size: 20rpx;
  288 + font-weight: 700;
  289 + display: flex;
  290 + align-items: center;
  291 + justify-content: center;
  292 + flex: 0 0 auto;
  293 +}
  294 +
  295 +.rank-name {
  296 + width: 160rpx;
  297 + flex-shrink: 0;
  298 + overflow: hidden;
  299 + text-overflow: ellipsis;
  300 + white-space: nowrap;
  301 +}
  302 +
  303 +.bar {
  304 + flex: 1;
  305 + height: 12rpx;
  306 + border-radius: 6rpx;
  307 + background: rgba(163, 213, 232, 0.25);
  308 + overflow: hidden;
  309 +}
  310 +
  311 +.bar-fill {
  312 + display: block;
  313 + height: 100%;
  314 + border-radius: 6rpx;
  315 + background: #a3d5e8;
  316 + animation: barGrow 0.6s ease-out both;
  317 +}
  318 +
  319 +@keyframes barGrow {
  320 + from {
  321 + width: 0 !important;
  322 + }
  323 +}
  324 +
  325 +.rank-val {
  326 + flex: 0 0 auto;
  327 + font-size: 22rpx;
  328 + color: #3d4a5c;
  329 +}
  330 +
  331 +.msg-bottom {
  332 + height: 8rpx;
  333 +}
  334 +
  335 +.hints {
  336 + white-space: nowrap;
  337 + padding: 0 20rpx 12rpx;
  338 + height: 64rpx;
  339 + box-sizing: border-box;
  340 +}
  341 +
  342 +.hint-btn {
  343 + display: inline-block;
  344 + background: #a3d5e8;
  345 + color: #1a1b1c;
  346 + border-radius: 28rpx;
  347 + padding: 10rpx 24rpx;
  348 + font-size: 22rpx;
  349 + margin-right: 12rpx;
  350 + vertical-align: middle;
  351 +}
  352 +
  353 +.confirm-box {
  354 + padding: 12rpx 20rpx;
  355 + background: rgba(255, 255, 255, 0.9);
  356 + border-top: 1rpx solid rgba(0, 0, 0, 0.06);
  357 + animation: msgIn 0.3s both;
  358 +}
  359 +
  360 +.confirm-text {
  361 + width: 100%;
  362 + border: 1rpx solid rgba(0, 0, 0, 0.14);
  363 + border-radius: 20rpx;
  364 + padding: 16rpx 20rpx;
  365 + font-size: 26rpx;
  366 + color: #1a1b1c;
  367 + line-height: 1.5;
  368 + min-height: 100rpx;
  369 + box-sizing: border-box;
  370 + background: #fff;
  371 +}
  372 +
  373 +.confirm-bar {
  374 + display: flex;
  375 + justify-content: flex-end;
  376 + gap: 16rpx;
  377 + margin-top: 12rpx;
  378 +}
  379 +
  380 +.cbtn {
  381 + border-radius: 28rpx;
  382 + padding: 10rpx 28rpx;
  383 + font-size: 24rpx;
  384 +}
  385 +
  386 +.cbtn.cancel {
  387 + background: rgba(26, 27, 28, 0.06);
  388 + color: #3d4a5c;
  389 +}
  390 +
  391 +.cbtn.ok {
  392 + background: #1a1b1c;
  393 + color: #fff;
  394 +}
  395 +
  396 +.inputbar {
  397 + display: flex;
  398 + align-items: center;
  399 + gap: 16rpx;
  400 + padding: 16rpx 20rpx calc(16rpx + env(safe-area-inset-bottom));
  401 + border-top: 1rpx solid rgba(0, 0, 0, 0.08);
  402 + background: rgba(255, 255, 255, 0.85);
  403 + backdrop-filter: blur(12px);
  404 +}
  405 +
  406 +.text-input {
  407 + flex: 1;
  408 + border: 1rpx solid rgba(0, 0, 0, 0.14);
  409 + border-radius: 40rpx;
  410 + padding: 16rpx 28rpx;
  411 + font-size: 26rpx;
  412 + color: #1a1b1c;
  413 + background: #fff;
  414 + height: 72rpx;
  415 + box-sizing: border-box;
  416 +}
  417 +
  418 +.ibtn {
  419 + width: 72rpx;
  420 + height: 72rpx;
  421 + border-radius: 50%;
  422 + display: flex;
  423 + align-items: center;
  424 + justify-content: center;
  425 + font-size: 30rpx;
  426 + flex-shrink: 0;
  427 +}
  428 +
  429 +.ibtn.voice {
  430 + background: #a3d5e8;
  431 +}
  432 +
  433 +.ibtn.voice.recording {
  434 + background: #ea6668;
  435 + animation: pulse 1s infinite;
  436 +}
  437 +
  438 +@keyframes pulse {
  439 +
  440 + 0%,
  441 + 100% {
  442 + box-shadow: 0 0 0 0 rgba(234, 102, 104, 0.5);
  443 + }
  444 +
  445 + 50% {
  446 + box-shadow: 0 0 0 16rpx rgba(234, 102, 104, 0);
  447 + }
  448 +}
  449 +
  450 +.ibtn.send {
  451 + background: #1a1b1c;
  452 + color: #fff;
  453 + font-size: 26rpx;
  454 +}
  455 +
  456 +.stop-btn {
  457 + background: #f4f3ee;
  458 + border: 1rpx solid rgba(0, 0, 0, 0.1);
  459 + color: #3d4a5c;
  460 + font-size: 22rpx;
  461 + border-radius: 32rpx;
  462 + padding: 0 24rpx;
  463 + height: 72rpx;
  464 + display: flex;
  465 + align-items: center;
  466 + justify-content: center;
  467 + flex-shrink: 0;
  468 +}
  469 +
  470 +.record-overlay {
  471 + position: absolute;
  472 + inset: 0;
  473 + background: rgba(255, 255, 255, 0.92);
  474 + backdrop-filter: blur(6px);
  475 + display: none;
  476 + flex-direction: column;
  477 + align-items: center;
  478 + justify-content: center;
  479 + gap: 28rpx;
  480 + z-index: 20;
  481 +}
  482 +
  483 +.record-overlay.show {
  484 + display: flex;
  485 + animation: fadeIn 0.2s both;
  486 +}
  487 +
  488 +.record-overlay.cancel .wave-bar {
  489 + background: #ea6668;
  490 +}
  491 +
  492 +.record-overlay.cancel .rec-time,
  493 +.record-overlay.cancel .rec-hint {
  494 + color: #ea6668;
  495 +}
  496 +
  497 +@keyframes fadeIn {
  498 + from {
  499 + opacity: 0;
  500 + }
  501 +
  502 + to {
  503 + opacity: 1;
  504 + }
  505 +}
  506 +
  507 +.rec-time {
  508 + font-size: 28rpx;
  509 + color: #ea6668;
  510 + font-weight: 600;
  511 +}
  512 +
  513 +.wave {
  514 + display: flex;
  515 + gap: 8rpx;
  516 + align-items: center;
  517 + height: 88rpx;
  518 +}
  519 +
  520 +.wave-bar {
  521 + width: 8rpx;
  522 + border-radius: 4rpx;
  523 + background: #a3d5e8;
  524 + animation: waveJump 1s ease-in-out infinite;
  525 +}
  526 +
  527 +.wave-bar:nth-child(1) {
  528 + height: 28rpx;
  529 +}
  530 +
  531 +.wave-bar:nth-child(2) {
  532 + height: 60rpx;
  533 + animation-delay: 0.1s;
  534 +}
  535 +
  536 +.wave-bar:nth-child(3) {
  537 + height: 44rpx;
  538 + animation-delay: 0.2s;
  539 +}
  540 +
  541 +.wave-bar:nth-child(4) {
  542 + height: 76rpx;
  543 + animation-delay: 0.3s;
  544 +}
  545 +
  546 +.wave-bar:nth-child(5) {
  547 + height: 52rpx;
  548 + animation-delay: 0.15s;
  549 +}
  550 +
  551 +.wave-bar:nth-child(6) {
  552 + height: 68rpx;
  553 + animation-delay: 0.25s;
  554 +}
  555 +
  556 +.wave-bar:nth-child(7) {
  557 + height: 36rpx;
  558 + animation-delay: 0.35s;
  559 +}
  560 +
  561 +@keyframes waveJump {
  562 +
  563 + 0%,
  564 + 100% {
  565 + transform: scaleY(0.5);
  566 + }
  567 +
  568 + 50% {
  569 + transform: scaleY(1);
  570 + }
  571 +}
  572 +
  573 +.rec-hint {
  574 + font-size: 22rpx;
  575 + color: #6b7280;
  576 +}
  1 +/** 无 API Key 时的本地演示回复 */
  2 +function mockReply(q) {
  3 + if (/桌位|空桌|座位/.test(q)) {
  4 + return {
  5 + text: '已查到今晚 20:00 空闲 4 人桌还有 5 张(共 12 张,其中 2 桌待确认预订)。',
  6 + card: {
  7 + type: 'rows',
  8 + title: '今晚 20:00 · 空桌情况',
  9 + rows: [{
  10 + label: '桌 A12 · 4人桌',
  11 + statusText: '空',
  12 + status: 'ok'
  13 + },
  14 + {
  15 + label: '桌 B06 · 4人桌',
  16 + statusText: '空',
  17 + status: 'ok'
  18 + },
  19 + {
  20 + label: '桌 A18 · 4人桌',
  21 + statusText: '待确认',
  22 + status: 'warn'
  23 + },
  24 + {
  25 + label: '桌 C03 · 6人桌',
  26 + statusText: '空',
  27 + status: 'ok'
  28 + },
  29 + ],
  30 + },
  31 + }
  32 + }
  33 +
  34 + if (/菜品|热销|销量/.test(q)) {
  35 + return {
  36 + text: '今日热销菜品排行(按销售额):',
  37 + card: {
  38 + type: 'rank',
  39 + title: '今日菜品销售排行',
  40 + items: [{
  41 + no: 1,
  42 + name: '招牌酸菜鱼',
  43 + percent: 92,
  44 + value: '¥6,420'
  45 + },
  46 + {
  47 + no: 2,
  48 + name: '口水鸡',
  49 + percent: 74,
  50 + value: '¥4,180'
  51 + },
  52 + {
  53 + no: 3,
  54 + name: '干锅肥肠',
  55 + percent: 58,
  56 + value: '¥3,260'
  57 + },
  58 + {
  59 + no: 4,
  60 + name: '时蔬拼盘',
  61 + percent: 35,
  62 + value: '¥1,940'
  63 + },
  64 + ],
  65 + },
  66 + }
  67 + }
  68 +
  69 + if (/预订/.test(q)) {
  70 + return {
  71 + text: '今日预订共 18 单:已确认 13 单,未确认 5 单。近 3 单如下:',
  72 + card: {
  73 + type: 'rows',
  74 + title: '今日预订(近3单)',
  75 + rows: [{
  76 + label: '王先生 · 18:30 · 4人',
  77 + statusText: '已确认',
  78 + status: 'ok'
  79 + },
  80 + {
  81 + label: '李女士 · 19:00 · 6人包间',
  82 + statusText: '已确认',
  83 + status: 'ok'
  84 + },
  85 + {
  86 + label: '张先生 · 20:15 · 2人',
  87 + statusText: '未确认',
  88 + status: 'warn'
  89 + },
  90 + ],
  91 + },
  92 + }
  93 + }
  94 +
  95 + if (/订单/.test(q)) {
  96 + return {
  97 + text: '最近 3 笔订单:',
  98 + card: {
  99 + type: 'rows',
  100 + title: '近期订单',
  101 + rows: [{
  102 + label: '#2026091718 · 桌A12',
  103 + statusText: '已支付 ¥386',
  104 + status: 'ok'
  105 + },
  106 + {
  107 + label: '#2026091719 · 桌B06',
  108 + statusText: '已支付 ¥258',
  109 + status: 'ok'
  110 + },
  111 + {
  112 + label: '#2026091720 · 桌C03',
  113 + statusText: '待支付 ¥412',
  114 + status: 'warn'
  115 + },
  116 + ],
  117 + },
  118 + }
  119 + }
  120 +
  121 + if (/营业额|概况|概览/.test(q) || (/今天/.test(q) && !/预订|订单|菜品|热销|空桌/.test(q))) {
  122 + return {
  123 + text: '今日(9月17日)营业额 28,600 元,环比昨日 +12.3%。',
  124 + card: {
  125 + type: 'kpi',
  126 + title: '今日经营概览',
  127 + items: [{
  128 + label: '营业额',
  129 + value: '¥28,600',
  130 + delta: '↑ 12.3%',
  131 + trend: 'up'
  132 + },
  133 + {
  134 + label: '订单量',
  135 + value: '186',
  136 + delta: '↑ 8.1%',
  137 + trend: 'up'
  138 + },
  139 + {
  140 + label: '人均消费',
  141 + value: '¥154',
  142 + delta: '较上周 +4',
  143 + trend: 'flat'
  144 + },
  145 + ],
  146 + },
  147 + }
  148 + }
  149 +
  150 + if (/同比|环比/.test(q)) {
  151 + return {
  152 + text: '同比去年今日 +8.6%(去年同期 26,335 元);环比昨日 +12.3%。已按今日·高新店口径计算。',
  153 + }
  154 + }
  155 +
  156 + return {
  157 + text: `收到,正在按「高新店 · 今日」口径查询:${q}。目前我支持经营数据、桌位、预订、订单、菜品销售这几类查询,你可以直接问具体数字。`,
  158 + }
  159 +}
  160 +
  161 +module.exports = {
  162 + mockReply,
  163 +}
@@ -340,10 +340,11 @@ module.exports = { @@ -340,10 +340,11 @@ module.exports = {
340 } 340 }
341 }); 341 });
342 }, 342 },
343 - toast: function (title, icon = 'success') { 343 + toast: function (title, icon = 'success',duration) {
344 wx.showToast({ 344 wx.showToast({
345 title: title, 345 title: title,
346 - icon: icon 346 + icon: icon,
  347 + duration,
347 }) 348 })
348 }, 349 },
349 showLoading: function (title) { 350 showLoading: function (title) {