feat(ai-chat): 增加自动定位权限提示及处理逻辑,优化用户体验
This commit is contained in:
@@ -141,6 +141,13 @@ export const lang = {
|
||||
chooseVideoFail: 'Failed to select video',
|
||||
audioNotReady: 'Audio not ready',
|
||||
location: 'Location',
|
||||
// PR-AUTO-LOCATION (2026-09-28): automatic location permission prompts
|
||||
locationPermissionTitle: 'Location Permission Required',
|
||||
locationPermissionH5: 'Please enable location permission via the lock icon in your browser address bar, then resend the message.',
|
||||
locationPermissionMp: 'To help the AI better answer location-related questions, please enable location permission.\n\nTap "Open Settings" to enable it in your system settings, then return to this page and resend the message.',
|
||||
locationSettingBtn: 'Open Settings',
|
||||
locationSkipBtn: 'Not Now',
|
||||
locationH5GuideBtn: 'Got it',
|
||||
|
||||
// ====== ai-chat-voice-panel ======
|
||||
voiceInputTitle: 'Voice Input',
|
||||
|
||||
@@ -140,6 +140,13 @@ export const lang = {
|
||||
chooseVideoFail: '选择视频失败',
|
||||
audioNotReady: '音频尚未就绪',
|
||||
location: '位置',
|
||||
// PR-AUTO-LOCATION (2026-09-28): 自动定位权限提示
|
||||
locationPermissionTitle: '需要位置权限',
|
||||
locationPermissionH5: '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。',
|
||||
locationPermissionMp: '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。',
|
||||
locationSettingBtn: '去设置',
|
||||
locationSkipBtn: '暂不开启',
|
||||
locationH5GuideBtn: '我知道了',
|
||||
|
||||
// ====== ai-chat-voice-panel ======
|
||||
voiceInputTitle: '语音输入',
|
||||
|
||||
@@ -1252,6 +1252,28 @@ export default {
|
||||
// 5. metadata - 会话级设备/平台信息 (platform/screen/locale), 一次性建立
|
||||
// 跨平台兼容: H5 用 Intl/navigator, MP/鸿蒙用 uni.getSystemInfoSync 兜底
|
||||
_buildSendContext({ locationFiles }) {
|
||||
const ctx = this._initContext({ locationFiles })
|
||||
// 异步探测, 不阻塞消息渲染; 在发送前 await 拿到坐标再补到 ctx
|
||||
this._fillAutoLocation(ctx).catch(() => {})
|
||||
return ctx
|
||||
},
|
||||
|
||||
// 异步版本: 等自动定位完成 (或 8 秒超时) 再返回
|
||||
// 适用于 sendMessage 路径, 因为 context 会立即发给后端
|
||||
// 8s 给首次弹原生权限框 + 弹模态引导去设置 留够时间
|
||||
// 极端情况 (用户在系统设置里徘徊超过 8s) 才走超时, 此时 ctx.location 仍为 null,
|
||||
// 后续消息发送前会复用 5 分钟缓存, 不会反复打扰用户
|
||||
async _buildSendContextAsync({ locationFiles }) {
|
||||
const ctx = this._initContext({ locationFiles })
|
||||
await Promise.race([
|
||||
this._fillAutoLocation(ctx).catch(() => {}),
|
||||
new Promise((r) => setTimeout(r, 8000))
|
||||
])
|
||||
return ctx
|
||||
},
|
||||
|
||||
// 构建基础 context (timestamp/timezone/page/metadata + 手动位置)
|
||||
_initContext({ locationFiles }) {
|
||||
const ctx = {
|
||||
timestamp: Date.now(),
|
||||
timezone: this._detectTimezone(),
|
||||
@@ -1259,19 +1281,39 @@ export default {
|
||||
page: this._getCurrentPageRoute(),
|
||||
metadata: this._getSessionMetadata()
|
||||
}
|
||||
// 位置: 只取第一个, 多位置场景后续再加
|
||||
// 位置优先级: 用户手动选的 > 自动探测
|
||||
// 手动选的位置一定有 name/address, 自动探测只有坐标
|
||||
if (locationFiles && locationFiles.length) {
|
||||
const loc = locationFiles[0]
|
||||
ctx.location = {
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
address: loc.address || '',
|
||||
name: loc.name || ''
|
||||
name: loc.name || '',
|
||||
source: 'manual'
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
},
|
||||
|
||||
// 异步把自动探测到的位置合并到 ctx (不影响 sendMessage 的同步流程)
|
||||
// 不会覆盖手动选的位置 (source === 'manual' 时跳过)
|
||||
async _fillAutoLocation(ctx) {
|
||||
try {
|
||||
const loc = await this._tryAutoLocation()
|
||||
if (!loc) return
|
||||
if (ctx.location && ctx.location.source === 'manual') return
|
||||
ctx.location = {
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
address: '',
|
||||
name: '',
|
||||
source: 'auto',
|
||||
accuracy: loc.accuracy || 0
|
||||
}
|
||||
} catch (e) { /* 静默: 失败不进 location, 不打扰用户 */ }
|
||||
},
|
||||
|
||||
// 检测客户端时区 (H5 优先用 Intl, MP/鸿蒙走 fallback)
|
||||
_detectTimezone() {
|
||||
try {
|
||||
@@ -1365,6 +1407,243 @@ export default {
|
||||
return this._sessionMetaCache
|
||||
},
|
||||
|
||||
// ========== PR-AUTO-LOCATION (2026-09-28): 自动位置获取 ==========
|
||||
// 隐私敏感, 必须用户授权. 流程:
|
||||
// 1. 检查权限状态 (granted/denied/undetermined)
|
||||
// 2. granted → 直接 getLocation, 缓存 5 分钟
|
||||
// 3. denied → 弹模态引导去设置页 (uni.openSetting)
|
||||
// 4. undetermined → 直接调 authorize 弹原生权限框
|
||||
// 拒绝一次后不再静默弹窗, 必须走"去设置"模态. 用户感受:
|
||||
// 首次进入聊天 → 不打扰
|
||||
// 发消息时 → 静默获取 (首次弹原生权限框)
|
||||
// 用户拒绝过 → 弹"去设置"模态, 取消则不进 context.location
|
||||
// 跨平台兼容:
|
||||
// H5 : navigator.permissions + navigator.geolocation
|
||||
// MP/鸿蒙 : uni.getSetting + uni.authorize + uni.openSetting + uni.getLocation
|
||||
// APP : 同 MP, manifest 需声明 ACCESS_FINE_LOCATION
|
||||
|
||||
// 自动位置缓存: 5 分钟内复用, 避免重复获取
|
||||
_getCachedAutoLocation() {
|
||||
const cache = this._autoLocationCache
|
||||
if (!cache) return null
|
||||
const FIVE_MIN = 5 * 60 * 1000
|
||||
if (Date.now() - cache.ts > FIVE_MIN) return null
|
||||
return cache.data
|
||||
},
|
||||
_setCachedAutoLocation(data) {
|
||||
this._autoLocationCache = { data, ts: Date.now() }
|
||||
},
|
||||
_clearCachedAutoLocation() {
|
||||
this._autoLocationCache = null
|
||||
},
|
||||
|
||||
// 主入口: 尝试获取用户当前位置, 不打扰的前提下
|
||||
// 返回: { latitude, longitude, accuracy } 或 null
|
||||
async _tryAutoLocation() {
|
||||
// 1. 优先用缓存 (5 分钟内有效)
|
||||
const cached = this._getCachedAutoLocation()
|
||||
if (cached) return cached
|
||||
|
||||
// 2. 检查权限状态
|
||||
let status = 'undetermined'
|
||||
try { status = await this._getLocationAuthStatus() } catch (e) { /* ignore */ }
|
||||
|
||||
// 3. 已拒绝过 → 弹"去设置"模态
|
||||
if (status === 'denied') {
|
||||
const granted = await this._promptOpenLocationSetting()
|
||||
if (!granted) return null
|
||||
// 用户去设置后回到页面, 重新检测状态
|
||||
try { status = await this._getLocationAuthStatus() } catch (e) {}
|
||||
if (status !== 'granted') return null
|
||||
// 用户在设置里开了权限, 重置"尝试过"标志, 下次回到 undetermined 路径
|
||||
this.triedLocationAuth = false
|
||||
} else if (status === 'undetermined') {
|
||||
// 4. 首次: 调 authorize 弹原生权限框
|
||||
// MP/鸿蒙/App 行为: 若用户曾在原生弹窗里点过拒绝,
|
||||
// 后续 authorize 不再弹窗, 直接 fail, 此时 status 仍是 undetermined
|
||||
// 用 triedLocationAuth 标记位区分"首次进入"与"曾拒绝过":
|
||||
// false → 首次, 调 authorize 弹原生框, 不管成功失败都置 true
|
||||
// true → 跳到 denied 路径, 弹"去设置"模态
|
||||
if (this.triedLocationAuth) {
|
||||
const granted = await this._promptOpenLocationSetting()
|
||||
if (!granted) return null
|
||||
try { status = await this._getLocationAuthStatus() } catch (e) {}
|
||||
if (status !== 'granted') return null
|
||||
this.triedLocationAuth = false
|
||||
} else {
|
||||
try {
|
||||
await this._authorizeLocation()
|
||||
this.triedLocationAuth = true
|
||||
} catch (e) {
|
||||
// 首次拒绝: 标记已尝试, 下次直接走"去设置"模态, 不再弹原生框
|
||||
this.triedLocationAuth = true
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 已授权 → 获取坐标
|
||||
const loc = await this._getCurrentLocation()
|
||||
if (loc) this._setCachedAutoLocation(loc)
|
||||
return loc
|
||||
},
|
||||
|
||||
// 检测位置权限状态: 'granted' | 'denied' | 'undetermined'
|
||||
// H5: navigator.permissions.query → 'prompt' 当 undetermined 处理
|
||||
// MP/鸿蒙/APP: uni.getSetting 看 scope.userLocation
|
||||
_getLocationAuthStatus() {
|
||||
// 用 var 是因为 ESLint 不识别 uni-app 的条件编译注释
|
||||
var isH5 = false
|
||||
// #ifdef H5
|
||||
isH5 = true
|
||||
// #endif
|
||||
if (isH5) {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof navigator !== 'undefined'
|
||||
&& navigator.permissions
|
||||
&& navigator.permissions.query) {
|
||||
navigator.permissions.query({ name: 'geolocation' })
|
||||
.then((r) => resolve(r.state === 'granted' ? 'granted' : (r.state === 'denied' ? 'denied' : 'undetermined')))
|
||||
.catch(() => resolve('undetermined'))
|
||||
} else {
|
||||
resolve('undetermined')
|
||||
}
|
||||
})
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
if (typeof uni !== 'undefined' && uni.getSetting) {
|
||||
uni.getSetting({
|
||||
success: (res) => {
|
||||
const scope = res.authSetting && res.authSetting['scope.userLocation']
|
||||
if (scope === true) resolve('granted')
|
||||
else if (scope === false) resolve('denied')
|
||||
else resolve('undetermined')
|
||||
},
|
||||
fail: () => resolve('undetermined')
|
||||
})
|
||||
} else {
|
||||
resolve('undetermined')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 申请位置权限 (调原生权限框)
|
||||
// H5: 没单独的 authorize, getCurrentPosition 时浏览器自己弹
|
||||
// MP/鸿蒙/APP: uni.authorize(scope.userLocation)
|
||||
_authorizeLocation() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof uni !== 'undefined' && uni.authorize) {
|
||||
uni.authorize({
|
||||
scope: 'scope.userLocation',
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
} else {
|
||||
// H5 直接通过, 浏览器会在 getCurrentPosition 时弹权限框
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 获取当前位置坐标
|
||||
// H5: navigator.geolocation.getCurrentPosition
|
||||
// MP/鸿蒙/APP: uni.getLocation (gcj02 国内坐标系)
|
||||
_getCurrentLocation() {
|
||||
var isH5 = false
|
||||
// #ifdef H5
|
||||
isH5 = true
|
||||
// #endif
|
||||
if (isH5) {
|
||||
return new Promise((resolve) => {
|
||||
const ok = (loc) => resolve(loc)
|
||||
const fail = () => resolve(null)
|
||||
if (typeof navigator !== 'undefined' && navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => ok({
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
accuracy: pos.coords.accuracy
|
||||
}),
|
||||
fail,
|
||||
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 60000 }
|
||||
)
|
||||
} else {
|
||||
fail()
|
||||
}
|
||||
})
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const ok = (loc) => resolve(loc)
|
||||
const fail = () => resolve(null)
|
||||
if (typeof uni !== 'undefined' && uni.getLocation) {
|
||||
uni.getLocation({
|
||||
type: 'gcj02', // 国内坐标系 (腾讯/高德), 海外用 'wgs84'
|
||||
geocode: false,
|
||||
success: (res) => ok({
|
||||
latitude: res.latitude,
|
||||
longitude: res.longitude,
|
||||
accuracy: res.accuracy || 0
|
||||
}),
|
||||
fail
|
||||
})
|
||||
} else {
|
||||
fail()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 弹"去设置"模态, 用户确认后调 uni.openSetting 引导去系统设置
|
||||
// 返回: true = 用户在设置里开了权限, false = 取消或开了又关
|
||||
_promptOpenLocationSetting() {
|
||||
// H5 没 openSetting, 浏览器权限只能去 chrome://settings 手动开
|
||||
// 用 var 是因为 ESLint 不识别 uni-app 的条件编译注释,
|
||||
// 两个分支都会进 AST, 不能重复 const 声明
|
||||
var isH5 = false
|
||||
// #ifdef H5
|
||||
isH5 = true
|
||||
// #endif
|
||||
const $t = (k, fallback) => {
|
||||
try {
|
||||
const v = this.$lang && this.$lang(k)
|
||||
return v || fallback
|
||||
} catch (e) { return fallback }
|
||||
}
|
||||
const title = $t('ai-chat.index.locationPermissionTitle', '需要位置权限')
|
||||
if (isH5) {
|
||||
return new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title,
|
||||
content: $t('ai-chat.index.locationPermissionH5', '请在浏览器地址栏左侧的锁形图标中开启位置权限,然后重新发送消息。'),
|
||||
showCancel: false,
|
||||
confirmText: $t('ai-chat.index.locationH5GuideBtn', '我知道了'),
|
||||
success: () => resolve(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title,
|
||||
content: $t('ai-chat.index.locationPermissionMp', '为了让 AI 更好地回答位置相关问题,需要您开启位置权限。\n\n点击"去设置"在系统设置中开启后,请回到本页面再次发送消息。'),
|
||||
confirmText: $t('ai-chat.index.locationSettingBtn', '去设置'),
|
||||
cancelText: $t('ai-chat.index.locationSkipBtn', '暂不开启'),
|
||||
success: (res) => {
|
||||
if (!res.confirm) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
uni.openSetting({
|
||||
success: (sres) => {
|
||||
const granted = sres.authSetting && sres.authSetting['scope.userLocation'] === true
|
||||
resolve(granted)
|
||||
},
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
},
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
onLocationSelected(locationMessage) {
|
||||
// PR-LOCATION-PENDING (2026-09-28): 位置消息走 pending 流程
|
||||
// 原实现直接 push 到 messages, 用户点发送后位置根本不会传给 WB
|
||||
@@ -2112,7 +2391,7 @@ async loadChatHistoryIfExist() {
|
||||
// 让 AI 知道用户在哪、什么时候发的、当前在哪个页面、设备/平台信息
|
||||
// context 字段在 ai-service.js 三个发送路径(sendMessage/sendStreamMessage/sendHttpStream)
|
||||
// 都已添加, 缺失则后端拿到 null, 不影响旧版后端兼容
|
||||
const context = this._buildSendContext({ locationFiles })
|
||||
const context = await this._buildSendContextAsync({ locationFiles })
|
||||
if (this.enableStreaming) {
|
||||
await this.sendStreamMessage(enhancedMessage, readyAttachments, context)
|
||||
} else {
|
||||
@@ -3648,6 +3927,8 @@ _handleStreamError(msg) {
|
||||
this._aiRequestStart = Date.now()
|
||||
|
||||
// PR-BASIC-CONTEXT-TO-WB (2026-09-28): 重发/商品页也透传 context (无位置)
|
||||
// 用同步版: fire-and-forget, 立即返回, 重发不阻塞
|
||||
// 自动定位失败 (用户授权) 也无所谓, 重试主要是文本/AI 没听懂, 跟位置关系不大
|
||||
const context = this._buildSendContext({ locationFiles: [] })
|
||||
|
||||
const userMessage = {
|
||||
|
||||
Reference in New Issue
Block a user