This commit is contained in:
feie9454 2025-09-30 09:18:31 +08:00
parent 15d5b70973
commit 998310cf2e
46 changed files with 20270 additions and 106 deletions

View File

@ -10,4 +10,8 @@ onHide(() => {
console.log("App Hide"); console.log("App Hide");
}); });
</script> </script>
<style></style> <style>
body{
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
</style>

View File

@ -44,7 +44,8 @@
"ios" : {}, "ios" : {},
/* SDK */ /* SDK */
"sdkConfigs" : {} "sdkConfigs" : {}
} },
"nativePlugins" : {}
}, },
/* */ /* */
"quickapp" : {}, "quickapp" : {},

View File

@ -42,6 +42,90 @@
"navigationBarTitleText": "我的" "navigationBarTitleText": "我的"
} }
} }
,
{
"path": "pages/fixed-assets/index",
"style": {
"navigationBarTitleText": "固定资产",
"enablePullDownRefresh": false
}
},
{
"path": "pages/fixed-assets/detail",
"style": {
"navigationBarTitleText": "资产详情"
}
},
{
"path": "pages/fixed-assets/maintenance-info",
"style": {
"navigationBarTitleText": "维修信息"
}
},
{
"path": "pages/fixed-assets/my-borrows",
"style": {
"navigationBarTitleText": "我的借用"
}
}
,
{
"path": "pages/fixed-assets/inventory-plan",
"style": {
"navigationBarTitleText": "资产盘点计划"
}
},
{
"path": "pages/fixed-assets/inventory-end",
"style": {
"navigationBarTitleText": "盘点计划(已结束)"
}
},
{
"path": "pages/fixed-assets/inventory-ing",
"style": {
"navigationBarTitleText": "盘点计划(进行中)"
}
}
,
{
"path": "pages/consumables/plan",
"style": {
"navigationBarTitleText": "易耗品领用计划"
}
},
{
"path": "pages/consumables/distribution",
"style": {
"navigationBarTitleText": "发放物资"
}
}
,
{
"path": "pages/consumables/inventory-plan",
"style": {
"navigationBarTitleText": "易耗品盘点计划"
}
},
{
"path": "pages/consumables/inventory-end",
"style": {
"navigationBarTitleText": "易耗品盘点(已结束)"
}
},
{
"path": "pages/consumables/inventory-ing",
"style": {
"navigationBarTitleText": "易耗品盘点(进行中)"
}
}
,
{
"path": "pages/fixed-assets/scan",
"style": {
"navigationBarTitleText": "扫码查看信息"
}
}
], ],
"globalStyle": { "globalStyle": {
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
@ -55,30 +139,31 @@
"backgroundColor": "#ffffff", "backgroundColor": "#ffffff",
"borderStyle": "black", "borderStyle": "black",
"height": "64px", "height": "64px",
"fontSize": "12px",
"list": [ "list": [
{ {
"pagePath": "pages/index/index", "pagePath": "pages/index/index",
"text": "首页", "text": "首页",
"iconPath": "static/icons/home.png", "iconPath": "static/icons/tabbar/home.png",
"selectedIconPath": "static/icons/home.png" "selectedIconPath": "static/icons/tabbar/home-selected.png"
}, },
{ {
"pagePath": "pages/message/index", "pagePath": "pages/message/index",
"text": "消息", "text": "消息",
"iconPath": "static/icons/messages.png", "iconPath": "static/icons/tabbar/messages.png",
"selectedIconPath": "static/icons/messages.png" "selectedIconPath": "static/icons/tabbar/messages-selected.png"
}, },
{ {
"pagePath": "pages/task/index", "pagePath": "pages/task/index",
"text": "任务", "text": "任务",
"iconPath": "static/icons/tasks.png", "iconPath": "static/icons/tabbar/tasks.png",
"selectedIconPath": "static/icons/tasks.png" "selectedIconPath": "static/icons/tabbar/tasks-selected.png"
}, },
{ {
"pagePath": "pages/mine/index", "pagePath": "pages/mine/index",
"text": "我的", "text": "我的",
"iconPath": "static/icons/profile.png", "iconPath": "static/icons/tabbar/profile.png",
"selectedIconPath": "static/icons/profile.png" "selectedIconPath": "static/icons/tabbar/profile-selected.png"
} }
] ]
} }

View File

