Compare commits

...

6 Commits

Author SHA1 Message Date
d3dab30d48 feat: 新增安卓自截图uts插件、修改ios允许截屏禁止录屏 2026-06-23 17:07:16 +08:00
c7b62b0f4d feat: 增加详情介绍链接 2026-06-12 13:52:35 +08:00
8a67e20fc2 feat: 增加视频水印和防盗录 2026-06-09 14:26:17 +08:00
8d46ee78d3 chore: 增加妇幼生殖VIP相关修改
- 将manifest.json中的版本号更新至1.0.63
- 调整baseUrl配置以使用本地开发环境
- 优化VIP权限描述,新增“妇幼生殖”分类
- 更新相关页面中的VIP课程描述,明确不包含论坛内容
2026-05-20 13:27:46 +08:00
df34c1ad48 chore: 更新版本号和依赖项
- 将manifest.json中的版本号更新至1.0.61
- 将edu-core依赖从本地路径更新至git仓库地址,版本为v1.0.13
- 优化wallet页面的布局和样式,增强可读性
2026-05-09 11:09:46 +08:00
5f4a4d4ae4 feat: 添加退款相关功能和状态显示
- 新增退款状态显示,包括“已退款”和“退款中”
- 添加退款目的地页面,支持查看订单退款信息
- 更新订单列表和订单详情页,支持退款操作
- 更新依赖edu-core至v1.0.13以支持新功能
2026-05-09 11:09:14 +08:00
36 changed files with 1730 additions and 64 deletions

View File

