feat:人员菜品预约统计增加人员和身份查询

feat:修改移动端权限
This commit is contained in:
dzc committed 2026-08-23 15:59:51 +08:00
1 parent f0db8417d8
commit b2d1597a54
22 files changed
+518 -142

No files matched your search

+2 -2
View File
@@ -34,10 +34,10 @@ VITE_OAUTH2_SOCIAL_CLIENT='social:social'
VITE_WEBSOCKET_ENABLE = true
# --------------------------- start 开发环境 start ----------------------------------
# VITE_APP_DEV_SERVICE = https://www.zianxin.com/api
VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc
# VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc
# VITE_APP_DEV_SERVICE = http://127.0.0.1:9999
# VITE_APP_DEV_SERVICE = https://www.zianxin.com/api
# VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc
VITE_APP_DEV_SERVICE = https://dzc-test-1.fy.takin.cc
#VITE_APP_DEV_SERVICE = http://127.0.0.1:9999
# VITE_IMAGE_DEV_PATH = https://www.zianxin.com/api
+21 -2
View File
@@ -8,6 +8,7 @@ import { globalWebSocket } from "@/sheep/utils/global-websocket"
import { handleCanteenDeliveryWebSocketFrame } from "@/sheep/utils/canteen-delivery-ws-push"
import jgPush from "@/sheep/utils/jg-push"
import { consumePendingPushRoute } from "@/sheep/utils/push-route"
import { showPermissionUsage } from "@/sheep/utils/permission"
export default {
data() {
@@ -27,9 +28,27 @@ export default {
debug: true,
autoInitService: true,
enableCrashHandler: true,
requestAndroidPermission: true,
requestIOSNotificationPermission: true,
requestAndroidPermission: false,
requestIOSNotificationPermission: false,
})
const pushStatus = jgPush.getStatus()
const pushPermissionGranted =
pushStatus.notificationPermission === "granted" ||
pushStatus.notificationPermission === "provisional" ||
pushStatus.notificationEnabled === true
if (!pushPermissionGranted) {
const shouldRequestPushPermission = await showPermissionUsage("通知", {
feature: "消息通知",
purpose: "用于接收订单、配送、核销和系统服务状态等重要消息提醒。",
})
if (shouldRequestPushPermission) {
if (this.system.platform === "android") {
await jgPush.requestAndroidPermission()
await jgPush.refreshStatus()
}
if (this.system.platform === "ios") await jgPush.requestNotificationAuthorization()
}
}
// 收到通知时刷新未读角标;只有点击通知时才消费服务层预先写入的路由队列。
jgPush.on("notification", (event) => {
const eventType = event?.payload?.notificationEventType
@@ -90,6 +90,7 @@
<script>
import uChartsMp from '@/components/u-charts/u-charts.js';
import cfu from '@/components/u-charts/config-ucharts.js';
import { showPermissionUsage } from '@/sheep/utils/permission';
// #ifdef APP-VUE || H5
import cfe from '@/components/u-charts/config-echarts.js';
// #endif
@@ -876,7 +877,18 @@ export default {
})
.exec();
},
saveImage() {
/**
* 将当前分析图表导出到系统相册。
* App 端在写入相册前同步告知权限用途,H5 保持原有下载行为。
*/
async saveImage() {
// #ifdef APP-PLUS
const shouldSave = await showPermissionUsage('存储(相册)', {
feature: '保存分析图表',
purpose: '用于将您主动保存的业务分析图表写入系统相册。'
});
if (!shouldSave) return;
// #endif
uni.canvasToTempFilePath({
canvasId: this.cid,
success: res => {
+6 -3
View File
@@ -2,8 +2,8 @@
"name" : "资安信",
"appid" : "__UNI__1BFE88F",
"description" : "资安信后勤管理系统",
"versionName" : "1.5.19",
"versionCode" : 1519,
"versionName" : "1.5.23",
"versionCode" : 1523,
"transformPx" : false,
/* 5+App特有相关 */
"app-plus" : {
@@ -43,6 +43,7 @@
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
@@ -62,7 +63,9 @@
"NSLocationWhenInUseUsageDescription" : "用于获取您的位置信息,以支持地图选点、地址定位和相关配送服务。",
"NSLocationAlwaysAndWhenInUseUsageDescription" : "用于获取您的位置信息,以支持地图选点、地址定位和相关配送服务。在您使用需要定位的功能时,应用会申请相应定位权限。",
"NSUserTrackingUsageDescription" : "为了改进应用性能,我们需要收集设备标识符用于崩溃分析和数据统计。",
"NSPhotoLibraryUsageDescription" : "用于扫码识别二维码、条形码,完成支付、核销等。"
"NSCameraUsageDescription" : "用于拍摄二维码、头像、配送凭证及业务图片,以完成扫码识别、支付、核销或业务资料提交。",
"NSPhotoLibraryUsageDescription" : "用于从相册选择二维码、头像、配送凭证及业务图片,以完成扫码识别、支付、核销或业务资料提交。",
"NSPhotoLibraryAddUsageDescription" : "用于将您主动选择保存的业务图片或分析图表写入系统相册。"
},
"capabilities" : {
"entitlements" : {
+4
View File
@@ -141,8 +141,10 @@
:url="uploadUrl"
:header="uploadAuthHeader()"
:max-count="1"
:source-type="['camera']"
column="4"
img-height="160"
permissionFeature="取餐照片"
@complete="onPickupComplete" />
<view class="action-btn" @tap="submitPickup">提交取餐照</view>
</view>
@@ -155,8 +157,10 @@
:url="uploadUrl"
:header="uploadAuthHeader()"
:max-count="1"
:source-type="['camera']"
column="4"
img-height="160"
permissionFeature="送达照片"
@complete="onDeliverComplete" />
<view class="action-btn" @tap="submitDeliver">提交送达照</view>
</view>
+19 -1
View File
@@ -61,6 +61,7 @@ import { onLoad, onShow } from "@dcloudio/uni-app"
import { hooks } from "@@/index"
import { deliveryApi } from "@@/api"
import { requestImagePermissions } from "@@/utils/permission"
const formRef = ref<any>(null)
const ocrImage = ref("")
@@ -107,7 +108,24 @@ const getImageBase64 = (path: string): Promise<string> => {
})
}
const chooseImageForOcr = () => {
/**
* 选择外卖单图片并提交 OCR 识别。
* App 端在打开系统相机或相册前分别说明权限用途,用户取消授权时不读取或上传图片。
*/
const chooseImageForOcr = async () => {
// #ifdef APP-PLUS
const permissionGranted = await requestImagePermissions(["camera", "album"], {
camera: {
feature: "外卖单 OCR 录入",
purpose: "用于拍摄外卖单图片,识别配送所需的手机号和地址信息。",
},
storage: {
feature: "外卖单 OCR 录入",
purpose: "用于从相册选择外卖单图片,识别配送所需的手机号和地址信息。",
},
})
if (!permissionGranted) return
// #endif
uni.chooseImage({
count: 1,
sourceType: ["camera", "album"],
+1 -1
View File
@@ -46,7 +46,7 @@
<view class="upload-card card bg-white">
<view class="second-text-22 mb-18rpx">至少上传1张,最多上传2张,每张图片不超过5M。</view>
<tm-upload-photo v-model="uploadFiles" :maxFileSize="1024 * 1024 * 5" :url="spuUploadActionUrl"
:header="uploadHeader" :maxCount="2" :column="2" img-height="280" />
:header="uploadHeader" :maxCount="2" :column="2" img-height="280" permissionFeature="人脸照片" />
<view class="limit-tip mt-18rpx flex items-start">
<tm-icon name="shield-check-line" :size="23" color="#0094ff" />
<text class="ml-8rpx flex-1">每张照片会生成一条独立审核记录。审核中和已通过的人脸合计最多保留3张。</text>
+4 -1
View File
@@ -177,7 +177,8 @@
<view class="pm-field-card">
<text class="pm-field-label">商品封面图</text>
<tm-upload-photo v-model="state.spuCoverList" :url="spuUploadActionUrl" :header="spuUploadHeader"
:maxCount="1" :column="4" imgHeight="160" round="12" @change="onSpuCoverChange"
:maxCount="1" :column="4" imgHeight="160" round="12" permissionFeature="商品封面图"
@change="onSpuCoverChange"
@complete="onSpuCoverChange" />
</view>
<view class="pm-advanced-toggle" @tap="state.showAdvancedForm = !state.showAdvancedForm">
@@ -190,6 +191,7 @@
<text class="pm-field-label">商品轮播图</text>
<tm-upload-photo v-model="state.spuAlbumList" :url="spuUploadActionUrl"
:header="spuUploadHeader" :maxCount="9" :column="4" imgHeight="140" round="12"
permissionFeature="商品轮播图"
@change="onSpuAlbumChange" @complete="onSpuAlbumChange" />
</view>
<view class="pm-field-card">
@@ -230,6 +232,7 @@
placeholder="输入文字..." :maxlength="5000" />
<tm-upload-photo v-else v-model="b.uploadList" :url="spuUploadActionUrl"
:header="spuUploadHeader" :maxCount="1" :column="4" imgHeight="160" round="12"
permissionFeature="商品详情图片"
@change="(list) => { b.uploadList = list; syncDetailBlockImageUrl(b) }"
@complete="(list) => { b.uploadList = list; syncDetailBlockImageUrl(b) }" />
</view>
+17 -3
View File
@@ -43,7 +43,7 @@ import dayjs from "dayjs"
import { hooks, router } from "@/sheep"
import { orderApi } from "@/sheep/api"
import { requestPermission } from "@/sheep/utils/permission"
import { requestImagePermissions } from "@/sheep/utils/permission"
import zTabs from "@@/components/other/z-tabs/z-tabs.vue"
import OrderCar from "@@/components/order/order-car.vue"
@@ -127,10 +127,24 @@ const datePickerConfirm = (date: any) => {
pageRef.value.reload(false)
mchTagSelectRefresh()
}
/**
* 打开订餐核销扫码器并解析订单短码。
* App 原生扫码器同时支持拍摄和相册识别,因此进入扫码页前必须完成两项权限用途告知。
*/
const onNavRightClick = async () => {
// #ifdef APP-PLUS
const cameraPermission = await requestPermission("camera")
if (cameraPermission !== "granted") return
// 原生扫码页内部还允许从相册选码,必须在进入页面前分别完成相机和相册用途告知。
const permissionGranted = await requestImagePermissions(["camera", "album"], {
camera: {
feature: "订餐核销扫一扫",
purpose: "用于拍摄订餐二维码并识别对应订单,完成现场核销。",
},
storage: {
feature: "订餐核销扫一扫",
purpose: "用于从相册选择订餐二维码图片并识别对应订单,完成现场核销。",
},
})
if (!permissionGranted) return
// #endif
const scanResult = await scanCodeAction()
if (!scanResult) {
+1 -1
View File
@@ -168,7 +168,7 @@
</view>
<view class="mt-18rpx">
<tm-upload-photo v-model="uploadFiles" :maxFileSize="1024 * 1024 * 5" :url="uploadActionUrl"
:header="uploadHeader" :maxCount="6" :column="4" img-height="142" />
:header="uploadHeader" :maxCount="6" :column="4" img-height="142" permissionFeature="退款凭证" />
</view>
<view class="mt-16rpx flex items-start text-20rpx leading-32rpx color-#667085">
<tm-icon name="information-line" :size="22" color="#667085" />
@@ -49,7 +49,7 @@
<view class="prefix_title mb-16rpx">分类图</view>
<tm-upload-photo v-model="form.picUrl" :maxFileSize="1024 * 1024 * 5"
:url="'/api/admin/sys-file/upload'" :header="hooks.useDefaultHeader()" :column="1.6"
img-height="360" :max-count="1" />
img-height="360" :max-count="1" permissionFeature="商品分类图片" />
<view class="sub-text-20 c-red! mb-16rpx">注:图片大小不超过5M</view>
<tm-button form-type="submit" block>保 存</tm-button>
</tm-form>
+1 -1
View File
@@ -52,7 +52,7 @@
<tm-form-item name="coverImage" label="商品主图">
<tm-upload-photo v-model="form.coverImage" :maxFileSize="1024 * 1024 * 5"
:url="'/api/admin/sys-file/upload'" :header="hooks.useDefaultHeader()" :column="1.2"
img-height="360" :max-count="1" />
img-height="360" :max-count="1" permissionFeature="商品主图" />
</tm-form-item>
<view class="sub-text-20 c-red! mb-16rpx">注:图片大小不超过5M</view>
<!-- <tm-form-item name="carouselImages" label="轮播图">
+17 -3
View File
@@ -108,7 +108,7 @@ import {
ROLE_ONLINE_CANTEEN_MCH,
} from "@@/utils/user-role"
import { APPENV } from "@/sheep/constant"
import { requestPermission } from "@/sheep/utils/permission"
import { requestImagePermissions } from "@/sheep/utils/permission"
import { useUserStore } from "@@/stores/user"
import { useAppMessageStore } from "@@/stores/app-message"
@@ -341,10 +341,24 @@ const actModalCancel = () => {
const { scanCodeAction, processUrlScanResult, processBase64ScanResult } = hooks.useScanCode(actModalShow)
/**
* 打开“我的”页扫一扫并处理支付、核销或设备二维码。
* App 原生扫码器同时支持拍摄和相册识别,因此进入扫码页前必须完成两项权限用途告知。
*/
const appScanCode = async () => {
// #ifdef APP-PLUS
const cameraPermission = await requestPermission("camera")
if (cameraPermission !== "granted") return
// 原生扫码页内部还允许从相册选码,必须在进入页面前分别完成相机和相册用途告知。
const permissionGranted = await requestImagePermissions(["camera", "album"], {
camera: {
feature: "扫一扫",
purpose: "用于拍摄二维码或条形码,完成支付、核销或设备开门。",
},
storage: {
feature: "扫一扫",
purpose: "用于从相册选择二维码或条形码图片并识别,完成支付、核销或设备开门。",
},
})
if (!permissionGranted) return
// #endif
const scanResult = await scanCodeAction()
if (!scanResult) {
+19 -1
View File
@@ -116,6 +116,7 @@ import { userApi } from "@@/api"
import { constant, router } from "@@/index"
import { useUserStore } from "@@/stores/user"
import { filePath } from "@@/utils/file"
import { requestImagePermissions } from "@@/utils/permission"
import { normalizePersonName, validatePersonName } from "@@/utils/person-name"
import {
getFileUploadActionUrl,
@@ -238,8 +239,25 @@ const loadUser = async () => {
}
}
const onChooseAvatar = () => {
/**
* 选择并上传个人头像。
* App 端在打开系统相机或相册前分别说明权限用途,用户取消授权时不继续选择或上传。
*/
const onChooseAvatar = async () => {
if (state.uploading) return
// #ifdef APP-PLUS
const permissionGranted = await requestImagePermissions(["camera", "album"], {
camera: {
feature: "更换头像",
purpose: "用于拍摄头像图片,并在您确认后上传为个人头像。",
},
storage: {
feature: "更换头像",
purpose: "用于从相册选择头像图片,并在您确认后上传为个人头像。",
},
})
if (!permissionGranted) return
// #endif
uni.chooseImage({
count: 1,
sizeType: ["compressed"],
+22
View File
@@ -4,8 +4,15 @@ import { router } from ".."
import { APPENV } from "../constant"
import { getAppInfo, isDevEnv } from "../utils/app"
import walletApi from "../api/wallet"
import { requestImagePermissions } from "../utils/permission"
export function useScanCode(actModalShow, { onlyFromCamera = false } = {}) {
/**
* 调起系统扫码能力并返回原始扫码文本。
* App 端在进入原生扫码页前完成相机/相册用途告知,开发环境模拟扫码不申请原生权限。
*
* @returns 扫码文本;用户取消权限或扫码失败时返回 undefined 或空字符串。
*/
async function scanCodeAction(): Promise<string | undefined> {
const appInfo = await getAppInfo()
if (isDevEnv() && appInfo != APPENV.APP) {
@@ -15,6 +22,21 @@ export function useScanCode(actModalShow, { onlyFromCamera = false } = {}) {
return Promise.resolve(testQrCode)
}
if (appInfo == APPENV.APP) {
// #ifdef APP-PLUS
// 扫码器允许拍摄和从相册识别;兜底处理所有直接调用该 Hook 的入口,避免原生扫码页自行申请未说明用途的权限。
const scanSourceTypes = onlyFromCamera ? (["camera"] as const) : (["camera", "album"] as const)
const permissionGranted = await requestImagePermissions(scanSourceTypes, {
camera: {
feature: "扫一扫",
purpose: "用于拍摄二维码或条形码,完成支付、核销或设备开门。",
},
storage: {
feature: "扫一扫",
purpose: "用于从相册选择二维码或条形码图片并识别,完成支付、核销或设备开门。",
},
})
if (!permissionGranted) return
// #endif
const scanResult = await new Promise<string>((resolve, reject) => {
uni.scanCode({
onlyFromCamera,
+15 -1
View File
@@ -12,6 +12,7 @@ import { useAppMessageStore } from "./app-message"
import { usePushStore } from "./push-store"
import { buildJPushDeviceBinding } from "../utils/push-device"
import { consumePendingPushRoute } from "../utils/push-route"
import { requestPermission } from "../utils/permission"
const getTabbarStore = async (): Promise<any | null> => {
let tabbarStore: any = null
@@ -378,8 +379,21 @@ export const useUserStore = defineStore("user", {
}
this.setUserSelectCity(this.address?.city)
},
// 手动激活当前定位地址,仅写入当前位置坐标,详细地址信息由地图页后续补全
/**
* 获取并激活当前定位地址。
* App 端先说明定位权限用途,随后读取 WGS84 坐标、转换为 GCJ-02,并补充腾讯地图逆地理编码结果。
*
* @returns 当前激活的地址;权限未授予或定位失败时抛出异常,由调用页面提示用户。
*/
async activateCurrentAddress() {
const platform = uni.getSystemInfoSync()?.platform
if (platform === "android" || platform === "ios") {
const locationPermission = await requestPermission("location", {
feature: "地图定位与地址选择",
purpose: "用于获取当前位置并提供地图选点、地址定位和配送地址服务。",
})
if (locationPermission !== "granted") throw new Error("定位权限未开启")
}
const location = await getCurrentLocation()
const gcj02 = wgs84ToGcj02(location.latitude, location.longitude)
const baseAddress: Partial<UserAddress> = {
+20 -7
View File
@@ -731,7 +731,7 @@ export class JgPushService {
}
if (this.initialized) {
await this.refreshStatus()
await this.refreshStatus(options.requestIOSNotificationPermission === true)
return this.getStatus()
}
@@ -781,7 +781,7 @@ export class JgPushService {
this.initializing = false
store.setInitializeState(false, true)
store.setLastError(null)
await this.refreshStatus()
await this.refreshStatus(options.requestIOSNotificationPermission === true)
return this.getStatus()
} catch (error) {
this.initialized = false
@@ -796,9 +796,11 @@ export class JgPushService {
/**
* 刷新推送状态。
* 主要用于页面回到前台、初始化完成后或业务怀疑状态过期时主动同步一次。
* 主要用于页面回到前台、初始化完成后或业务怀疑状态过期时主动同步一次。默认只读取状态,避免第三方原生插件在没有用途说明时直接申请 iOS 通知权限。
*
* @param requestIOSNotificationPermission 是否允许在刷新 iOS 状态时调用原生授权 API,必须在业务用途说明后显式传 true。
*/
async refreshStatus(): Promise<PushStatusSnapshot> {
async refreshStatus(requestIOSNotificationPermission = false): Promise<PushStatusSnapshot> {
const store = this.getPushStore()
store.setEnvironment(this.isSupported(), this.platform)
store.patchState({
@@ -827,7 +829,7 @@ export class JgPushService {
}
try {
const notificationState = await this.queryNotificationState()
const notificationState = await this.queryNotificationState(requestIOSNotificationPermission)
store.setNotificationState(notificationState.permission, notificationState.enabled)
} catch (error) {
console.warn("[JGPush] 刷新通知权限状态失败", error)
@@ -1360,10 +1362,14 @@ export class JgPushService {
/**
* 统一查询通知权限状态。
* Android 读通知总开关,iOS 读授权状态,其它平台返回 disabled。
* Android 读通知总开关,iOS 默认只返回已缓存状态;只有业务完成用途说明并显式传 true 时才调用原生授权 API。
*
* @param requestAuthorization 是否允许 iOS 调用原生授权 API。
*/
async queryNotificationState(): Promise<{
async queryNotificationState(requestAuthorization = false): Promise<{
/** 当前通知权限状态;未主动查询 iOS 原生状态时使用 Store 中的最近状态。 */
permission: PushPermissionState
/** 当前系统通知总开关;尚未查询到时为 null。 */
enabled: boolean | null
}> {
if (!this.isSupported()) {
@@ -1373,6 +1379,13 @@ export class JgPushService {
}
}
if (this.platform === "ios") {
if (!requestAuthorization) {
const store = this.getPushStore()
return {
permission: store.notificationPermission,
enabled: store.notificationEnabled,
}
}
const result = await this.requestNotificationAuthorization()
const permission = mapIOSPermissionState(result.status)
return {
+265 -100
View File
@@ -1,20 +1,67 @@
/**
* location - 位置权限
* storage - 存储权限
* camera - 相机权限
* 应用主动申请的敏感权限类型。
* 这些标识仅用于应用内部权限流程,不属于接口或持久化协议。
*/
type PermissionId = "location" | "storage" | "camera"
/**
* 权限请求状态
* - none: 未请求
* - granted: 已授权
* - denied: 已拒绝|受限
* - prompt: 未授权
* 原生权限请求结果。
* granted 表示已授权,denied 表示已拒绝或受限,prompt 表示尚未向用户申请。
*/
type RequestStatus = "granted" | "denied" | "prompt"
/**
* 跳转应用权限设置页面
* 权限用途说明。
* 系统权限框出现前会将权限名称、具体服务功能和使用目的同步展示给用户。
*/
export type PermissionUsage = {
/** 触发权限申请的具体服务功能,例如“扫一扫”或“更换头像”。 */
feature: string
/** 该功能使用权限的具体目的,不得使用空泛的通用授权文案。 */
purpose: string
}
/**
* 图片来源类型。
* 值与 uni.chooseImage 的 sourceType 保持一致,不作为业务协议持久化。
*/
type ImageSource = "album" | "camera"
/**
* 拍照或选图场景的权限用途说明。
* 相机与相册可能由不同的系统权限控制,因此必须分别描述用途。
*/
export type ImagePermissionUsage = {
/** 拍照时申请相机权限的服务功能和用途。 */
camera: PermissionUsage
/** 从相册选图时申请图片读取权限的服务功能和用途。 */
storage: PermissionUsage
}
/**
* 在系统权限框前展示任意原生权限的用途说明。
* 用于通知等由第三方原生插件发起、无法复用本文件状态检测的权限申请。
*
* @param permissionName 面向用户展示的权限名称。
* @param usage 当前业务功能及其权限用途。
* @returns 用户点击“继续”时返回 true,取消或弹窗失败时返回 false。
*/
export const showPermissionUsage = (permissionName: string, usage: PermissionUsage): Promise<boolean> => {
return new Promise((resolve) => {
uni.showModal({
title: `${permissionName}权限使用说明`,
content: `权限名称:${permissionName}权限\n服务功能:${usage.feature}\n使用目的:${usage.purpose}`,
confirmText: "继续",
cancelText: "取消",
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
}
/**
* 跳转当前应用的系统权限设置页。
* 仅在用户已拒绝权限并主动选择“去设置”后调用。
*/
export const gotoAppPermissionSetting = () => {
const system = uni.getSystemInfoSync()
@@ -22,41 +69,81 @@ export const gotoAppPermissionSetting = () => {
plus.runtime.openURL("app-settings:")
return
}
if (system.platform == "android") {
if (system.platform === "android") {
const Intent = plus.android.importClass("android.content.Intent")
const Settings = plus.android.importClass("android.provider.Settings")
const Uri = plus.android.importClass("android.net.Uri")
const mainActivity = plus.android.runtimeMainActivity()
// @ts-ignore
// @ts-ignore HTML5+ 注入的 Android 原生类不在项目 TypeScript 类型声明中。
const intent = new Intent()
// @ts-ignore
// @ts-ignore HTML5+ 原生 Intent 方法不在项目 TypeScript 类型声明中。
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
// @ts-ignore
// @ts-ignore HTML5+ 原生 Uri 方法不在项目 TypeScript 类型声明中。
const uri = Uri.fromParts("package", mainActivity.getPackageName(), null)
intent.setData(uri)
// @ts-ignore
// @ts-ignore HTML5+ 原生 Activity 方法不在项目 TypeScript 类型声明中。
mainActivity.startActivity(intent)
}
}
export const requestPermission = (permissionId: PermissionId): Promise<RequestStatus> => {
/**
* 申请单项敏感权限。
* 权限尚未授予时先展示由用户手动确认的用途说明,同意后才调用系统授权 API。
*
* @param permissionId 应用内部权限类型。
* @param usage 当前业务功能及其使用该权限的具体目的。
* @returns 最终权限状态;用户取消用途说明时返回 denied,且不会拉起系统权限框。
*/
export const requestPermission = (permissionId: PermissionId, usage: PermissionUsage): Promise<RequestStatus> => {
// H5 在 Android/iOS 浏览器中也会返回移动平台名称,但不存在 HTML5+ 原生桥;此时交由浏览器或 uni API 自行处理授权。
if (typeof plus === "undefined" || !plus.runtime) return Promise.resolve("granted")
const system = uni.getSystemInfoSync()
if (system.platform === "android") {
return requestAndroidWithGuide(permissionId)
return requestAndroidWithGuide(permissionId, usage)
}
if (system.platform === "ios") {
return requestIosWithGuide(permissionId)
return requestIosWithGuide(permissionId, usage)
}
return Promise.resolve("denied")
}
/******************************** ios相关 ********************************/
/**
* 申请选图场景实际需要的相机和相册权限。
* 扫码器同时支持拍摄与相册识别时会在进入原生扫码页前完成两项告知,避免原生页面内部直接弹出未说明用途的存储权限框。
*
* @param sourceTypes 当前功能允许的图片来源。
* @param usage 相机和相册各自的服务功能与用途。
* @returns 所需权限全部获得授权时返回 true;任一权限未授权时返回 false。
*/
export const requestImagePermissions = async (
sourceTypes: readonly ImageSource[],
usage: ImagePermissionUsage,
): Promise<boolean> => {
if (sourceTypes.includes("camera")) {
const cameraStatus = await requestPermission("camera", usage.camera)
if (cameraStatus !== "granted") return false
}
if (sourceTypes.includes("album")) {
const storageStatus = await requestPermission("storage", usage.storage)
if (storageStatus !== "granted") return false
}
return true
}
const requestIosWithGuide = async (permissionId: PermissionId): Promise<RequestStatus> => {
/**
* 执行 iOS 权限申请流程。
* 已授权时直接返回;首次申请前先同步告知用途;已拒绝时只提供设置页入口。
*
* @param permissionId 应用内部权限类型。
* @param usage 当前业务功能及其权限用途。
* @returns iOS 原生权限状态。
*/
const requestIosWithGuide = async (permissionId: PermissionId, usage: PermissionUsage): Promise<RequestStatus> => {
const status = getIosPermissionStatus(permissionId)
console.log("status:", status, "permissionId:", permissionId)
if (status === "granted") return "granted"
if (status === "prompt") {
const shouldRequest = await showPermissionUsageModal(permissionId, usage)
if (!shouldRequest) return "denied"
const result = await requestIosPermission(permissionId)
if (result === "denied") {
const shouldOpen = await showPermissionDeniedModal(permissionId)
@@ -69,6 +156,12 @@ const requestIosWithGuide = async (permissionId: PermissionId): Promise<RequestS
return "denied"
}
/**
* 读取 iOS 单项权限状态。
*
* @param permissionId 应用内部权限类型。
* @returns 当前授权状态。
*/
const getIosPermissionStatus = (permissionId: PermissionId): RequestStatus => {
switch (permissionId) {
case "location":
@@ -83,64 +176,71 @@ const getIosPermissionStatus = (permissionId: PermissionId): RequestStatus => {
}
/**
* 请求ios定位权限
* @returns 权限请求状态
* 读取 iOS 使用期间定位权限状态。
*
* @returns 当前定位权限状态;系统定位服务关闭时返回 denied。
*/
const iosLocation = (): RequestStatus => {
let result: RequestStatus = "denied"
// @ts-ignore
const cllocationManger = plus.ios.import("CLLocationManager")
const enable = cllocationManger.locationServicesEnabled()
const status = cllocationManger.authorizationStatus()
// @ts-ignore HTML5+ 注入的 iOS 原生类不在项目 TypeScript 类型声明中。
const locationManager = plus.ios.import("CLLocationManager")
const enable = locationManager.locationServicesEnabled()
const status = locationManager.authorizationStatus()
if (!enable) {
result = "denied"
} else if (status === 0) {
result = "prompt"
} else if (status === 3 || status === 4) {
result = "granted"
} else {
result = "denied"
}
plus.ios.deleteObject(cllocationManger)
plus.ios.deleteObject(locationManager)
return result
}
/**
* 请求ios存储权限
* @returns 权限请求状态
* 读取 iOS 相册读取权限状态。
*
* @returns 当前相册权限状态;受限照片访问也视为可用。
*/
const iosStorage = (): RequestStatus => {
let result: RequestStatus = "denied"
// @ts-ignore
const PHPhotoLibrary = plus.ios.import("PHPhotoLibrary")
const status = PHPhotoLibrary.authorizationStatus()
// @ts-ignore HTML5+ 注入的 iOS 原生类不在项目 TypeScript 类型声明中。
const photoLibrary = plus.ios.import("PHPhotoLibrary")
const status = photoLibrary.authorizationStatus()
if (status === 0) {
result = "prompt"
} else if (status === 3 || status === 4) {
result = "granted"
} else {
result = "denied"
}
plus.ios.deleteObject(PHPhotoLibrary)
plus.ios.deleteObject(photoLibrary)
return result
}
/**
* 读取 iOS 相机权限状态。
*
* @returns 当前相机权限状态。
*/
const iosCamera = (): RequestStatus => {
let result: RequestStatus = "denied"
// @ts-ignore
const AVCaptureDevice = plus.ios.import("AVCaptureDevice")
const status = AVCaptureDevice.authorizationStatusForMediaType("vide")
// @ts-ignore HTML5+ 注入的 iOS 原生类不在项目 TypeScript 类型声明中。
const captureDevice = plus.ios.import("AVCaptureDevice")
const status = captureDevice.authorizationStatusForMediaType("vide")
if (status === 0) {
result = "prompt"
} else if (status === 3) {
result = "granted"
} else {
result = "denied"
}
plus.ios.deleteObject(AVCaptureDevice)
plus.ios.deleteObject(captureDevice)
return result
}
/**
* 调用 iOS 对应的原生权限申请方法。
*
* @param permissionId 应用内部权限类型。
* @returns 用户完成系统授权操作后的权限状态。
*/
const requestIosPermission = (permissionId: PermissionId): Promise<RequestStatus> => {
switch (permissionId) {
case "location":
@@ -154,32 +254,48 @@ const requestIosPermission = (permissionId: PermissionId): Promise<RequestStatus
}
}
/**
* 请求 iOS 相机权限。
*
* @returns 用户完成系统授权操作后的权限状态。
*/
const requestIosCameraPermission = (): Promise<RequestStatus> => {
return new Promise((resolve) => {
// @ts-ignore
const AVCaptureDevice = plus.ios.import("AVCaptureDevice")
AVCaptureDevice.requestAccessForMediaTypecompletionHandler("vide", (granted: boolean) => {
plus.ios.deleteObject(AVCaptureDevice)
// @ts-ignore HTML5+ 注入的 iOS 原生类不在项目 TypeScript 类型声明中。
const captureDevice = plus.ios.import("AVCaptureDevice")
captureDevice.requestAccessForMediaTypecompletionHandler("vide", (granted: boolean) => {
plus.ios.deleteObject(captureDevice)
resolve(granted ? "granted" : "denied")
})
})
}
/**
* 请求 iOS 相册读取权限。
*
* @returns 用户完成系统授权操作后的权限状态;受限照片访问也视为可用。
*/
const requestIosStoragePermission = (): Promise<RequestStatus> => {
return new Promise((resolve) => {
// @ts-ignore
const PHPhotoLibrary = plus.ios.import("PHPhotoLibrary")
PHPhotoLibrary.requestAuthorization((status: number) => {
plus.ios.deleteObject(PHPhotoLibrary)
// @ts-ignore HTML5+ 注入的 iOS 原生类不在项目 TypeScript 类型声明中。
const photoLibrary = plus.ios.import("PHPhotoLibrary")
photoLibrary.requestAuthorization((status: number) => {
plus.ios.deleteObject(photoLibrary)
resolve(status === 3 || status === 4 ? "granted" : "denied")
})
})
}
/**
* 请求 iOS 使用期间定位权限,并轮询等待系统授权结果。
*
* @returns 最长约三秒内取得的定位权限状态。
*/
const requestIosLocationPermission = (): Promise<RequestStatus> => {
return new Promise((resolve) => {
// @ts-ignore
// @ts-ignore HTML5+ 注入的 iOS 原生类不在项目 TypeScript 类型声明中。
const CLLocationManager = plus.ios.import("CLLocationManager")
// @ts-ignore HTML5+ 注入的 iOS 原生构造器不在项目 TypeScript 类型声明中。
const manager = new CLLocationManager()
manager.requestWhenInUseAuthorization()
let attempt = 0
@@ -190,79 +306,109 @@ const requestIosLocationPermission = (): Promise<RequestStatus> => {
clearInterval(timer)
plus.ios.deleteObject(manager)
plus.ios.deleteObject(CLLocationManager)
if (status === 3 || status === 4) {
resolve("granted")
return
}
resolve("denied")
resolve(status === 3 || status === 4 ? "granted" : "denied")
}
}, 300)
})
}
/******************************** andorid相关 ********************************/
/**
* 请求android权限
* 执行 Android 权限申请流程。
* 已授权时直接返回;未授权时先展示用途说明,再调用系统权限框。
*
* @param permissionId 应用内部权限类型。
* @param usage 当前业务功能及其权限用途。
* @returns Android 原生权限状态。
*/
const requestAndroidWithGuide = async (permissionID: PermissionId): Promise<RequestStatus> => {
const result = await requestAndroid(permissionID)
const requestAndroidWithGuide = async (permissionId: PermissionId, usage: PermissionUsage): Promise<RequestStatus> => {
const permissionKeys = getAndroidPermissionKeys(permissionId)
if (!permissionKeys.length) return "denied"
if (isAndroidPermissionGranted(permissionKeys)) return "granted"
const shouldRequest = await showPermissionUsageModal(permissionId, usage)
if (!shouldRequest) return "denied"
const result = await requestAndroid(permissionKeys, permissionId)
if (result === "denied") {
const shouldOpen = await showPermissionDeniedModal(permissionID)
const shouldOpen = await showPermissionDeniedModal(permissionId)
if (shouldOpen) gotoAppPermissionSetting()
}
return result
}
const requestAndroid = (permissionID: PermissionId): Promise<RequestStatus> => {
/**
* 获取 Android 权限名称。
* 当前应用 targetSdkVersion 固定为 30,按 Android 兼容模式使用 READ_EXTERNAL_STORAGE 读取用户主动选择的图片。
*
* @param permissionId 应用内部权限类型。
* @returns 需要申请的 Android 原生权限数组。
*/
const getAndroidPermissionKeys = (permissionId: PermissionId): string[] => {
switch (permissionId) {
case "location":
return ["android.permission.ACCESS_FINE_LOCATION"]
case "storage":
return ["android.permission.READ_EXTERNAL_STORAGE"]
case "camera":
return ["android.permission.CAMERA"]
default:
return []
}
}
/**
* 检查 Android 原生权限是否已全部授予。
*
* @param permissionKeys Android 原生权限名称数组。
* @returns 所有权限均已授权时返回 true;检查失败时按未授权处理。
*/
const isAndroidPermissionGranted = (permissionKeys: string[]): boolean => {
try {
const mainActivity = plus.android.runtimeMainActivity()
plus.android.importClass(mainActivity)
// @ts-ignore HTML5+ 原生 Activity 方法不在项目 TypeScript 类型声明中。
return permissionKeys.every((permissionKey) => mainActivity.checkSelfPermission(permissionKey) === 0)
} catch {
return false
}
}
/**
* 调用 Android 系统权限框。
*
* @param permissionKeys Android 原生权限名称数组。
* @param permissionId 应用内部权限类型,仅用于输出不含个人信息的错误日志。
* @returns 用户完成系统授权操作后的权限状态。
*/
const requestAndroid = (permissionKeys: string[], permissionId: PermissionId): Promise<RequestStatus> => {
return new Promise((resolve) => {
let androidPermissionKey = ""
switch (permissionID) {
case "location":
androidPermissionKey = "android.permission.ACCESS_FINE_LOCATION"
break
case "storage":
androidPermissionKey = "android.permission.WRITE_EXTERNAL_STORAGE"
break
case "camera":
androidPermissionKey = "android.permission.CAMERA"
break
}
if (!androidPermissionKey) {
resolve("denied")
}
plus.android.requestPermissions(
[androidPermissionKey],
function (resultObj) {
let result: RequestStatus = "denied"
for (let i = 0; i < resultObj.granted.length; i++) {
console.log("已获取的权限:")
result = "granted"
}
for (let i = 0; i < resultObj.deniedPresent.length; i++) {
console.log("拒绝本次申请的权限:")
result = "denied"
}
for (let i = 0; i < resultObj.deniedAlways.length; i++) {
console.log("永久拒绝申请的权限:")
result = "denied"
}
resolve(result)
permissionKeys,
(resultObj) => {
const grantedPermissions = Array.isArray(resultObj.granted) ? resultObj.granted : []
const allGranted = permissionKeys.every((permissionKey) => grantedPermissions.includes(permissionKey))
resolve(allGranted ? "granted" : "denied")
},
function (error) {
console.error(`|请求android权限|${permissionID}:`, error)
(error) => {
console.error(`|请求 Android 权限|${permissionId}:`, error)
resolve("denied")
},
)
})
}
const getPermissionDisplayName = (permissionId: PermissionId) => {
/**
* 获取面向用户展示的权限名称。
*
* @param permissionId 应用内部权限类型。
* @returns 权限中文名称。
*/
const getPermissionDisplayName = (permissionId: PermissionId): string => {
switch (permissionId) {
case "location":
return "定位"
case "storage":
return "相册"
return "存储(相册)"
case "camera":
return "相机"
default:
@@ -270,6 +416,25 @@ const getPermissionDisplayName = (permissionId: PermissionId) => {
}
}
/**
* 在系统权限框出现前同步告知权限用途。
* uni.showModal 只有用户主动点击后才会消失,满足用途说明不得自动消失的审核要求。
*
* @param permissionId 应用内部权限类型。
* @param usage 当前业务功能及其权限用途。
* @returns 用户点击“继续”时返回 true,取消或弹窗失败时返回 false。
*/
const showPermissionUsageModal = (permissionId: PermissionId, usage: PermissionUsage): Promise<boolean> => {
const permissionName = getPermissionDisplayName(permissionId)
return showPermissionUsage(permissionName, usage)
}
/**
* 权限拒绝后说明功能受限,并由用户决定是否前往系统设置。
*
* @param permissionId 应用内部权限类型。
* @returns 用户点击“去设置”时返回 true,取消或弹窗失败时返回 false。
*/
const showPermissionDeniedModal = (permissionId: PermissionId): Promise<boolean> => {
const permissionName = getPermissionDisplayName(permissionId)
return new Promise((resolve) => {
@@ -278,7 +443,7 @@ const showPermissionDeniedModal = (permissionId: PermissionId): Promise<boolean>
content: `请在系统设置中开启${permissionName}权限后再试`,
confirmText: "去设置",
cancelText: "取消",
success: (res) => resolve(!!res.confirm),
success: (res) => resolve(Boolean(res.confirm)),
fail: () => resolve(false),
})
})
@@ -143,6 +143,7 @@
</script>
<script>
import node from './mp-node.vue'
import { showPermissionUsage } from '@/sheep/utils/permission'
export default {
name: 'node',
options: {
@@ -289,16 +290,24 @@
},
/**
* @description 图片长按
*/
imgLongTap(e) {
/**
* 处理富文本图片长按保存。
* 用户确认保存后先展示相册写入用途,再调用系统相册接口;取消用途说明时不下载、不写入图片。
*
* @param e 图片节点长按事件,包含当前图片索引。
*/
async imgLongTap(e) {
// #ifdef APP-PLUS
const attrs = this.childs[e.currentTarget.dataset.i].attrs
if (this.opts[3] && !attrs.ignore) {
uni.showActionSheet({
itemList: ['保存图片'],
success: () => {
success: async () => {
const shouldSave = await showPermissionUsage('存储(相册)', {
feature: '保存富文本图片',
purpose: '用于将您长按并确认保存的业务图片写入系统相册。'
})
if (!shouldSave) return
const save = path => {
uni.saveImageToPhotosAlbum({
filePath: path,
@@ -620,4 +629,4 @@
}
/* #endif */
</style>
</style>
@@ -44,6 +44,7 @@ import { type PropType, computed, onMounted, ref, shallowRef, watch } from 'vue'
import { covetUniNumber } from '../../libs/tool';
import { TMUPLOAD_PHOTO_STATUS } from '../../interface';
import { useTmConfig } from "../../libs/config";
import { requestImagePermissions } from '@/sheep/utils/permission';
/**
* @displayName 图片上传
@@ -236,6 +237,14 @@ const props = defineProps({
type: [Array<"album" | "camera">],
default: () => ["album", "camera"]
},
/**
* 当前图片上传所属的具体业务功能。
* App 首次申请相机或相册权限时用于生成同步用途说明,不参与上传接口参数。
*/
permissionFeature: {
type: String,
default: "图片上传"
},
/**
* 是否压缩
*/
@@ -341,9 +350,24 @@ const covertFileTypeByModelvalue = (files: Array<Record<string, any> | string>)
return filst2;
}
/**
* 选择图片
* 选择图片。
* App 端在调用系统选图能力前统一完成相机和相册用途告知,H5 与小程序保留原有选择流程。
*/
const choose = () => {
const choose = async () => {
// #ifdef APP-PLUS
const permissionGranted = await requestImagePermissions(props.sourceType, {
camera: {
feature: props.permissionFeature,
purpose: `用于拍摄${props.permissionFeature}所需图片,并在您确认后上传。`
},
storage: {
feature: props.permissionFeature,
purpose: `用于从相册选择${props.permissionFeature}所需图片,并在您确认后上传。`
}
})
if (!permissionGranted) return
// #endif
let resultfiles = [] as CHOOSEFILE_FILES[];
let index = 0;
// uni.showLoading({ title: '...' })
@@ -626,4 +650,4 @@ export default {
border-radius: 10rpx;
overflow: hidden;
}
</style>
</style>
@@ -41,10 +41,11 @@
</template>
<script setup lang="ts">
import { PropType, computed, onMounted, ref, shallowRef, watch } from 'vue'
import { type PropType, computed, onMounted, ref, shallowRef, watch } from 'vue'
import { covetUniNumber } from '../../libs/tool';
import { TMUPLOAD_PHOTO_STATUS } from '../../interface';
import { useTmConfig } from "../../libs/config";
import { requestImagePermissions } from '@/sheep/utils/permission';
/**
* @displayName 视频上传
@@ -255,6 +256,14 @@ const props = defineProps({
type: [Array<"album" | "camera">],
default: () => ["album", "camera"]
},
/**
* 当前视频上传所属的具体业务功能。
* App 首次申请相机或相册权限时用于生成同步用途说明,不参与上传接口参数。
*/
permissionFeature: {
type: String,
default: "视频上传"
},
/**
* 是否压缩
*/
@@ -340,9 +349,24 @@ const covertFileTypeByModelvalue = (files: Array<Record<string, any> | string>)
return filst2;
}
/**
* 选择图片
* 选择视频。
* App 端在调用系统选视频能力前统一完成相机和相册用途告知,H5 与小程序保留原有选择流程。
*/
const choose = () => {
const choose = async () => {
// #ifdef APP-PLUS
const permissionGranted = await requestImagePermissions(props.sourceType, {
camera: {
feature: props.permissionFeature,
purpose: `用于拍摄${props.permissionFeature}所需视频,并在您确认后上传。`
},
storage: {
feature: props.permissionFeature,
purpose: `用于从相册选择${props.permissionFeature}所需视频,并在您确认后上传。`
}
})
if (!permissionGranted) return
// #endif
let resultfiles = [] as CHOOSEFILE_FILES[];
let index = 0;
// uni.showLoading({ title: '...' })
@@ -549,4 +573,4 @@ export default {
border-radius: 10rpx;
overflow: hidden;
}
</style>
</style>
Binary file not shown.