feat(ai-chat): 增加自动定位权限提示及处理逻辑,优化用户体验

This commit is contained in:
2026-09-28 13:57:46 +08:00
parent 4168c24fd6
commit 5efae5c38b
3 changed files with 298 additions and 3 deletions

View File

@@ -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',

View File

@@ -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: '语音输入',

View File

@@ -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 = {