@@ -4,6 +4,7 @@ if (process.env.NODE_ENV === 'development') {
// 开发环境
// baseUrl = "http://192.168.110.100:9200/pb/"; // 张川川
baseUrl = "https://api.nuttyreading.com/"; // 线上正式
// baseUrl = "http://192.168.110.131:9200/pb/"; // 王亚男
} else if (process.env.NODE_ENV === 'production') {
// 生产环境11
//baseUrl = "http://192.168.110.100:9200/pb/"; // 张川川

View File

@@ -352,7 +352,8 @@ $http.dataFactory = async function(res) {
// 返回错误的结果(catch接受数据)
return Promise.reject({
statusCode: 0,
errMsg: "【request】" + (httpData.info || httpData.msg),
// errMsg: "【request】" + (httpData.info || httpData.msg),
errMsg: (httpData.info || httpData.msg),
data: res.data
});
}

View File

@@ -125,6 +125,9 @@ Vue.component('common-video', commonVideo);
import CommonCourseVideo from 'edu-core/components/course-video'
Vue.component('CommonCourseVideo', CommonCourseVideo);
import CommonRefundDestination from 'edu-core/components/order/refund-destination.vue'
Vue.component('common-refund-destination', CommonRefundDestination);
import commonCoupon from '@/pages/component/commonComponents/coupon/index.vue'
Vue.component('common-coupon', commonCoupon);
import commonGoodsList from '@/pages/component/commonComponents/goodsList.vue'

View File

@@ -13,8 +13,8 @@
"src" : "图片路径"
}
],
"versionName" : "1.0.61",
"versionCode" : 1061,
"versionName" : "1.0.66",
"versionCode" : 1066,
"app-plus" : {
"nvueCompiler" : "weex",
"compatible" : {

16
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "3.4.5",
"license": "MIT",
"dependencies": {
"edu-core": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.12",
"edu-core": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.15",
"jquery": "^3.7.1",
"tcplayer.js": "^5.1.0"
},
@@ -17,12 +17,6 @@
"postcss-px-to-viewport": "^1.1.1"
}
},
"../edu-core": {
"version": "1.0.8",
"extraneous": true,
"license": "ISC",
"devDependencies": {}
},
"node_modules/babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmmirror.com/babel-runtime/-/babel-runtime-6.26.0.tgz",
@@ -74,8 +68,8 @@
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/edu-core": {
"version": "1.0.12",
"resolved": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#ea1dca213de69ac5a01a44a352ab33024edd2577",
"version": "1.0.15",
"resolved": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#df2306c058445de44914622bf56459f76247f5b1",
"license": "ISC"
},
"node_modules/es5-shim": {
@@ -399,8 +393,8 @@
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"edu-core": {
"version": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#ea1dca213de69ac5a01a44a352ab33024edd2577",
"from": "edu-core@git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.12"
"version": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#df2306c058445de44914622bf56459f76247f5b1",
"from": "edu-core@git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.15"
},
"es5-shim": {
"version": "4.6.7",

View File

@@ -21,7 +21,7 @@
},
"homepage": "https://github.com/dcloudio/hello-uniapp#readme",
"dependencies": {
"edu-core": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.12",
"edu-core": "git+https://git.nuttyreading.com/chenghuan/edu-core.git#v1.0.15",
"jquery": "^3.7.1",
"tcplayer.js": "^5.1.0"
},

View File

@@ -193,6 +193,18 @@
}
}
},
{
"path": "pages/bookShop/refundDestination",
"style": {
"navigationBarTitleText": "钱款去向",
"enablePullDownRefresh": false,
"app-plus": {
"bounce": "none",
"titleNView": false,
"popGesture": "none"
}
}
},
{
"path": "pages/goods/order/index",
"style": {
@@ -557,6 +569,18 @@
}
}
},
{
"path": "pages/medicaldes/medurl",
"style": {
"navigationBarTitleText": "详细介绍",
"enablePullDownRefresh": false,
"app-plus": {
"bounce": "none",
"titleNView": false,
"popGesture": "none"
}
}
},
{
"path": "pages/medicaldes/recordDetail",
"style": {

View File

@@ -88,6 +88,16 @@
v-if="orderContet.orderStatus == 5"
>已超时</text
>
<text
class="orderState orderState6"
v-if="orderContet.orderStatus == 6"
>已退款</text
>
<text
class="orderState orderState7"
v-if="orderContet.orderStatus == 7"
>退款中</text
>
</view>
<view
class="orderContent"
@@ -1105,14 +1115,15 @@ export default {
text: "继续付款",
});
}
if (this.orderContet.orderStatus == 0) {
this.customButton.push({
width: "160rpx",
text: "取消订单",
color: "#333",
backgroundColor: "#f0f0f0",
});
}
// sociology注释取消订单按钮
// if (this.orderContet.orderStatus == 0) {
// this.customButton.push({
// width: "160rpx",
// text: "取消订单",
// color: "#333",
// backgroundColor: "#f0f0f0",
// });
// }
// var seconds = res.result.timestamp + 30 * 60 + 2 // 过期时间
// var nowSeconds = Math.floor(new Date().getTime() / 1000);
@@ -1131,6 +1142,10 @@ export default {
this.titleStat = "待收到";
} else if (this.orderContet.orderStatus == 3) {
this.titleStat = "已完成";
} else if (this.orderContet.orderStatus == 6) {
this.titleStat = "已退款";
} else if (this.orderContet.orderStatus == 7) {
this.titleStat = "退款中";
}
if (
this.orderContet.orderStatus >= 2 &&
@@ -1173,10 +1188,28 @@ export default {
},
});
},
checkPayTimeout(payItem) {
const createTime = payItem && payItem.createTime;
if (!createTime) return true;
const createdAt = new Date(String(createTime).replace(/-/g, "/")).getTime();
if (!createdAt) return true;
const expired = Date.now() - createdAt > 10 * 60 * 1000;
if (expired) {
uni.showModal({
title: "提示",
content: "订单已超时,不能继续支付,请重新下单",
confirmText: "知道了",
showCancel: false,
});
return false;
}
return true;
},
// 支付
goPay(payItem) {
console.log(payItem, "订单数据");
if (!this.checkPayTimeout(payItem)) return;
if (payItem.paymentMethod == 2) {
console.log("阿里支付");
setPay(
@@ -1320,6 +1353,12 @@ uni-view {
.orderState5 {
background-color: #787878;
}
.orderState6 {
background-color: #f56c6c;
}
.orderState7 {
background-color: #f56c6c;
}
.guoqi {
font-size: 28rpx;
align-items: center;

View File

@@ -24,7 +24,7 @@
<view class="cateList flexbox">
<common-sticky
itemStyle="width:20%; height: 68rpx;font-size:24rpx;"
itemStyle="height: 68rpx;font-size:24rpx; padding: 0 20rpx;"
:list="ordersTabs"
label="name"
:currentCateIndex="currentCateIndex"
@@ -120,6 +120,16 @@
v-show="slotProps.row.orderStatus == 5"
>已过期</text
>
<text
class="orderstatus"
v-show="slotProps.row.orderStatus == 6"
>已退款</text
>
<text
class="orderstatus"
v-show="slotProps.row.orderStatus == 7"
>退款中</text
>
</view>
</view>
<view
@@ -380,11 +390,12 @@
class="operation_box boxShadow"
v-if="slotProps.row.isShowMore == true"
>
<view
<!-- sociology注释取消订单入口 -->
<!-- <view
v-if="slotProps.row.orderStatus == 0"
@click.native.stop="canceOrder(slotProps.row)"
>取消订单</view
>
> -->
</view>
<view class="btns flexbox" style="margin-top: 10rpx">
<view
@@ -421,8 +432,18 @@
>
<view
class="orderstatusbtn"
v-if="slotProps.row.orderStatus == 3"
>申请售后</view
v-if="
slotProps.row.orderStatus == 6 ||
slotProps.row.orderStatus == 7
"
@click.stop="goRefundDestination(slotProps.row)"
>钱款去向</view
>
<view
class="orderstatusbtn"
v-if="slotProps.row.refundableStatus === true"
@click.stop="confirmApplyRefund(slotProps.row)"
>申请退款</view
>
<!-- <view
class="orderstatusbtn"
@@ -615,12 +636,14 @@ export default {
come: "1",
isShowTab: false,
isLoadingHide: false,
moreList: [
{
name: "取消订单",
key: "false",
},
],
// sociology注释取消订单菜单
// moreList: [
// {
// name: "取消订单",
// key: "false",
// },
// ],
moreList: [],
currentCateIndex: 0,
pagination: {
// 请求参数
@@ -671,6 +694,16 @@ export default {
value: 3,
badge: {},
},
{
name: "已退款",
value: 6,
badge: {},
},
{
name: "退款中",
value: 7,
badge: {},
},
],
selectOrderInfo: {},
ordersListTab: 1,
@@ -790,7 +823,8 @@ export default {
console.log("index at line 609:", index);
if (index.key == "false") {
this.isShowMore = false;
this.canceOrder(this.selectOrderInfo);
// sociology注释取消订单触发
// this.canceOrder(this.selectOrderInfo);
}
},
openMore(row, index) {
@@ -856,6 +890,51 @@ export default {
val.orderSn,
});
},
goRefundDestination(row) {
uni.navigateTo({
url: `/pages/bookShop/refundDestination?orderId=${row.orderId}`,
});
},
confirmApplyRefund(row) {
uni.showModal({
title: "申请退款",
content: "请确认是否提交退款申请?",
confirmText: "确认提交",
cancelText: "取消",
success: (res) => {
if (res.confirm) {
this.submitOrderRefund(row);
}
},
});
},
submitOrderRefund(orderRow) {
this.$http
.request({
url: "book/buyOrder/refundOrder",
method: "POST",
data: {
orderId: orderRow.orderId,
},
header: {
"Content-Type": "application/json",
},
})
.then((res) => {
if (res.code === 0) {
this.$commonJS.showToast("申请退款成功");
this.newestpage = 1;
this.pagination.page = 1;
this.newList = [];
this.getBookList(this.ordersListTab, false);
return;
}
this.$commonJS.showToast(res.errMsg || "申请退款失败");
})
.catch(() => {
this.$commonJS.showToast("申请退款失败");
});
},
getBookList(flag, refreshflag) {
this.isLoadingHide = false;
var that = this;
@@ -882,6 +961,8 @@ export default {
// * 3已完成
// * 4: 交易失败
// * 5: 已过期
// * 6: 已退款
// * 7: 退款中
console.log("res at line 757:", res);
that.map = res.data;
@@ -909,7 +990,7 @@ export default {
var params = {
userId: this.userInfo.id,
come: this.come,
orderStatus: flag == -1 ? "" : flag, //传null为全部订单状态 0-待付款 1-待发出 2-待收到 3-已完成 4-交易失败 5-已过期
orderStatus: flag == -1 ? "" : flag, //传null为全部订单状态 0-待付款 1-待发出 2-待收到 3-已完成 4-交易失败 5-已过期 6-已退款 7-退款中
...this.pagination,
// limit: 10,
// page: this.newestpage,
@@ -949,9 +1030,27 @@ export default {
this.axiosStatus = 1;
},
checkPayTimeout(payItem) {
const createTime = payItem && payItem.createTime;
if (!createTime) return true;
const createdAt = new Date(String(createTime).replace(/-/g, "/")).getTime();
if (!createdAt) return true;
const expired = Date.now() - createdAt > 10 * 60 * 1000;
if (expired) {
uni.showModal({
title: "提示",
content: "订单已超时,不能继续支付,请重新下单",
confirmText: "知道了",
showCancel: false,
});
return false;
}
return true;
},
// 支付
goPay(payItem) {
console.log(payItem, "订单数据");
if (!this.checkPayTimeout(payItem)) return;
if (payItem.paymentMethod == 2) {
console.log("阿里支付");
setPay(
@@ -1629,9 +1728,9 @@ export default {
z-index: 970;
}
/deep/.u-tabs__wrapper__nav__item {
padding: 0 !important;
}
// /deep/.u-tabs__wrapper__nav__item {
// padding: 0 !important;
// }
.commonDetailPage {
background-color: #f5f5f5;

View File

@@ -0,0 +1,27 @@
<template>
<view class="page-wrap">
<public-module></public-module>
<common-refund-destination :order-id="orderId" :http="$http" />
</view>
</template>
<script>
export default {
data() {
return {
orderId: "",
};
},
onLoad(options) {
if (options && options.orderId != null) {
this.orderId = options.orderId;
}
},
};
</script>
<style scoped>
.page-wrap {
min-height: 100vh;
}
</style>

View File

@@ -445,4 +445,8 @@ export default {
font-weight: 700;
}
}
.title_list {
margin-bottom: 20rpx;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<!-- <scroll-view class="scroll-view_H cateList" scroll-x="true" scroll-left="0"> -->
<view class="" style="background-color: #fff; width: 100%">
<view class="" style="background-color: #fff; width: 100%; padding: 0 20rpx;">
<u-tabs
lineWidth="30"
lineColor="#3AB3AE"

View File

@@ -15,6 +15,7 @@
:course="{courseTitle:options.navTitle, chapterTitle: curriculumData.title, catalogueId: curriculumData.catalogueId || '', courseId: curriculumData.courseId || ''}"
:cover="options.curriculumImgUrl || ''"
:http="$http"
:user-info="userInfo"
/>
<view

View File

@@ -702,9 +702,11 @@ export default {
role = "心理学";
} else if (type == 9) {
role = "中西汇通学";
} else if (type == 10) {
role = "妇幼生殖";
}
this.goBuyTitle =
"尊贵的" + role + "VIP您的有效期到" + res.userVip.endTime;
"尊贵的" + role + "VIP您的有效期到" + res.userVip.endTime + '(不包含论坛)';
this.goBuyType = 1;
} else {
//否则没有开通vip
@@ -745,6 +747,8 @@ export default {
text = "心理学";
} else if (item == "9") {
text = "中西汇通学";
} else if (item == "10") {
text = "妇幼生殖";
}
this.textList.push(text);
});

View File

@@ -356,11 +356,11 @@ export default {
},
onReady() {},
onLoad() {
if (plus.os.name == "Android") {
this.isShowTaihu=true
}else{
this.isShowTaihu=false
}
if (plus.os.name == "Android") {
this.isShowTaihu=true
}else{
this.isShowTaihu=false
}
this.$nextTick(() => {
this.getAdvertisement();
});

View File

@@ -63,12 +63,15 @@
<uni-section
class="mb-10"
titleFontSize="18px"
title="详细介绍"
title="说明"
type="line"
v-if="prescriptDetail.content && prescriptDetail.content != ''"
>
<view class="item" v-html="prescriptDetail.content"> </view>
</uni-section>
<div class="detail-link-wrap" v-if="prescriptDetail.url && prescriptDetail.url != ''">
<text class="detail-link" @click="openDetailUrl">详细介绍&gt;&gt;</text>
</div>
<!-- <uni-section class="mb-10" titleFontSize="18px" title="配伍" type="line">
<view class="item" v-if="prescriptDetail.compatibility && prescriptDetail.compatibility != ''" v-html="prescriptDetail.compatibility">
@@ -129,6 +132,13 @@ export default {
},
});
},
openDetailUrl() {
uni.navigateTo({
url:
"/pages/medicaldes/medurl?url=" +
encodeURIComponent(this.prescriptDetail.url),
});
},
// 方剂详情
getDetail() {
$http
@@ -207,6 +217,18 @@ export default {
display: flex;
}
.detail-link-wrap {
padding: 10rpx 20rpx;
padding-bottom: 20rpx;
.detail-link {
font-size: 14px;
font-weight: bold;
color: #007aff;
text-decoration: none;
}
}
/deep/ .uni-section-header__decoration.line {
background-color: #18bc37;
}

View File

@@ -23,7 +23,25 @@ export default {
};
},
onLoad(e) {
this.id = uni.getStorageSync("prescriptUrl");
this.id = e.url ? decodeURIComponent(e.url) : uni.getStorageSync("prescriptUrl");
// #ifdef APP-PLUS
const pages = getCurrentPages()
const page = pages[pages.length - 1];
const currentWebview = page.$getAppWebview()
currentWebview.setStyle({
titleNView: {
buttons: [{
float: 'right',
type: 'close',
onclick: function() {
uni.navigateBack({
delta: 1
});
}
}]
}
})
// #endif
},
computed: {},
watch: {},

View File

@@ -763,6 +763,9 @@ export default {
} else if (item.type == "9") {
item.text = "中西汇通学";
this.textList.push(item.text);
} else if (item.type == "10") {
item.text = "妇幼生殖";
this.textList.push(item.text);
} else if (item.type == "1") {
item.text = "医学S";
} else if (item.type == "2") {

View File

@@ -88,21 +88,21 @@
>
<text class="font_bold" style="display: block" v-else>VIP权限</text>
<view class="vip_qx_v" v-if="item.type == 1"
>无限制观看吴门医述APP中医学中西汇通学针灸学肿瘤学四个板块任意课程</view
>无限制观看吴门医述APP中医学中西汇通学针灸学肿瘤学妇幼生殖五个板块任意课程不包含论坛</view
>
<view class="vip_qx_v" v-else-if="item.type == 2"
>无限制观看众妙之门APP与心灵空间APP任意课程</view
>无限制观看众妙之门APP与心灵空间APP任意课程不包含论坛</view
>
<view class="vip_qx_v" v-else-if="item.type == 7"
>无限制观看众妙之门APP任意课程</view
>无限制观看众妙之门APP任意课程不包含论坛</view
>
<view class="vip_qx_v" v-else-if="item.type == 8"
>无限制观看心灵空间APP任意课程</view
>无限制观看心灵空间APP任意课程不包含论坛</view
>
<view class="vip_qx_v" v-else
>无限制观看吴门医述APP{{
item.title.replace(/VIP/g, "")
}}板块任意课程</view
}}板块任意课程不包含论坛</view
>
<view class="vip_qx_v">

View File

@@ -63,17 +63,19 @@
>
<template slot="labelSlot" slot-scope="slotProps">
<view class="label_content AC_List">
<view class="left">
<view class="title">{{ slotProps.row.orderType }}</view>
</view>
<view
:class="`right ${
slotProps.row.changeAmount > 0 ? 'Hot' : ''
}`"
>
<text v-if="slotProps.row.changeAmount > 0">+</text>
<text>{{ slotProps.row.changeAmount }}</text>
</view>
<view style=" display: flex; align-items: center; justify-content: space-between;">
<view class="left">
<view class="title">{{ slotProps.row.productName || slotProps.row.orderType}}</view>
</view>
<view
:class="`right ${
slotProps.row.changeAmount > 0 ? 'Hot' : ''
}`"
>
<text v-if="slotProps.row.changeAmount > 0">+</text>
<text>{{ slotProps.row.changeAmount }}</text>
</view>
</view>
<view class="AC_mark" v-if="slotProps.row.remark">{{slotProps.row.remark}}</view>
<view class="AC_note" v-if="slotProps.row.note&&slotProps.row.note!='null'">说明{{slotProps.row.note}}</view>
<view class="AC_time">{{ slotProps.row.createTime }}</view>
@@ -348,6 +350,12 @@ export default {
.AC_List {
overflow: hidden;
.title,
.AC_note,
.AC_mark {
word-break: break-word;
overflow-wrap: break-word;
}
.left {
width: calc(100% - 220rpx) !important;
font-weight: 700;
@@ -365,6 +373,19 @@ export default {
font-weight: 700;
color: #333;
}
> view:first-child {
.left {
flex: 1;
min-width: 0;
width: auto !important;
float: none;
}
.right {
flex-shrink: 0;
float: none;
width: auto !important;
}
}
// border-bottom: 1px solid #eee;
// padding: 40rpx 10rpx;

View File

@@ -376,4 +376,8 @@ button::after {
// border-bottom: 0.5px solid #dadbde;
}
}
}
uni-text {
white-space: normal;
}

View File

@@ -0,0 +1,16 @@
## 1.0.72026-06-16
- 新增 iOS setUserCaptureScreen 支持 antiRecordOnly 参数enable 为 false 且 antiRecordOnly 为 true 时,仅禁止录屏/投屏允许截屏。实现方式为进入时提前挂载安全层UITextField.isSecureTextEntry默认不保护、保留 field 实例),通过 UIScreen.isCaptured 监听录屏/投屏,仅在录屏期间翻转 isSecureTextEntry——实时显示正常、录制到的文件为黑屏平时不影响截屏录屏进行中截屏也为黑。Android/鸿蒙忽略该参数,仍为截屏+录屏一起禁止。
## 1.0.62024-11-22
- 修复 HarmonyOS Next 上调用 setUserCaptureScreen 报错的 Bug
## 1.0.52024-10-14
- 新增 支持 HarmonyOS Next 调用
## 1.0.42023-03-24
新增开启/关闭防截屏功能
## 1.0.32023-03-17
修复android平台 部分场景下js可能报错的问题
## 1.0.22023-03-16
修复Android平台在小米设备无法监听的问题 修复Android平台调用uni.onUserCaptureScreen必然会触发回调的问题
## 1.0.12022-10-27
修改插件描述
## 1.0.02022-10-26
支持安卓、iOS、微信小程序平台

View File

@@ -0,0 +1,163 @@
declare namespace UniNamespace {
/**
* uni.onUserCaptureScreen/uni.offUserCaptureScreen回调参数
*/
type OnUserCaptureScreenCallbackResult = {
/**
* 截屏文件路径仅Android返回
*/
path ?: string
}
/**
* uni.onUserCaptureScreen/uni.offUserCaptureScreen回调函数定义
*/
type UserCaptureScreenCallback = (res : OnUserCaptureScreenCallbackResult) => void
type OnUserCaptureScreen = (callback : UserCaptureScreenCallback | null) => void
type OffUserCaptureScreen = (callback : UserCaptureScreenCallback | null) => void
/**
* uni.setUserCaptureScreen成功回调参数
*/
type SetUserCaptureScreenSuccess = {
}
/**
* uni.setUserCaptureScreen成功回调函数定义
*/
type SetUserCaptureScreenSuccessCallback = (res : SetUserCaptureScreenSuccess) => void
/**
* 错误码
* - 12001 "setUserCaptureScreen:system not support"
* - 12010 "setUserCaptureScreen:system internal error"
*/
type SetUserCaptureScreenErrorCode = 12001 | 12010;
/**
* SetUserCaptureScreen 的错误回调参数
*/
interface SetUserCaptureScreenFail {
errCode : SetUserCaptureScreenErrorCode
}
/**
* uni.setUserCaptureScreen失败回调函数定义
*/
type SetUserCaptureScreenFailCallback = (res : SetUserCaptureScreenFail) => void
/**
* uni.setUserCaptureScreen完成回调函数定义
*/
type SetUserCaptureScreenCompleteCallback = (res : any) => void
/**
* uni.setUserCaptureScreen参数
*/
type SetUserCaptureScreenOptions = {
/**
* true: 允许用户截屏 false: 不允许用户截屏,防止用户截屏到应用页面内容
*/
enable : boolean;
/**
* 仅 iOS 生效。仅在 enable 为 false 时有意义:
* true: 仅禁止录屏/投屏(录屏时画面被遮挡),允许截屏;
* false/不传: 截屏与录屏一起禁止(默认行为)。
*/
antiRecordOnly ?: boolean;
/**
* 接口调用成功的回调函数
*/
// success : SetUserCaptureScreenSuccessCallback | null,
success ?: SetUserCaptureScreenSuccessCallback,
/**
* 接口调用失败的回调函数
*/
// fail : SetUserCaptureScreenFailCallback | null,
fail ?: SetUserCaptureScreenFailCallback,
/**
* 接口调用结束的回调函数(调用成功、失败都会执行)
*/
// complete : SetUserCaptureScreenSuccessCallback | SetUserCaptureScreenFailCallback | null
complete ?: SetUserCaptureScreenCompleteCallback
}
type SetUserCaptureScreen = (options : SetUserCaptureScreenOptions) => void
}
declare interface Uni {
/**
* 开启截屏监听
*
* @param {UserCaptureScreenCallback} callback
* @tutorial https://uniapp.dcloud.net.cn/api/system/capture-screen.html#onusercapturescreen
* @uniPlatform {
* "app": {
* "android": {
* "osVer": "4.4.4",
* "uniVer": "3.7.7",
* "unixVer": "3.9.0"
* },
* "ios": {
* "osVer": "9.0",
* "uniVer": "3.7.7",
* "unixVer": "x"
* }
* }
* }
* @uniVersion 3.7.7
* @uniVueVersion 2,3 //支持的vue版本
* @autotest { expectCallback: true }
*/
onUserCaptureScreen(callback : UniNamespace.UserCaptureScreenCallback | null) : void,
/**
* 关闭截屏监听
*
* @param {UserCaptureScreenCallback} callback
* @tutorial https://uniapp.dcloud.net.cn/api/system/capture-screen.html#offusercapturescreen
* @uniPlatform {
* "app": {
* "android": {
* "osVer": "4.4.4",
* "uniVer": "3.7.7",
* "unixVer": "3.9.0"
* },
* "ios": {
* "osVer": "9.0",
* "uniVer": "3.7.7",
* "unixVer": "x"
* }
* }
* }
* @uniVersion 3.7.7
* @uniVueVersion 2,3 //支持的vue版本
* @autotest { expectCallback: true }
*/
offUserCaptureScreen(callback : UniNamespace.UserCaptureScreenCallback | null) : void,
/**
* 设置防截屏
*
* @param {SetUserCaptureScreenOptions} options
* @tutorial https://uniapp.dcloud.net.cn/api/system/capture-screen.html#setusercapturescreen
* @uniPlatform {
* "app": {
* "android": {
* "osVer": "4.4.4",
* "uniVer": "3.7.7",
* "unixVer": "3.9.0"
* },
* "ios": {
* "osVer": "13.0",
* "uniVer": "3.7.7",
* "unixVer": "x"
* }
* }
* }
* @uniVersion 3.7.7
* @uniVueVersion 2,3 //支持的vue版本
*/
setUserCaptureScreen(options : UniNamespace.SetUserCaptureScreenOptions) : void
}

View File

@@ -0,0 +1,97 @@
{
"id": "uni-usercapturescreen",
"displayName": "uni-usercapturescreen",
"version": "1.0.7",
"description": "用户主动截屏事件监听",
"keywords": [
"截屏"
],
"repository": "",
"engines": {
"HBuilderX": "^3.7.7"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"uni-ext-api":{
"uni": {
"onUserCaptureScreen": {
"web": false
},
"offUserCaptureScreen": {
"web": false
},
"setUserCaptureScreen": {
"web": false,
"mp-weixin": false
}
}
},
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-android": "y",
"app-ios": "y",
"app-harmony": "y"
},
"H5-mobile": {
"Safari": "n",
"Android Browser": "n",
"微信浏览器(Android)": "n",
"QQ浏览器(Android)": "n"
},
"H5-pc": {
"Chrome": "n",
"IE": "n",
"Edge": "n",
"Firefox": "n",
"Safari": "n"
},
"小程序": {
"微信": "y",
"阿里": "n",
"百度": "n",
"字节跳动": "n",
"QQ": "n",
"钉钉": "n",
"快手": "n",
"飞书": "n",
"京东": "n"
},
"快应用": {
"华为": "n",
"联盟": "n"
}
}
}
}
}

View File

@@ -0,0 +1,21 @@
# uni-usercapturescreen
用户主动截屏事件监听
### uni.onUserCaptureScreen
监听用户主动截屏事件,用户使用系统截屏按键截屏时触发此事件。
> 使用文档:[https://uniapp.dcloud.net.cn/api/system/capture-screen.html#onusercapturescreen](https://uniapp.dcloud.net.cn/api/system/capture-screen.html#onusercapturescreen)
### uni.offUserCaptureScreen
用户主动截屏事件。取消事件监听。
> 使用文档:[https://uniapp.dcloud.net.cn/api/system/capture-screen.html#offusercapturescreen](https://uniapp.dcloud.net.cn/api/system/capture-screen.html#offusercapturescreen)
### uni.setUserCaptureScreen
开启/关闭防截屏。
> 使用文档:[https://uniapp.dcloud.net.cn/api/system/capture-screen.html#setusercapturescreen](https://uniapp.dcloud.net.cn/api/system/capture-screen.html#setusercapturescreen)

View File

@@ -0,0 +1,139 @@
import { UTSAndroid } from "io.dcloud.uts";
import ActivityCompat from "androidx.core.app.ActivityCompat";
import Manifest from "android.Manifest";
import PackageManager from "android.content.pm.PackageManager";
import Build from "android.os.Build";
import FileObserver from "android.os.FileObserver";
import File from "java.io.File";
import Environment from "android.os.Environment";
import System from 'java.lang.System';
import WindowManager from 'android.view.WindowManager';
import { OnUserCaptureScreenCallbackResult, UserCaptureScreenCallback, OnUserCaptureScreen, OffUserCaptureScreen, SetUserCaptureScreenSuccess, SetUserCaptureScreenOptions, SetUserCaptureScreen } from "../interface.uts";
import string from 'android.R.string';
/**
* 文件监听器
*/
let observer : ScreenFileObserver | null = null;
/**
* 记录文件监听器上次监听的时间戳,避免重复监听
*/
let lastObserverTime : number = 0;
/**
* 截屏回调
*/
let listener : UserCaptureScreenCallback | null = null;
/**
* android 文件监听实现
*/
class ScreenFileObserver extends FileObserver {
/**
* 截屏文件目录
*/
private screenFile : File;
constructor(screenFileStr : string) {
super(screenFileStr);
this.screenFile = new File(screenFileStr);
}
override onEvent(event : Int, path : string | null) : void {
// 只监听文件新增事件
if (event == FileObserver.CREATE) {
if (path != null) {
const currentTime = System.currentTimeMillis();
if ((currentTime - lastObserverTime) < 1000) {
// 本地截屏行为比上一次超过1000ms, 才认为是一个有效的时间
return;
}
lastObserverTime = currentTime;
const screenShotPath = new File(this.screenFile, path).getPath();
const res : OnUserCaptureScreenCallbackResult = {
path: screenShotPath
}
listener?.(res);
}
}
}
}
/**
* 开启截图监听
*/
export const onUserCaptureScreen : OnUserCaptureScreen = function (callback : UserCaptureScreenCallback | null) {
// 检查相关权限是否已授予
if (ActivityCompat.checkSelfPermission(UTSAndroid.getAppContext()!, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
// 无权限,申请权限
ActivityCompat.requestPermissions(UTSAndroid.getUniActivity()!, arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), 1001);
return;
}
// 更新监听
listener = callback;
let directory_screenshot : File;
if (Build.MANUFACTURER.toLowerCase() == "xiaomi") {
// @Suppress("DEPRECATION")
directory_screenshot = new File(new File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DCIM), "Screenshots");
} else {
// @Suppress("DEPRECATION")
directory_screenshot = new File(new File(Environment.getExternalStorageDirectory(), Environment.DIRECTORY_PICTURES), "Screenshots");
}
// 先结束监听 再开启监听
observer?.stopWatching();
observer = new ScreenFileObserver(directory_screenshot.getPath());
observer?.startWatching();
UTSAndroid.onAppActivityDestroy(function(){
observer?.stopWatching()
observer = null
})
}
/**
* 关闭截屏监听
*/
export const offUserCaptureScreen : OffUserCaptureScreen = function (_ : UserCaptureScreenCallback | null) {
// android10以上关闭监听通过移除文件监听器实现
observer?.stopWatching();
observer = null;
lastObserverTime = 0;
}
/**
* 设置是否禁止截屏
*/
export const setUserCaptureScreen : SetUserCaptureScreen = function (option : SetUserCaptureScreenOptions) {
// 切换到UI线程
UTSAndroid.getUniActivity()?.runOnUiThread(new SetUserCaptureScreenRunnable(option.enable));
const res : SetUserCaptureScreenSuccess = {}
option.success?.(res);
option.complete?.(res);
}
class SetUserCaptureScreenRunnable extends Runnable {
/**
* ture: 允许用户截屏
* false: 不允许用户截屏,防止用户截屏到应用页面内容
*/
private enable : boolean;
constructor(enable : boolean) {
super();
this.enable = enable;
}
override run() : void {
if (this.enable) {
UTSAndroid.getUniActivity()?.getWindow()?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE);
} else {
UTSAndroid.getUniActivity()?.getWindow()?.addFlags(WindowManager.LayoutParams.FLAG_SECURE);
}
}
}

View File

@@ -0,0 +1,87 @@
import { display } from '@kit.ArkUI';
import { window } from '@kit.ArkUI';
import { Callback, BusinessError } from '@kit.BasicServicesKit';
import { getAbilityContext } from '@dcloudio/uni-runtime';
import {
OnUserCaptureScreen, UserCaptureScreenCallback,
OffUserCaptureScreen,
SetUserCaptureScreen, SetUserCaptureScreenOptions, SetUserCaptureScreenSuccess
} from '../interface.uts';
const onUserCaptureScreenCallbacks: Function[] = []
const harmonyCaptureStatusChange: Callback<boolean> = (captureStatus: boolean) => {
if (captureStatus) {
onUserCaptureScreenCallbacks.forEach(cb => {
typeof cb === 'function' && cb()
})
}
}
display.on('captureStatusChange', harmonyCaptureStatusChange)
export const onUserCaptureScreen: OnUserCaptureScreen = function (callback: UserCaptureScreenCallback | null) {
if (callback) {
onUserCaptureScreenCallbacks.push(callback)
}
}
export const offUserCaptureScreen: OffUserCaptureScreen = function (callback: UserCaptureScreenCallback | null) {
if (callback) {
const index = onUserCaptureScreenCallbacks.indexOf(callback)
if (index > -1) {
onUserCaptureScreenCallbacks.splice(index, 1)
}
}
}
export const setUserCaptureScreen: SetUserCaptureScreen = function (options: SetUserCaptureScreenOptions) {
const errSubject = 'uni-usercapturescreen'
const setUserCaptureScreenSuccess: SetUserCaptureScreenSuccess = {}
window.getLastWindow(getAbilityContext()!, (err, window) => {
const errCode: number = err.code;
if (errCode) {
options.fail?.({
errCode: (err as BusinessError).code,
errSubject,
errMsg: `setUserCaptureScreen:fail ${(err as BusinessError).message}`
} as IUniError)
options.complete?.(setUserCaptureScreenSuccess);
return;
} else {
try {
UTSHarmony.requestSystemPermission(['ohos.permission.PRIVACY_WINDOW'], (allRight: boolean, _grantedList: string[]) => {
if (allRight) {
window.setWindowPrivacyMode(!options.enable, (err: BusinessError) => {
const errCode: number = err.code;
if (errCode) {
options.fail?.({
errCode: err.code,
errSubject,
errMsg: `setUserCaptureScreen:fail ${err.message}`
} as IUniError)
options.complete?.(setUserCaptureScreenSuccess);
return;
}
options.success?.(setUserCaptureScreenSuccess);
options.complete?.(setUserCaptureScreenSuccess);
});
} else {
throw new Error('permission denied')
}
}, (_doNotAskAgain: boolean, _grantedList: string[]) => {
throw new Error('permission denied');
})
} catch (err) {
options.fail?.({
errCode: (err as BusinessError).code,
errSubject,
errMsg: `setUserCaptureScreen:fail ${(err as BusinessError).message}`
} as IUniError)
options.complete?.(setUserCaptureScreenSuccess);
}
}
})
}

View File

@@ -0,0 +1,324 @@
import { NotificationCenter } from 'Foundation';
import { CGRect } from "CoreFoundation";
import { UIApplication, UIView, UITextField, UIScreen, UIDevice } from "UIKit"
import { UTSiOS } from "DCloudUTSFoundation"
import { DispatchQueue } from 'Dispatch';
import { SetUserCaptureScreenOptions, OnUserCaptureScreenCallbackResult, OnUserCaptureScreen, OffUserCaptureScreen, SetUserCaptureScreen, UserCaptureScreenCallback, SetUserCaptureScreenSuccess } from "../interface.uts"
import { SetUserCaptureScreenFailImpl } from "../unierror.uts"
/**
* 定义监听截屏事件工具类
*/
class CaptureScreenTool {
static listener : UserCaptureScreenCallback | null;
static secureView : UIView | null;
// ===== 仅防录屏(动态切换)相关状态 =====
// 保留 UITextField使 isSecureTextEntry 可在录屏开始/结束时反复切换
static secureField : UITextField | null;
// field 的安全画布(已挂入视图层级,承载 App 根视图)
static secureCanvas : UIView | null;
// 被安全画布包裹的 App 根视图(用于卸载时还原)
static secureRootView : UIView | null;
// 是否已为"仅防录屏"挂载安全层
static recordWrapped : boolean = false;
// 监听截屏
static listenCaptureScreen(callback : UserCaptureScreenCallback | null) {
this.listener = callback
// 注册监听截屏事件及回调方法
// target-action 回调方法需要通过 Selector("方法名") 构建
const method = Selector("userDidTakeScreenshot")
NotificationCenter.default.addObserver(this, selector = method, name = UIApplication.userDidTakeScreenshotNotification, object = null)
}
// 捕获截屏回调的方法
// target-action 的方法前需要添加 @objc 前缀
@objc static userDidTakeScreenshot() {
// 回调
const res: OnUserCaptureScreenCallbackResult = {
}
this.listener?.(res)
}
// 移除监听事件
static removeListen(callback : UserCaptureScreenCallback | null) {
this.listener = null
NotificationCenter.default.removeObserver(this)
}
static createSecureView() : UIView | null {
let field = new UITextField(frame = CGRect.zero)
field.isSecureTextEntry = true
if (field.subviews.length > 0 && UIDevice.current.systemVersion != '15.1') {
let view = field.subviews[0]
view.subviews.forEach((item) => {
item.removeFromSuperview()
})
view.isUserInteractionEnabled = true
return view
}
return null
}
// 应用防截屏遮罩secureView实时显示正常但被截屏/录屏捕获到的内容为黑屏)
// 不触发回调,供内部复用;注意:需在主线程调用
static applySecureViewInternal() {
if (this.secureView != null) {
// 已应用,避免重复包裹
return
}
let secureView = this.createSecureView()
let window = UTSiOS.getKeyWindow()
let rootView = window.rootViewController == null ? null : window.rootViewController!.view
if (secureView != null && rootView != null) {
let rootSuperview = rootView!.superview
if (rootSuperview != null) {
this.secureView = secureView
rootSuperview!.addSubview(secureView!)
rootView!.removeFromSuperview()
secureView!.addSubview(rootView!)
// secureView 充满父视图并随父视图宽高自适应(横竖屏、全屏切换时自动跟随)
secureView!.frame = rootSuperview!.bounds
secureView!.autoresizingMask = [UIView.AutoresizingMask.flexibleWidth, UIView.AutoresizingMask.flexibleHeight]
// rootView 充满 secureView 并自适应,避免全屏时被固定在竖屏尺寸而缩到角落
rootView!.frame = secureView!.bounds
rootView!.autoresizingMask = [UIView.AutoresizingMask.flexibleWidth, UIView.AutoresizingMask.flexibleHeight]
}
}
}
// 开启防截屏
static onAntiScreenshot(option : SetUserCaptureScreenOptions) {
// uts方法默认会在子线程中执行涉及 UI 操作必须在主线程中运行,通过 DispatchQueue.main.async 方法可将代码在主线程中运行
DispatchQueue.main.async(execute = () : void => {
this.applySecureViewInternal()
let res: SetUserCaptureScreenSuccess = {
}
option.success?.(res)
option.complete?.(res)
})
}
// ===== 仅防录屏:提前挂载安全层 + 翻转 isSecureTextEntry =====
// 提前挂载安全层(应在录屏开始前调用,例如进入页面时)。
// 挂载后默认不保护isSecureTextEntry = false→ 截屏正常、实时正常。
// 注意:需在主线程调用。
static setupRecordSecureWrapper() {
if (this.recordWrapped) {
return
}
let field = new UITextField(frame = CGRect.zero)
field.isSecureTextEntry = true
// 15.1 系统 secureView 不可用;拿不到安全画布则放弃
if (field.subviews.length == 0 || UIDevice.current.systemVersion == '15.1') {
return
}
let canvas = field.subviews[0]
canvas.subviews.forEach((item) => {
item.removeFromSuperview()
})
canvas.isUserInteractionEnabled = true
let window = UTSiOS.getKeyWindow()
let rootView = window.rootViewController == null ? null : window.rootViewController!.view
if (rootView == null) {
return
}
let rootSuperview = rootView!.superview
if (rootSuperview == null) {
return
}
// 把安全画布插入原层级,并把 App 根视图移入画布
rootSuperview!.addSubview(canvas)
rootView!.removeFromSuperview()
canvas.addSubview(rootView!)
canvas.frame = rootSuperview!.bounds
canvas.autoresizingMask = [UIView.AutoresizingMask.flexibleWidth, UIView.AutoresizingMask.flexibleHeight]
rootView!.frame = canvas.bounds
rootView!.autoresizingMask = [UIView.AutoresizingMask.flexibleWidth, UIView.AutoresizingMask.flexibleHeight]
// 关键:保留 field后续才能切换 isSecureTextEntry
this.secureField = field
this.secureCanvas = canvas
this.secureRootView = rootView
this.recordWrapped = true
// 默认不保护:允许截屏、实时正常
field.isSecureTextEntry = false
}
// 切换录屏保护true=保护录屏文件黑、实时正常、此刻截屏也黑false=不保护(允许截屏)
// 注意:需在主线程调用。
static setRecordSecure(secure : boolean) {
if (this.secureField != null) {
this.secureField!.isSecureTextEntry = secure
}
}
// 卸载安全层,恢复原始视图层级。注意:需在主线程调用。
static teardownRecordSecureWrapper() {
if (!this.recordWrapped) {
return
}
if (this.secureField != null) {
this.secureField!.isSecureTextEntry = false
}
if (this.secureCanvas != null && this.secureRootView != null) {
let rootSuperview = this.secureCanvas!.superview
if (rootSuperview != null) {
rootSuperview!.addSubview(this.secureRootView!)
this.secureRootView!.frame = rootSuperview!.bounds
this.secureCanvas!.removeFromSuperview()
}
}
this.secureField = null
this.secureCanvas = null
this.secureRootView = null
this.recordWrapped = false
}
// 移除防截屏遮罩视图(不触发回调,供内部复用)
// 注意:需在主线程调用
static removeSecureViewInternal() {
if (this.secureView != null) {
let window = UTSiOS.getKeyWindow()
let rootView = window.rootViewController == null ? null : window.rootViewController!.view
if (rootView != null && this.secureView!.superview != null) {
let rootSuperview = this.secureView!.superview
if (rootSuperview != null) {
rootSuperview!.addSubview(rootView!)
this.secureView!.removeFromSuperview()
}
}
this.secureView = null
}
}
// 关闭防截屏
static offAntiScreenshot(option : SetUserCaptureScreenOptions) {
DispatchQueue.main.async(execute = () : void => {
this.removeSecureViewInternal()
let res: SetUserCaptureScreenSuccess = {
}
option.success?.(res)
option.complete?.(res)
})
}
}
/**
* 防录屏工具类:基于 UIScreen.isCaptured 监听录屏/投屏状态,动态切换安全层。
* 截屏不会触发 isCaptured因此可做到"允许截屏、仅在录屏时保护"。
*
* 实现要点(避免实时画面变黑):
* 1) 进入时(录屏开始前)就提前挂好安全层,且默认不保护 → 截屏正常、实时正常;
* 2) 仅在录屏期间翻转 isSecureTextEntry = true → 录屏文件黑、实时正常、此刻截屏也黑;
* 3) 录屏结束翻回 false → 截屏恢复正常。
*/
class ScreenRecordTool {
// 是否已注册监听
static monitoring : boolean = false;
// 开启防录屏:提前挂载安全层并监听录屏状态变化
static onAntiScreenRecord() {
DispatchQueue.main.async(execute = () : void => {
// 关键:录屏开始前就把安全层挂好(默认不保护)
CaptureScreenTool.setupRecordSecureWrapper()
if (!this.monitoring) {
this.monitoring = true
const method = Selector("screenCaptureDidChange")
NotificationCenter.default.addObserver(this, selector = method, name = UIScreen.capturedDidChangeNotification, object = null)
}
// 进入时若已经在录屏,立即按当前状态保护
this.applyIfNeeded()
})
}
// 录屏状态变化回调
@objc static screenCaptureDidChange() {
DispatchQueue.main.async(execute = () : void => {
this.applyIfNeeded()
})
}
// 根据当前录屏状态翻转安全层开关
static applyIfNeeded() {
CaptureScreenTool.setRecordSecure(UIScreen.main.isCaptured)
}
// 关闭防录屏:移除监听并卸载安全层
static offAntiScreenRecord() {
DispatchQueue.main.async(execute = () : void => {
if (this.monitoring) {
NotificationCenter.default.removeObserver(this, name = UIScreen.capturedDidChangeNotification, object = null)
this.monitoring = false
}
CaptureScreenTool.teardownRecordSecureWrapper()
})
}
}
/**
* 开启截图监听
*/
export const onUserCaptureScreen : OnUserCaptureScreen = function (callback : UserCaptureScreenCallback | null) {
CaptureScreenTool.listenCaptureScreen(callback)
}
/**
* 关闭截屏监听
*/
export const offUserCaptureScreen : OffUserCaptureScreen = function (callback : UserCaptureScreenCallback | null) {
CaptureScreenTool.removeListen(callback)
}
/**
* 开启/关闭防截屏
*/
export const setUserCaptureScreen : SetUserCaptureScreen = function (options : SetUserCaptureScreenOptions) {
const systemVersion = UIDevice.current.systemVersion
if (options.enable == true) {
// 允许截屏与录屏:无条件清理录屏遮罩,再移除防截屏遮罩。
// 清理动作不受版本限制,避免遗留监听/遮罩。
ScreenRecordTool.offAntiScreenRecord()
CaptureScreenTool.offAntiScreenshot(options)
return
}
if (options.antiRecordOnly == true) {
// 仅禁止录屏、允许截屏:进入页面时提前挂好安全层(默认不保护),
// 用 UIScreen.isCapturediOS 11+)监听录屏/投屏,仅在录屏期间翻转 isSecureTextEntry
// —— 实时显示正常、录到的画面为黑屏,且平时不影响截屏。
// 注意secureView 在 iOS 15.1 上不可用,该版本录屏保护会失效(属系统已知问题)。
if (systemVersion < "11.0") {
let res = new SetUserCaptureScreenFailImpl(12001)
options.fail?.(res)
options.complete?.(res)
return
}
ScreenRecordTool.onAntiScreenRecord()
let res: SetUserCaptureScreenSuccess = {
}
options.success?.(res)
options.complete?.(res)
return
}
// 默认截屏与录屏一起禁止secureView 方案,受系统版本限制)
if (systemVersion < "13.0") {
let res = new SetUserCaptureScreenFailImpl(12001)
options.fail?.(res)
options.complete?.(res)
} else if (systemVersion == "15.1") {
let res = new SetUserCaptureScreenFailImpl(12010)
options.fail?.(res)
options.complete?.(res)
} else {
CaptureScreenTool.onAntiScreenshot(options)
}
}

View File

@@ -0,0 +1,176 @@
/**
* uni.onUserCaptureScreen/uni.offUserCaptureScreen回调参数
*/
export type OnUserCaptureScreenCallbackResult = {
/**
* 截屏文件路径仅Android返回
*/
path ?: string
}
/**
* uni.onUserCaptureScreen/uni.offUserCaptureScreen回调函数定义
*/
export type UserCaptureScreenCallback = (res : OnUserCaptureScreenCallbackResult) => void
export type OnUserCaptureScreen = (callback : UserCaptureScreenCallback | null) => void
export type OffUserCaptureScreen = (callback : UserCaptureScreenCallback | null) => void
/**
* uni.setUserCaptureScreen成功回调参数
*/
export type SetUserCaptureScreenSuccess = {
}
/**
* uni.setUserCaptureScreen成功回调函数定义
*/
export type SetUserCaptureScreenSuccessCallback = (res : SetUserCaptureScreenSuccess) => void
/**
* uni.setUserCaptureScreen失败回调函数定义
*/
export type SetUserCaptureScreenFailCallback = (res : IUniError) => void
/**
* uni.setUserCaptureScreen完成回调函数定义
*/
export type SetUserCaptureScreenCompleteCallback = (res : any) => void
/**
* uni.setUserCaptureScreen参数
*/
export type SetUserCaptureScreenOptions = {
/**
* true: 允许用户截屏 false: 不允许用户截屏,防止用户截屏到应用页面内容
*/
enable : boolean;
/**
* 仅 iOS 生效。仅在 enable 为 false 时有意义:
* true: 仅禁止录屏/投屏(录屏时画面被遮挡),允许截屏;
* false/不传: 截屏与录屏一起禁止(默认行为)。
*/
antiRecordOnly ?: boolean;
/**
* 接口调用成功的回调函数
*/
// success : SetUserCaptureScreenSuccessCallback | null,
success ?: SetUserCaptureScreenSuccessCallback,
/**
* 接口调用失败的回调函数
*/
// fail : SetUserCaptureScreenFailCallback | null,
fail ?: SetUserCaptureScreenFailCallback,
/**
* 接口调用结束的回调函数(调用成功、失败都会执行)
*/
// complete : SetUserCaptureScreenSuccessCallback | SetUserCaptureScreenFailCallback | null
complete ?: SetUserCaptureScreenCompleteCallback
}
/**
* 错误码
* - 12001 "setUserCaptureScreen:system not support"
* - 12010 "setUserCaptureScreen:system internal error"
*/
export type SetUserCaptureScreenErrorCode = 12001 | 12010;
/**
* SetUserCaptureScreen 的错误回调参数
*/
export interface SetUserCaptureScreenFail extends IUniError {
errCode : SetUserCaptureScreenErrorCode
};
export type SetUserCaptureScreen = (options : SetUserCaptureScreenOptions) => void
export interface Uni {
/**
* 开启截屏监听
*
* @param {UserCaptureScreenCallback} callback
* @tutorial https://uniapp.dcloud.net.cn/api/system/capture-screen.html#onusercapturescreen
* @uniPlatform {
* "app": {
* "android": {
* "osVer": "4.4.4",
* "uniVer": "3.7.7",
* "unixVer": "3.9.0"
* },
* "ios": {
* "osVer": "12.0",
* "uniVer": "3.7.7",
* "unixVer": "4.11"
* },
* "harmony": {
* "osVer": "3.0",
* "uniVer": "4.25",
* "unixVer": "x"
* }
* }
* }
* @uniVersion 3.7.7
* @uniVueVersion 2,3 //支持的vue版本
* @autotest { expectCallback: true }
*/
onUserCaptureScreen(callback : UserCaptureScreenCallback | null) : void,
/**
* 关闭截屏监听
*
* @param {UserCaptureScreenCallback} callback
* @tutorial https://uniapp.dcloud.net.cn/api/system/capture-screen.html#offusercapturescreen
* @uniPlatform {
* "app": {
* "android": {
* "osVer": "4.4.4",
* "uniVer": "3.7.7",
* "unixVer": "3.9.0"
* },
* "ios": {
* "osVer": "12.0",
* "uniVer": "3.7.7",
* "unixVer": "4.11"
* },
* "harmony": {
* "osVer": "3.0",
* "uniVer": "4.25",,
* "unixVer": "x"
* }
* }
* }
* @uniVersion 3.7.7
* @uniVueVersion 2,3 //支持的vue版本
* @autotest { expectCallback: true }
*/
offUserCaptureScreen(callback : UserCaptureScreenCallback | null) : void,
/**
* 设置防截屏
*
* @param {SetUserCaptureScreenOptions} options
* @tutorial https://uniapp.dcloud.net.cn/api/system/capture-screen.html#setusercapturescreen
* @uniPlatform {
* "app": {
* "android": {
* "osVer": "4.4.4",
* "uniVer": "3.7.7",
* "unixVer": "3.9.0"
* },
* "ios": {
* "osVer": "13.0",
* "uniVer": "3.7.7",
* "unixVer": "4.11"
* },
* "harmony": {
* "osVer": "3.0",
* "uniVer": "4.25",
* "unixVer": "x"
* }
* }
* }
* @uniVersion 3.7.7
* @uniVueVersion 2,3 //支持的vue版本
*/
setUserCaptureScreen(options : SetUserCaptureScreenOptions) : void
}

View File

@@ -0,0 +1,7 @@
export function onUserCaptureScreen (callback) {
return wx.onUserCaptureScreen(callback)
}
export function offUserCaptureScreen (callback) {
return wx.offUserCaptureScreen(callback)
}

View File

@@ -0,0 +1,35 @@
import { SetUserCaptureScreenErrorCode, SetUserCaptureScreenFail } from "./interface.uts"
/**
* 错误主题
*/
export const UniErrorSubject = 'uni-usercapturescreen';
/**
* 错误信息
* @UniError
*/
export const UniErrors : Map<SetUserCaptureScreenErrorCode, string> = new Map([
/**
* 错误码及对应的错误信息
*/
[12001, 'setUserCaptureScreen:system not support'],
[12010, 'setUserCaptureScreen:system internal error'],
]);
/**
* 错误对象实现
*/
export class SetUserCaptureScreenFailImpl extends UniError implements SetUserCaptureScreenFail {
/**
* 错误对象构造函数
*/
constructor(errCode : SetUserCaptureScreenErrorCode) {
super();
this.errSubject = UniErrorSubject;
this.errCode = errCode;
this.errMsg = UniErrors[errCode] ?? "";
}
}

View File

@@ -0,0 +1,29 @@
export type CaptureScreenSuccess = {
/**
* 截图结果,格式为 data URLdata:image/jpeg;base64,xxxx
*/
base64 : string
}
export type CaptureScreenSuccessCallback = (res : CaptureScreenSuccess) => void
export type CaptureScreenFail = {
errCode : number,
errMsg : string
}
export type CaptureScreenFailCallback = (res : CaptureScreenFail) => void
export type CaptureScreenCompleteCallback = (res : any) => void
export type CaptureScreenOptions = {
success ?: CaptureScreenSuccessCallback,
fail ?: CaptureScreenFailCallback,
complete ?: CaptureScreenCompleteCallback
}
/**
* 截取当前应用窗口画面(含视频等硬件加速渲染内容),通过 base64 返回。
* 仅 App-Android 生效,基于 Android PixelCopy 实现。
*/
export declare const captureScreen : (options : CaptureScreenOptions) => void

View File

@@ -0,0 +1,85 @@
{
"id": "yb-screen-capture",
"displayName": "yb-screen-capture",
"version": "1.0.0",
"description": "截取当前应用窗口(含硬件加速渲染的视频画面),返回 base64",
"keywords": [
"截图",
"截屏",
"PixelCopy"
],
"repository": "",
"engines": {
"HBuilderX": "^3.7.7"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-android": "y",
"app-ios": "n",
"app-harmony": "n"
},
"H5-mobile": {
"Safari": "n",
"Android Browser": "n",
"微信浏览器(Android)": "n",
"QQ浏览器(Android)": "n"
},
"H5-pc": {
"Chrome": "n",
"IE": "n",
"Edge": "n",
"Firefox": "n",
"Safari": "n"
},
"小程序": {
"微信": "n",
"阿里": "n",
"百度": "n",
"字节跳动": "n",
"QQ": "n",
"钉钉": "n",
"快手": "n",
"飞书": "n",
"京东": "n"
},
"快应用": {
"华为": "n",
"联盟": "n"
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
# yb-screen-capture
截取当前应用窗口画面(含视频等硬件加速渲染内容),通过 base64 返回。
-**App-Android** 生效,基于 Android `PixelCopy` 实现Android 8.0 及以上能正确截取视频画面;低于 8.0 降级为 View 截图,视频区域可能为黑屏)。
- 截图范围为「当前屏幕可见区域」,不是长页面截图。
## 使用
```js
uni.captureScreen({
success: (res) => {
// res.base64 形如data:image/jpeg;base64,xxxx
console.log(res.base64)
},
fail: (err) => {
console.error(err.errCode, err.errMsg)
}
})
```
> 注意UTS 插件需要打自定义基座(标准基座无法运行)才能调试/运行。

View File

@@ -0,0 +1,127 @@
import { UTSAndroid } from "io.dcloud.uts";
import PixelCopy from "android.view.PixelCopy";
import OnPixelCopyFinishedListener from "android.view.PixelCopy.OnPixelCopyFinishedListener";
import Bitmap from "android.graphics.Bitmap";
import Canvas from "android.graphics.Canvas";
import Handler from "android.os.Handler";
import Looper from "android.os.Looper";
import Build from "android.os.Build";
import ByteArrayOutputStream from "java.io.ByteArrayOutputStream";
import Base64 from "android.util.Base64";
import {
CaptureScreen,
CaptureScreenOptions,
CaptureScreenSuccess,
CaptureScreenFail
} from "../interface.uts";
/**
* 将 Bitmap 压缩为 JPEG 并编码为 data URL 形式的 base64
*/
function encodeBitmapToBase64(bitmap : Bitmap) : string {
const baos = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos);
const bytes = baos.toByteArray();
const base64 = Base64.encodeToString(bytes, Base64.NO_WRAP);
bitmap.recycle();
return "data:image/jpeg;base64," + base64;
}
/**
* PixelCopy 完成监听器
*/
class PixelCopyListener extends OnPixelCopyFinishedListener {
private bitmap : Bitmap;
private options : CaptureScreenOptions;
constructor(bitmap : Bitmap, options : CaptureScreenOptions) {
super();
this.bitmap = bitmap;
this.options = options;
}
override onPixelCopyFinished(copyResult : Int) : void {
if (copyResult == PixelCopy.SUCCESS) {
const base64 = encodeBitmapToBase64(this.bitmap);
const res : CaptureScreenSuccess = { base64: base64 };
this.options.success?.(res);
this.options.complete?.(res);
} else {
this.bitmap.recycle();
const res : CaptureScreenFail = {
errCode: 12010,
errMsg: "captureScreen:fail PixelCopy copyResult=" + copyResult
};
this.options.fail?.(res);
this.options.complete?.(res);
}
}
}
/**
* 在 UI 线程执行实际截图
*/
class CaptureRunnable extends Runnable {
private options : CaptureScreenOptions;
constructor(options : CaptureScreenOptions) {
super();
this.options = options;
}
override run() : void {
try {
const activity = UTSAndroid.getUniActivity();
if (activity == null) {
const res : CaptureScreenFail = { errCode: 12010, errMsg: "captureScreen:fail activity is null" };
this.options.fail?.(res);
this.options.complete?.(res);
return;
}
// 注意UTS 中 getWindow() 返回可空类型,必须用 ! 断言,否则编译失败
const window = activity!.getWindow()!;
const decorView = window.getDecorView()!;
const width = decorView.getWidth();
const height = decorView.getHeight();
if (width <= 0 || height <= 0) {
const res : CaptureScreenFail = { errCode: 12010, errMsg: "captureScreen:fail invalid window size" };
this.options.fail?.(res);
this.options.complete?.(res);
return;
}
const bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Android 8.0+:使用 PixelCopy 可以正确截取 SurfaceView/视频等硬件加速画面
const handler = new Handler(Looper.getMainLooper());
PixelCopy.request(window, bitmap, new PixelCopyListener(bitmap, this.options), handler);
} else {
// 低版本降级:仅能截取 View 层级内容,视频区域可能为黑屏
const canvas = new Canvas(bitmap);
decorView.draw(canvas);
const base64 = encodeBitmapToBase64(bitmap);
const res : CaptureScreenSuccess = { base64: base64 };
this.options.success?.(res);
this.options.complete?.(res);
}
} catch (e) {
const res : CaptureScreenFail = { errCode: 12010, errMsg: "captureScreen:fail " + e.toString() };
this.options.fail?.(res);
this.options.complete?.(res);
}
}
}
export const captureScreen : CaptureScreen = function (options : CaptureScreenOptions) {
const activity = UTSAndroid.getUniActivity();
if (activity == null) {
const res : CaptureScreenFail = { errCode: 12010, errMsg: "captureScreen:fail activity is null" };
options.fail?.(res);
options.complete?.(res);
return;
}
activity!.runOnUiThread(new CaptureRunnable(options));
}

View File

@@ -0,0 +1,73 @@
/**
* uni.captureScreen 成功回调参数
*/
export type CaptureScreenSuccess = {
/**
* 截图结果,格式为 data URLdata:image/jpeg;base64,xxxx
*/
base64 : string
}
/**
* uni.captureScreen 成功回调函数定义
*/
export type CaptureScreenSuccessCallback = (res : CaptureScreenSuccess) => void
/**
* uni.captureScreen 失败回调参数
*/
export type CaptureScreenFail = {
errCode : number,
errMsg : string
}
/**
* uni.captureScreen 失败回调函数定义
*/
export type CaptureScreenFailCallback = (res : CaptureScreenFail) => void
/**
* uni.captureScreen 完成回调函数定义(成功、失败都会执行)
*/
export type CaptureScreenCompleteCallback = (res : any) => void
/**
* uni.captureScreen 参数
*/
export type CaptureScreenOptions = {
/**
* 接口调用成功的回调函数
*/
success ?: CaptureScreenSuccessCallback,
/**
* 接口调用失败的回调函数
*/
fail ?: CaptureScreenFailCallback,
/**
* 接口调用结束的回调函数(调用成功、失败都会执行)
*/
complete ?: CaptureScreenCompleteCallback
}
export type CaptureScreen = (options : CaptureScreenOptions) => void
export interface Uni {
/**
* 截取当前应用窗口画面(含视频等硬件加速渲染内容),通过 base64 返回。
* 仅 App-Android 生效,基于 Android PixelCopy 实现。
*
* @param {CaptureScreenOptions} options
* @uniPlatform {
* "app": {
* "android": {
* "osVer": "8.0",
* "uniVer": "3.7.7",
* "unixVer": "3.9.0"
* }
* }
* }
* @uniVersion 3.7.7
* @uniVueVersion 2,3
*/
captureScreen(options : CaptureScreenOptions) : void
}