@ -0,0 +1,712 @@
const BASE_URL = 'http://10.3.1.212:8089';//定义后端基础接口地址
import config from '../../config.js'; // 引入配置文件
// 根据手机号搜索用户列表
export function searchUserByMobile(mobile) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/assetInformation/getUserListByUserMobile`,
method: 'GET',
header: getHeaders(),
data: {
mobile: mobile
},
success: (res) => {
console.log(res);
if (res.statusCode === 200) {
resolve(res.data);
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//——————————————————————————————————————————————————————时间————————————————————————————————————————————————————
// 数字补零工具函数(辅助日期格式化)
const formatNumber = n => {
n = n.toString()// 1. 将数字转为字符串(如 5 → "5"12 → "12"
return n[1] ? n : '0' + n// 2. 补零逻辑:若字符串长度<2如 "5"),则在前面加"0" → "05";否则直接返回(如 "12"
}
//时间格式化函数(仅返回时分秒)
const formatTime = date => {
date = new Date(date);// 1. 将输入的日期参数(可能是时间戳/字符串)转为标准 Date 对象
const hour = date.getHours()// 2. 获取小时0-23
const minute = date.getMinutes()// 3. 获取分钟0-59
const second = date.getSeconds()// 4. 获取秒0-59
// 5. 拼接为 "时:分:秒" 格式(调用 formatNumber 补零),如 "09:05:03
return `${formatNumber(hour)}:${formatNumber(minute)}:${formatNumber(second)}`
}
//日期格式化函数(仅返回年月日)
const formatDate = date => {
date = new Date(date);// 1. 转为标准 Date 对象
const year = date.getFullYear()// 2. 获取完整年份(如 2024
const month = date.getMonth() + 1// 3. 获取月份注意Date 中月份是 0-11需+1 才是实际月份,如 2→3月
const day = date.getDate()// 4. 获取日期1-31
// 5. 拼接为 "年-月-日" 格式(补零),如 "2024-03-15"
return `${year}-${formatNumber(month)}-${formatNumber(day)}`
}
//完整日期时间格式化函数(年月日 + 时分秒)
const formatDateTime = date => {
return `${formatDate(date)} ${formatTime(date)}`
}
//——————————————————————————————————————————————————————————————————————————————————————————————————————————————————
//用户信息初始化与判断
let staff = uni.getStorageSync('staff'); // 1. 从 UniApp 本地缓存中读取 "staff"用户信息如ID、医院ID、手机号等
let staffisnull = (staff == null || staff == undefined || staff == '');// 2. 判断用户信息是否为空null/undefined/空字符串)
//统一请求头配置函数(核心!!!!!!!!!!!!!!!!!!!!所有接口共用)
export function getHeaders() {
const staff = uni.getStorageSync('staff') || null// 1. 再次读取用户信息(避免缓存过期,确保最新)
// console.log("用户信息:",staff)// 2. 控制台打印用户信息,用于开发调试
const staffisnull = !staff // 3. 简化判断:用户信息为空则为 true否则为 false
// 4. 返回请求头对象(接口请求时会携带这些信息,供后端验证)
return {
'content-type': 'application/json',// ① 声明请求体格式为 JSON后端需按 JSON 解析数据)
//'Authorization': staffisnull ? null : `Bearer ${uni.getStorageSync('token')}`,
'hosp-ID': staffisnull ? config.defaultHospId : staff.hosp_id,// ③ 医院ID用户未登录用配置文件的默认值登录后用用户所属医院ID
'user-ID': staffisnull ? null : staff.id,// ④ 用户ID用户未登录为 null登录后用用户ID后端用于识别请求用户
'datetime': formatDateTime(new Date()),// ⑤ 请求时间:当前完整日期时间(后端可用于校验请求时效性)
'createby': staffisnull ? null : staff.id// ⑥ 创建人ID与 user-ID 类似,用于后端记录操作人
}////核心作用:统一所有接口的请求头格式,避免每个接口重复写头信息,同时支持 “登录 / 未登录” 状态的动态适配。
}
//————————————————————————————————————————————————————固定资产相关————————————————————————————————————————————————————————
//分页请求固定资产数据
//作用:前端加载固定资产列表时调用(如 “资产列表页” 分页加载数据)。
// 所有接口函数均遵循 “返回 Promise + 封装 uni.request” 的模式,目的是:
// 统一接口调用方式(用 async/await 或 .then() 处理异步结果);
// 统一成功 / 失败判断逻辑(以 HTTP 状态码 200 为成功标准);
// 失败时通过 reject 抛出错误,便于前端捕获处理(如提示 “请求失败”)。
export function fetchFixedAssets(pageNum, pageSize) {
return new Promise((resolve, reject) => { // 返回 Promise支持异步调用
uni.request({ // UniApp 内置的网络请求方法
url: `${BASE_URL}/api/assetInformation/page?pageNum=${pageNum}&pageSize=${pageSize}`,// 接口路径:拼接基础地址+分页参数pageNum=页码pageSize=每页条数)
method: 'POST',// 请求方法POST
data: {// 请求体:空查询条件(后端可能支持按条件筛选,此处默认无筛选)
query:{},
},
header:getHeaders(),// 请求头:调用上面的 getHeaders() 获取统一头信息
success: (res) => { // 请求成功回调(后端有响应,无论业务成功与否)
if (res.statusCode === 200) {// 若 HTTP 状态码为 200表示网络请求成功
resolve(res.data); // 根据你的接口返回结构调整 // 将后端返回的业务数据传给前端(前端用 .then() 接收)
} else { // HTTP 状态码非 200如 404 接口不存在、500 后端报错)
reject(res); // 抛出错误(前端用 .catch() 捕获)
}
},
fail: (err) => {// 请求失败回调(如网络超时、断网)
reject(err);// 抛出错误
}
});
});
}
//按名称搜索固定资产
//分页请求固定资产数据
//作用:前端通过 “资产名称” 搜索特定固定资产时调用(如搜索 “电脑”“打印机”)
export function searchFixedAssets(name) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/assetInformation/list`,// 接口路径:资产搜索接口
method: 'POST',
data: {
assetName:name // 请求体传递搜索关键词assetName=资产名称)
},
header:getHeaders(),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//通过id获取设备信息
export function fetchAssetInfoById(id) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/assetInformation/list`, // 接口路径:获取单条资产信息
method: 'POST',
header:getHeaders(),
data:{
"assetSn":id // 请求体传递资产编号assetSn=资产唯一编号)
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//通过id获取设备维修信息
export function fetchMaintenanceInfoById(id) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/asset/maintenance/getByAssetId/${id}`, // 接口路径拼接资产IDRESTful 风格)
method: 'GET', // 请求方法GET此处用 GET 传递路径参数)
header:getHeaders(),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//借用资产
export function borrowAssetById(addDTO) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/asset/borrow/add`, // 接口路径:资产借用申请接口
method: 'POST',
header: getHeaders(),
data: { // 请求体:传递借用所需的所有参数(从前端表单收集的信息)
assetId: addDTO.assetId, // 资产ID
assetName: addDTO.assetName, // 资产名称
borrowDate: addDTO.borrowDate,// 借用日期(需前端传递格式化后的日期)
borrowerId: addDTO.borrowerId,// 借用人ID
borrowerMobile: addDTO.borrowerMobile, // 借用人手机号
lenderId: addDTO.lenderId,// 出借人ID
lenderMobile: addDTO.lenderMobile,// 出借人手机号
note: addDTO.note,// 备注(可选)
registrantDate: addDTO.registrantDate,// 登记日期
registrantId: addDTO.registrantId,// 登记人ID
returnDate: addDTO.returnDate// 预计归还日期
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data);
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//查询个人借用信息
//作用:前端 “个人借用记录页” 加载数据时调用(如显示当前用户借了哪些资产)。
export function borrowInfo(borrowerId) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/asset/borrow/list`,// 接口路径:个人借用记录列表
method: 'POST',
header: getHeaders(),
data: {
borrowerId:borrowerId // 请求体传递借用人ID查询该用户的所有借用记录
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data);
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//归还资产
//作用:前端点击 “归还” 按钮时调用(将资产状态从 “已借出” 更新为 “已归还”)。
export function returnAsset(id) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/asset/borrow/returnAsset?assetId=${id}`, // 接口路径拼接资产IDURL参数
method: 'PUT', // 请求方法PUT通常用于“更新资源状态”此处更新资产为“已归还”
header:getHeaders(),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//————————————————————————————————————————————————业务接口函数(易耗品相关)——————————————————————————————————————————
//易耗品领用
//作用:前端提交 “易耗品领用表单” 时调用(完成领用申请)。
export function getConsumption(addDTO) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/consumable-distribution-item/add`, //接口路径:易耗品领用提交
method: 'POST',
header:getHeaders(),
data:{ // 请求体易耗品领用所需参数数量、组别ID、备注等
actualQuantity:addDTO.actualQuantity, // 实际领用数量
groupId:addDTO.groupId,// 所属组别ID
notes:addDTO.notes,// 备注
planId:addDTO.planId,// 发放计划ID
recipientMobile:addDTO.recipientMobile,// 领取人手机号
skuId:addDTO.skuId// 易耗品SKU编号唯一标识
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//根据条码查询sku Id
export function getConsumptionSkuId(barcode) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/consumable/sku/list`,// 接口路径条码查询SKU
method: 'POST',
header:getHeaders(),
data:{
barcode:barcode // 请求体传递易耗品条码通过条码获取对应的SKU ID
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//查询商品库存
export function getConsumptionQuantity(goodsInfo) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/consumable-inventory/findByCondition`, // 接口路径:库存查询
method: 'POST',
header:getHeaders(),
data:{ // 请求体库存查询条件SKU ID、仓库ID、位置ID
skuId:goodsInfo.skuId,
locationId:goodsInfo.locationId,
warehouseId:goodsInfo.warehouseId
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//——————————————————————————————————————————易耗品领用计划??????????????????????????????
//获取组别信息
export function getGroupsInfo(mobile) { //参数 mobile 表示“组长手机号”
return new Promise((resolve, reject) => { // 返回 Promise 对象,支持异步调用(用 async/await 或 .then() 处理结果)
// 调用 UniApp 的网络请求 API
uni.request({
// 请求地址:拼接基础地址 + 易耗品组别查询接口
// 接口作用:根据条件查询易耗品组别(此处条件是组长手机号)
url: `${BASE_URL}/api/consumable-groups/findByCondition`,
method: 'POST',
header:getHeaders(), // 请求头:使用统一的 getHeaders() 配置(携带用户、医院等信息)
data:{ // 请求体:传递查询条件
groupHeadMobile:mobile // 按“组长手机号”查询对应的组别(如组长管理的易耗品分组)
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//获取发放计划信息
export function getPlanInfo(id,pageNum, pageSize) {
return new Promise((resolve, reject) => {
uni.request({
// 请求地址:拼接基础地址 + 发放计划接口 + 分页参数pageNum/pageSize
url: `${BASE_URL}/api/consumable-distribution-plan/findByCondition?pageNum=${pageNum}&pageSize=${pageSize}`,
method: 'POST',
header:getHeaders(),
data:{
hospId:id // 按“医院ID”查询该医院的易耗品发放计划如“2024年Q1耗材发放计划”
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//获取易耗品领用详情
export function getConsumptionDetail(id) {
return new Promise((resolve, reject) => {
uni.request({
// 请求地址:拼接基础地址 + 领用详情接口 + 领用记录IDRESTful风格
url: `${BASE_URL}/api/consumable-distribution-item/app_getDistributeItem/${id}`,
method: 'GET', // 请求方法GET查询详情常用GET
header:getHeaders(),
data:{
// GET请求无需请求体参数通过URL路径传递
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//确认易耗品领用
export function consumptonConfirm(id) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/consumable-distribution-item/app_getDistributeItem/${id}`,
method: 'POST', // 请求方法POST用于更新状态确认领用
header:getHeaders(),
data:{
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//————————————————————————————————————————————————资产盘点————————————————————————————————————————————————————————————
//新增固定资产盘点
export function addInventory(addDTO) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/asset/inventory/add`, // 接口路径:固定资产盘点记录提交接口
method: 'POST', // 请求方法POST新增数据用POST
data: { // 请求体:盘点记录所需的核心参数(从前端盘点表单收集)
assetId:addDTO.assetId, // 被盘点的固定资产ID
assetName:addDTO.assetName, // 固定资产名称(冗余字段,便于后端校验)
inventoryDate:addDTO.inventoryDate, // 盘点日期(格式化后的日期时间)
inventoryManId:addDTO.inventoryManId, // 盘点人ID当前操作用户ID
inventoryManMobile:addDTO.inventoryManMobile, // 盘点人手机号(用于身份校验)
inventoryPic:addDTO.inventoryPic, // 盘点照片可选如资产实物照片通常是Base64或图片URL
inventoryPlanId:addDTO.inventoryPlanId, // 所属盘点计划ID关联到具体的盘点任务
inventoryResult:addDTO.inventoryResult, // 盘点结果(如“正常”“缺失”“损坏”等枚举值)
note:addDTO.note // 盘点备注(可选,记录特殊情况,如“资产外观有划痕”)
},
header:getHeaders(), // 统一请求头(携带用户、医院等信息)
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 成功时返回后端的盘点记录ID或成功信息
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//获取固定资产盘点计划信息
export function getInventoryPlan(pageNum, pageSize) {
return new Promise((resolve, reject) => {
uni.request({
// 接口路径固定资产盘点计划分页接口拼接页码pageNum和每页条数pageSize
url: `${BASE_URL}/api/asset/inventoryPlan/page?pageNum=${pageNum}&pageSize=${pageSize}`,
method: 'POST',
header:getHeaders(),
data:{
// 请求体:空(后端可能默认返回当前用户有权限的盘点计划,无需额外筛选条件)
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//获取盘点详情(计划结束)
//作用:前端点击 “已结束” 的盘点计划时调用(查看该计划的完整盘点结果,如哪些资产正常、哪些异常)。
export function getInventoryDetailEnd(id) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/asset/inventory/app_getInventoryPlan/${id}`,
// 接口路径拼接盘点计划ID专门获取“进行中”计划的详情与已结束计划接口区分开
method: 'GET',
header:getHeaders(),
data:{
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 返回进行中计划的详情(如待盘点资产列表、已盘点进度)
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//获取盘点详情(计划进行中)
//作用:前端点击 “进行中” 的盘点计划时调用(查看待盘点的资产列表,继续完成未盘点的任务)。
export function getInventoryDetailIng(id) {
return new Promise((resolve, reject) => {
uni.request({
// 接口路径拼接盘点计划ID专门获取“进行中”计划的详情与已结束计划接口区分开
url: `${BASE_URL}/api/asset/inventory/app_getInventoryPlan2/${id}`,
method: 'GET',
header:getHeaders(),
data:{
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//盘点
export function inventory(addDTO) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/asset/inventory/add`, // 与“addInventory”接口路径相同可能是后端兼容的简化版
method: 'POST',
header:getHeaders(),
data:{ // 请求体简化后的参数去掉了assetName、note等非必填项inventoryDate注释后可能由后端自动填充
assetId:addDTO.assetId, // 被盘点资产ID必填
inventoryManId:addDTO.inventoryManId, // 盘点人ID必填
inventoryManMobile:addDTO.inventoryManMobile, // 盘点人手机号(必填)
inventoryPic:addDTO.inventoryPic, // 盘点照片(可选)
inventoryPlanId:addDTO.inventoryPlanId, // 所属计划ID必填
inventoryResult:addDTO.inventoryResult, // 盘点结果(必填)
//inventoryDate:addDTO.inventoryDate // 注释后,后端可能自动用当前时间作为盘点日期
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//————————————————————————————————————————————————易耗品盘点——————————————————————————————————————————————————————
//获取消耗品盘点计划信息
//作用:前端 “易耗品盘点计划页” 加载数据时调用(与固定资产盘点计划逻辑一致,仅业务类型不同)。
export function getConsumpationPlan(pageNum, pageSize) {
return new Promise((resolve, reject) => {
uni.request({
// 接口路径:易耗品盘点计划分页接口,名称区分于固定资产计划
url: `${BASE_URL}/api/consumable-check-plan/findByConditionWithPage?pageNum=${pageNum}&pageSize=${pageSize}`,
method: 'POST',
header:getHeaders(),
data:{
// 空请求体(后端默认返回当前用户的易耗品盘点计划)
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 返回易耗品盘点计划的分页数据
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//获取消耗品盘点详情(计划结束)
export function getConsumpationInventoryDetailEnd(id) {
return new Promise((resolve, reject) => {
uni.request({
// 接口路径拼接易耗品盘点计划ID专门获取“已结束”计划的详情
url: `${BASE_URL}/api/consumable-check-item/app_getInventoryPlan/${id}`,
method: 'GET',
header:getHeaders(),
data:{
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 返回已结束计划的详情(如易耗品盘点总数、盈亏统计)
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//获取消耗品盘点详情(计划进行中)
//作用:前端查看 “进行中” 的易耗品盘点计划时调用(对应固定资产的 “getInventoryDetailIng”
export function getConsumpationInventoryDetailIng(id) {
return new Promise((resolve, reject) => {
uni.request({
// 接口路径拼接易耗品盘点计划ID专门获取“进行中”计划的详情
url: `${BASE_URL}/api/consumable-check-item/app_getInventoryPlan2/${id}`,
method: 'GET',
header:getHeaders(),
data:{
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 返回进行中计划的详情(如待盘点易耗品列表、已盘点进度)
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}
//盘点消耗品
export function addComInventory(addDTO) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/consumable-check-item/add`, // 接口路径:易耗品盘点记录提交接口
method: 'POST',
header:getHeaders(),
data:{ // 请求体:易耗品盘点的核心参数(与固定资产盘点参数差异在于“数量”字段)
checkPlanId:addDTO.checkPlanId, // 所属易耗品盘点计划ID
checkResult:addDTO.checkResult, // 盘点结果(如“账实相符”“盘盈”“盘亏”)
inventoryId:addDTO.inventoryId, // 库存ID关联到具体的易耗品库存记录
quantity:addDTO.quantity // 实际盘点数量(易耗品需统计数量,固定资产通常是“有无”)
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}

View File

@ -0,0 +1,70 @@
const BASE_URL = 'http://10.3.1.212:8089';
const config = require('config.js');
const formatTime = date => {
date = new Date(date);
const hour = date.getHours()
const minute = date.getMinutes()
const second = date.getSeconds()
return `${formatNumber(hour)}:${formatNumber(minute)}:${formatNumber(second)}`
}
const formatNumber = n => {
n = n.toString()
return n[1] ? n : '0' + n
}
const formatDate = date => {
date = new Date(date);
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
return `${year}-${formatNumber(month)}-${formatNumber(day)}`
}
const formatDateTime = date => {
return `${formatDate(date)} ${formatTime(date)}`
}
let staff = uni.getStorageSync('staff');
let staffisnull = (staff == null || staff == undefined || staff == '');
export function getHeaders() {
const staff = uni.getStorageSync('staff') || null
const staffisnull = !staff
return {
'content-type': 'application/json',
'Authorization': staffisnull ? null : `Bearer ${uni.getStorageSync('token')}`,
'hosp-ID': staffisnull ? config.defaultHospId : staff.hosp_id,
'user-ID': staffisnull ? null : staff.id,
'datetime': formatDateTime(new Date()),
'createby': staffisnull ? null : staff.id
}
}
//分页请求固定资产盘点信息
export function fetchAssetInventory(pageNum, pageSize) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE_URL}/api/asset/inventory/page?pageNum=${pageNum}&pageSize=${pageSize}`,
method: 'POST',
data: {
query:{},
},
header:getHeaders(),
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data); // 根据你的接口返回结构调整
} else {
reject(res);
}
},
fail: (err) => {
reject(err);
}
});
});
}

View File

@ -0,0 +1,144 @@
<template>
<scroll-view scroll-y class="page">
<view v-for="item in detailPlan" :key="item.Id || item.id" class="item-card">
<view class="row"><text class="label">物资名称</text><text class="value">{{ item.goodsName }}</text></view>
<view class="row"><text class="label">发放数量</text><text class="value highlight">{{ item.actualQuantity
}}</text></view>
<view class="row"><text class="label">领用小组</text><text class="value">{{ item.groupName }}</text></view>
<view class="row"><text class="label">备注</text><text class="value">{{ item.notes || '无' }}</text></view>
<view class="row"><text class="label">发放计划</text><text class="value">{{ item.planTitle }}</text></view>
<view class="action">
<button v-if="item.isDistributed == 0" size="mini" class="primary" @tap="reciept(item)">领用</button>
<view v-else class="done">已领用</view>
</view>
</view>
<view v-if="detailPlan.length === 0 && !loading" class="empty">暂无更多数据</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
// @ts-ignore JS
import { getConsumptionDetail, consumptonConfirm } from '@/pages/api/fixedAssets.js'
type Item = Record<string, any>
const detailPlan = ref<Item[]>([])
const loading = ref(false)
const planId = ref<string | number>('')
async function loadDetail(id: string | number) {
loading.value = true
try {
const res = await getConsumptionDetail(id)
const list: any[] = (res?.data?.records)
|| (res?.data?.list)
|| (Array.isArray(res?.data) ? res.data : [])
|| []
detailPlan.value = list
} catch (e) {
console.error('获取发放详情失败', e)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
async function reciept(item: any) {
uni.showModal({
content: '确认领用?',
success: async (res: any) => {
if (res.confirm) {
try {
const r = await consumptonConfirm(item.id || item.Id)
const code = r?.code ?? r?.data?.code
if (code === 0) {
await loadDetail(planId.value)
uni.showToast({ title: '领用成功', icon: 'success' })
} else {
uni.showToast({ title: '领用失败', icon: 'none' })
}
} catch (err) {
uni.showToast({ title: '领用失败', icon: 'none' })
}
}
}
})
}
onLoad((query) => {
const id = query?.id
if (!id) {
uni.showToast({ title: '缺少计划ID', icon: 'none' })
return
}
planId.value = id
loadDetail(id)
})
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 24rpx;
background: #f5f7fb;
box-sizing: border-box;
}
.item-card {
background: #fff;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 16rpx;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.06);
}
.row {
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
.label {
color: #888;
min-width: 160rpx;
}
.value {
color: #333;
}
.highlight {
color: #e53935;
font-weight: 600;
}
.action {
margin-top: 10rpx;
display: flex;
justify-content: flex-end;
align-items: center;
}
.done {
color: #2e7d32;
font-size: 24rpx;
}
.empty {
color: #9a9aa0;
text-align: center;
padding: 24rpx 0;
}
button.primary {
background: #4b7aff;
color: #fff;
border-radius: 8rpx;
padding: 0rpx 24rpx;
margin: 0;
}
</style>

View File

@ -0,0 +1,67 @@
<template>
<scroll-view scroll-y class="page">
<view v-for="item in detailList" :key="item.id" class="card">
<view class="head">{{ item.goodsName }}</view>
<view class="body">
<view class="row">📦 盘点数量<text class="value">{{ item.quantity }}</text></view>
<view class="row">📅 盘点日期<text class="value">{{ item.checkDate }}</text></view>
<view class="row">👤 盘点人<text class="value">{{ item.checkManName }}</text></view>
<view class="row">📞 盘点人电话<text class="value">{{ item.checkManMobile }}</text></view>
<view class="row">结果
<text class="result lose" v-if="item.checkResult==0">盘亏</text>
<text class="result normal" v-else-if="item.checkResult==1">账实相符</text>
<text class="result win" v-else-if="item.checkResult==2">盘盈</text>
<text class="result unknown" v-else>未知</text>
</view>
</view>
</view>
<view v-if="detailList.length===0 && !loading" class="empty">暂无更多数据</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
// @ts-ignore JS
import { getConsumpationInventoryDetailEnd } from '@/pages/api/fixedAssets.js'
const detailList = ref<any[]>([])
const loading = ref(false)
async function loadList(planId: string | number) {
loading.value = true
try {
const res = await getConsumpationInventoryDetailEnd(planId)
const list: any[] = (res?.data?.list) || (res?.data?.records) || (Array.isArray(res?.data) ? res.data : []) || []
detailList.value = list
} catch (e) {
console.error(e)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
onLoad((query) => {
const planId = query?.planId
if (!planId) {
uni.showToast({ title: '缺少计划ID', icon: 'none' })
return
}
loadList(planId)
})
</script>
<style scoped>
.page { min-height: 100vh; padding: 24rpx; background: #f5f7fb; box-sizing: border-box;}
.card { background: #fff; border-radius: 16rpx; padding: 20rpx; margin-bottom: 16rpx; box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.06); }
.head { font-size: 30rpx; color: #222; font-weight: 600; margin-bottom: 10rpx; }
.row { margin: 6rpx 0; color: #444; }
.value { color: #333; }
.result { padding: 2rpx 8rpx; border-radius: 999rpx; font-size: 22rpx; }
.lose { color: #e53935; background: #ffebee; }
.normal { color: #2e7d32; background: #e8f5e9; }
.win { color: #1e88e5; background: #e3f2fd; }
.unknown { color: #757575; background: #eeeeee; }
.empty { color: #9a9aa0; text-align: center; padding: 24rpx 0; }
</style>

View File

@ -0,0 +1,139 @@
<template>
<scroll-view scroll-y class="page">
<view v-for="item in detailList" :key="item.id" class="card">
<view class="head">📦 {{ item.goodsName }}</view>
<view class="body">
<view class="row">库存数量<text class="value">{{ item.quantity }}</text></view>
<view class="row">当前状态<text class="status">{{ item.checkResult || '未盘点' }}</text></view>
</view>
<view class="actions" v-if="item.checkResult === '未盘点'">
<button size="mini" class="check" @tap="openCheckModal(item)">去盘点</button>
</view>
</view>
<view v-if="detailList.length===0 && !loading" class="empty">暂无更多数据</view>
<!-- 盘点弹窗 -->
<view v-if="showModal" class="modal-mask">
<view class="modal">
<view class="modal-title">盘点物资{{ currentItem?.goodsName }}</view>
<view class="modal-field">
<text class="label">盘点数量</text>
<input type="number" v-model="checkForm.quantity" placeholder="请输入盘点数量" class="modal-input" />
</view>
<view class="modal-field">
<text class="label">盘点结果</text>
<picker :range="statusOptions" :value="statusIndex" @change="onStatusChange">
<view class="picker">{{ statusOptions[statusIndex] }}</view>
</picker>
</view>
<view class="modal-actions">
<button class="cancel" @tap="closeModal">取消</button>
<button class="confirm" @tap="submitCheck">确认</button>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
// @ts-ignore JS
import { getConsumpationInventoryDetailIng, addComInventory } from '@/pages/api/fixedAssets.js'
type Item = Record<string, any>
const planId = ref<string | number>('')
const detailList = ref<Item[]>([])
const loading = ref(false)
const showModal = ref(false)
const currentItem = ref<Item | null>(null)
const statusOptions = ['盘亏', '账实相符', '盘盈']
const statusIndex = ref(1)
const checkForm = ref<{ quantity: string; status: number }>({ quantity: '', status: 1 })
async function loadList(id: string | number) {
loading.value = true
try {
const res = await getConsumpationInventoryDetailIng(id)
const list: any[] = (res?.data?.list) || (res?.data?.records) || (Array.isArray(res?.data) ? res.data : []) || []
detailList.value = list
} catch (e) {
console.error(e)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function openCheckModal(item: Item) {
currentItem.value = item
showModal.value = true
checkForm.value = { quantity: '', status: 1 }
statusIndex.value = 1
}
function onStatusChange(e: any) {
statusIndex.value = e.detail.value
checkForm.value.status = parseInt(e.detail.value)
}
function closeModal() { showModal.value = false }
async function submitCheck() {
if (!checkForm.value.quantity) {
uni.showToast({ title: '请输入盘点数量', icon: 'none' })
return
}
try {
await addComInventory({
checkPlanId: planId.value,
checkResult: checkForm.value.status,
inventoryId: currentItem.value?.id,
quantity: checkForm.value.quantity
})
uni.showToast({ title: '盘点成功', icon: 'none' })
showModal.value = false
await loadList(planId.value)
} catch (e) {
uni.showToast({ title: '盘点失败', icon: 'none' })
}
}
onLoad((query) => {
const id = query?.planId
if (!id) {
uni.showToast({ title: '缺少计划ID', icon: 'none' })
return
}
planId.value = id
loadList(id)
})
</script>
<style scoped>
.page { min-height: 100vh; padding: 24rpx; background: #f5f7fb; box-sizing: border-box;}
.card { background: #fff; border-radius: 16rpx; padding: 20rpx; margin-bottom: 16rpx; box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.06); }
.head { font-size: 30rpx; color: #222; font-weight: 600; margin-bottom: 10rpx; }
.row { margin: 6rpx 0; color: #444; }
.value { color: #333; }
.status { color: #757575; }
.actions { margin-top: 8rpx; display: flex; justify-content: flex-end; }
button.check { background: #4b7aff; color: #fff; border-radius: 8rpx; padding: 6rpx 16rpx; }
.empty { color: #9a9aa0; text-align: center; padding: 24rpx 0; }
/* modal */
.modal-mask { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; }
.modal { width: 86%; background: #fff; border-radius: 16rpx; padding: 24rpx; }
.modal-title { font-size: 30rpx; color: #222; font-weight: 600; margin-bottom: 16rpx; }
.modal-field { display: flex; align-items: center; margin-bottom: 12rpx; }
.label { color: #666; min-width: 160rpx; }
.modal-input { flex: 1; border: 2rpx solid #e0e0e0; border-radius: 8rpx; padding: 8rpx 12rpx; }
.picker { border: 2rpx solid #e0e0e0; border-radius: 8rpx; padding: 10rpx 12rpx; color: #333; }
.modal-actions { margin-top: 16rpx; display: flex; justify-content: flex-end; gap: 16rpx; }
button.cancel { background: #eceff1; color: #333; border-radius: 8rpx; padding: 8rpx 16rpx; }
button.confirm { background: #4b7aff; color: #fff; border-radius: 8rpx; padding: 8rpx 16rpx; }
</style>

View File

@ -0,0 +1,101 @@
<template>
<scroll-view scroll-y class="page">
<view v-if="inventoryPlanList.length === 0 && !loading" class="empty">暂无更多数据</view>
<view class="inventory-list">
<view class="inventory-item" v-for="item in inventoryPlanList" :key="item.inventoryId || item.id" @tap="gotoPlanDetail(item)">
<view class="header">
<text class="title">📋 盘点计划</text>
<text class="status-tag" :class="statusClass(item.status)">{{ item.status || '未知' }}</text>
</view>
<view class="content">
<view class="row">开始日期<text class="value">{{ item.checkStartDate || '-' }}</text></view>
<view class="row">结束日期<text class="value">{{ item.checkEndDate || '-' }}</text></view>
<view class="row">创建人<text class="value">{{ item.createByName || '-' }}</text></view>
</view>
</view>
</view>
<view class="load-status">
<text v-if="loading">加载中...</text>
<text v-else-if="noMore">没有更多数据了</text>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
// @ts-ignore JS
import { getConsumpationPlan } from '@/pages/api/fixedAssets.js'
type Plan = Record<string, any>
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)
const noMore = ref(false)
const inventoryPlanList = ref<Plan[]>([])
function statusClass(status?: string) {
if (status === '已结束') return 'ended'
if (status === '正在进行') return 'running'
return 'unknown'
}
async function fetchInventoryList() {
if (loading.value || noMore.value) return
loading.value = true
try {
const res = await getConsumpationPlan(page.value, pageSize.value)
const list: any[] = (res?.data?.list) || (res?.data?.records) || (Array.isArray(res?.data) ? res.data : []) || []
inventoryPlanList.value = inventoryPlanList.value.concat(list)
if (list.length < pageSize.value) noMore.value = true
} catch (e) {
console.error('获取易耗品盘点计划失败', e)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function gotoPlanDetail(item: any) {
const planId = item?.id || item?.inventoryId
if (!planId && planId !== 0) {
uni.showToast({ title: '缺少计划ID', icon: 'none' })
return
}
if (item.status === '已结束') {
uni.navigateTo({ url: `/pages/consumables/inventory-end?planId=${planId}` })
} else {
uni.navigateTo({ url: `/pages/consumables/inventory-ing?planId=${planId}` })
}
}
onLoad(() => {
fetchInventoryList()
})
onReachBottom(() => {
if (!loading.value && !noMore.value) {
page.value += 1
fetchInventoryList()
}
})
</script>
<style scoped>
.page { min-height: 100vh; background: #f5f7fb; padding: 24rpx; box-sizing: border-box; }
.empty { color: #9a9aa0; text-align: center; padding: 24rpx 0; }
.inventory-list { display: flex; flex-direction: column; gap: 16rpx; }
.inventory-item { background: #fff; border-radius: 16rpx; padding: 20rpx; box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.06); }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
.title { font-size: 30rpx; color: #222; font-weight: 600; }
.status-tag { font-size: 24rpx; padding: 4rpx 10rpx; border-radius: 999rpx; }
.status-tag.running { color: #1e88e5; background: #e3f2fd; }
.status-tag.ended { color: #2e7d32; background: #e8f5e9; }
.status-tag.unknown { color: #757575; background: #eeeeee; }
.content .row { margin-top: 6rpx; color: #444; }
.value { color: #333; }
.load-status { text-align: center; color: #9a9aa0; padding: 20rpx 0; }
</style>

View File

@ -0,0 +1,159 @@
<template>
<scroll-view scroll-y class="page">
<view v-if="planList.length === 0 && !loading" class="empty">暂无更多数据</view>
<view v-for="plan in planList" :key="plan.id" class="plan-card">
<view class="plan-header">
<text class="plan-title">{{ plan.planTitle }}</text>
<text class="plan-action" @tap="goToDistribution(plan.id)">发放物资</text>
</view>
<view class="plan-info">
<view class="info-item">
<text class="label">医院</text>
<text class="value">{{ plan.hospName }}</text>
</view>
<view class="info-item">
<text class="label">周期类型</text>
<text class="value">{{ plan.periodType }}</text>
</view>
<view class="info-item">
<text class="label">发放数量</text>
<text class="value">{{ plan.quantityPerPeriod }}</text>
</view>
</view>
<view class="plan-time">
<view class="info-item">
<text class="label">上次分配</text>
<text class="value">{{ plan.lastDistributionDate || '-' }}</text>
</view>
<view class="info-item">
<text class="label">下次分配</text>
<text class="value">{{ plan.nextDistributionDate || '-' }}</text>
</view>
</view>
<view class="plan-footer">
<text class="created">创建人{{ plan.createdByName }}{{ plan.createdByMobile }}</text>
<text class="time">创建时间{{ plan.createdTime }}</text>
</view>
</view>
<view class="load-status">
<text v-if="loading">加载中...</text>
<text v-else-if="noMore">没有更多数据了</text>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
// @ts-ignore JS
import * as api from '@/pages/api/fixedAssets.js'
type PlanItem = Record<string, any>
const planList = ref<PlanItem[]>([])
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)
const noMore = ref(false)
function resetAndLoad() {
planList.value = []
page.value = 1
noMore.value = false
getPlan()
}
async function getPlan() {
if (loading.value || noMore.value) return
loading.value = true
try {
const staff = uni.getStorageSync('staff') || {}
const hospId = staff.hosp_id || staff.hospId || 0
// API
const res = await api.getPlanInfo(hospId, page.value, pageSize.value)
const list: any[] = (res?.data?.records)
|| (res?.data?.list)
|| (Array.isArray(res?.data) ? res.data : [])
|| []
planList.value = planList.value.concat(list)
if (list.length < pageSize.value) noMore.value = true
} catch (e) {
console.error('获取易耗品领用计划失败', e)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function goToDistribution(id: string | number) {
if (!id && id !== 0) {
uni.showToast({ title: '缺少计划ID', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/consumables/distribution?id=${id}` })
}
onLoad(() => {
resetAndLoad()
})
onReachBottom(() => {
if (!loading.value && !noMore.value) {
page.value += 1
getPlan()
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 24rpx;
background: #f5f7fb;
box-sizing: border-box;
}
.empty {
color: #9a9aa0;
text-align: center;
padding: 24rpx 0;
}
.plan-card {
background: #fff;
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 16rpx;
box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.06);
}
.plan-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.plan-title {
font-size: 30rpx;
color: #222;
font-weight: 600;
}
.plan-action {
font-size: 26rpx;
color: #4b7aff;
}
.plan-info, .plan-time {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12rpx;
margin-bottom: 8rpx;
}
.info-item { color: #444; }
.label { color: #888; }
.value { color: #333; }
.plan-footer { color: #666; font-size: 24rpx; display: flex; flex-direction: column; gap: 6rpx; }
.load-status { text-align: center; color: #9a9aa0; padding: 20rpx 0; }
</style>

View File

@ -0,0 +1,140 @@
<template>
<view class="page-container">
<view class="popup-content card">
<view class="popup-title">资产详情</view>
<view class="title-divider"></view>
<view class="info-group">
<view class="group-label">基础信息</view>
<view class="popup-text">资产名称{{ detailData.assetName }}</view>
<view class="popup-text">设备序列号{{ detailData.assetSn }}</view>
<view class="popup-text">规格{{ detailData.specifications }}</view>
<view class="popup-text">型号{{ detailData.model }}</view>
</view>
<view class="info-group">
<view class="group-label">资产属性</view>
<view class="popup-text">品牌{{ detailData.assetBrand }}</view>
<view class="popup-text">状态
<text class="status-tag" v-if="detailData.status === 1 || detailData.status === 0">使用中</text>
<text class="status-tag" v-else-if="detailData.status === 2">报废</text>
<text class="status-tag" v-else-if="detailData.status === 3">闲置</text>
<text class="status-tag" v-else-if="detailData.status === 4">维修中</text>
<text class="status-tag" v-else-if="detailData.status === 5">外借</text>
<text class="status-tag" v-else>未知</text>
</view>
<view class="popup-text">制造商{{ detailData.manufacturer }}</view>
<view class="popup-text">保存位置{{ detailData.location }}</view>
</view>
<view class="info-group">
<view class="group-label">财务信息</view>
<view class="popup-text">采购日期{{ detailData.purchaseDate }}</view>
<view class="popup-text">购买价格{{ detailData.purchasePrice }}</view>
<view class="popup-text">购买数量{{ detailData.purchaseQuantity }}</view>
<view class="popup-text">单位{{ detailData.unit }}</view>
<view class="popup-text">购买人{{ detailData.purchaseManName }}</view>
<view class="popup-text">现有价值{{ detailData.value }}</view>
<view class="popup-text">折旧率{{ detailData.depreciationRate }}</view>
<view class="popup-text">使用期限单位年{{ detailData.usefullife }}</view>
</view>
<view class="info-group">
<view class="group-label">使用信息</view>
<view class="popup-text">使用人{{ detailData.useManName }}</view>
<view class="popup-text">使用部门{{ detailData.departmentName }}</view>
<view class="popup-text">负责人{{ detailData.headerName }}</view>
<view class="popup-text">是否处置
<text class="disposal-tag" v-if="detailData.isDisposal === 0"></text>
<text class="disposal-tag" v-else></text>
</view>
<view class="popup-text">处置日期{{ detailData.disposaldate || '未处置' }}</view>
<view class="popup-text">保修到期时间{{ detailData.warrantyDate }}</view>
<view class="popup-text">说明{{ detailData.note || '无' }}</view>
</view>
<view class="info-group">
<view class="group-label">登记信息</view>
<view class="popup-text">登记人{{ detailData.registrantName }}</view>
<view class="popup-text">登记时间{{ detailData.registrantDate }}</view>
</view>
<view class="image-viewer">
<view class="image-label">设备照片</view>
<view class="image-container" @tap="previewImage">
<image class="image" :src="detailData.assetPic || defaultImage" mode="aspectFit" />
<view class="image-hint" v-if="detailData.assetPic">点击预览大图</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
// @ts-ignore JS
import { fetchAssetInfoById } from '@/pages/api/fixedAssets.js'
const detailData = ref<any>({})
const defaultImage = '/static/images/asset-default.png'
onLoad(async (options?: Record<string, any>) => {
const sn = options?.assetSn || options?.sn
if (sn) {
await fetchBySn(sn)
return
}
// 退使 eventChannel
const pages = getCurrentPages()
// @ts-ignore API
const eventChannel = (pages[pages.length - 1] as any)?.getOpenerEventChannel?.()
eventChannel?.on('sendData', (res: any) => { detailData.value = res?.data || {} })
})
async function fetchBySn(sn: string) {
try {
const data: any = await fetchAssetInfoById(sn)
//
const d = data?.data
if (Array.isArray(d) && d.length > 0) {
detailData.value = d[0]
} else if (Array.isArray(d?.list) && d.list.length > 0) {
detailData.value = d.list[0]
} else if (d && typeof d === 'object') {
detailData.value = d
} else {
detailData.value = {}
}
} catch (e) {
console.error('获取资产详情失败', e)
uni.showToast({ title: '获取详情失败', icon: 'none' })
}
}
function previewImage() {
if (detailData.value.assetPic) {
uni.previewImage({ urls: [detailData.value.assetPic], current: detailData.value.assetPic,
fail: () => uni.showToast({ title: '预览失败,请稍后重试', icon: 'none' }) })
} else {
uni.showToast({ title: '暂无设备照片', icon: 'none' })
}
}
</script>
<style scoped>
.page-container { padding: 24rpx; }
.card { background: #fff; border-radius: 20rpx; box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.06); }
.popup-content { padding: 20rpx; }
.popup-title { font-size: 32rpx; font-weight: 600; color: #222; }
.title-divider { height: 2rpx; background: #f0f1f5; margin: 12rpx 0 20rpx; }
.info-group { margin-bottom: 16rpx; }
.group-label { font-size: 26rpx; color: #666; margin-bottom: 6rpx; }
.popup-text { font-size: 24rpx; color: #333; margin-top: 6rpx; }
.status-tag, .disposal-tag { color: #3a5ddd; }
.image-viewer { margin-top: 20rpx; }
.image-label { font-size: 26rpx; color: #666; margin-bottom: 8rpx; }
.image-container { background: #fafafa; border: 1rpx solid #eee; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; padding: 12rpx; position: relative; }
.image { width: 100%; height: 360rpx; }
.image-hint { position: absolute; right: 12rpx; bottom: 12rpx; font-size: 22rpx; color: #9a9aa0; }
</style>

View File

@ -0,0 +1,442 @@
<template>
<view class="container">
<!-- 搜索栏 -->
<view class="search-bar card">
<input v-model="searchName" placeholder="请输入资产名" class="search-input" />
<button class="btn btn-primary" @tap="search">搜索</button>
</view>
<!-- 空状态/加载状态 -->
<view class="load-status" v-if="loading">加载中...</view>
<view class="load-status" v-else-if="!assetList.length">没有更多数据了</view>
<!-- 资产列表 -->
<view class="list">
<view class="asset-item card" v-for="item in assetList" :key="item.assetId || item.assetid">
<view class="asset-main" @tap="gotoDetail(item)">
<view class="asset-name">{{ item.assetName }}</view>
<view class="asset-detail">采购日期{{ item.purchaseDate }}</view>
<view class="asset-detail">采购价格{{ item.purchasePrice }}</view>
<view class="asset-detail">采购数量{{ item.purchaseQuantity }}</view>
<view class="asset-detail">设备状态
<text v-if="item.status === 1 || item.status === 0">使用中</text>
<text v-else-if="item.status === 2">报废</text>
<text v-else-if="item.status === 3">闲置</text>
<text v-else-if="item.status === 4">维修中</text>
<text v-else-if="item.status === 5">外借</text>
<text v-else>未知</text>
</view>
</view>
<view class="asset-actions">
<button class="btn btn-primary outline" v-if="item.status === 3"
@tap.stop="borrow(item)">借用</button>
<button class="btn" @tap="gotoShowMaintenanceInfo(item)">维修信息</button>
<button class="btn" @tap="gotoDetail(item)">详细信息</button>
</view>
</view>
</view>
<!-- 分页加载状态 -->
<view class="load-status">
<text v-if="loading && assetList.length">加载中...</text>
<text v-else-if="noMore && assetList.length">没有更多数据了</text>
</view>
<!-- 借用弹窗 -->
<view class="modal" v-if="showBorrowPopup">
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">借用设备信息</text>
<text class="modal-close" @tap="cancelBorrow">×</text>
</view>
<view class="modal-body">
<view class="row"><text class="label">设备名称</text><text class="value">{{ selectedAsset?.assetName
}}</text></view>
<view class="form-item">
<text class="label">借用人电话</text>
<input v-model="borrowForm.lenderMobile" placeholder="请输入借用人电话" type="number"
@input="searchUserByMobile" />
<view class="user-suggestions" v-if="userSuggestions.length">
<view class="suggestion-item" v-for="u in userSuggestions" :key="u.userMobile"
@tap="selectUser(u)">
{{ u.userName }} ({{ u.userMobile }}) (id: {{ u.id }})
</view>
</view>
</view>
<!-- <view class="form-item">
<text class="label">借用人姓名</text>
<input v-model="borrowForm.borrowerName" disabled placeholder="请输入手机号查询" />
</view>
<view class="form-item">
<text class="label">借用人ID</text>
<input v-model="borrowForm.borrowerId" disabled placeholder="请输入手机号查询" />
</view> -->
<view class="form-item">
<text class="label">借用日期</text>
<picker mode="date" @change="onBorrowDateChange">
<view class="picker-value">{{ borrowForm.borrowDate || '请选择借用日期' }}</view>
</picker>
</view>
<view class="form-item">
<text class="label">预计归还日期</text>
<picker mode="date" @change="onReturnDateChange">
<view class="picker-value">{{ borrowForm.returnDate || '请选择归还日期' }}</view>
</picker>
</view>
<view class="form-item">
<text class="label">备注</text>
<textarea v-model="borrowForm.note" placeholder="请输入备注" />
</view>
</view>
<view class="modal-footer">
<button class="btn" @tap="cancelBorrow">取消</button>
<button class="btn btn-primary" @tap="confirmBorrow">确认</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
// @ts-ignore JS
import { searchFixedAssets, fetchFixedAssets, fetchMaintenanceInfoById, borrowAssetById, returnAsset, searchUserByMobile as apiSearchUserByMobile } from '@/pages/api/fixedAssets.js'
interface AssetItem { [k: string]: any }
const assetList = ref<AssetItem[]>([])
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)
const noMore = ref(false)
const searchName = ref('')
const showBorrowPopup = ref(false)
const selectedAsset = ref<AssetItem | null>(null)
const userInfo = uni.getStorageSync('staff') || {}
const borrowForm = ref({
lenderMobile: '',
borrowDate: '',
returnDate: '',
location: '',
borrowDepartmentId: '',
approverId: '',
approverMobile: '',
note: ''
})
const userSuggestions = ref<any[]>([])
let searchTimeout: any = null
const isSearching = ref(false)
onLoad(() => {
getAssetList()
})
onReachBottom(() => {
if (!loading.value && !noMore.value) {
page.value++
getAssetList()
}
})
async function search() {
loading.value = true
try {
if (!searchName.value) {
assetList.value = []
page.value = 1
noMore.value = false
await getAssetList()
return
}
const data: any = await searchFixedAssets(searchName.value)
assetList.value = data?.data || []
noMore.value = true
} catch (e) {
console.error('获取数据失败', e)
} finally {
loading.value = false
}
}
async function getAssetList() {
loading.value = true
try {
const data: any = await fetchFixedAssets(page.value, pageSize.value)
const list = data?.data?.list || []
if (list.length < pageSize.value) noMore.value = true
assetList.value = assetList.value.concat(list)
} catch (e) {
console.error('获取数据失败', e)
} finally {
loading.value = false
}
}
function gotoDetail(item: AssetItem) {
const sn = item.assetSn || item.assetSN || item.asset_sn
if (sn) {
uni.navigateTo({ url: `/pages/fixed-assets/detail?assetSn=${encodeURIComponent(sn)}` })
} else {
uni.navigateTo({ url: '/pages/fixed-assets/detail', success(res) { res.eventChannel.emit('sendData', { data: item }) } })
}
}
function gotoShowMaintenanceInfo(item: AssetItem) {
const id = item.assetId || item.assetid
if (id)
uni.navigateTo({ url: `/pages/fixed-assets/maintenance-info?assetId=${id}` })
else
uni.navigateTo({
url: '/pages/fixed-assets/maintenance-info',
success(res) { res.eventChannel.emit('sendData', { data: item }) }
})
}
function borrow(item: AssetItem) {
selectedAsset.value = item
showBorrowPopup.value = true
userSuggestions.value = []
borrowForm.value.lenderMobile = ''
}
function cancelBorrow() {
showBorrowPopup.value = false
userSuggestions.value = []
}
function onBorrowDateChange(e: any) { borrowForm.value.borrowDate = e.detail.value }
function onReturnDateChange(e: any) { borrowForm.value.returnDate = e.detail.value }
async function searchUserByMobile() {
const mobile = borrowForm.value.lenderMobile
if (searchTimeout) clearTimeout(searchTimeout)
if (!mobile || mobile.length < 3) { userSuggestions.value = []; return }
isSearching.value = true
searchTimeout = setTimeout(async () => {
try {
const resp: any = await apiSearchUserByMobile(mobile)
userSuggestions.value = resp.data ?? []
} catch (err) {
userSuggestions.value = []
uni.showToast({ title: '搜索失败', icon: 'none' })
} finally {
isSearching.value = false
}
}, 500)
}
function selectUser(user: any) {
borrowForm.value.lenderMobile = user.userMobile
}
async function confirmBorrow() {
const s = selectedAsset.value || {}
const payload = {
assetId: s.assetId || s.assetid,
assetName: s.assetName,
borrowDate: borrowForm.value.borrowDate,
borrowerMobile: borrowForm.value.lenderMobile,
lenderMobile: s.headerMobile,
note: borrowForm.value.note,
registrantDate: new Date().toISOString(),
registrantId: s.registrantId,
registrantMobile: s.registrantMobile,
returnDate: borrowForm.value.returnDate
}
try {
await borrowAssetById(payload)
uni.showModal({ content: '借用成功', showCancel: false })
showBorrowPopup.value = false
//
assetList.value = []
page.value = 1
noMore.value = false
await getAssetList()
} catch (err) {
uni.showToast({ title: '借用失败', icon: 'none' })
}
}
</script>
<style scoped>
.container {
padding: 24rpx;
}
.card {
background: #fff;
border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.search-bar {
display: flex;
gap: 12rpx;
padding: 20rpx;
align-items: center;
}
.search-input {
flex: 1;
background: #f5f7fb;
border-radius: 12rpx;
padding: 12rpx 16rpx;
}
.btn {
padding: 0 20rpx;
background: #e9efff;
color: #3a5ddd;
border-radius: 10rpx;
font-size: 24rpx;
}
.btn-primary {
background: #3a5ddd;
color: #fff;
}
.btn-primary.outline {
background: #fff;
color: #3a5ddd;
border: 2rpx solid #3a5ddd;
}
.list {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-top: 20rpx;
}
.asset-item {
padding: 20rpx;
}
.asset-main {
border-bottom: 1rpx solid #f0f1f5;
padding-bottom: 16rpx;
margin-bottom: 12rpx;
}
.asset-name {
font-size: 30rpx;
color: #222;
font-weight: 600;
}
.asset-detail {
font-size: 24rpx;
color: #666;
margin-top: 6rpx;
}
.asset-actions {
display: flex;
gap: 12rpx;
justify-content: flex-end;
}
.load-status {
text-align: center;
color: #9a9aa0;
margin: 20rpx 0;
}
/* 弹窗样式与项目统一 */
.modal {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 86%;
max-height: 76vh;
background: #fff;
border-radius: 16rpx;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 20rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1rpx solid #eee;
}
.modal-title {
font-size: 30rpx;
color: #222;
font-weight: 600;
}
.modal-close {
font-size: 40rpx;
color: #999;
}
.modal-body {
padding: 16rpx 24rpx;
max-height: 60vh;
}
.modal-footer {
padding: 16rpx 24rpx;
display: flex;
justify-content: flex-end;
gap: 16rpx;
border-top: 1rpx solid #eee;
}
.modal-footer .btn {
flex: 1;
}
.label {
color: #666;
font-size: 24rpx;
}
.value {
color: #333;
font-size: 26rpx;
}
.form-item {
margin: 16rpx 0;
}
.picker-value {
padding: 10rpx 12rpx;
background: #f5f7fb;
border-radius: 8rpx;
color: #333;
}
.user-suggestions {
background: #fff;
border: 1rpx solid #eee;
border-radius: 8rpx;
margin-top: 8rpx;
max-height: 240rpx;
overflow: auto;
}
.suggestion-item {
padding: 10rpx 12rpx;
border-bottom: 1rpx solid #f5f5f5;
}
</style>

View File

@ -0,0 +1,64 @@
<template>
<view class="page">
<view class="borrow-list">
<view v-for="item in detailList" :key="item.assetId" class="borrow-item card">
<view class="item-header"><text class="asset-name">{{ item.assetName }}</text></view>
<view class="content">
<view class="row">📅 盘点日期<text class="value">{{ item.inventoryDate }}</text></view>
<view class="row">👤 盘点人<text class="value">{{ item.inventoryManName }}</text></view>
<view class="row">📝 备注<text class="value">{{ item.note || '无' }}</text></view>
</view>
<view class="result-row">
<text class="label">盘点结果</text>
<text class="result normal" v-if="item.inventoryResult==0">正常</text>
<text class="result damaged" v-else-if="item.inventoryResult==1">损坏</text>
<text class="result lost" v-else-if="item.inventoryResult==2">丢失</text>
<text class="result unknown" v-else>未知</text>
</view>
</view>
</view>
<view class="none" v-if="detailList.length===0">没有更多数据了</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
// @ts-ignore JS
import { getInventoryDetailEnd } from '@/pages/api/fixedAssets.js'
const planId = ref<string | number | null>(null)
const detailList = ref<any[]>([])
onLoad(async (options?: Record<string, any>) => {
planId.value = options?.planId || null
if (!planId.value) return
await loadDetail()
})
async function loadDetail() {
try {
const res: any = await getInventoryDetailEnd(planId.value)
detailList.value = res?.data || []
} catch (e) {
console.error(e)
uni.showToast({ title: '加载失败', icon: 'none' })
}
}
</script>
<style scoped>
.page { min-height: 100vh; background: #f5f7fb; padding: 24rpx; }
.card { background: #fff; border-radius: 20rpx; box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.06); padding: 20rpx; margin-bottom: 16rpx; }
.asset-name { font-size: 30rpx; color: #222; font-weight: 600; }
.row { font-size: 24rpx; color: #666; margin-top: 6rpx; }
.result-row { margin-top: 8rpx; }
.label { font-size: 24rpx; color: #666; margin-right: 8rpx; }
.result { font-size: 24rpx; }
.result.normal { color: #38b000; }
.result.damaged { color: #ff6b6b; }
.result.lost { color: #f39c12; }
.result.unknown { color: #999; }
.none { text-align: center; color: #9a9aa0; margin-top: 20rpx; }
.value { color: #333; }
</style>

View File

@ -0,0 +1,99 @@
<template>
<view class="page">
<view class="borrow-list">
<view v-for="item in detailList" :key="item.assetId" class="borrow-item card">
<view class="item-header"><text class="asset-name">设备名称{{ item.assetName }}</text></view>
<view class="detail">使用人<text class="value">{{ item.useManName }}</text></view>
<view class="detail">设备购入日期<text class="value">{{ item.purchaseDate }}</text></view>
<view class="detail">负责人<text class="value">{{ item.headerName }}</text></view>
<view class="detail">备注<text class="value">{{ item.note || '无' }}</text></view>
<view class="detail">盘点状态<text class="value">{{ item.isInventory }}</text></view>
<view class="action-row" v-if="item.isInventory!='已盘点'">
<view @tap="setInventory(0,item)" class="inventory-btn inventory-win">正常</view>
<view @tap="setInventory(1,item)" class="inventory-btn inventory-lose">损坏</view>
<view @tap="setInventory(2,item)" class="inventory-btn inventory-normal">丢失</view>
</view>
</view>
</view>
<view class="none" v-if="detailList.length===0">没有更多数据了</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
// @ts-ignore JS
import { getInventoryDetailIng, inventory } from '@/pages/api/fixedAssets.js'
const planId = ref<string | number | null>(null)
const userInfo = uni.getStorageSync('staff') || {}
const detailList = ref<any[]>([])
const inventoryInfo = ref({
assetId: '',
inventoryManId: '',
inventoryManMobile: '',
inventoryPic: '',
inventoryPlanId: '',
inventoryResult: '',
inventoryDate: ''
})
onLoad(async (options?: Record<string, any>) => {
planId.value = options?.planId || null
if (!planId.value) return
await loadDetail()
})
async function loadDetail() {
try {
const res: any = await getInventoryDetailIng(planId.value)
detailList.value = res?.data || []
} catch (e) {
console.error(e)
uni.showToast({ title: '加载失败', icon: 'none' })
}
}
function setInventory(status: number, item: any) {
const statusMap: Record<number, string> = { 0: '正常', 1: '损坏', 2: '丢失' }
const statusText = statusMap[status] || '未知'
uni.showModal({
title: '确认操作',
content: `确定要将盘点状态设置为【${statusText}】吗?`,
success: async (r) => {
if (r.confirm) {
const payload = {
assetId: item.assetId,
inventoryManId: userInfo.id,
inventoryManMobile: userInfo.mobile,
inventoryPic: '',
inventoryPlanId: planId.value,
inventoryResult: status
}
try {
await inventory(payload)
uni.showToast({ title: '提交成功', icon: 'success' })
await loadDetail()
} catch (e) {
uni.showToast({ title: '提交失败', icon: 'none' })
}
}
}
})
}
</script>
<style scoped>
.page { min-height: 100vh; background: #f5f7fb; padding: 24rpx; }
.card { background: #fff; border-radius: 20rpx; box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.06); padding: 20rpx; margin-bottom: 16rpx; }
.asset-name { font-size: 30rpx; color: #222; font-weight: 600; }
.detail { font-size: 24rpx; color: #666; margin-top: 6rpx; }
.action-row { display: flex; gap: 12rpx; margin-top: 10rpx; }
.inventory-btn { padding: 12rpx 20rpx; border-radius: 10rpx; color: #fff; font-size: 24rpx; }
.inventory-win { background: #38b000; }
.inventory-lose { background: #ff6b6b; }
.inventory-normal { background: #f39c12; }
.none { text-align: center; color: #9a9aa0; margin-top: 20rpx; }
.value { color: #333; }
</style>

View File

@ -0,0 +1,170 @@
<template>
<view class="page">
<view class="load-status" v-if="!inventoryPlanList.length && !loading">没有更多数据了</view>
<view class="inventory-list">
<view class="inventory-item card" v-for="item in inventoryPlanList" :key="item.inventoryPlanId">
<view class="left" @tap="gotoPlanDetail(item)">
<view class="header"><text class="title">📋 盘点计划</text></view>
<view class="content">
<view class="row">开始日期<text class="value">{{ item.inventoryStartDate }}</text></view>
<view class="row">结束日期<text class="value">{{ item.inventoryEndDate }}</text></view>
<view class="row">盘点人<text class="value">{{ item.inventoryManName }}</text></view>
<view class="row">备注<text class="value">{{ item.note || '无' }}</text></view>
</view>
<view class="status">计划状态<text class="value">{{ item.status || '无' }}</text></view>
</view>
<view class="preview" @click="previewImage(item.image)">
<image class="preview-img" :src="'data:image/png;base64,' + item.image" mode="aspectFill" />
</view>
</view>
</view>
<view class="load-status">
<text v-if="loading">加载中...</text>
<text v-else-if="noMore && inventoryPlanList.length">没有更多数据了</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
// @ts-ignore JS
import { getInventoryPlan } from '@/pages/api/fixedAssets.js'
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)
const noMore = ref(false)
const inventoryPlanList = ref<any[]>([])
onLoad(() => { fetchInventoryList() })
onReachBottom(() => {
if (!loading.value && !noMore.value) { page.value++; fetchInventoryList() }
})
async function fetchInventoryList() {
loading.value = true
try {
const data: any = await getInventoryPlan(page.value, pageSize.value)
const list = data?.data?.list || []
if (list.length < pageSize.value) noMore.value = true
console.log(list);
inventoryPlanList.value = inventoryPlanList.value.concat(list)
} catch (e) {
console.error('获取数据失败', e)
} finally {
loading.value = false
}
}
function gotoPlanDetail(item: any) {
const id = item.inventoryPlanId || item.inventoryId || item.id
if (!id) return
if (item.status === '已结束') {
uni.navigateTo({ url: `/pages/fixed-assets/inventory-end?planId=${id}` })
} else if (item.status === '正在进行') {
uni.navigateTo({ url: `/pages/fixed-assets/inventory-ing?planId=${id}` })
} else {
//
uni.navigateTo({ url: `/pages/fixed-assets/inventory-ing?planId=${id}` })
}
}
function previewImage(imageBase64: string) {
if (!imageBase64) {
uni.showToast({ title: '无图片可预览', icon: 'none' })
return
}
uni.previewImage({
urls: ['data:image/png;base64,' + imageBase64],
current: 'data:image/png;base64,' + imageBase64
})
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
padding: 24rpx;
}
.card {
background: #fff;
border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.inventory-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.inventory-item {
padding: 20rpx;
display: flex;
align-items: stretch;
/* 让左右两侧等高,右侧图片可拉满高度 */
gap: 16rpx;
}
.left {
/* 按内容宽度,不挤占右侧空间 */
flex: 0 0 auto;
min-width: 0;
}
.preview {
/* 占据剩余空间 */
flex: 1 1 0;
min-width: 0;
display: flex;
/* 使内部图片可以 100% 拉伸 */
}
.header {
margin-bottom: 8rpx;
}
.title {
font-size: 28rpx;
color: #222;
font-weight: 600;
}
.content .row {
font-size: 24rpx;
color: #666;
margin-top: 6rpx;
}
.status {
font-size: 24rpx;
color: #333;
margin-top: 10rpx;
}
.load-status {
text-align: center;
color: #9a9aa0;
margin: 20rpx 0;
}
.value {
color: #333;
}
/* 右侧图片自适应填满容器,保持裁剪 */
.preview-img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
/* H5 有效;小程序端由 mode=aspectFill 生效 */
border-radius: 12rpx;
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<view class="page-container">
<view class="error" v-if="!maintenanceInfo?.data">
{{ maintenanceInfo?.msg || '未查询到维修信息' }}
</view>
<view v-else>
<view class="maintenance-item card" v-for="item in maintenanceInfo.data" :key="item.id || item.maintenanceDate">
<view class="popup-text">资产名称{{ item.assetName }}</view>
<view class="popup-text">维修日期{{ item.maintenanceDate }}</view>
<view class="popup-text">维修费用{{ item.maintenanceCost }}</view>
<view class="popup-text">维修单位{{ item.maintenanceDapartment }}</view>
<view class="popup-text">维修人员{{ item.maintenanceMan }}</view>
<view class="popup-text">联系方式{{ item.maintenancePhone }}</view>
<view class="popup-text">登记人员{{ item.registrantName }}</view>
<view class="popup-text">登记日期{{ item.registrantDate }}</view>
<view class="popup-text">备注{{ item.note }}</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
// @ts-ignore JS
import { fetchMaintenanceInfoById } from '@/pages/api/fixedAssets.js'
const maintenanceInfo = ref<any>({})
const assetId = ref<string | number | null>(null)
onLoad((options?: Record<string, any>) => {
// 1) assetId
const fromQuery = options?.assetId || options?.assetid
if (fromQuery) {
assetId.value = fromQuery
}
// 2) 退 eventChannel
if (!assetId.value) {
const pages = getCurrentPages()
// @ts-ignore uni-app
const eventChannel = (pages[pages.length - 1] as any)?.getOpenerEventChannel?.()
eventChannel?.on('sendData', (res: any) => {
const data = res?.data || {}
assetId.value = data.assetId || data.assetid || null
if (assetId.value) fetchById(assetId.value)
})
}
})
onShow(() => {
// assetId
if (assetId.value && !maintenanceInfo.value?.data) {
fetchById(assetId.value)
}
})
async function fetchById(id: string | number) {
try {
const data: any = await fetchMaintenanceInfoById(id)
maintenanceInfo.value = data
} catch (e) {
console.error('获取数据失败', e)
maintenanceInfo.value = { msg: '获取数据失败' }
}
}
</script>
<style scoped>
.page-container { padding: 24rpx; }
.card { background: #fff; border-radius: 20rpx; box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.06); padding: 20rpx; margin-bottom: 16rpx; }
.popup-text { font-size: 24rpx; color: #333; margin-top: 6rpx; }
.error { color: #ef5350; background: #fff; border-radius: 12rpx; padding: 20rpx; }
</style>

View File

@ -0,0 +1,133 @@
<template>
<view class="page">
<view class="borrow-list">
<view v-for="item in borrowList.filter(item => !item.isReturn)" :key="item.borrowId || item.id"
class="borrow-item card">
<view class="item-header">
<text class="asset-name">{{ item.assetName }}</text>
<!-- <button class="btn btn-primary outline" @tap="button_returnAsset(item)">归还</button> -->
</view>
<view class="detail">借用日期{{ item.borrowDate }}</view>
<view class="detail">预计归还日期{{ item.returnDate }}</view>
<view class="detail">借用人{{ item.borrowerName }}{{ item.borrowerMobile }}</view>
<view class="detail">出借人{{ item.lenderName }}{{ item.lenderMobile }}</view>
<view class="detail">备注{{ item.note || '无' }}</view>
</view>
</view>
<view class="empty" v-if="borrowList.length === 0">暂无更多数据</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
// @ts-ignore JS
import { borrowInfo, returnAsset } from '@/pages/api/fixedAssets.js'
const borrowList = ref<any[]>([])
const userInfo = uni.getStorageSync('staff') || {}
onLoad(() => { loadBorrowList() })
onShow(() => { loadBorrowList() })
function loadBorrowList() {
if (!userInfo?.id) {
borrowList.value = []
return
}
borrowInfo(userInfo.id)
.then((res: any) => { borrowList.value = res?.data || [] })
.catch((err: any) => {
console.error(err)
uni.showToast({ title: '加载失败', icon: 'none' })
})
}
function button_returnAsset(item: any) {
uni.showModal({
content: '确认归还?',
success: async (r) => {
if (r.confirm) {
try {
await returnAsset(item.assetId)
uni.showToast({ title: '归还成功', icon: 'success' })
loadBorrowList()
} catch (e) {
uni.showToast({ title: '归还失败', icon: 'none' })
}
}
}
})
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f5f7fb;
padding: 24rpx;
}
.card {
background: #fff;
border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.borrow-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.borrow-item {
padding: 20rpx;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
}
.asset-name {
font-size: 30rpx;
color: #222;
font-weight: 600;
flex: 1;
}
.detail {
font-size: 24rpx;
color: #666;
margin-top: 6rpx;
}
.btn {
padding: 0 20rpx;
background: #e9efff;
color: #3a5ddd;
border-radius: 10rpx;
font-size: 24rpx;
}
.btn-primary {
background: #3a5ddd;
color: #fff;
}
.btn-primary.outline {
background: #fff;
color: #3a5ddd;
border: 2rpx solid #3a5ddd;
}
.empty {
text-align: center;
color: #9a9aa0;
margin-top: 40rpx;
}
</style>

View File

@ -0,0 +1,150 @@
<template>
<scroll-view scroll-y class="page">
<view class="card">
<view class="title">扫码查看信息</view>
<view class="desc">请扫描资产二维码二维码内容为资产编号 assetSn我们会自动跳转到资产详情页</view>
<button class="scan-btn" @tap="startScan">开始扫码</button>
<view v-if="lastContent" class="last">最近一次扫描内容{{ lastContent }}</view>
<view class="tips">如扫码失败可点击上方按钮重试</view>
</view>
</scroll-view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const lastContent = ref('')
function extractAssetSn(raw: string): string | null {
if (!raw) return null
const text = String(raw).trim()
// URL assetSn
const m = text.match(/[?#&]assetSn=([^&#]+)/i)
if (m && m[1]) {
try {
return decodeURIComponent(m[1])
} catch (_) {
return m[1]
}
}
// assetSn
return text || null
}
function toDetail(assetSn: string) {
if (!assetSn) {
uni.showToast({ title: '二维码无效', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/fixed-assets/detail?assetSn=${encodeURIComponent(assetSn)}` })
}
// @ts-ignore
import jzH5ScanCode from '@/uni_modules/jz-h5-scanCode/js/index.js'
function startScan() {
//
// #ifdef WEB
jzH5ScanCode.scanCode({
success: (res: any) => {
console.log('扫码成功:', res.result)
const content = res.result
lastContent.value = content || ''
const sn = extractAssetSn(content)
if (sn) {
toDetail(sn)
} else {
uni.showToast({ title: '未识别到资产编号', icon: 'none' })
}
},
fail: (res: any) => {
console.log('扫码失败:', res.errMsg)
uni.showToast({ title: '扫码已取消,' + res.errMsg, icon: 'none' })
}
})
// #endif
// #ifndef WEB
uni.scanCode({
scanType: ['qrCode'],
autoZoom: false,
success: (res) => {
const content = (res as any)?.result || (res as any)?.path || ''
lastContent.value = content || ''
const sn = extractAssetSn(content)
if (sn) {
toDetail(sn)
} else {
uni.showToast({ title: '未识别到资产编号', icon: 'none' })
}
},
fail: (e) => {
uni.showToast({ title: '扫码已取消,' + JSON.stringify(e), icon: 'none' })
},
complete: () => {
console.log(1);
//
}
})
// #endif
}
/* onMounted(() => {
// 便使
startScan()
}) */
</script>
<style scoped>
.page {
min-height: 100vh;
padding: 24rpx;
background: #f5f7fb;
box-sizing: border-box;
}
.card {
background: #fff;
border-radius: 16rpx;
padding: 28rpx;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.06);
}
.title {
font-size: 34rpx;
font-weight: 600;
color: #222;
margin-bottom: 10rpx;
}
.desc {
font-size: 26rpx;
color: #666;
margin-bottom: 24rpx;
line-height: 1.6;
}
.scan-btn {
background: #4b7aff;
color: #fff;
border-radius: 12rpx;
padding: 0;
font-size: 28rpx;
}
.last {
margin-top: 16rpx;
color: #444;
font-size: 24rpx;
word-break: break-all;
}
.tips {
margin-top: 8rpx;
color: #9a9aa0;
font-size: 22rpx;
}
</style>

View File

@ -19,19 +19,9 @@
<!-- 顶部滑动轮播图 --> <!-- 顶部滑动轮播图 -->
<view class="swiper-card card"> <view class="swiper-card card">
<swiper <swiper class="swiper" :current="swiperIndex" @change="changeSwiper" previous-margin="50rpx" next-margin="50rpx"
class="swiper" :indicator-dots="true" indicator-color="rgba(51,51,51,0.25)" indicator-active-color="#4b7aff" autoplay
:current="swiperIndex" interval="3000" circular>
@change="changeSwiper"
previous-margin="50rpx"
next-margin="50rpx"
:indicator-dots="true"
indicator-color="rgba(51,51,51,0.25)"
indicator-active-color="#4b7aff"
autoplay
interval="3000"
circular
>
<swiper-item v-for="(img, idx) in swiperImgs" :key="idx"> <swiper-item v-for="(img, idx) in swiperImgs" :key="idx">
<view class="swiper-item-wrap" :class="{ 'swiper-scale': swiperIndex !== idx }"> <view class="swiper-item-wrap" :class="{ 'swiper-scale': swiperIndex !== idx }">
<image class="swiper-img" :src="img" mode="aspectFill" /> <image class="swiper-img" :src="img" mode="aspectFill" />
@ -100,8 +90,8 @@ const assetOpen = ref(true)
const assetItems = [ const assetItems = [
{ key: 'fixed', text: '固定资产', icon: '/static/icons/fixed-assets.png' }, { key: 'fixed', text: '固定资产', icon: '/static/icons/fixed-assets.png' },
{ key: 'mine', text: '我的借用', icon: '/static/icons/my-loans.png' }, { key: 'mine', text: '我的借用', icon: '/static/icons/my-loans.png' },
{ key: 'inventory', text: '资产盘点', icon: '/static/icons/asset-inventory.png' },
{ key: 'scan', text: '扫码查看信息', icon: '/static/icons/scan-to-view-info.png' }, { key: 'scan', text: '扫码查看信息', icon: '/static/icons/scan-to-view-info.png' },
{ key: 'inventory', text: '资产盘点', icon: '/static/icons/asset-inventory.png' },
{ key: 'plan', text: '易耗品领用计划', icon: '/static/icons/consumables-plan.png' }, { key: 'plan', text: '易耗品领用计划', icon: '/static/icons/consumables-plan.png' },
{ key: 'consumable', text: '易耗品盘点', icon: '/static/icons/consumables-inventory.png' } { key: 'consumable', text: '易耗品盘点', icon: '/static/icons/consumables-inventory.png' }
] ]
@ -202,7 +192,34 @@ function toggleAsset() {
assetOpen.value = !assetOpen.value assetOpen.value = !assetOpen.value
} }
function handleAsset(key: string) { function handleAsset(key: string) {
uni.showToast({ title: `打开:${assetItems.find(i => i.key === key)?.text}`, icon: 'none' }) const item = assetItems.find(i => i.key === key)
if (!item) return
if (key === 'fixed') {
uni.navigateTo({ url: '/pages/fixed-assets/index' })
return
}
if (key === 'mine') {
uni.navigateTo({ url: '/pages/fixed-assets/my-borrows' })
return
}
if (key === 'inventory') {
uni.navigateTo({ url: '/pages/fixed-assets/inventory-plan' })
return
}
if (key === 'plan') {
uni.navigateTo({ url: '/pages/consumables/plan' })
return
}
if (key === 'consumable') {
uni.navigateTo({ url: '/pages/consumables/inventory-plan' })
return
}
if (key === 'scan') {
uni.navigateTo({ url: '/pages/fixed-assets/scan' })
return
}
//
uni.showToast({ title: `打开:${item.text}`, icon: 'none' })
} }
function previewCharge() { function previewCharge() {
uni.previewImage({ uni.previewImage({
@ -220,7 +237,7 @@ function previewCharge() {
.card { .card {
background: #fff; background: #fff;
border-radius: 20rpx; border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.06); box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
} }
.header { .header {
@ -230,31 +247,95 @@ function previewCharge() {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.header-left { .header-left {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10rpx; gap: 10rpx;
} }
.hello { color: #7a7a7a; font-size: 26rpx; }
.app-name { color: #111; font-size: 40rpx; font-weight: 600; } .hello {
.sub { color: #9a9aa0; font-size: 24rpx; } color: #7a7a7a;
.logo { width: 120rpx; height: 120rpx; } font-size: 26rpx;
.header-right { display: flex; align-items: center; gap: 12rpx; } }
.login-btn, .logout-btn { padding: 10rpx 20rpx; background: #3a5ddd; color: #fff; border-radius: 10rpx; font-size: 24rpx; }
.logout-btn { background: #ef5350; } .app-name {
.user-box { display: flex; align-items: center; gap: 12rpx; } color: #111;
.user-name { font-size: 24rpx; color: #333; } font-size: 40rpx;
font-weight: 600;
}
.sub {
color: #9a9aa0;
font-size: 24rpx;
}
.logo {
width: 120rpx;
height: 120rpx;
}
.header-right {
display: flex;
align-items: center;
gap: 12rpx;
}
.login-btn,
.logout-btn {
padding: 0rpx 20rpx;
background: #3a5ddd;
color: #fff;
border-radius: 10rpx;
font-size: 24rpx;
}
.logout-btn {
background: #ef5350;
}
.user-box {
display: flex;
align-items: center;
gap: 12rpx;
flex-direction: column;
}
.user-name {
font-size: 24rpx;
color: #333;
}
/* 轮播卡片 */ /* 轮播卡片 */
.swiper-card { margin: 0 32rpx 24rpx; padding: 12rpx; } .swiper-card {
.swiper { width: 100%; height: 300rpx; } margin: 0 32rpx 24rpx;
.swiper-item-wrap { padding: 12rpx;
width: 100%; height: 100%; border-radius: 24rpx; overflow: hidden; }
transition: transform 0.25s ease, box-shadow 0.25s ease;
box-shadow: 0 6rpx 18rpx rgba(0,0,0,0.08); .swiper {
width: 100%;
height: 300rpx;
}
.swiper-item-wrap {
width: 100%;
height: 100%;
border-radius: 24rpx;
overflow: hidden;
transition: transform 0.25s ease, box-shadow 0.25s ease;
box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.08);
}
.swiper-scale {
transform: scale(0.95);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
}
.swiper-img {
width: 100%;
height: 100%;
display: block;
} }
.swiper-scale { transform: scale(0.95); box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.06); }
.swiper-img { width: 100%; height: 100%; display: block; }
.ops { .ops {
margin: 0 32rpx 24rpx; margin: 0 32rpx 24rpx;
@ -262,6 +343,7 @@ function previewCharge() {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 24rpx; gap: 24rpx;
} }
.op-btn { .op-btn {
background: linear-gradient(180deg, #f9fbff 0%, #ffffff 100%); background: linear-gradient(180deg, #f9fbff 0%, #ffffff 100%);
border-radius: 20rpx; border-radius: 20rpx;
@ -271,23 +353,57 @@ function previewCharge() {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 12rpx; gap: 12rpx;
box-shadow: 0 6rpx 16rpx rgba(0,0,0,0.04); box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.04);
}
.op-icon {
width: 80rpx;
height: 80rpx;
}
.op-text {
font-size: 26rpx;
color: #333;
}
.section-card {
margin: 0 32rpx 24rpx;
} }
.op-icon { width: 80rpx; height: 80rpx; }
.op-text { font-size: 26rpx; color: #333; }
.section-card { margin: 0 32rpx 24rpx; }
.section-header { .section-header {
padding: 24rpx; padding: 24rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.section-left { display: flex; align-items: center; gap: 16rpx; }
.section-icon { width: 48rpx; height: 48rpx; } .section-left {
.section-text { display: flex; flex-direction: column; } display: flex;
.section-title-text { font-size: 30rpx; color: #222; font-weight: 600; } align-items: center;
.section-sub { font-size: 22rpx; color: #9a9aa0; } gap: 16rpx;
}
.section-icon {
width: 48rpx;
height: 48rpx;
}
.section-text {
display: flex;
flex-direction: column;
}
.section-title-text {
font-size: 30rpx;
color: #222;
font-weight: 600;
}
.section-sub {
font-size: 22rpx;
color: #9a9aa0;
}
.chevron { .chevron {
width: 24rpx; width: 24rpx;
height: 24rpx; height: 24rpx;
@ -296,7 +412,10 @@ function previewCharge() {
transform: rotate(45deg); transform: rotate(45deg);
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
.chevron.open { transform: rotate(135deg); }
.chevron.open {
transform: rotate(135deg);
}
.asset-grid { .asset-grid {
padding: 0 24rpx 24rpx; padding: 0 24rpx 24rpx;
@ -304,6 +423,7 @@ function previewCharge() {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 20rpx; gap: 20rpx;
} }
.grid-item { .grid-item {
background: #fafbff; background: #fafbff;
border-radius: 16rpx; border-radius: 16rpx;
@ -314,8 +434,18 @@ function previewCharge() {
padding: 24rpx 12rpx; padding: 24rpx 12rpx;
gap: 10rpx; gap: 10rpx;
} }
.grid-icon { width: 64rpx; height: 64rpx; }
.grid-text { font-size: 24rpx; color: #444; text-align: center; line-height: 1.4; } .grid-icon {
width: 64rpx;
height: 64rpx;
}
.grid-text {
font-size: 24rpx;
color: #444;
text-align: center;
line-height: 1.4;
}
.section-title { .section-title {
padding: 0 32rpx 12rpx; padding: 0 32rpx 12rpx;
@ -328,6 +458,16 @@ function previewCharge() {
padding: 20rpx; padding: 20rpx;
text-align: center; text-align: center;
} }
.notice-img { width: 100%; border-radius: 12rpx; }
.notice-tip { display: block; color: #9a9aa0; font-size: 22rpx; margin-top: 12rpx; } .notice-img {
width: 100%;
border-radius: 12rpx;
}
.notice-tip {
display: block;
color: #9a9aa0;
font-size: 22rpx;
margin-top: 12rpx;
}
</style> </style>

View File

@ -88,7 +88,7 @@ async function onLogin() {
} else { } else {
uni.showToast({ title: res?.data?.msg || '登录失败', icon: 'error', duration: 3000 }) uni.showToast({ title: res?.data?.msg || '登录失败', icon: 'error', duration: 3000 })
} }
} catch (err:any) { } catch (err: any) {
// util.request // util.request
console.warn('login error', err) console.warn('login error', err)
} }
@ -105,24 +105,72 @@ function onCancel() {
background: #f5f7fb; background: #f5f7fb;
padding: 24rpx 0 40rpx; padding: 24rpx 0 40rpx;
} }
.card { .card {
background: #fff; background: #fff;
border-radius: 20rpx; border-radius: 20rpx;
margin: 0 32rpx 24rpx; margin: 0 32rpx 24rpx;
box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.06); box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
} }
.header { padding: 28rpx 24rpx; }
.title { font-size: 36rpx; color: #111; font-weight: 600; }
.sub { display: block; margin-top: 8rpx; font-size: 24rpx; color: #888; }
.form { padding: 20rpx 24rpx; } .header {
.field { margin-bottom: 20rpx; } padding: 28rpx 24rpx;
.label { display: block; color: #666; font-size: 26rpx; margin-bottom: 10rpx; } }
.input {
width: 100%; height: 80rpx; padding: 0 20rpx; .title {
border: 2rpx solid #e6e8ef; border-radius: 12rpx; background: #fcfdff; font-size: 36rpx;
color: #111;
font-weight: 600;
}
.sub {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #888;
}
.form {
padding: 20rpx 24rpx;
}
.field {
margin-bottom: 20rpx;
}
.label {
display: block;
color: #666;
font-size: 26rpx;
margin-bottom: 10rpx;
}
.input {
box-sizing: border-box;
width: 100%;
height: 80rpx;
padding: 0 20rpx;
border: 2rpx solid #e6e8ef;
border-radius: 12rpx;
background: #fcfdff;
}
.actions {
display: flex;
gap: 20rpx;
margin-top: 8rpx;
}
.btn {
flex: 1;
border-radius: 14rpx;
background: #eef2ff;
color: #3a5ddd;
}
.btn.primary {
background: #3a5ddd;
color: #fff;
} }
.actions { display: flex; gap: 20rpx; margin-top: 8rpx; }
.btn { flex: 1; height: 84rpx; border-radius: 14rpx; background: #eef2ff; color: #3a5ddd; }
.btn.primary { background: #3a5ddd; color: #fff; }
</style> </style>

View File

@ -1,75 +1,400 @@
<template> <template>
<view class="page"> <scroll-view scroll-y class="page">
<view class="profile"> <!-- 个人信息卡片 -->
<view class="profile card">
<image class="avatar" src="/static/logo.png" /> <image class="avatar" src="/static/logo.png" />
<view class="info" v-if="!isLoggedIn"> <view class="info not-login-in" v-if="!isLoggedIn" @tap="goLogin">
<text class="name">未登录</text> <text class="name">未登录</text>
<text class="sub" @tap="goLogin">点击登录/注册</text> <text class="sub">点击登录/注册</text>
</view> </view>
<view class="info" v-else> <view class="info" v-else>
<text class="name">{{ staff?.name || '已登录' }}</text> <text class="name">{{ staff?.user_name || staff?.name || '已登录' }}</text>
<text class="sub">工号{{ staff?.job_no || '-' }}</text> <text class="sub">类型{{ userTypeText }}</text>
<text class="sub">角色{{ staff?.role_name || '-' }}</text>
<text class="sub" v-if="staff?.job_no">工号{{ staff?.job_no }}</text>
<view class="actions"> <view class="actions">
<button class="btn" @tap="logout">退出登录</button> <button class="btn btn-danger" @tap="logout">退出登录</button>
</view>
</view>
</view>
<!-- 我的任务标题 -->
<view class="section-title">
<text>我的任务</text>
</view>
<!-- 任务列表 -->
<view class="task-list">
<view v-if="loading" class="empty">加载中...</view>
<view v-else-if="!taskList.length" class="empty">暂无任务</view>
<view v-else class="list-wrap">
<view class="task card" v-for="(task, idx) in taskList" :key="task.id || idx">
<view class="row">
<text class="label">申请时间</text>
<text class="value">{{ fmt(task.decTime) }}</text>
</view>
<view class="row">
<text class="label">申请科室</text>
<text class="value">{{ fmt(task.decDeptName) }}</text>
</view>
<view class="row two-cols">
<view class="col">
<text class="label">申请床号</text>
<text class="value">{{ fmt(task.decBedNo) }}</text>
</view>
<view class="col">
<text class="label">运送工具</text>
<text class="value">{{ fmt(task.carryingtoolsName) }}</text>
</view>
</view>
<view class="row two-cols">
<view class="col">
<text class="label">是否预约</text>
<text class="value">{{ task.ordered ? '预约' : '立即' }}</text>
</view>
<view class="col" v-if="task.ordered">
<text class="label">预约时间</text>
<text class="value">{{ fmt(task.orderedDatetime) }}</text>
</view>
</view>
<view class="row">
<text class="label">运单状态</text>
<text class="value">{{ fmt(task.tasksheetStateName) }}</text>
</view>
<view class="row">
<text class="label">接单时间</text>
<text class="value">{{ fmt(task.recieveTime) }}</text>
</view>
<view class="row">
<text class="label">接单电话</text>
<text class="value">{{ fmt(task.undertakePersonTel) }}</text>
</view>
<view class="row">
<text class="label">启运时间</text>
<text class="value">{{ fmt(task.transportTime) }}</text>
</view>
<view class="row">
<text class="label">结运时间</text>
<text class="value">{{ fmt(task.finishTime) }}</text>
</view>
<view class="row">
<text class="label">服务费用</text>
<text class="value">{{ fmt(task.serviceFee) }}</text>
</view>
<view class="row" v-if="task.tasksheetState === '06'">
<text class="label">评价等级</text>
<text class="value">{{ fmt(task.satisfactionEvaluationName) }}</text>
</view>
<view class="actions-inline">
<button class="btn btn-text red" v-if="task.tasksheetState === '03' && task.undertakePersonTel" @tap="onPhoneCall(idx)">电话</button>
<button class="btn btn-text blue" v-if="task.tasksheetState === '05'" @tap="onEvaluate(idx)">评价</button>
<button class="btn btn-text yellow" @tap="dispNewDetail(idx)">详情</button>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<!-- 详情弹层 -->
<view v-if="isDispDetail" class="modal">
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">当前运单信息</text>
<text class="modal-close" @tap="isDispDetail = false">×</text>
</view>
<scroll-view scroll-y class="modal-body">
<view class="row"><text class="label">申请时间</text><text class="value">{{ fmt(currentTask?.decTime) }}</text></view>
<view class="row"><text class="label">申请科室</text><text class="value">{{ fmt(currentTask?.decDeptName) }}</text></view>
<view class="row"><text class="label">申请床号</text><text class="value">{{ fmt(currentTask?.decBedNo) }}</text></view>
<view class="row"><text class="label">申请电话</text><text class="value">{{ fmt(currentTask?.decTel) }}</text></view>
<view class="row"><text class="label">运输工具</text><text class="value">{{ fmt(currentTask?.carryingtoolsName) }}</text></view>
<view class="row"><text class="label">工具数量</text><text class="value">{{ fmt(currentTask?.carryingtoolsCount) }}</text></view>
<view class="row"><text class="label">是否预约</text><text class="value">{{ currentTask?.ordered ? '预约' : '立即' }}</text></view>
<view class="row" v-if="currentTask?.ordered"><text class="label">预约时间:</text><text class="value">{{ fmt(currentTask?.orderedDatetime) }}</text></view>
<view class="row"><text class="label">支付方式</text><text class="value">{{ fmt(currentTask?.paymentMethodName) }}</text></view>
<view class="row"><text class="label">代申请部门</text><text class="value">{{ fmt(currentTask?.needcontactDeptName) }}</text></view>
<view class="row"><text class="label">代申请电话</text><text class="value">{{ fmt(currentTask?.needcontactTel) }}</text></view>
<view class="row"><text class="label">运单状态</text><text class="value">{{ fmt(currentTask?.tasksheetStateName) }}</text></view>
<view class="row"><text class="label">接单骑手</text><text class="value">{{ fmt(currentTask?.undertakePersonName) }}</text></view>
<view class="row"><text class="label">骑手电话</text><text class="value">{{ fmt(currentTask?.undertakePersonTel) }}</text></view>
<view class="row"><text class="label">接单时间</text><text class="value">{{ fmt(currentTask?.recieveTime) }}</text></view>
<view class="row"><text class="label">启运时间</text><text class="value">{{ fmt(currentTask?.transportTime) }}</text></view>
<view class="row"><text class="label">结运时间</text><text class="value">{{ fmt(currentTask?.finishTime) }}</text></view>
<view class="row"><text class="label">服务费用</text><text class="value">{{ fmt(currentTask?.serviceFee) }}</text></view>
<view class="row" v-if="currentTask?.tasksheetState === '06'"><text class="label">评价等级:</text><text class="value">{{ fmt(currentTask?.satisfactionEvaluationName) }}</text></view>
<view class="row" v-if="currentTask?.tasksheetState === '06'"><text class="label">评价意见:</text><text class="value">{{ fmt(currentTask?.satisfactionContent) }}</text></view>
</scroll-view>
<view class="modal-footer">
<button class="btn" @tap="isDispDetail = false">关闭</button>
</view>
</view>
</view>
<!-- 评价弹层 -->
<view v-if="isEvaluate" class="modal">
<view class="modal-content">
<view class="modal-header">
<text class="modal-title">请对担架运送服务进行评价</text>
<text class="modal-close" @tap="cancelEvaluate">×</text>
</view>
<view class="modal-body">
<view class="form-row">
<text class="label">服务评价</text>
<view class="radio-group">
<label v-for="item in satisfactionEvaluationDatas" :key="item.value" class="radio-item">
<radio :value="item.value" :checked="evaluateRequest.satisfactionEvaluation === item.value" @tap="evaluateRequest.satisfactionEvaluation = item.value" />
<text>{{ item.label }}</text>
</label>
</view>
</view>
<view class="form-row">
<text class="label">意见</text>
<textarea class="textarea" maxlength="200" placeholder="请输入评价信息" v-model="evaluateRequest.satisfactionContent" />
</view>
</view>
<view class="modal-footer">
<button class="btn btn-primary" @tap="submitEvaluate">评价</button>
<button class="btn" @tap="cancelEvaluate">取消</button>
</view>
</view>
</view>
</scroll-view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
// @ts-ignore JS
import queryService from '@/service/queryService.js'
// @ts-ignore JS
import getService from '@/service/getService.js'
// @ts-ignore JS
import config from '@/config.js'
type Task = Record<string, any>
const isLoggedIn = ref(false) const isLoggedIn = ref(false)
const staff = ref<any | null>(null) const staff = ref<any | null>(null)
const isWeixin = ref(false)
const taskList = ref<Task[]>([])
const loading = ref(false)
const isDispDetail = ref(false)
const currentTask = ref<Task | null>(null)
const isEvaluate = ref(false)
const evaluateRequest = ref<{ id: string | number | null; satisfactionContent: string | null; satisfactionEvaluation: string }>({
id: null,
satisfactionContent: null,
satisfactionEvaluation: '04'
})
const satisfactionEvaluationDatas = [
{ value: '03', label: '不满意' },
{ value: '04', label: '满意' },
{ value: '05', label: '非常满意' }
]
const userTypeText = computed(() => {
const t = staff.value?.user_type
if (!t) return '非注册用户'
if (t === 1) return '系统用户'
if (t === 2) return '医院职工'
if (t === 3) return '物业员工'
return '非注册用户'
})
function fmt(v: any) {
return v === null || v === undefined || v === '' ? '' : String(v)
}
function goLogin() {
uni.navigateTo({ url: '/pages/login/index' })
}
function logout() {
uni.setStorageSync('staff', null)
uni.setStorageSync('token', null)
staff.value = null
isLoggedIn.value = false
taskList.value = []
uni.showToast({ title: '已退出登录', icon: 'none' })
}
async function initEnv() {
//
// @ts-ignore
isWeixin.value = process?.env?.VUE_APP_PLATFORM === 'mp-weixin'
if (isWeixin.value) {
// openid
const openid = uni.getStorageSync('openid')
if (!openid) {
try { await getService.getOpenId?.() } catch {}
}
}
}
async function loadTasks() {
loading.value = true
try {
const staffId = staff.value?.id
let openid: string | null = null
if (!staffId) {
openid = uni.getStorageSync('openid') || (config?.defaultOpenId || null)
}
const data = {
decTel: null,
needcontactTel: null,
openid: staffId ? staffId : openid,
decendDatetime: null,
decstartDatetime: null,
index: 0,
page: 0,
size: 0,
sort: null,
tasksheetStates: null
}
const res = await queryService.queryStretcherTask(data)
if (res?.data?.code === 0) {
taskList.value = res.data.data || []
} else {
taskList.value = []
}
} catch (e) {
taskList.value = []
console.warn('加载任务失败:', e)
} finally {
loading.value = false
}
}
function dispNewDetail(index: number) {
currentTask.value = taskList.value[index] || null
isDispDetail.value = true
}
function onPhoneCall(index: number) {
const t = taskList.value[index]
const tel = t?.undertakePersonTel
if (!tel) {
return uni.showToast({ title: '暂无联系电话', icon: 'none' })
}
uni.makePhoneCall({
phoneNumber: tel,
success() { console.log('拨打成功') },
fail() { uni.showToast({ title: '拨打失败', icon: 'error', duration: 3000 }) }
})
}
function onEvaluate(index: number) {
currentTask.value = taskList.value[index] || null
if (!currentTask.value) return
evaluateRequest.value.id = currentTask.value.id || null
evaluateRequest.value.satisfactionContent = null
evaluateRequest.value.satisfactionEvaluation = '04'
isEvaluate.value = true
}
async function submitEvaluate() {
if (!currentTask.value || !evaluateRequest.value.id) {
return uni.showToast({ title: '无法提交评价', icon: 'none' })
}
try {
const res = await queryService.evaluateStretcherTransport(evaluateRequest.value)
if (res?.data?.code === 0) {
uni.showToast({ title: '评价提交成功', icon: 'none' })
isEvaluate.value = false
//
evaluateRequest.value = { id: null, satisfactionContent: null, satisfactionEvaluation: '04' }
//
await loadTasks()
} else {
uni.showToast({ title: '评价提交失败', icon: 'error', duration: 3000 })
}
} catch (e) {
uni.showToast({ title: '评价提交失败', icon: 'error', duration: 3000 })
}
}
function cancelEvaluate() {
isEvaluate.value = false
evaluateRequest.value = { id: null, satisfactionContent: null, satisfactionEvaluation: '04' }
}
function sync() { function sync() {
staff.value = uni.getStorageSync('staff') || null staff.value = uni.getStorageSync('staff') || null
isLoggedIn.value = !!staff.value isLoggedIn.value = !!staff.value
} }
function goLogin() { onMounted(async () => {
uni.navigateTo({ url: '/pages/login/index' })
}
function logout() {
uni.setStorageSync('staff', null)
uni.setStorageSync('token', null)
sync() sync()
uni.showToast({ title: '已退出登录', icon: 'none' }) await initEnv()
} await loadTasks()
})
onMounted(sync) onShow(async () => {
sync()
await loadTasks()
})
</script> </script>
<style scoped> <style scoped>
.page { .page {
min-height: 100vh; min-height: 100vh;
background: #f7f8fa; background: #f5f7fb;
}
.card {
background: #fff;
border-radius: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.06);
} }
.profile { .profile {
margin: 32rpx;
padding: 32rpx;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24rpx; gap: 24rpx;
padding: 40rpx 32rpx;
background: #fff;
} }
.avatar { .avatar {
width: 96rpx; width: 96rpx;
height: 96rpx; height: 96rpx;
border-radius: 50%; border-radius: 50%;
} }
.info { .info { display: flex; flex-direction: column; }
display: flex; .name { font-size: 32rpx; color: #333; }
flex-direction: column; .sub { font-size: 24rpx; color: #999; }
}
.name {
font-size: 32rpx;
color: #333;
}
.sub {
font-size: 24rpx;
color: #999;
}
.actions { margin-top: 16rpx; } .actions { margin-top: 16rpx; }
.btn { padding: 12rpx 20rpx; background: #ef5350; color: #fff; border-radius: 10rpx; font-size: 24rpx; } .btn { padding: 12rpx 20rpx; background: #3a5ddd; color: #fff; border-radius: 10rpx; font-size: 24rpx; }
.btn-danger { background: #ef5350; }
.btn-primary { background: #3a5ddd; }
.btn-text { background: transparent; color: #3a5ddd; padding: 6rpx 10rpx; }
.btn-text.red { color: #ef5350; }
.btn-text.blue { color: #3a5ddd; }
.btn-text.yellow { color: #f39c12; }
.section-title { padding: 0 32rpx 12rpx; color: #666; font-size: 26rpx; }
.task-list { padding: 0 32rpx 32rpx; }
.empty { text-align: center; color: #9a9aa0; padding: 40rpx 0; }
.list-wrap { display: flex; flex-direction: column; gap: 20rpx; }
.task { padding: 20rpx; }
.row { display: flex; align-items: center; padding: 8rpx 0; flex-wrap: wrap; }
.two-cols { justify-content: space-between; }
.two-cols .col { width: 48%; display: flex; }
.label { color: #666; font-size: 24rpx; }
.value { color: #333; font-size: 26rpx; }
.actions-inline { display: flex; justify-content: flex-end; gap: 16rpx; padding-top: 8rpx; }
.actions-inline .btn{ flex: 1;}
/* 简易弹层样式 */
.modal { position: fixed; left: 0; top: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 999; }
.modal-content { width: 86%; max-height: 76vh; background: #fff; border-radius: 16rpx; overflow: hidden; display: flex; flex-direction: column; }
.modal-header { padding: 20rpx 24rpx; display: flex; align-items: center; justify-content: space-between; border-bottom: 1rpx solid #eee; }
.modal-title { font-size: 30rpx; color: #222; font-weight: 600; }
.modal-close { font-size: 40rpx; color: #999; }
.modal-body { padding: 16rpx 24rpx; max-height: 60vh; }
.modal-footer { padding: 16rpx 24rpx; display: flex; justify-content: flex-end; gap: 16rpx; border-top: 1rpx solid #eee; }
.modal-footer .btn { flex: 1; }
.form-row { margin-bottom: 20rpx; }
.radio-group { display: flex; gap: 24rpx; flex-wrap: wrap; }
.radio-item { display: flex; align-items: center; gap: 8rpx; }
.textarea { width: 100%; min-height: 160rpx; border: 1rpx solid #e5e6eb; border-radius: 12rpx; padding: 12rpx; font-size: 26rpx; }
</style> </style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

View File

@ -0,0 +1,244 @@
## 1.0.52025-07-12
新增 静态销毁方法
// 销毁插件
jzH5ScanCode.destroy();
用于页面左滑返回 监听不了返回的情况
使用方法
import {
onLoad,
onBackPress
} from "@dcloudio/uni-app";
onBackPress(() => {
jzH5ScanCode.destroy();
})
## 1.0.42025-07-09
--修复 在pc 网页中 canvas 画面变形 导致不能识别的问题采用局部中心canvas 解决
--移除大量不必要的打印日志
## 1.0.32025-07-08
修复部分手机canvas 初始化 尺寸错位问题
## 1.0.22025-07-07
---优化初始进入 显示局部canvas
---修复选择图片/拍照 所用的图片没有二维码,无法失败回调的问题
## 1.0.12025-06-27
修复用户选择相册-> 拍照后导致卡死的问题
优化性能,减少卡死概率
# 更新日志
## 1.1.92024-12-19
### 🔧 拍照扫码体验优化 - 完美解决警告弹窗和界面卡死问题
#### 🐛 关键问题修复
- **消除重复弹窗**:修复拍照成功后仍然出现警告弹窗的问题
- **自动隐藏界面**:拍照完成后自动隐藏扫描界面,避免界面卡死
- **统一错误处理**:重构错误处理逻辑,避免多重弹窗冲突
- **处理流程优化**:统一图片处理路径,消除重复处理导致的异常
#### 🎯 用户体验提升
- **无干扰扫码**:成功识别后不再显示多余的错误提示
- **智能界面管理**成功1秒后隐藏界面失败1.5秒后隐藏
- **静默失败处理**:失败时只在控制台记录,不干扰用户
- **流畅交互体验**:消除了拍照->识别->弹窗->卡死的不良体验
#### 🛠️ 技术改进
- **处理逻辑重构**`handleUniImageSelected`现在统一调用`processUniSelectedImage`
- **新增统一方法**`hideScannerUIAfterImageProcess`统一管理界面隐藏
- **错误处理优化**:避免`handleScanError``showErrorMessage`的重复调用
- **资源清理增强**:确保界面隐藏时完整清理所有状态
#### 📊 修复效果
- **警告弹窗消除**100%解决拍照成功后的警告弹窗问题
- **界面卡死修复**:完全避免拍照后的界面卡死问题
- **用户流程优化**:拍照->识别->自动关闭的完整体验
- **错误提示减少**非必要错误提示减少90%
## 1.1.82024-12-19
### 🔧 图片加载修复 - 彻底解决选择图片后的错误弹窗
#### 🐛 关键问题修复
- **图片加载失败修复**:修复选择图片后显示"图片加载失败"的问题
- **跨域问题解决**修复uniapp临时文件路径的跨域访问问题
- **重复弹窗修复**:解决同一错误导致多个弹窗的问题
- **路径兼容性**:智能识别不同类型的图片路径格式
#### 🛠️ 技术改进
- **智能路径处理**自动检测uniapp临时文件、网络图片等不同路径类型
- **错误传播优化**:避免多层错误处理导致的重复提示
- **降级机制完善**Worker处理失败时的更好恢复能力
- **异常捕获增强**:更精确的错误分类和处理
#### 💡 处理策略
- **临时文件检测**:识别`tmp_``temp``wxfile://`等uniapp路径
- **网络图片处理**自动为http/https图片设置跨域模式
- **错误去重机制**:避免相同错误的多次弹窗提示
- **资源清理优化**:确保处理失败时的完整资源释放
#### 📊 修复效果
- **图片加载成功率**从60%提升到95%
- **错误弹窗减少**从平均2-3个减少到1个
- **用户体验改善**:消除了选择图片后的立即报错问题
- **兼容性提升**:支持更多图片路径格式
## 1.1.72024-12-19
### 🔧 编译修复 - Web Worker 优化
#### 🐛 问题修复
- **编译错误修复**修复WorkerManager导出格式不兼容的问题
- **模块导入优化**统一使用ES6 export default导出格式
- **兼容性优化**暂时禁用OffscreenCanvas依赖确保浏览器兼容性
#### ⚡ 性能优化
- **主线程优化**:改进图片压缩的主线程处理逻辑
- **UI响应性提升**使用requestAnimationFrame确保UI更新流畅
- **进度显示优化**:实时显示压缩进度,避免假死感觉
#### 🎨 用户体验改进
- **进度条动画**:绿色渐变进度条,视觉效果更佳
- **成功提示动画**:添加识别成功的动画反馈
- **处理状态提示**:实时显示"正在分析图片..."等状态信息
- **防立即失败**:添加缓冲时间,避免拍照后立即报错
#### 🔧 技术改进
- **Worker降级策略**不支持Worker时自动使用主线程处理
- **错误处理优化**:更完善的异常捕获和用户友好提示
- **资源管理优化**改进Worker生命周期管理
- **模块化重构**:更清晰的模块职责划分
#### 📊 编译兼容性
- ✅ **HBuilder X**:完美支持项目编译打包
- ✅ **Webpack**:兼容现代打包工具
- ✅ **Vite**:支持快速开发构建
- ✅ **uni-app**完整的uni-app生态兼容
---
## 1.1.62024-12-28
### 🚀 重大优化 - 彻底解决拍照卡死问题
#### 🎯 核心问题解决
- **极限压缩模式**:针对超大图片(>4MP启用极限压缩避免拍照后卡死
- **分片处理技术**大图片分块处理每200px一个区块避免一次性处理导致卡死
- **智能尺寸控制**最大尺寸从1920px降低到800px超大图片压缩到400px正方形
#### ⚡ 性能突破
- **图片处理速度**分片处理提升90%处理速度
- **内存占用优化**极限压缩减少95%内存使用
- **解码效率提升**:简化预处理流程,去除复杂算法
- **CPU占用降低**:快速渲染模式,关闭抗锯齿优化
#### 🔧 技术升级
- 新增`extremeCompressForScan()`极限压缩方法
- 实现`drawImageInChunks()`分片绘制技术
- 添加多重超时机制图片处理6秒、解码操作8秒
- 强制资源清理:超时或错误时立即释放内存
#### 🎨 用户体验改进
- 添加处理进度提示UI"正在优化图片..."
- 智能压缩策略:自动识别图片大小选择压缩模式
- 友好的错误提示:超时提示更明确
- 无感知处理:后台自动优化,用户无需干预
#### 📊 性能数据
- **图片压缩比**从2MB→50KB96%压缩率)
- **处理时间**从15秒→2秒87%提升)
- **内存峰值**从200MB→10MB95%降低)
- **卡死率**从80%→0%(完全解决)
#### 🛠️ 技术细节
- 超大图片检测:`originalSize > 4000000`像素
- 分片大小200px区块10ms间隔处理
- 压缩目标400px正方形0.6质量
- 超时控制6层超时保护机制
## 1.0.02025-06-17
### 🎉 首次发布
#### ✨ 核心功能
- **完全兼容uni.scanCode**提供与官方API完全一致的接口
- **摄像头实时扫码**:支持二维码、条形码实时识别
- **图片扫码**:支持从相册选择图片进行扫码
- **多种码制支持**:二维码(QR Code)、条形码(EAN、Code128等)
#### 🎨 用户界面
- **微信风格UI**:仿微信扫一扫界面设计
- **扫描动画**:流畅的扫描线动画效果
- **响应式布局**:适配不同屏幕尺寸
- **简洁操作**:一键扫码,操作简单
#### ⚡ 性能优化
- **高效扫码引擎**集成jsQR和Quagga.js双引擎
- **智能降级**优先使用原生BarcodeDetector API
- **内存优化**:及时清理摄像头和解码资源
- **CPU优化**控制扫码频率减少CPU占用
#### 📱 平台兼容性
- ✅ **H5浏览器**Chrome、Safari、Firefox、Edge
- ✅ **移动端H5**iOS Safari、Android Chrome
- ✅ **微信浏览器**:支持微信内置浏览器
- ✅ **App端**通过webview组件支持
- ❌ **小程序端**:不支持(技术限制)
#### 🔧 技术特性
- **模块化设计**5个核心模块分工明确
- **TypeScript支持**:完整的类型定义
- **错误处理**:完善的异常捕获和用户提示
- **调试模式**:开发环境下的详细日志
#### 📦 核心模块
- **index.js**主入口和API管理
- **cameraScanner.js**:摄像头访问和管理
- **qrCodeDecoder.js**:二维码识别引擎
- **uiManager.js**:用户界面管理
- **utils.js**:工具函数和调试
#### 🎯 典型应用场景
- 商品条码扫描购物应用
- 二维码支付扫码功能
- 会议签到扫码应用
- 任何需要H5扫码的场景
#### 🔧 主要API
```javascript
// 基础扫码
uni.scanCode({
success: (res) => {
console.log('扫码结果:', res.result);
}
});
// 高级配置
uni.scanCode({
scanType: ['qrCode', 'barCode'],
autoDecodeCharSet: true,
success: (res) => {
console.log('码制:', res.scanType);
console.log('结果:', res.result);
}
});
```
#### 🚀 性能提升
- **CPU占用降低**扫码间隔优化降低约60%CPU占用
- **内存优化**资源清理优化降低约30%内存占用
- **响应速度**UI简化和异步优化提升约40%响应速度
- **稳定性提升**:修复卡死问题,显著提升稳定性
---
### 📈 版本统计
- **总代码行数**约2000行
- **核心模块数**5个专业模块
- **支持码制数**10+种常见码制
- **兼容浏览器数**:主流浏览器全覆盖
### 🚀 后续计划
- [ ] 支持更多条码格式
- [ ] 添加扫码历史记录
- [ ] 优化低光环境识别
- [ ] 支持批量扫码模式
---
> **注意**本插件基于MIT协议开源专为H5环境设计提供最佳的扫码体验

View File

@ -0,0 +1,364 @@
# jz-h5-scanCode
## 🔍 插件简介
jz-h5-scanCode 是一个专为 H5 环境设计的二维码扫描插件,提供与 `uni.scanCode` 完全兼容的 API 接口。插件采用模块化架构,支持多种识别引擎,提供微信风格的扫码界面。
## ✨ 核心特性
### 🎯 API 兼容性
- 与 `uni.scanCode` API 完全兼容
- 支持相同的参数配置和回调函数
- 平台自动判断H5 使用自定义实现,其他平台使用原生 API
### 📱 用户界面
- **微信风格设计**:仿微信扫码界面,用户体验一致
- **全屏沉浸式**:黑色背景,突出扫码区域
- **动态扫描线**:绿色扫描线上下移动,提供视觉反馈
- **自定义配置**:支持 8 种扫描框颜色选择
### 🔧 技术架构
- **模块化设计**:核心功能分离,便于维护和扩展
- **多重识别引擎**BarcodeDetector API + jsQR 库双重保障
- **智能降级**:摄像头不可用时自动切换到图片选择模式
- **图像预处理**:多种算法提高二维码识别成功率
## 📦 安装使用
### 1. uni-app 项目中使用
```javascript
// 导入插件
import jzH5ScanCode from '@/uni_modules/jz-h5-scanCode/js/index.js'
// 基础扫码
jzH5ScanCode.scanCode({
success: (res) => {
console.log('扫码成功:', res.result)
},
fail: (res) => {
console.log('扫码失败:', res.errMsg)
}
})
```
### 2. 普通 H5 项目中使用
```javascript
// ES6 模块导入
import jzH5ScanCode from './uni_modules/jz-h5-scanCode/js/index.js'
// 或者直接引入
<script type="module">
import jzH5ScanCode from './uni_modules/jz-h5-scanCode/js/index.js'
// 使用插件
</script>
```
## 🔧 API 文档
### scanCode(options)
主要扫码方法,与 uni.scanCode API 完全兼容。
#### 参数说明
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| success | Function | 否 | - | 扫码成功回调函数 |
| fail | Function | 否 | - | 扫码失败回调函数 |
| complete | Function | 否 | - | 扫码完成回调函数 |
| scanType | Array | 否 | ['qrCode'] | 扫码类型,目前仅支持二维码 |
| onlyFromCamera | Boolean | 否 | false | 是否仅允许从相机扫码 |
| scanFrameColor | String | 否 | '#00ff00' | 扫描框颜色 |
#### 回调参数
**success 回调参数:**
| 参数名 | 类型 | 说明 |
|--------|------|------|
| result | String | 扫码内容 |
| scanType | String | 扫码类型 |
| charSet | String | 字符集 |
| imageChannel | String | 图像来源camera/album |
| errMsg | String | 错误信息 |
**fail 回调参数:**
| 参数名 | 类型 | 说明 |
|--------|------|------|
| errMsg | String | 错误信息 |
#### 使用示例
```javascript
// 基础扫码
jzH5ScanCode.scanCode({
success: (res) => {
console.log('扫码结果:', res.result)
console.log('扫码类型:', res.scanType)
console.log('图像来源:', res.imageChannel)
},
fail: (res) => {
console.log('扫码失败:', res.errMsg)
},
complete: () => {
console.log('扫码完成')
}
})
// 高级配置
jzH5ScanCode.scanCode({
scanType: ['qrCode'],
onlyFromCamera: true,
scanFrameColor: '#007aff',
success: (res) => {
// 处理扫码结果
}
})
```
### 静态方法
#### isCameraSupported()
检查设备是否支持摄像头。
```javascript
const supported = await jzH5ScanCode.isCameraSupported()
console.log('摄像头支持:', supported)
```
#### isBarcodeDetectorSupported()
检查浏览器是否支持 BarcodeDetector API。
```javascript
const supported = jzH5ScanCode.isBarcodeDetectorSupported()
console.log('BarcodeDetector 支持:', supported)
```
#### getSupportedFormats()
获取支持的条码格式。
```javascript
const formats = await jzH5ScanCode.getSupportedFormats()
console.log('支持的格式:', formats)
```
#### getPluginInfo()
获取插件信息。
```javascript
const info = jzH5ScanCode.getPluginInfo()
console.log('插件信息:', info)
```
## 🎨 界面配置
### 扫描框颜色
插件支持 8 种预设颜色:
- `#00ff00` - 绿色(默认)
- `#007aff` - 蓝色
- `#ff3b30` - 红色
- `#ff9500` - 橙色
- `#ffcc00` - 黄色
- `#34c759` - 青绿色
- `#af52de` - 紫色
- `#ffffff` - 白色
```javascript
jzH5ScanCode.scanCode({
scanFrameColor: '#007aff', // 蓝色扫描框
success: (res) => {
// 处理结果
}
})
```
## 🏗️ 技术架构
### 核心模块
#### 1. index.js - 主入口
- API 兼容性处理
- 平台判断逻辑
- 事件管理和协调
#### 2. cameraScanner.js - 摄像头管理
- 摄像头权限申请
- 视频流获取和管理
- 图像数据提取
#### 3. qrCodeDecoder.js - 二维码解码
- BarcodeDetector API 集成
- jsQR 库集成
- 图像预处理算法
- 多重识别策略
#### 4. uiManager.js - 界面管理
- 微信风格 UI 创建
- 扫描动画效果
- 用户交互处理
- 响应式布局
#### 5. utils.js - 工具函数
- 调试日志管理
- 错误处理
- 通用工具方法
### 识别流程
```mermaid
graph TD
A[开始扫码] --> B{检查平台}
B -->|H5| C[检查摄像头权限]
B -->|其他平台| D[使用 uni.scanCode]
C -->|有权限| E[创建扫码界面]
C -->|无权限| F[降级到图片选择]
E --> G[初始化解码器]
F --> G
G --> H[开始识别循环]
H --> I{识别成功?}
I -->|是| J[返回结果]
I -->|否| H
```
## 🔍 识别引擎
### 1. BarcodeDetector API
- **优先级**:最高
- **支持浏览器**Chrome 83+, Edge 83+
- **特点**:原生实现,性能最佳
### 2. jsQR 库
- **优先级**:备用
- **支持浏览器**:所有现代浏览器
- **特点**:纯 JavaScript 实现,兼容性好
### 3. 图像预处理
- **灰度转换**:提高识别速度
- **对比度增强**:改善图像质量
- **二值化处理**:突出二维码特征
- **图像缩放**:适配不同尺寸
- **图像旋转**:处理倾斜的二维码
## 📱 浏览器兼容性
| 功能 | Chrome | Firefox | Safari | Edge | 移动端 |
|------|--------|---------|--------|------|--------|
| 摄像头扫码 | ✅ 60+ | ✅ 55+ | ✅ 11+ | ✅ 79+ | ✅ |
| 图片扫码 | ✅ | ✅ | ✅ | ✅ | ✅ |
| BarcodeDetector | ✅ 83+ | ❌ | ❌ | ✅ 83+ | 部分 |
| jsQR 识别 | ✅ | ✅ | ✅ | ✅ | ✅ |
## 🛠️ 开发指南
### 本地开发
1. **环境要求**
- 现代浏览器
- HTTPS 或 localhost 环境
- HTTP 服务器(不能直接打开 HTML 文件)
2. **调试模式**
```javascript
// 开启调试日志
console.log('调试模式已开启')
```
3. **常见问题**
- **摄像头权限**:确保使用 HTTPS 或 localhost
- **识别失败**:检查二维码清晰度和光线条件
- **界面异常**:检查 CSS 样式冲突
### 自定义扩展
```javascript
// 扩展识别引擎
import { QRCodeDecoder } from './qrCodeDecoder.js'
class CustomDecoder extends QRCodeDecoder {
// 自定义识别逻辑
}
// 扩展 UI 样式
import { UIManager } from './uiManager.js'
class CustomUIManager extends UIManager {
// 自定义界面
}
```
## 📊 性能优化
### 识别性能
- **多重引擎**BarcodeDetector + jsQR 双重保障
- **图像预处理**:多种算法提高识别率
- **智能采样**:减少不必要的计算
### 内存管理
- **及时清理**:自动释放摄像头资源
- **事件解绑**:防止内存泄漏
- **DOM 清理**:完全移除 UI 元素
### 用户体验
- **快速响应**:扫码成功立即返回
- **流畅动画**60fps 扫描线动画
- **错误提示**:友好的错误信息
## 🔧 故障排除
### 常见问题
1. **摄像头无法启动**
- 检查是否使用 HTTPS 协议
- 确认浏览器摄像头权限
- 尝试刷新页面重新授权
2. **二维码识别失败**
- 确保二维码清晰可见
- 调整光线条件
- 尝试不同角度和距离
3. **界面显示异常**
- 检查 CSS 样式冲突
- 确认 z-index 设置
- 验证 DOM 结构完整性
### 调试信息
```javascript
// 获取详细的调试信息
const info = jzH5ScanCode.getPluginInfo()
console.log('插件版本:', info.version)
console.log('浏览器支持:', info.cameraSupported)
console.log('识别引擎:', info.barcodeDetectorSupported)
```
## 📄 许可证
MIT License - 详见 [LICENSE](../../LICENSE) 文件
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📞 支持
如有问题,请通过以下方式联系:
- 提交 Issue
- 邮箱:[your-email@example.com]
- 文档:查看项目 README
---
**版本**: 1.0.0
**更新时间**: 2024年
**作者**: JZ

View File

@ -0,0 +1,513 @@
/**
* 摄像头扫描器模块
* 负责处理摄像头访问视频流管理和图像数据获取
*/
class CameraScanner {
constructor() {
this.video = null;
this.canvas = null;
this.ctx = null;
this.stream = null;
this.drawFrameId = null; // 添加绘制帧ID
this.lastDrawTime = 0; // 上次绘制时间
this.drawInterval = 33; // 绘制间隔约30fps
this.config = {
width: 640,
height: 480,
facingMode: 'environment' // 后置摄像头
};
}
/**
* 检测设备类型
* @returns {string} 'mobile' | 'tablet' | 'desktop'
*/
detectDeviceType() {
const userAgent = navigator.userAgent;
const screenWidth = window.innerWidth;
// 检测移动设备
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
// 更精确的判断
if (isMobile) {
// iPad或大屏手机视为平板
if (/iPad/i.test(userAgent) || screenWidth >= 768) {
return 'tablet';
}
return 'mobile';
}
// PC端
return 'desktop';
}
/**
* 判断是否为手机设备
*/
isMobileDevice() {
return this.detectDeviceType() === 'mobile';
}
/**
* 初始化摄像头
* @param {HTMLCanvasElement} canvasElement - canvas元素
* @param {Object} config - 配置选项
* @param {Function} onVideoSizeReady - 视频尺寸确定后的回调函数
*/
async init(canvasElement, config = {}, onVideoSizeReady = null) {
try {
this.canvas = canvasElement;
this.ctx = this.canvas.getContext('2d');
this.config = { ...this.config, ...config };
this.onVideoSizeReady = onVideoSizeReady;
// 获取video元素由UIManager创建
this.video = document.querySelector('.jz-scanner-video');
if (!this.video) {
throw new Error('Video元素未找到');
}
// 获取摄像头流
await this.startCamera();
// 摄像头初始化成功
} catch (error) {
console.error('摄像头初始化失败:', error);
throw error;
}
}
/**
* 启动摄像头
*/
async startCamera() {
try {
const constraints = {
video: {
width: { ideal: this.config.width },
height: { ideal: this.config.height },
facingMode: { ideal: this.config.facingMode }
},
audio: false
};
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
this.video.srcObject = this.stream;
// 等待视频开始播放
await new Promise((resolve, reject) => {
this.video.onloadedmetadata = () => {
this.video.play().then(resolve).catch(reject);
};
this.video.onerror = reject;
});
// 设置canvas尺寸根据设备类型采用不同策略
const videoWidth = this.video.videoWidth;
const videoHeight = this.video.videoHeight;
if (videoWidth > 0 && videoHeight > 0) {
// 计算合适的canvas尺寸
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60; // 减去导航栏高度
const isMobile = this.isMobileDevice();
const videoAspectRatio = videoWidth / videoHeight;
let canvasWidth, canvasHeight;
if (isMobile) {
// 手机端:全屏显示,保持视频比例填充
const screenAspectRatio = screenWidth / screenHeight;
if (videoAspectRatio > screenAspectRatio) {
// 视频更宽,以高度为准
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
} else {
// 视频更高,以宽度为准
canvasWidth = screenWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
} else {
// PC/平板端高度铺满宽度最大600px
const maxWidth = 600;
// 以高度为基准计算宽度
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
// 如果宽度超过最大限制,则以宽度为准
if (canvasWidth > maxWidth) {
canvasWidth = maxWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
}
this.canvas.width = Math.round(canvasWidth);
this.canvas.height = Math.round(canvasHeight);
// Canvas尺寸已更新
// 通知主程序视频尺寸已确定可以更新UI中的canvas
if (this.onVideoSizeReady && typeof this.onVideoSizeReady === 'function') {
this.onVideoSizeReady(videoWidth, videoHeight);
}
} else {
console.warn('视频尺寸无效使用屏幕尺寸初始化canvas');
// 如果视频尺寸无效,根据设备类型使用不同策略
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60;
const isMobile = this.isMobileDevice();
let canvasWidth, canvasHeight;
if (isMobile) {
// 手机端:全屏
canvasWidth = screenWidth;
canvasHeight = screenHeight;
} else {
// PC/平板端高度铺满宽度最大600px
const maxWidth = 600;
canvasWidth = Math.min(screenWidth, maxWidth);
canvasHeight = screenHeight;
}
this.canvas.width = Math.round(canvasWidth);
this.canvas.height = Math.round(canvasHeight);
// 即使视频尺寸无效也要显示canvas让用户能看到界面
this.showCanvasDirectly();
}
// 开始绘制视频帧到canvas用于二维码识别
this.drawVideoFrame();
// 在视频开始播放后再次检查尺寸,确保正确设置
setTimeout(() => {
if (this.video && this.canvas && this.video.videoWidth > 0 && this.video.videoHeight > 0) {
const newVideoWidth = this.video.videoWidth;
const newVideoHeight = this.video.videoHeight;
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60;
const isMobile = this.isMobileDevice();
// 计算期望的canvas尺寸使用最新的设备适配逻辑
const videoAspectRatio = newVideoWidth / newVideoHeight;
let expectedWidth, expectedHeight;
if (isMobile) {
// 手机端全屏逻辑
const screenAspectRatio = screenWidth / screenHeight;
if (videoAspectRatio > screenAspectRatio) {
expectedHeight = screenHeight;
expectedWidth = expectedHeight * videoAspectRatio;
} else {
expectedWidth = screenWidth;
expectedHeight = expectedWidth / videoAspectRatio;
}
} else {
// PC/平板端逻辑
const maxWidth = 600;
expectedHeight = screenHeight;
expectedWidth = expectedHeight * videoAspectRatio;
if (expectedWidth > maxWidth) {
expectedWidth = maxWidth;
expectedHeight = expectedWidth / videoAspectRatio;
}
}
expectedWidth = Math.round(expectedWidth);
expectedHeight = Math.round(expectedHeight);
// 如果尺寸有变化重新设置canvas
if (this.canvas.width !== expectedWidth || this.canvas.height !== expectedHeight) {
// 延迟检查发现canvas尺寸需要调整
this.updateCanvasSizeInternal(newVideoWidth, newVideoHeight);
}
}
}, 500); // 500ms后再次检查
// 摄像头启动成功
// 备用显示机制如果1秒后canvas仍然隐藏强制显示
setTimeout(() => {
if (this.canvas && this.canvas.style.opacity === '0') {
// 备用机制强制显示canvas
this.canvas.style.opacity = '1';
}
}, 1000);
} catch (error) {
console.error('启动摄像头失败:', error);
throw new Error('无法访问摄像头: ' + error.message);
}
}
/**
* 绘制视频帧到canvas用于二维码识别- 优化版本
*/
drawVideoFrame() {
// 检查是否需要停止绘制
if (!this.video || !this.canvas || !this.ctx || !this.stream) {
// 绘制条件不满足,停止绘制循环
return;
}
const now = Date.now();
// 控制绘制频率减少CPU占用
if (now - this.lastDrawTime >= this.drawInterval) {
if (this.video.readyState === this.video.HAVE_ENOUGH_DATA) {
try {
// 清除canvas以避免残留图像
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 将video画面绘制到canvas用于二维码识别
// 确保完整绘制,保持视频比例
this.ctx.drawImage(
this.video,
0, 0, this.video.videoWidth, this.video.videoHeight,
0, 0, this.canvas.width, this.canvas.height
);
this.lastDrawTime = now;
} catch (error) {
console.warn('绘制视频帧失败:', error);
// 绘制失败时也要停止循环
return;
}
}
}
// 只有在所有条件都满足时才继续绘制下一帧
if (this.video && this.canvas && this.ctx && this.stream) {
this.drawFrameId = requestAnimationFrame(() => {
this.drawVideoFrame();
});
} else {
// 绘制资源已释放,停止绘制循环
}
}
/**
* 获取当前图像数据
* @returns {ImageData|null} 图像数据
*/
getImageData() {
if (!this.canvas || !this.ctx) {
console.warn('getImageData: canvas或context不存在');
return null;
}
// 检查canvas尺寸
if (this.canvas.width <= 0 || this.canvas.height <= 0) {
console.warn('getImageData: canvas尺寸无效', {
width: this.canvas.width,
height: this.canvas.height
});
return null;
}
try {
const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
// 验证获取的ImageData
if (!imageData || !imageData.data || imageData.data.length === 0) {
console.warn('getImageData: 获取的ImageData无效');
return null;
}
// 验证数据长度是否与尺寸匹配
const expectedLength = this.canvas.width * this.canvas.height * 4;
if (imageData.data.length !== expectedLength) {
console.warn('getImageData: 数据长度不匹配', {
expected: expectedLength,
actual: imageData.data.length,
width: this.canvas.width,
height: this.canvas.height
});
return null;
}
return imageData;
} catch (error) {
console.error('getImageData: 获取图像数据失败', error);
return null;
}
}
/**
* 停止摄像头
*/
async stop() {
try {
// 停止绘制循环
if (this.drawFrameId) {
cancelAnimationFrame(this.drawFrameId);
this.drawFrameId = null;
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
if (this.video) {
this.video.srcObject = null;
// 注意video元素由UIManager管理不在这里删除
this.video = null;
}
// 重置状态
this.lastDrawTime = 0;
// 摄像头已停止
} catch (error) {
console.error('停止摄像头失败:', error);
}
}
/**
* 检查摄像头权限
*/
async checkCameraPermission() {
try {
// 检查是否为HTTPS环境
if (location.protocol !== 'https:' && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') {
throw new Error('摄像头访问需要HTTPS环境当前为HTTP协议。请使用HTTPS访问或在localhost环境下测试。');
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('当前浏览器不支持摄像头访问功能');
}
// 尝试获取权限状态
if (navigator.permissions) {
const permission = await navigator.permissions.query({ name: 'camera' });
if (permission.state === 'denied') {
throw new Error('摄像头权限被拒绝,请在浏览器设置中允许摄像头访问');
}
}
// 尝试访问摄像头验证权限
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
stream.getTracks().forEach(track => track.stop());
return true;
} catch (error) {
console.error('检查摄像头权限失败:', error);
// 根据错误类型提供更友好的提示
if (error.name === 'NotAllowedError') {
throw new Error('摄像头权限被拒绝,请允许访问摄像头权限');
} else if (error.name === 'NotFoundError') {
throw new Error('未检测到摄像头设备');
} else if (error.name === 'NotSupportedError' || error.name === 'OverconstrainedError') {
throw new Error('摄像头不支持请求的配置');
} else if (error.message.includes('HTTPS')) {
throw error; // 直接抛出HTTPS相关错误
} else {
throw new Error('无法访问摄像头: ' + error.message);
}
}
}
/**
* 内部方法更新canvas尺寸根据设备类型采用不同策略
*/
updateCanvasSizeInternal(videoWidth, videoHeight) {
if (!this.canvas || !videoWidth || !videoHeight) {
return;
}
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight - 60; // 减去导航栏高度
const isMobile = this.isMobileDevice();
const videoAspectRatio = videoWidth / videoHeight;
let canvasWidth, canvasHeight;
if (isMobile) {
// 手机端:全屏显示,保持视频比例填充
const screenAspectRatio = screenWidth / screenHeight;
if (videoAspectRatio > screenAspectRatio) {
// 视频更宽,以高度为准
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
} else {
// 视频更高,以宽度为准
canvasWidth = screenWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
} else {
// PC/平板端高度铺满宽度最大600px
const maxWidth = 600;
// 以高度为基准计算宽度
canvasHeight = screenHeight;
canvasWidth = canvasHeight * videoAspectRatio;
// 如果宽度超过最大限制,则以宽度为准
if (canvasWidth > maxWidth) {
canvasWidth = maxWidth;
canvasHeight = canvasWidth / videoAspectRatio;
}
}
this.canvas.width = Math.round(canvasWidth);
this.canvas.height = Math.round(canvasHeight);
// Canvas尺寸已更新内部调用
// 通知主程序视频尺寸已确定
if (this.onVideoSizeReady && typeof this.onVideoSizeReady === 'function') {
this.onVideoSizeReady(videoWidth, videoHeight);
}
}
/**
* 直接显示canvas在视频尺寸无效时的备用方案
*/
showCanvasDirectly() {
if (!this.canvas) return;
// 延迟显示避免iOS Safari的闪烁问题
setTimeout(() => {
if (this.canvas) {
this.canvas.style.opacity = '1';
// Canvas已直接显示备用方案
}
}, 200); // 稍长延迟确保iOS Safari处理完毕
}
/**
* 检查是否支持摄像头
*/
static async isSupported() {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
}
/**
* 获取可用摄像头列表
*/
static async getCameras() {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
return [];
}
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(device => device.kind === 'videoinput');
} catch (error) {
console.error('获取摄像头列表失败:', error);
return [];
}
}
}
export default CameraScanner;

View File

@ -0,0 +1,286 @@
/**
* 图片处理Web Worker
* 用于异步处理图片压缩避免阻塞主线程
*/
// 监听主线程消息
self.onmessage = function(e) {
const { action, data, taskId } = e.data;
try {
switch (action) {
case 'compressImage':
handleCompressImage(data, taskId);
break;
case 'extremeCompress':
handleExtremeCompress(data, taskId);
break;
default:
postMessage({
taskId,
success: false,
error: '未知的操作类型: ' + action
});
}
} catch (error) {
postMessage({
taskId,
success: false,
error: error.message || '处理过程中发生未知错误'
});
}
};
/**
* 处理标准图片压缩
*/
async function handleCompressImage(data, taskId) {
try {
const { imageData, maxSize, minSize, quality } = data;
// 发送进度更新
postMessage({
taskId,
type: 'progress',
message: '正在压缩图片...',
progress: 20
});
// 创建canvas进行压缩
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
const ctx = canvas.getContext('2d');
// 将ImageData绘制到canvas
ctx.putImageData(imageData, 0, 0);
// 计算压缩尺寸
const { width, height } = calculateOptimalSize(
imageData.width,
imageData.height,
maxSize,
minSize
);
postMessage({
taskId,
type: 'progress',
message: `压缩尺寸: ${width}x${height}`,
progress: 50
});
// 创建压缩后的canvas
const compressedCanvas = new OffscreenCanvas(width, height);
const compressedCtx = compressedCanvas.getContext('2d');
// 使用高质量缩放
compressedCtx.imageSmoothingEnabled = true;
compressedCtx.imageSmoothingQuality = 'high';
// 绘制压缩后的图片
compressedCtx.drawImage(canvas, 0, 0, width, height);
postMessage({
taskId,
type: 'progress',
message: '生成压缩结果...',
progress: 80
});
// 获取压缩后的ImageData
const compressedImageData = compressedCtx.getImageData(0, 0, width, height);
postMessage({
taskId,
success: true,
result: {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: width + 'x' + height,
compressionRatio: ((1 - (width * height) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
},
type: 'complete',
progress: 100
});
} catch (error) {
postMessage({
taskId,
success: false,
error: '图片压缩失败: ' + error.message
});
}
}
/**
* 处理极限压缩
*/
async function handleExtremeCompress(data, taskId) {
try {
const { imageData } = data;
postMessage({
taskId,
type: 'progress',
message: '正在极限压缩图片...',
progress: 10
});
// 创建canvas
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
const ctx = canvas.getContext('2d');
// 将ImageData绘制到canvas
ctx.putImageData(imageData, 0, 0);
postMessage({
taskId,
type: 'progress',
message: '应用极限压缩算法...',
progress: 30
});
// 极限压缩固定400px正方形
const targetSize = 400;
const compressedCanvas = new OffscreenCanvas(targetSize, targetSize);
const compressedCtx = compressedCanvas.getContext('2d');
// 关闭图片平滑,提高速度
compressedCtx.imageSmoothingEnabled = false;
postMessage({
taskId,
type: 'progress',
message: '正在生成400x400压缩图...',
progress: 60
});
// 分块处理,避免一次性处理大图
await drawImageInChunks(compressedCtx, canvas, targetSize, targetSize, taskId);
postMessage({
taskId,
type: 'progress',
message: '压缩完成,生成结果...',
progress: 90
});
// 获取压缩后的ImageData
const compressedImageData = compressedCtx.getImageData(0, 0, targetSize, targetSize);
postMessage({
taskId,
success: true,
result: {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: targetSize + 'x' + targetSize,
compressionRatio: ((1 - (targetSize * targetSize) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
},
type: 'complete',
progress: 100
});
} catch (error) {
postMessage({
taskId,
success: false,
error: '极限压缩失败: ' + error.message
});
}
}
/**
* 分块绘制图片
*/
async function drawImageInChunks(ctx, sourceCanvas, targetWidth, targetHeight, taskId) {
return new Promise((resolve, reject) => {
try {
const chunkSize = 100; // 每次处理100px的区块
let currentY = 0;
const drawChunk = () => {
if (currentY >= targetHeight) {
resolve();
return;
}
const chunkHeight = Math.min(chunkSize, targetHeight - currentY);
const sourceY = (currentY / targetHeight) * sourceCanvas.height;
const sourceHeight = (chunkHeight / targetHeight) * sourceCanvas.height;
try {
// 绘制当前区块
ctx.drawImage(
sourceCanvas,
0, sourceY, sourceCanvas.width, sourceHeight,
0, currentY, targetWidth, chunkHeight
);
currentY += chunkHeight;
// 更新进度
const progress = 60 + Math.floor((currentY / targetHeight) * 25);
postMessage({
taskId,
type: 'progress',
message: `处理进度: ${Math.floor((currentY / targetHeight) * 100)}%`,
progress
});
// 异步处理下一个区块
setTimeout(drawChunk, 5);
} catch (error) {
reject(error);
}
};
drawChunk();
} catch (error) {
reject(error);
}
});
}
/**
* 计算最优压缩尺寸
*/
function calculateOptimalSize(originalWidth, originalHeight, maxSize, minSize) {
let width = originalWidth;
let height = originalHeight;
// 计算压缩比例
const ratio = Math.min(maxSize / width, maxSize / height);
if (ratio < 1) {
width = Math.floor(width * ratio);
height = Math.floor(height * ratio);
}
// 确保不小于最小尺寸
if (width < minSize && height < minSize) {
const minRatio = Math.max(minSize / width, minSize / height);
width = Math.floor(width * minRatio);
height = Math.floor(height * minRatio);
}
// 针对扫码场景,优先正方形比例
const avgSize = Math.floor((width + height) / 2);
if (avgSize <= maxSize && avgSize >= minSize) {
const squareSize = Math.min(avgSize, 600);
return { width: squareSize, height: squareSize };
}
return { width, height };
}
// 错误处理
self.onerror = function(error) {
postMessage({
success: false,
error: 'Worker发生错误: ' + error.message
});
};
// 图片处理Worker已启动

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,332 @@
/**
* 工具类模块
* 提供调试错误处理和通用功能
*/
class Utils {
/**
* 调试模式
*/
static DEBUG = false;
/**
* 日志输出
*/
static log(message, ...args) {
if (this.DEBUG) {
console.log(`[jz-h5-scanCode] ${message}`, ...args);
}
}
/**
* 错误日志
*/
static error(message, error) {
console.error(`[jz-h5-scanCode] ${message}`, error);
}
/**
* 警告日志
*/
static warn(message, ...args) {
console.warn(`[jz-h5-scanCode] ${message}`, ...args);
}
/**
* 检查是否为移动设备
*/
static isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
/**
* 检查是否为iOS设备
*/
static isIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent);
}
/**
* 检查是否为Android设备
*/
static isAndroid() {
return /Android/i.test(navigator.userAgent);
}
/**
* 检查浏览器支持情况
*/
static checkBrowserSupport() {
const support = {
getUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
barcodeDetector: 'BarcodeDetector' in window,
canvas: !!document.createElement('canvas').getContext,
webGL: (() => {
try {
const canvas = document.createElement('canvas');
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
} catch (e) {
return false;
}
})(),
requestAnimationFrame: 'requestAnimationFrame' in window,
vibrate: 'vibrate' in navigator
};
return support;
}
/**
* 创建错误对象
*/
static createError(code, message, details = null) {
const error = new Error(message);
error.code = code;
error.details = details;
return error;
}
/**
* 错误代码常量
*/
static ERROR_CODES = {
CAMERA_PERMISSION_DENIED: 'CAMERA_PERMISSION_DENIED',
CAMERA_NOT_FOUND: 'CAMERA_NOT_FOUND',
DECODER_INIT_FAILED: 'DECODER_INIT_FAILED',
SCAN_TIMEOUT: 'SCAN_TIMEOUT',
BROWSER_NOT_SUPPORTED: 'BROWSER_NOT_SUPPORTED',
USER_CANCELLED: 'USER_CANCELLED'
};
/**
* 获取友好的错误信息
*/
static getErrorMessage(error) {
const messages = {
[this.ERROR_CODES.CAMERA_PERMISSION_DENIED]: '请允许访问摄像头权限',
[this.ERROR_CODES.CAMERA_NOT_FOUND]: '未检测到摄像头设备',
[this.ERROR_CODES.DECODER_INIT_FAILED]: '扫码器初始化失败',
[this.ERROR_CODES.SCAN_TIMEOUT]: '扫码超时',
[this.ERROR_CODES.BROWSER_NOT_SUPPORTED]: '当前浏览器不支持扫码功能',
[this.ERROR_CODES.USER_CANCELLED]: '用户取消扫码'
};
return messages[error.code] || error.message || '未知错误';
}
/**
* 防抖函数
*/
static debounce(func, wait, immediate = false) {
let timeout;
return function executedFunction(...args) {
const later = () => {
timeout = null;
if (!immediate) func.apply(this, args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(this, args);
};
}
/**
* 节流函数
*/
static throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
/**
* 深度克隆对象
*/
static deepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj.getTime());
if (obj instanceof Array) return obj.map(item => this.deepClone(item));
if (typeof obj === 'object') {
const clonedObj = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = this.deepClone(obj[key]);
}
}
return clonedObj;
}
}
/**
* 生成唯一ID
*/
static generateId() {
return Math.random().toString(36).substr(2, 9);
}
/**
* 格式化文件大小
*/
static formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
/**
* 获取设备信息
*/
static getDeviceInfo() {
return {
userAgent: navigator.userAgent,
platform: navigator.platform,
language: navigator.language,
cookieEnabled: navigator.cookieEnabled,
onLine: navigator.onLine,
screenWidth: screen.width,
screenHeight: screen.height,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
pixelRatio: window.devicePixelRatio || 1,
isMobile: this.isMobile(),
isIOS: this.isIOS(),
isAndroid: this.isAndroid()
};
}
/**
* 性能监测
*/
static performanceMonitor() {
if (!performance || !performance.mark) {
return {
start: () => {},
end: () => 0
};
}
return {
start: (name) => {
performance.mark(`${name}-start`);
},
end: (name) => {
performance.mark(`${name}-end`);
performance.measure(name, `${name}-start`, `${name}-end`);
const measure = performance.getEntriesByName(name)[0];
return measure ? measure.duration : 0;
}
};
}
/**
* 安全执行函数
*/
static safeExecute(func, defaultValue = null) {
try {
return func();
} catch (error) {
this.error('Safe execute failed:', error);
return defaultValue;
}
}
/**
* 异步安全执行
*/
static async safeExecuteAsync(func, defaultValue = null) {
try {
return await func();
} catch (error) {
this.error('Safe execute async failed:', error);
return defaultValue;
}
}
/**
* 简单的事件发射器
*/
static createEventEmitter() {
const events = {};
return {
on(event, callback) {
if (!events[event]) {
events[event] = [];
}
events[event].push(callback);
},
off(event, callback) {
if (events[event]) {
events[event] = events[event].filter(cb => cb !== callback);
}
},
emit(event, ...args) {
if (events[event]) {
events[event].forEach(callback => {
try {
callback(...args);
} catch (error) {
Utils.error(`Event callback error for ${event}:`, error);
}
});
}
}
};
}
/**
* URL参数解析
*/
static parseUrlParams(url = window.location.href) {
const params = {};
const urlObj = new URL(url);
urlObj.searchParams.forEach((value, key) => {
params[key] = value;
});
return params;
}
/**
* 检查网络连接状态
*/
static checkNetworkStatus() {
return {
online: navigator.onLine,
connection: navigator.connection || navigator.mozConnection || navigator.webkitConnection
};
}
/**
* 检查是否为安全环境HTTPS或localhost
*/
static isSecureContext() {
return location.protocol === 'https:' ||
location.hostname === 'localhost' ||
location.hostname === '127.0.0.1' ||
location.hostname === '0.0.0.0';
}
/**
* 获取当前协议和主机信息
*/
static getLocationInfo() {
return {
protocol: location.protocol,
hostname: location.hostname,
port: location.port,
isSecure: this.isSecureContext(),
needsHTTPS: !this.isSecureContext()
};
}
}
export default Utils;

View File

@ -0,0 +1,533 @@
/**
* Web Worker 管理器
* 负责管理图片处理Worker的生命周期和通信
*/
class WorkerManager {
constructor() {
this.worker = null;
this.tasks = new Map(); // 存储任务
this.taskIdCounter = 0;
this.isSupported = typeof Worker !== 'undefined';
this.progressCallback = null;
}
/**
* 检查浏览器是否支持Web Worker
*/
checkWorkerSupport() {
try {
// 由于OffscreenCanvas兼容性问题暂时禁用Worker
// 将来可以考虑使用ImageBitmap等替代方案
return false; // 暂时禁用Worker使用主线程处理
} catch (e) {
return false;
}
}
/**
* 初始化Worker
*/
async init() {
if (!this.isSupported) {
console.warn('不支持Web Worker使用主线程处理');
return false;
}
if (this.worker) return true;
try {
const workerCode = `
self.onmessage = function(e) {
const { action, data, taskId } = e.data;
if (action === 'compressImage') {
try {
const { imageData, maxSize, minSize } = data;
self.postMessage({
taskId,
type: 'progress',
message: '正在压缩图片...',
progress: 20
});
// 在Worker中不能使用OffscreenCanvas时的降级处理
self.postMessage({
taskId,
success: false,
error: '当前浏览器不支持OffscreenCanvas将使用主线程处理'
});
} catch (error) {
self.postMessage({
taskId,
success: false,
error: error.message
});
}
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
this.worker = new Worker(URL.createObjectURL(blob));
this.worker.onmessage = (e) => {
this.handleWorkerMessage(e.data);
};
this.worker.onerror = (error) => {
console.error('Worker错误:', error);
this.handleWorkerError(error);
};
return true;
} catch (error) {
console.error('Worker初始化失败:', error);
return false;
}
}
/**
* 处理Worker消息
*/
handleWorkerMessage(data) {
const { taskId, type, success, result, error, message, progress } = data;
const task = this.tasks.get(taskId);
if (!task) return;
if (type === 'progress' && task.onProgress) {
task.onProgress(message, progress);
} else if (success !== undefined) {
this.tasks.delete(taskId);
if (success) {
task.resolve(result);
} else {
task.reject(new Error(error));
}
}
}
/**
* 处理Worker错误
*/
handleWorkerError(error) {
for (const [taskId, task] of this.tasks) {
task.reject(new Error('Worker错误: ' + error.message));
}
this.tasks.clear();
this.worker = null;
}
/**
* 设置进度回调
*/
setProgressCallback(callback) {
this.progressCallback = callback;
}
/**
* 压缩图片
*/
async compressImage(imageData, maxSize = 800, minSize = 400, quality = 0.6, onProgress = null) {
// 由于浏览器兼容性问题,直接使用主线程处理
return this.fallbackCompress(imageData, maxSize, minSize, quality, onProgress);
}
/**
* 极限压缩
*/
async extremeCompress(imageData, onProgress = null) {
// 如果不支持Worker使用主线程处理
if (!this.isSupported || !this.worker) {
return this.fallbackExtremeCompress(imageData, onProgress);
}
const taskId = ++this.taskIdCounter;
return new Promise((resolve, reject) => {
// 存储任务
this.tasks.set(taskId, { resolve, reject, onProgress });
// 发送任务到Worker
this.worker.postMessage({
action: 'extremeCompress',
taskId,
data: { imageData }
});
// 设置超时
setTimeout(() => {
if (this.tasks.has(taskId)) {
this.tasks.delete(taskId);
reject(new Error('极限压缩超时'));
}
}, 20000); // 20秒超时
});
}
/**
* 主线程降级处理 - 标准压缩
*/
async fallbackCompress(imageData, maxSize, minSize, quality, onProgress) {
try {
if (onProgress) onProgress('开始处理图片...', 10);
// 使用requestAnimationFrame确保UI更新
await new Promise(resolve => requestAnimationFrame(resolve));
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = imageData.width;
canvas.height = imageData.height;
ctx.putImageData(imageData, 0, 0);
if (onProgress) onProgress('计算压缩尺寸...', 30);
await new Promise(resolve => requestAnimationFrame(resolve));
const { width, height } = this.calculateOptimalSize(
imageData.width,
imageData.height,
maxSize,
minSize
);
if (onProgress) onProgress(`压缩到 ${width}x${height}`, 50);
await new Promise(resolve => requestAnimationFrame(resolve));
const compressedCanvas = document.createElement('canvas');
const compressedCtx = compressedCanvas.getContext('2d');
compressedCanvas.width = width;
compressedCanvas.height = height;
compressedCtx.imageSmoothingEnabled = true;
compressedCtx.imageSmoothingQuality = 'high';
if (onProgress) onProgress('绘制压缩图片...', 80);
await new Promise(resolve => requestAnimationFrame(resolve));
compressedCtx.drawImage(canvas, 0, 0, width, height);
if (onProgress) onProgress('生成结果...', 95);
await new Promise(resolve => requestAnimationFrame(resolve));
const compressedImageData = compressedCtx.getImageData(0, 0, width, height);
if (onProgress) onProgress('压缩完成', 100);
return {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: width + 'x' + height,
compressionRatio: ((1 - (width * height) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
};
} catch (error) {
throw new Error('图片压缩失败: ' + error.message);
}
}
/**
* 主线程降级处理 - 极限压缩
*/
async fallbackExtremeCompress(imageData, onProgress) {
try {
if (onProgress) onProgress('主线程极限压缩...', 10);
await new Promise(resolve => requestAnimationFrame(resolve));
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = imageData.width;
canvas.height = imageData.height;
ctx.putImageData(imageData, 0, 0);
if (onProgress) onProgress('生成400x400图片...', 50);
await new Promise(resolve => requestAnimationFrame(resolve));
const targetSize = 400;
const compressedCanvas = document.createElement('canvas');
const compressedCtx = compressedCanvas.getContext('2d');
compressedCanvas.width = targetSize;
compressedCanvas.height = targetSize;
compressedCtx.imageSmoothingEnabled = false;
if (onProgress) onProgress('绘制极限压缩图...', 80);
await new Promise(resolve => requestAnimationFrame(resolve));
compressedCtx.drawImage(canvas, 0, 0, targetSize, targetSize);
if (onProgress) onProgress('生成结果...', 95);
await new Promise(resolve => requestAnimationFrame(resolve));
const compressedImageData = compressedCtx.getImageData(0, 0, targetSize, targetSize);
if (onProgress) onProgress('极限压缩完成', 100);
return {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: targetSize + 'x' + targetSize,
compressionRatio: ((1 - (targetSize * targetSize) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
};
} catch (error) {
throw new Error('主线程极限压缩失败: ' + error.message);
}
}
/**
* 计算最优压缩尺寸
*/
calculateOptimalSize(originalWidth, originalHeight, maxSize, minSize) {
let width = originalWidth;
let height = originalHeight;
const ratio = Math.min(maxSize / width, maxSize / height);
if (ratio < 1) {
width = Math.floor(width * ratio);
height = Math.floor(height * ratio);
}
if (width < minSize && height < minSize) {
const minRatio = Math.max(minSize / width, minSize / height);
width = Math.floor(width * minRatio);
height = Math.floor(height * minRatio);
}
const avgSize = Math.floor((width + height) / 2);
if (avgSize <= maxSize && avgSize >= minSize) {
const squareSize = Math.min(avgSize, 600);
return { width: squareSize, height: squareSize };
}
return { width, height };
}
/**
* 销毁Worker
*/
destroy() {
if (this.worker) {
for (const [taskId, task] of this.tasks) {
task.reject(new Error('Worker被销毁'));
}
this.tasks.clear();
this.worker.terminate();
this.worker = null;
}
}
/**
* 获取Worker代码
*/
getWorkerCode() {
return `
// 图片处理Worker代码
self.onmessage = function(e) {
const { action, data, taskId } = e.data;
try {
switch (action) {
case 'compressImage':
handleCompressImage(data, taskId);
break;
case 'extremeCompress':
handleExtremeCompress(data, taskId);
break;
default:
postMessage({
taskId,
success: false,
error: '未知的操作类型: ' + action
});
}
} catch (error) {
postMessage({
taskId,
success: false,
error: error.message || '处理过程中发生未知错误'
});
}
};
function handleCompressImage(data, taskId) {
try {
const { imageData, maxSize, minSize, quality } = data;
postMessage({
taskId,
type: 'progress',
message: '正在压缩图片...',
progress: 20
});
// 检查OffscreenCanvas支持
if (typeof OffscreenCanvas === 'undefined') {
throw new Error('当前浏览器不支持OffscreenCanvas');
}
// 使用canvas进行压缩
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
const ctx = canvas.getContext('2d');
ctx.putImageData(imageData, 0, 0);
const { width, height } = calculateOptimalSize(imageData.width, imageData.height, maxSize, minSize);
postMessage({
taskId,
type: 'progress',
message: '压缩尺寸: ' + width + 'x' + height,
progress: 50
});
const compressedCanvas = new OffscreenCanvas(width, height);
const compressedCtx = compressedCanvas.getContext('2d');
compressedCtx.imageSmoothingEnabled = true;
compressedCtx.imageSmoothingQuality = 'high';
compressedCtx.drawImage(canvas, 0, 0, width, height);
postMessage({
taskId,
type: 'progress',
message: '生成压缩结果...',
progress: 80
});
const compressedImageData = compressedCtx.getImageData(0, 0, width, height);
postMessage({
taskId,
success: true,
result: {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: width + 'x' + height,
compressionRatio: ((1 - (width * height) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
},
type: 'complete',
progress: 100
});
} catch (error) {
postMessage({
taskId,
success: false,
error: '图片压缩失败: ' + error.message
});
}
}
function handleExtremeCompress(data, taskId) {
try {
const { imageData } = data;
postMessage({
taskId,
type: 'progress',
message: '正在极限压缩图片...',
progress: 10
});
// 检查OffscreenCanvas支持
if (typeof OffscreenCanvas === 'undefined') {
throw new Error('当前浏览器不支持OffscreenCanvas');
}
const canvas = new OffscreenCanvas(imageData.width, imageData.height);
const ctx = canvas.getContext('2d');
ctx.putImageData(imageData, 0, 0);
const targetSize = 400;
const compressedCanvas = new OffscreenCanvas(targetSize, targetSize);
const compressedCtx = compressedCanvas.getContext('2d');
compressedCtx.imageSmoothingEnabled = false;
postMessage({
taskId,
type: 'progress',
message: '正在生成400x400压缩图...',
progress: 60
});
compressedCtx.drawImage(canvas, 0, 0, targetSize, targetSize);
postMessage({
taskId,
type: 'progress',
message: '压缩完成,生成结果...',
progress: 90
});
const compressedImageData = compressedCtx.getImageData(0, 0, targetSize, targetSize);
postMessage({
taskId,
success: true,
result: {
imageData: compressedImageData,
originalSize: imageData.width + 'x' + imageData.height,
compressedSize: targetSize + 'x' + targetSize,
compressionRatio: ((1 - (targetSize * targetSize) / (imageData.width * imageData.height)) * 100).toFixed(1) + '%'
},
type: 'complete',
progress: 100
});
} catch (error) {
postMessage({
taskId,
success: false,
error: '极限压缩失败: ' + error.message
});
}
}
function calculateOptimalSize(originalWidth, originalHeight, maxSize, minSize) {
let width = originalWidth;
let height = originalHeight;
const ratio = Math.min(maxSize / width, maxSize / height);
if (ratio < 1) {
width = Math.floor(width * ratio);
height = Math.floor(height * ratio);
}
if (width < minSize && height < minSize) {
const minRatio = Math.max(minSize / width, minSize / height);
width = Math.floor(width * minRatio);
height = Math.floor(height * minRatio);
}
const avgSize = Math.floor((width + height) / 2);
if (avgSize <= maxSize && avgSize >= minSize) {
const squareSize = Math.min(avgSize, 600);
return { width: squareSize, height: squareSize };
}
return { width, height };
}
self.onerror = function(error) {
postMessage({
success: false,
error: 'Worker发生错误: ' + error.message
});
};
// 图片处理Worker已启动
`;
}
}
// 导出管理器
export default WorkerManager;

View File

@ -0,0 +1,106 @@
{
"id": "jz-h5-scanCode",
"displayName": "jz-h5-scanCode 扫码插件",
"name": "jz-h5-scancode",
"version": "1.0.5",
"description": "H5环境下的二维码扫描插件提供与 uni.scanCode 完全兼容的 API 接口支持摄像头实时扫码和图片扫码。v1.0.0优化性能,修复卡死问题",
"keywords": [
"扫码",
"二维码",
"h5扫码",
"h5扫码插件"
],
"repository": "https://gitcode.com/Etangzeng/jz-h5-scanCode",
"engines": {
"HBuilderX": "^4.65",
"uni-app": "^4.62",
"uni-app-x": ""
},
"dcloudext": {
"type": "sdk-js",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "578031621"
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "摄像头权限"
},
"npmurl": "https://www.npmjs.com/package/jz-h5-scancode",
"darkmode": "√",
"i18n": "x",
"widescreen": "√"
},
"uni_modules": {
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√",
"alipay": "x"
},
"client": {
"uni-app": {
"vue": {
"vue2": "√",
"vue3": "√"
},
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"vue": "√",
"nvue": "-",
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "x",
"alipay": "x",
"toutiao": "x",
"baidu": "x",
"kuaishou": "x",
"jd": "x",
"harmony": "x",
"qq": "x",
"lark": "x"
},
"quickapp": {
"huawei": "x",
"union": "x"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
},
"main": "js/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "JZ",
"license": "MIT"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long