完善生产查询与登录权限配置

This commit is contained in:
2026-09-03 19:56:19 +08:00
parent 793f140a45
commit 02c8fb7021
438 changed files with 1595 additions and 573 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = tab
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true
+3
View File
@@ -0,0 +1,3 @@
unpackage/
.DS_Store
.hbuilderx/
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+1
View File
@@ -0,0 +1 @@
mrleeAPP
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
</GradleProjectSettings>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<component name="libraryTable">
<library name="index">
<CLASSES>
<root url="jar://$PROJECT_DIR$/unpackage/cache/uts_custom_android/app-android/uts/uni_modules/mas-code-scan/index.jar!/" />
</CLASSES>
<JAVADOC />
<SOURCES />
</library>
</component>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/unpackage/dist/build/build.iml" filepath="$PROJECT_DIR$/unpackage/dist/build/build.iml" />
<module fileurl="file://$PROJECT_DIR$/unpackage/cache/cache.iml" filepath="$PROJECT_DIR$/unpackage/cache/cache.iml" />
<module fileurl="file://$PROJECT_DIR$/unpackage/dist/dev/dev.iml" filepath="$PROJECT_DIR$/unpackage/dist/dev/dev.iml" />
<module fileurl="file://$PROJECT_DIR$/unpackage/resources/resources.iml" filepath="$PROJECT_DIR$/unpackage/resources/resources.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/scanapp.iml" filepath="$PROJECT_DIR$/.idea/scanapp.iml" />
</modules>
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+189
View File
@@ -0,0 +1,189 @@
<script>
import {
compareVersions,
getCurrentAppVersion,
getLatestRelease,
getReleaseUpdateInfo,
markAutoUpdateChecked,
shouldAutoCheckForUpdate
} from '@/api/app-update.js'
export default {
onLaunch: function() {
// #ifdef APP-PLUS
this.checkUpdateOnFirstLaunchToday()
// #endif
},
onShow: function() {
console.log('App Show')
},
onHide: function() {
console.log('App Hide')
},
methods: {
checkUpdateOnFirstLaunchToday() {
if (!shouldAutoCheckForUpdate()) return
// 当日第一次启动即记录,网络失败时不在同一天内反复弹出失败提示。
markAutoUpdateChecked()
getLatestRelease()
.then((release) => {
const updateInfo = getReleaseUpdateInfo(release)
if (compareVersions(updateInfo.latestVersion, getCurrentAppVersion()) <= 0) return
const content = `发现新版本 v${updateInfo.latestVersion}\n\n${updateInfo.updateNote}${updateInfo.apkUrl ? '\n\n是否立即下载并安装?' : '\n\n更新包暂未发布,请稍后再试。'}`
uni.showModal({
title: '发现新版本',
content,
confirmText: updateInfo.apkUrl ? '立即更新' : '知道了',
showCancel: Boolean(updateInfo.apkUrl),
cancelText: '暂不更新',
success: (result) => {
if (result.confirm && updateInfo.apkUrl) {
this.downloadAndInstallApk(updateInfo.apkUrl)
}
}
})
})
.catch(() => {
// 自动检查失败静默处理,不打扰用户;手动检查仍会显示具体失败原因。
})
},
downloadAndInstallApk(downloadUrl) {
if (!downloadUrl) return
uni.showLoading({ title: '正在下载更新包', mask: true })
uni.downloadFile({
url: downloadUrl,
success: (result) => {
if (result.statusCode !== 200) {
uni.showToast({ title: '更新包下载失败', icon: 'none' })
return
}
plus.runtime.install(result.tempFilePath, {}, () => {}, (error) => {
uni.showModal({
title: '安装失败',
content: error.message || '请允许安装未知来源应用后重试',
showCancel: false
})
})
},
fail: () => uni.showToast({ title: '更新包下载失败,请检查网络', icon: 'none' }),
complete: () => uni.hideLoading()
})
}
}
}
</script>
<style lang="scss">
@import "@/uni_modules/uview-pro/index.scss";
/* 全局深色主题:页面根节点由 themeMode 自动添加 theme-dark。 */
.theme-dark {
background: #151923 !important;
color: #f2f5fa;
}
.theme-dark .home-hero,
.theme-dark .user-card,
.theme-dark .menu-panel,
.theme-dark .chart-panel,
.theme-dark .settings-card,
.theme-dark .auth-panel,
.theme-dark .profile-header,
.theme-dark .profile-card,
.theme-dark .form-card,
.theme-dark .config-card,
.theme-dark .query-header,
.theme-dark .query-tabs,
.theme-dark .query-panel,
.theme-dark .result-card,
.theme-dark .repair-hero,
.theme-dark .repair-card,
.theme-dark .task-card,
.theme-dark .statistics-card,
.theme-dark .summary-card,
.theme-dark .trend-card,
.theme-dark .progress-card,
.theme-dark .loading-card,
.theme-dark .empty-card,
.theme-dark .field-card {
background: #222936 !important;
border-color: #364152 !important;
box-shadow: none !important;
}
.theme-dark .menu-group-0,
.theme-dark .menu-group-1,
.theme-dark .menu-item--active {
background: #1c2431 !important;
}
.theme-dark .setting-row,
.theme-dark .menu-item,
.theme-dark .info-row,
.theme-dark .action-item,
.theme-dark .list-item,
.theme-dark .record-item,
.theme-dark .task-item {
border-color: #364152 !important;
}
.theme-dark .summary-card,
.theme-dark .trend-card,
.theme-dark .progress-card,
.theme-dark .stat-item,
.theme-dark .bar-track,
.theme-dark .progress-track {
background: #1c2431 !important;
}
.theme-dark .hero-title,
.theme-dark .brand-title,
.theme-dark .panel-title,
.theme-dark .page-title,
.theme-dark .form-title,
.theme-dark .query-title,
.theme-dark .card-title,
.theme-dark .workspace-title,
.theme-dark .chart-title,
.theme-dark .chart-section-title,
.theme-dark .setting-label,
.theme-dark .menu-title,
.theme-dark .user-account,
.theme-dark .summary-value,
.theme-dark .progress-value,
.theme-dark .field-text,
.theme-dark .profile-name,
.theme-dark .info-value,
.theme-dark .action-left,
.theme-dark .list-title,
.theme-dark .record-title,
.theme-dark .task-title {
color: #f2f5fa !important;
}
.theme-dark .hero-subtitle,
.theme-dark .brand-subtitle,
.theme-dark .panel-desc,
.theme-dark .footer-link,
.theme-dark .page-subtitle,
.theme-dark .form-desc,
.theme-dark .query-subtitle,
.theme-dark .card-subtitle,
.theme-dark .setting-value,
.theme-dark .setting-desc,
.theme-dark .menu-desc,
.theme-dark .user-name,
.theme-dark .profile-account,
.theme-dark .info-label,
.theme-dark .stat-label,
.theme-dark .chart-subtitle,
.theme-dark .summary-label,
.theme-dark .bar-label,
.theme-dark .progress-label,
.theme-dark .footer-tip,
.theme-dark .list-desc,
.theme-dark .record-desc,
.theme-dark .task-desc {
color: #aeb9ca !important;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
const GITEE_LATEST_RELEASE_API = 'https://gitee.com/api/v5/repos/li-hua-88/scanapp/releases/latest'
const AUTO_UPDATE_CHECK_DATE_KEY = 'scanapp_auto_update_check_date'
export function getCurrentAppVersion() {
// #ifdef APP-PLUS
return plus.runtime.version || '1.0.0'
// #endif
try {
return uni.getSystemInfoSync().appVersion || '1.0.0'
} catch (error) {
return '1.0.0'
}
}
export function compareVersions(leftVersion, rightVersion) {
const left = String(leftVersion || '0').replace(/^v/i, '').split('.')
const right = String(rightVersion || '0').replace(/^v/i, '').split('.')
const length = Math.max(left.length, right.length)
for (let index = 0; index < length; index += 1) {
const leftPart = Number(left[index] || 0)
const rightPart = Number(right[index] || 0)
if (leftPart > rightPart) return 1
if (leftPart < rightPart) return -1
}
return 0
}
export function getReleaseUpdateInfo(release) {
const latestVersion = String(release?.tag_name || release?.name || '').replace(/^v/i, '')
if (!latestVersion) throw new Error('未获取到有效的版本号')
const apkAsset = (release.assets || []).find((asset) => /\.apk(?:\?|$)/i.test(asset.name || asset.browser_download_url || ''))
return {
latestVersion,
apkUrl: apkAsset?.browser_download_url || '',
releaseUrl: release.html_url || 'https://gitee.com/li-hua-88/scanapp/releases/latest',
updateNote: String(release.body || '修复已知问题并优化使用体验').replace(/\r?\n/g, '\n').trim()
}
}
export function getTodayUpdateCheckKey() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function shouldAutoCheckForUpdate() {
return uni.getStorageSync(AUTO_UPDATE_CHECK_DATE_KEY) !== getTodayUpdateCheckKey()
}
export function markAutoUpdateChecked() {
uni.setStorageSync(AUTO_UPDATE_CHECK_DATE_KEY, getTodayUpdateCheckKey())
}
export function getLatestRelease() {
return new Promise((resolve, reject) => {
uni.request({
url: GITEE_LATEST_RELEASE_API,
method: 'GET',
timeout: 15000,
header: {
Accept: 'application/json'
},
success: (res) => {
let release = res.data
if (typeof release === 'string') {
try {
release = JSON.parse(release)
} catch (error) {
release = null
}
}
if (res.statusCode >= 200 && res.statusCode < 300 && release && typeof release === 'object') {
resolve(release)
return
}
reject(new Error(`更新服务请求失败(HTTP ${res.statusCode || 0})`))
},
fail: () => reject(new Error('无法连接更新服务,请检查网络后重试'))
})
})
}
+14
View File
@@ -0,0 +1,14 @@
import request from '@/utils/request.js'
import config from '@/config/index.js'
export function login(data) {
return request.post('/auth/login', data, {
baseURL: config.apiBaseURL
})
}
export function logout() {
return request.post('/auth/logout', {}, {
baseURL: config.apiBaseURL
})
}
+38
View File
@@ -0,0 +1,38 @@
import config from '@/config/index.js'
import request from '@/utils/request.js'
import { normalizeImageUrl } from '@/api/upload.js'
export const normalizeFeedbackImageUrl = normalizeImageUrl
function normalizeFeedbackRecord(record) {
if (!record || typeof record !== 'object') return record
return {
...record,
images: Array.isArray(record.images) ? record.images.map(normalizeFeedbackImageUrl) : record.images
}
}
export function submitFeedback(data) {
return request.post('/feedback', {
...data,
images: Array.isArray(data?.images) ? data.images.map(normalizeFeedbackImageUrl) : data?.images
}, {
baseURL: config.apiBaseURL
})
}
export async function getMyFeedbackList(params) {
const result = await request.get('/feedback/mine', params, {
baseURL: config.apiBaseURL
})
return result && Array.isArray(result.items)
? { ...result, items: result.items.map(normalizeFeedbackRecord) }
: result
}
export async function getMyFeedbackDetail(id) {
const result = await request.get(`/feedback/mine/${id}`, {}, {
baseURL: config.apiBaseURL
})
return normalizeFeedbackRecord(result)
}
+20
View File
@@ -0,0 +1,20 @@
import request from '@/utils/request.js'
import config from '@/config/index.js'
export function getUserInfo() {
return request.get('/member/user-info', {}, {
baseURL: config.apiBaseURL
})
}
export function updateUserInfo(data) {
return request.put('/member/user/update', data, {
baseURL: config.apiBaseURL
})
}
export function changePassword(data) {
return request.put('/member/user/change-password', data, {
baseURL: config.apiBaseURL
})
}
+67
View File
@@ -0,0 +1,67 @@
import request from '@/utils/request.js'
export function getProcessOptions() {
return request.get('/produce/process')
}
export function getStepOptions(processId) {
return request.get('/produce/process-step', {
process_id: processId || ''
})
}
export function getStatusOptions(stepId) {
return request.get('/produce/step-status', {
step_id: stepId || ''
})
}
export function getDataEntryConfig(params = {}) {
return request.get('/produce/config/data-entry', params)
}
export function lookupMaterial(productCode) {
return request.get('/produce/material/lookup', {
product_code: String(productCode || '').trim()
})
}
export function getScanAppMaterialCategories() {
return request.get('/produce/scanapp/material-category')
}
export function getHardwareFaultDescriptionOptions(categoryIds = []) {
return request.get('/produce/hardware-fault-description/options', {
category_ids: categoryIds.filter(Boolean).join(',')
})
}
export function checkMemberUser(userCode) {
return request.get('/produce/member-user/check', {
user_code: String(userCode || '').trim()
})
}
export function getOrderRecentRecords(params = {}) {
return request.get('/produce/scan-data', params)
}
export function getOrderStatistics(params = {}) {
return request.get('/produce/scan-data/statistics', params)
}
export function submitOrderRecord(data) {
return request.post('/produce/scan-data/record', data)
}
export function getProductRecentRecords(params = {}) {
return request.get('/produce/product-record/recent', params)
}
export function getProductStatistics(params = {}) {
return request.get('/produce/product-record/statistics', params)
}
export function submitProductRecord(data) {
return request.post('/produce/product-record/record', data)
}
+41
View File
@@ -0,0 +1,41 @@
import request from '@/utils/request.js'
export function getExceptionSettings() {
return request.get('/produce/exception/settings')
}
export function getExceptionStatistics(params = {}) {
return request.get('/produce/exception/statistics', params)
}
export function getRecentExceptions(params = {}) {
return request.get('/produce/exception/recent', params)
}
export function checkExceptionOrder(orderNo) {
return request.get('/produce/exception/order-check', {
order_no: String(orderNo || '').trim()
})
}
export function getRepairTasks(params = {}) {
return request.get('/produce/repair-record/tasks', params)
}
export function getRepairStatistics(params = {}) {
return request.get('/produce/repair-record/statistics', params)
}
export function getRepairTask(deviceNo) {
return request.get('/produce/exception/repair-task', {
device_no: String(deviceNo || '').trim()
})
}
export function submitExceptionRecord(data) {
return request.post('/produce/exception/record', data)
}
export function submitRepairRecord(data) {
return request.post('/produce/repair-record/record', data)
}
+13
View File
@@ -0,0 +1,13 @@
import request from '@/utils/request.js'
/** 查询生产记录(扫码、整机、异常、维修)。 */
export function queryProduceRecords(params = {}) {
return request.get('/produce/query/records', params, { showError: false })
}
/** 查询指定订单的生产时间线。 */
export function queryProduceTimeline(orderNo) {
return request.get('/produce/query/timeline', {
order_no: String(orderNo || '').trim()
}, { showError: false })
}
+9
View File
@@ -0,0 +1,9 @@
import config from '@/config/index.js'
import request from '@/utils/request.js'
export function getConfigByCode(code) {
return request.get(`/system/config/code/${encodeURIComponent(code)}`, {}, {
baseURL: config.apiBaseURL,
showError: false
})
}
+34
View File
@@ -0,0 +1,34 @@
import config from '@/config/index.js'
import { uploadFile } from '@/utils/request.js'
const getBackendOrigin = () => config.apiBaseURL.replace(/\/api\/?$/, '')
/**
* 将后端返回的图片地址转换为当前 APP 可访问的地址。
* 本地存储配置可能生成 127.0.0.1,Android 真机访问时必须改用局域网后端地址。
*/
export function normalizeImageUrl(imageUrl) {
if (typeof imageUrl !== 'string' || !imageUrl) return imageUrl
if (/^data:image\//i.test(imageUrl) || /^file:\/\//i.test(imageUrl)) return imageUrl
if (/^https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?(?=\/|$)/i.test(imageUrl)) {
return imageUrl.replace(/^https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?/i, getBackendOrigin())
}
if (imageUrl.startsWith('//127.0.0.1') || imageUrl.startsWith('//localhost')) {
return imageUrl.replace(/^\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?/i, getBackendOrigin())
}
return imageUrl.startsWith('/') ? `${getBackendOrigin()}${imageUrl}` : imageUrl
}
/**
* 上传单张图片。页面只负责保存本地临时路径,提交时才调用本方法。
*/
export async function uploadImage(filePath, subDir = 'image') {
const result = await uploadFile('/file/image', filePath, {
sub_dir: subDir
}, {
baseURL: config.apiBaseURL
})
const imageUrl = result?.url || result?.full_url || result?.path
if (!imageUrl) throw new Error('图片上传未返回地址')
return normalizeImageUrl(imageUrl)
}
+12
View File
@@ -0,0 +1,12 @@
const backendURL = 'http://192.168.11.30:8001'
const config = {
baseURL: `${backendURL}/appapi`,
apiBaseURL: `${backendURL}/api`,
tokenKey: 'scanapp_token',
tokenHeaderKey: 'Authorization',
channelHeaderKey: 'channel',
channel: 'app'
}
export default config
@@ -0,0 +1,719 @@
# mas-code-scan iOS 端支持方案
> 文档状态:方案设计,尚未开始 iOS 原生实现。
>
> 目标是在不改变现有业务调用方式的前提下,为 `mas-code-scan` 增加 iOS App 支持,并与 Android 端尽量保持相同的功能、参数语义、回调顺序和错误码。
---
## 1. 背景与目标
当前 `mas-code-scan` 是 Android UTS API 插件,Android 原生层基于 CameraX + Google ML Kit 实现。对外 API 已沉淀在 `utssdk/interface.uts`,业务页面通过 `openCodeScan` 调用,因此具备扩展 iOS 实现的良好基础。
### 1.1 当前状态
| 项目 | 当前情况 |
| --- | --- |
| 对外 API | `openCodeScan(options)` |
| 公共类型 | `CodeScanOptions`、`CodeScanSuccess`、`CodeScanFail`、`ScanRegion`、`CodeScanStyle` |
| Android 实现 | `utssdk/app-android/`,CameraX + Google ML Kit |
| iOS 实现 | 暂无 `utssdk/app-ios/` 目录 |
| 业务调用 | `pages/scan/prototype.vue` 只需调用统一 API |
| 当前支持平台 | Android App(uni-app Vue2/Vue3) |
### 1.2 建设目标
1. **API 不变**:iOS 与 Android 均使用 `openCodeScan`,业务页面不增加平台分支。
2. **回调一致**:统一 `success`、`fail`、`complete` 的数据结构和触发时机。
3. **优先原生能力**:使用 iOS 系统框架,尽量不新增第三方识别库。
4. **功能分阶段交付**:先完成可真机使用的最小链路,再逐步补齐 Android 已有能力。
5. **平台差异显式处理**:对系统不支持或实现成本较高的能力,明确降级或延后,不以静默不生效代替。
### 1.3 非目标
本期方案不包含:
- 鸿蒙、H5、小程序、nvue 支持;
- 修改 Android 识别链路;
- 将扫码页改造为 Vue 组件;
- 为尚未经过真机验证的 iOS 能力提前修改 README 平台声明。
---
## 2. 总体架构
### 2.1 目录规划
在保持现有 Android 目录不变的基础上新增 iOS 实现:
```text
frontend/scanapp/uni_modules/mas-code-scan/
├── package.json
├── readme.md
├── changelog.md
└── utssdk/
├── interface.uts # Android / iOS 共用公开类型
├── unierror.uts # Android / iOS 共用错误对象
├── app-android/ # 现有 Android 实现
│ ├── index.uts
│ ├── CodeCaptureActivity.kt
│ ├── MultiCodePickView.kt
│ └── ScanFrameDecorView.kt
└── app-ios/ # 新增 iOS 实现
├── index.uts # UTS 桥接与回调编排
├── CodeCaptureViewController.swift
├── CodeScanner.swift
├── ScanOverlayView.swift
├── MultiCodePickView.swift
├── ScanStyleParser.swift
└── Info.plist # 或按 UTS iOS 配置规范声明权限
```
> `app-ios` 的最终配置文件和 Swift 文件参与编译方式,必须以当前 HBuilderX 版本的 UTS iOS 官方规范为准;开始编码前需要用官方最小示例验证目录结构。
### 2.2 分层职责
```text
业务页面
↓ openCodeScan(options)
UTS 平台入口(app-android/index.uts 或 app-ios/index.uts)
↓ 参数转换 / 生命周期 / 回调派发
原生扫码控制器
↓ 相机、识别、相册、交互
原生绘制层
↓ 扫描框、扫描线、多码点选、呼吸动画
```
- `interface.uts`:作为跨端 API 契约,不应写入 Android 或 iOS 私有字段。
- `app-ios/index.uts`:负责将 `CodeScanOptions` 转为原生配置,处理单次与连续扫码回调。
- `CodeCaptureViewController`:负责页面展示、权限、相机生命周期、工具栏和结果模式。
- `CodeScanner`:负责相机帧分析、Vision 识别、格式过滤、区域过滤和缩放策略。
- `ScanOverlayView`:负责 `scanStyle` 与 `scanRegion` 的视觉绘制。
- `MultiCodePickView`:负责多码定格、点选和绿色呼吸波纹。
---
## 3. iOS 技术选型
| 能力 | 推荐技术 | 选择理由 |
| --- | --- | --- |
| 相机预览与帧采集 | `AVFoundation` | 系统原生、稳定、无需新增依赖 |
| 条码识别 | `Vision` 的 `VNDetectBarcodesRequest` | 覆盖常见二维码与条形码,识别结果可获取码制和边界框 |
| 相册选图 | `PHPickerViewController` | iOS 14+ 可按用户选择读取图片,通常无需完整相册权限 |
| 低版本相册兼容 | `UIImagePickerController` | 仅在确认最低系统版本低于 iOS 14 时补充 |
| 扫描线动画 | `CAShapeLayer` + `CABasicAnimation` | 使用 Core Animation,避免定时器逐帧重绘 |
| 多码呼吸动画 | `CAShapeLayer` + `CABasicAnimation` | 与 Android 绿点呼吸波纹实现保持视觉一致 |
| 手势缩放 | `UIPinchGestureRecognizer` | 对应 Android 双指捏合 |
| 震动反馈 | `UIImpactFeedbackGenerator` | 适配 iOS 系统触感反馈 |
### 3.1 不引入第三方扫码库
首版不建议接入 ZXing、ML Kit iOS 等第三方库,原因:
- 系统 `Vision` 已能覆盖主要码制;
- 避免 CocoaPods/SPM 与 UTS iOS 构建的额外集成风险;
- 减少安装包和维护成本;
- 便于优先验证 UTS + Swift 桥接与相机链路。
若后续测试确认某些业务关键码制在 `Vision` 上识别率不足,再单独评估第三方引擎,不在首版预置依赖。
---
## 4. 公共 API 与行为约束
### 4.1 保持现有 API
现有 `CodeScanOptions` 不新增必填字段,iOS 端识别并实现以下参数:
| 参数 | iOS 首版目标 | 说明 |
| --- | --- | --- |
| `formats` | 支持 | 映射到 Vision 支持的 symbology;不支持值忽略 |
| `vibrate` | 支持 | 识别成功后触发轻触感反馈 |
| `fromAlbum` | 支持 | 打开扫码页后直接进入相册选择 |
| `scanRegion` | 支持 | 控制实时识别区域与扫描框;相册识图不受限制 |
| `scanStyle` | 支持 | 控制遮罩、边框、扫描线及动画 |
| `resultMode` | 支持 | `immediate`、`confirm`、`pause` |
| `continuous` | 支持 | 多次 `success`,关闭时 `complete` |
| `showPauseButton` | 支持 | 仅连续扫码时展示暂停/继续控制 |
| `torch: 'off'` | 支持 | 默认关闭 |
| `torch: 'on'` | 支持 | 设备支持时尝试开启 |
| `smartZoom` | 第二阶段 | 首版可读取但不自动变焦,后续补齐 |
| `torch: 'auto'` | 第二阶段 | 需要基于视频帧亮度策略验证 |
### 4.2 成功数据
iOS 识别结果必须统一转换为:
```ts
{
content: string,
format: string,
fromAlbum: boolean
}
```
格式名称与 Android 对齐,至少包括:
```text
QR_CODE
EAN_13
EAN_8
UPC_A
UPC_E
CODE_128
CODE_39
CODE_93
ITF
CODABAR
PDF417
AZTEC
DATA_MATRIX
```
当 Vision 不支持某个 Android 已支持的格式时:
1. `formats` 中的该值不参与 Vision 请求;
2. 不因单个非法或不支持值使整个调用失败;
3. 若过滤后没有任何 iOS 支持的格式,可按全格式或返回 `9021004`,最终策略需在编码前固定,并同步文档;
4. 推荐与 Android 保持一致:过滤为空时退回全格式识别。
### 4.3 回调一致性
| 场景 | `success` | `fail` | `complete` |
| --- | --- | --- | --- |
| 单次扫码成功 | 调用一次 | 不调用 | 紧随成功调用一次,参数为成功结果 |
| 单次取消/关闭 | 不调用 | `9021003` | 紧随失败调用一次 |
| 相机权限拒绝 | 不调用 | `9021002` | 紧随失败调用一次 |
| 无有效扫码结果的异常结束 | 不调用 | `9021004` | 紧随失败调用一次 |
| 连续扫码单次命中 | 每次命中调用 | 不调用 | 页面未关闭前不调用 |
| 连续扫码关闭且已有命中 | 不再调用 | 不调用 | `complete({ closed: true, hitCount })` |
| 连续扫码关闭且无命中 | 不调用 | `9021003` | 紧随失败调用一次 |
所有业务回调必须在主线程派发,控制器关闭、权限回调和异步识别完成之间要有会话状态保护,确保不会重复回调。
### 4.4 错误码复用
继续使用 `unierror.uts` 中的错误码:
| 错误码 | 语义 | iOS 对应场景 |
| --- | --- | --- |
| `9021001` | 当前无法获取页面 Activity | 无法获取当前 `UIViewController` 或不能展示扫码页 |
| `9021002` | 相机权限被拒绝 | `.denied` / `.restricted` 或用户拒绝授权 |
| `9021003` | 用户取消扫码 | 点击关闭、系统返回或取消图片选择 |
| `9021004` | 未获取到有效的扫码结果 | 扫码处理异常结束、相册图片无有效条码后由调用方选择结束 |
---
## 5. 核心功能设计
### 5.1 相机权限与页面生命周期
流程:
```text
openCodeScan
↓
获取当前 UIViewController
↓
检查 AVCaptureDevice 相机权限
├─ 已授权 → 初始化扫码控制器与会话
├─ 未决定 → 请求授权 → 成功后初始化
└─ 拒绝/受限 → fail(9021002) → complete
```
控制器生命周期要求:
- 页面展示时创建并启动 `AVCaptureSession`;
- 页面进入后台时暂停会话;
- 页面恢复前台时,未完成且未暂停时恢复会话;
- 页面关闭、成功返回或发生不可恢复错误时停止会话;
- 释放 `AVCaptureVideoDataOutput` 委托、图片引用、动画图层和 UTS 回调引用;
- 防止异步 Vision 回调在页面关闭后再提交结果。
### 5.2 实时扫码链路
```text
AVCaptureVideoDataOutput
↓ CMSampleBuffer
Vision VNDetectBarcodesRequest
↓ VNBarcodeObservation 列表
formats 过滤
↓
scanRegion 区域过滤
↓
单码 / 多码 / 连续扫码 / 结果模式处理
```
性能策略:
- 使用串行 `DispatchQueue` 处理 Vision 请求,避免同一时间处理多帧;
- 在上一帧识别尚未结束时丢弃新帧;
- 多码定格、确认弹窗、暂停状态、页面关闭后停止提交识别请求;
- 连续扫码需按 `content + format` 做短时间去重,建议冷却窗口 `800ms ~ 1200ms`;
- 不在 Vision 回调线程直接更新 UIKit,统一回主线程更新界面。
### 5.3 formats 映射
新增 iOS 私有映射表:
```text
QR_CODE → VNBarcodeSymbologyQR
EAN_13 → VNBarcodeSymbologyEAN13
EAN_8 → VNBarcodeSymbologyEAN8
UPC_A → VNBarcodeSymbologyUPCE(需以实际 Vision 结果确认映射)
UPC_E → VNBarcodeSymbologyUPCE
CODE_128 → VNBarcodeSymbologyCode128
CODE_39 → VNBarcodeSymbologyCode39
CODE_93 → VNBarcodeSymbologyCode93
ITF → VNBarcodeSymbologyI2of5
CODABAR → VNBarcodeSymbologyCodabar
PDF417 → VNBarcodeSymbologyPDF417
AZTEC → VNBarcodeSymbologyAztec
DATA_MATRIX → VNBarcodeSymbologyDataMatrix
```
> `VNBarcodeSymbology` 的实际可用范围与命名随 iOS SDK 变化。开发时必须以目标 Xcode / iOS SDK 编译结果为准,不能假定每个 Android 格式都一一对应。
### 5.4 scanRegion 坐标转换与过滤
`scanRegion` 是业务 API 中最容易出现跨平台差异的部分,iOS 必须确保“画面上显示的扫描框”与“实际允许识别的区域”一致。
处理步骤:
1. 根据 `width`、`height`、`centerX`、`centerY` 计算预览层的扫描框 `CGRect`;
2. 使用 `AVCaptureVideoPreviewLayer` 将 Vision 的归一化 `boundingBox` 转换为 UIKit 坐标;
3. 判断条码边界框中心点是否在扫描框中;
4. 仅保留区域内条码;
5. `fullScreen: true` 时不绘制扫描框且不进行区域过滤;
6. 相册识图跳过 `scanRegion` 过滤,保持 Android 行为。
验证重点:
- Vision 坐标原点与 UIKit 坐标原点不同;
- `.resizeAspectFill` 会产生裁切偏移;
- 前置镜像、横竖屏和刘海安全区可能影响坐标;
- 多码点选标记必须与真实条码中心重合。
### 5.5 scanStyle 绘制
`ScanOverlayView` 使用 `CAShapeLayer` 与 `CAGradientLayer` 绘制,配置语义与 Android 一致:
| 参数 | iOS 实现 |
| --- | --- |
| `showMask` | 显示/隐藏扫描框外遮罩层 |
| `showBeam` | 显示/隐藏扫描线图层 |
| `borderColor` | `CGColor` 边框颜色 |
| `maskColor` | 解析 `#RRGGBB`、`#AARRGGBB`、`rgba()` 后填充遮罩 |
| `beamColor` | 扫描线颜色 |
| `beamDuration` | 扫描线 `CABasicAnimation.duration`,限制 `100 ~ 60000ms` |
| `cornerStyle: 'corner'` | 绘制四角边框 |
| `cornerStyle: 'full'` | 绘制完整矩形边框 |
默认值必须沿用当前接口默认行为:
```ts
{
showMask: true,
showBeam: true,
borderColor: '#19be6b',
maskColor: 'rgba(0, 0, 0, 0.45)',
beamColor: '#12e880',
beamDuration: 1800,
cornerStyle: 'corner'
}
```
颜色解析建议单独实现 `ScanStyleParser`:
- 支持 `#RRGGBB`;
- 支持 `#AARRGGBB`;
- 支持 `rgba(r,g,b,a)`;
- 解析失败时回退默认色;
- 所有回退规则与 Android `readScanStyle()` 保持一致。
### 5.6 单码、多码和点选
#### 单码
单个有效条码按 `resultMode` 处理:
- `immediate`:停止识别并立即返回;
- `confirm`:暂停识别,弹出确认框;
- `pause`:暂停识别,显示继续扫描和确认返回操作。
#### 多码
同一帧出现两个及以上有效、有内容且有边界框的条码时:
1. 停止实时识别;
2. 抓取当前预览帧;
3. 显示定格画面和半透明遮罩;
4. 把各码的边界框映射到屏幕;
5. 在中心位置绘制点选标记;
6. 显示“已识别到多个码,请选择一个”提示;
7. 用户点击最近标记后提交对应条码结果。
`MultiCodePickView` 的标记视觉与 Android 对齐:
```text
绿色中心圆点
+ 白色圆形底
+ 绿色半透明外圈
+ 周期性呼吸扩散动画
```
建议使用 `CAKeyframeAnimation` 或 `CABasicAnimation` 同步变化外圈的 `transform.scale` 与 `opacity`,周期暂定 `1500ms`。
点击策略:
- 以点位中心为基准选择最近候选;
- 设置最小可点击半径,建议约 `28pt`;
- 无命中点时继续等待选择,不直接取消;
- 用户主动关闭时按取消流程处理。
### 5.7 相册识图
优先采用 `PHPickerViewController`:
```text
fromAlbum: true 或点击相册按钮
↓
系统图片选择器
↓
获取 UIImage / CGImage
↓
Vision VNDetectBarcodesRequest
↓
单码直接返回;多码进入点选
```
规则:
- 相册识图结果的 `fromAlbum` 固定为 `true`;
- 相册图片不受 `scanRegion` 限制;
- 用户取消图片选择:单次扫码按 `9021003` 处理;
- 图片中未识别到条码时,提示“未识别到有效条码”,并返回扫码页继续操作;
- 多码相册图片使用图片本身作为定格背景并进入点选。
### 5.8 连续扫码与暂停
`continuous: true` 时:
- 命中一次条码调用一次 `success`;
- 扫码页不关闭;
- 同一内容需通过短时冷却避免高频重复提交;
- `showPauseButton: true` 时展示暂停/继续按钮;
- 用户关闭时,若已有命中,则仅调用:
```ts
complete({ closed: true, hitCount })
```
- 用户关闭时,若没有命中,则按 `fail(9021003)` 后 `complete`;
- 多码点选确认后,在连续模式下应投递一次成功结果、清除定格层、恢复相机识别,而不是关闭控制器。
### 5.9 手电筒、缩放与震动
#### 手电筒
- 使用 `AVCaptureDevice.hasTorch` 判断设备能力;
- 使用 `lockForConfiguration()` + `torchMode` 变更状态;
- `off`:默认关闭;
- `on`:启动后尝试开启;
- 无手电筒设备隐藏或禁用页面按钮;
- 页面结束时关闭手电筒。
#### 手势缩放
- 使用 `UIPinchGestureRecognizer`;
- 映射至 `videoZoomFactor`;
- 限制在 `1.0 ... activeFormat.videoMaxZoomFactor` 的安全范围;
- 变焦期间禁止智能变焦抢占。
#### 智能变焦
作为第二阶段能力:
- 根据条码边界框占画面比例计算建议倍率;
- 设定最大尝试次数、最大倍率和平滑过渡;
- 检测到多个码时先恢复 `1.0x`,避免多码点选区域被放大裁切;
- 与手势缩放、连续扫码、定格状态互斥。
#### 震动
- `vibrate: true` 时使用 `UIImpactFeedbackGenerator(style: .light)`;
- 连续扫码每次实际成功投递时触发;
- 系统可能因“减少动态效果”、静音模式或设备策略弱化触感反馈,业务层不应依赖其作为唯一成功提示。
---
## 6. UTS 桥接设计
### 6.1 app-ios/index.uts 职责
`app-ios/index.uts` 负责:
1. 获取可展示扫码页的当前 `UIViewController`;
2. 校验当前是否已有扫码会话,避免重复打开;
3. 归一化 `CodeScanOptions` 默认值;
4. 创建并展示 `CodeCaptureViewController`;
5. 接收原生事件并转换为 `CodeScanSuccess` / `CodeScanFailImpl`;
6. 保证回调顺序和只执行一次的终止语义;
7. 连续扫码时维护 `hitCount`;
8. 控制器退出后解除事件与对象引用。
### 6.2 建议的原生事件模型
控制器向桥接层输出有限事件,不直接感知 UTS 回调:
```text
onResult(content, format, fromAlbum)
onCancel()
onPermissionDenied()
onFatalError()
onClosed(hitCount, hadHits)
```
桥接层统一决定:
- 单次成功:`success` → `complete`;
- 单次失败:`fail` → `complete`;
- 连续扫码:每次 `onResult` → `success`,关闭时再 `complete`;
- 已结束会话的后续事件全部丢弃。
### 6.3 会话状态
建议 iOS 桥接层维护:
```text
idle → presenting → scanning → paused / picking → finishing → finished
```
关键约束:
- `finished` 后不允许再次派发回调;
- 权限请求、Vision 回调、相册回调都需检查当前状态;
- 控制器 dismissal 完成后再释放强引用;
- 业务层再次调用时必须创建新会话,不复用旧回调。
---
## 7. 权限与配置
### 7.1 必需权限
iOS 必须声明:
```xml
<key>NSCameraUsageDescription</key>
<string>需要使用相机扫描二维码和条形码</string>
```
若采用 `PHPickerViewController` 且最低系统版本为 iOS 14,一般不需要完整相册访问权限。
若最终需要 `UIImagePickerController` 或直接读取照片库,则补充:
```xml
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以识别图片中的二维码和条形码</string>
```
### 7.2 最低系统版本建议
建议首版目标:
```text
iOS 14+
```
理由:
- 可以使用 `PHPickerViewController`;
- Vision 条码能力更成熟;
- 降低图片权限和兼容逻辑复杂度。
若产品必须支持 iOS 13,需要增加相册选择兼容分支,并重新评估 Vision 可识别格式集合。
### 7.3 打包与调试
iOS 原生 UTS 代码不能通过 Web 预览验证,必须使用:
- 包含插件的 iOS 自定义调试基座;或
- iOS 云打包 / 本地原生打包产物;
- 真机设备与 Xcode 控制台。
模拟器不应作为相机扫码主验收环境;可用于页面布局、权限分支和相册图片识别的部分验证。
---
## 8. 分阶段实施计划
### 阶段 0:UTS iOS 可编译性验证
目标:确认当前 HBuilderX 与项目配置可编译 `app-ios` 原生实现。
任务:
- 创建最小 `app-ios/index.uts`;
- 验证获取当前 iOS 控制器与展示原生页面的官方方式;
- 验证 Swift 文件参与 UTS iOS 编译的目录和配置;
- 在 iPhone 真机执行一个最小“打开原生页并返回”示例;
- 固化项目实际约定后再开始完整扫码实现。
验收:iOS 自定义基座可编译并成功打开、关闭原生测试页面。
### 阶段 1:最小可用扫码
范围:
- 相机权限;
- 后置相机预览;
- Vision 单码实时识别;
- `success`、`fail`、`complete`;
- 用户关闭;
- 常见二维码与条形码格式。
验收:iPhone 真机可扫描 QR Code 和 Code 128,并按既定顺序回调。
### 阶段 2:跨端核心功能对齐
范围:
- `formats`;
- `scanRegion`;
- `scanStyle`;
- 相册识图;
- 多码定格点选;
- 绿色呼吸波纹;
- `resultMode`;
- `vibrate`。
验收:Android 与 iOS 对同一配置的显示、区域过滤和数据结构一致。
### 阶段 3:会话与相机增强
范围:
- `continuous`;
- `showPauseButton`;
- `torch: 'off' | 'on'`;
- 手势缩放;
- 连续扫码去重与关闭结算。
验收:连续扫码有命中和无命中关闭时的回调行为与 Android 一致。
### 阶段 4:高级策略
范围:
- `smartZoom`;
- `torch: 'auto'`;
- 复杂机型上的性能优化;
- 必要时评估第三方识别引擎。
验收:在目标机型上无明显误触发、频繁跳变、发热或回调重复问题。
---
## 9. 验收矩阵
### 9.1 功能验收
| 场景 | 预期 |
| --- | --- |
| 单个二维码 | 返回 `QR_CODE`、`fromAlbum: false` |
| 单个 Code 128 | 返回 `CODE_128` |
| formats 过滤 | 仅接受指定码制 |
| 全屏识别 | 不显示扫描框,整画面可识别 |
| 自定义区域 | 框外条码不返回,框内条码正常返回 |
| 相册单码 | 返回 `fromAlbum: true` |
| 相册多码 | 显示定格图片与多个可点击标记 |
| 实时多码 | 显示定格画面、提示语和绿色呼吸点 |
| confirm 模式 | 确认后返回,取消后继续识别 |
| pause 模式 | 可继续扫描或确认结果 |
| 连续扫码 | 多次 success,关闭后返回正确 hitCount |
| 连续扫码无命中关闭 | fail `9021003` 后 complete |
| 相机权限拒绝 | fail `9021002` 后 complete |
| 无手电筒设备 | 不崩溃,按钮隐藏或禁用 |
### 9.2 机型与系统验收
至少覆盖:
- iPhone 小屏机型;
- 刘海屏机型;
- 灵动岛机型;
- 无手电筒或手电筒不可用场景;
- iOS 14、16、17 及项目实际要求的最新系统;
- 深色模式;
- 权限首次授权、拒绝、设置页重新授权;
- 前后台切换、来电或控制中心打断后恢复;
- 快速打开、关闭、再次打开扫码页。
### 9.3 性能与稳定性验收
- 连续识别 5 分钟不出现明显内存持续增长;
- 多次进入、退出扫码页后相机可再次正常打开;
- 多码定格帧不会造成明显卡顿或黑屏;
- 页面关闭后不再收到 `success`;
- 不发生重复 `complete` 或 success/fail 同时触发;
- 扫描框与实际识别区域、点选标记位置对齐。
---
## 10. 风险与应对
| 风险 | 影响 | 应对措施 |
| --- | --- | --- |
| UTS iOS 原生目录/Swift 桥接规则与预期不同 | 无法编译或调用 | 阶段 0 使用官方最小示例先验证,不直接大规模开发 |
| Vision 支持的码制与 Android 不完全一致 | formats 行为差异 | 建立格式能力矩阵,过滤不支持项并在 README 明示 |
| Vision 到预览层坐标转换错误 | scanRegion、点选位置偏移 | 单独编写坐标转换测试清单,覆盖 Aspect Fill、旋转和安全区 |
| 定格帧转换开销高 | 多码时卡顿、内存上涨 | 限制图像分辨率,及时释放 `CGImage` / `UIImage`,仅保留一帧 |
| 异步回调竞争 | 重复回调、页面关闭后回调 | 使用会话状态机和主线程串行化终止事件 |
| Torch / zoom 在机型上差异大 | 功能不稳定 | 将 `auto` 与 smartZoom 放到后续阶段,先实现基础能力 |
| iOS 权限文案或 Info.plist 缺失 | 审核或运行时崩溃 | 将权限配置纳入构建前检查和真机回归 |
---
## 11. 代码与文档更新清单
### 11.1 开发完成后需要修改
| 文件 | 修改内容 |
| --- | --- |
| `utssdk/app-ios/*` | 新增 iOS UTS 桥接和 Swift 原生实现 |
| `package.json` | `displayName`、描述、关键字、`platforms.client.uni-app.app.ios` 改为支持 |
| `readme.md` | 支持平台改为 Android / iOS;补充 iOS 权限、最低版本和格式差异说明 |
| `changelog.md` | 记录 iOS 首次支持及功能范围 |
| `pages/scan/prototype.vue` | 将 Android 专属提示改为 Android / iOS App 提示 |
### 11.2 开发完成前不得修改
以下内容必须等 iOS 自定义基座成功编译且完成真机验收后再调整:
- README 的“支持 iOS”声明;
- `package.json` 中的 `ios: '√'`;
- 业务页面中“Android 专属”的运行提示;
- changelog 中的正式 iOS 发布记录。
---
## 12. 推荐实施顺序
1. 确认项目的 HBuilderX 版本、Xcode 版本和最低 iOS 版本;
2. 用官方 UTS iOS 最小原生页面验证 `app-ios` 目录与 Swift 编译方式;
3. 实现相机权限 + 单码 QR 实时识别闭环;
4. 固化 `success` / `fail` / `complete` 的 iOS 回调行为;
5. 加入 formats、scanRegion 和 scanStyle;
6. 加入相册识图和多码定格点选;
7. 加入连续扫码、暂停、手电筒和手势缩放;
8. 最后实现 smartZoom、auto torch 等设备敏感能力;
9. 真机回归通过后更新插件元信息、README、changelog 和演示页文案。
---
## 13. 结论
推荐采用 **AVFoundation + Vision + PHPickerViewController** 的原生 iOS 实现方案,并在 `utssdk/app-ios/` 中增加独立平台入口。现有 `interface.uts`、`unierror.uts` 和 `openCodeScan` 调用方式继续作为跨端契约,Android 代码无需重构。
实施上应先完成 iOS UTS 编译与原生页面呈现验证,再逐步建立单码扫码、多码点选、相册识图和连续扫码能力。这样能将最大的构建与坐标转换风险前置,同时避免在未验证前修改对外平台支持声明。
@@ -0,0 +1,617 @@
# mas-code-scan 功能与设置规划
> 本文用于整理 `mas-code-scan` 当前已有能力、可继续增加的功能,以及建议的开发优先级。
>
> 状态说明:
>
> - **已实现**:当前插件代码已经支持;
> - **建议增加**:功能方向已经明确,但尚未实现;
> - **后续评估**:需要结合实际业务、设备兼容性和实现成本再决定。
---
## 1. 插件当前能力
| 功能 | 状态 | 说明 |
|---|---|---|
| Android 相机扫码 | 已实现 | 基于 CameraX + Google ML Kit |
| 全格式识别 | 已实现 | 未传 `formats` 时按全格式识别 |
| 指定码制识别 | 已实现 | 通过 `formats` 限定二维码、条形码等格式 |
| 单次扫码 | 已实现 | 成功后自动返回结果 |
| 连续扫码 | 已实现 | `continuous: true`,识别后继续停留在扫码页 |
| 相册识图 | 已实现 | 支持打开后直接进入相册,也支持扫码页内打开相册 |
| 多码识别 | 已实现 | 同屏多个码时进入点选逻辑 |
| 智能变焦 | 已实现 | `smartZoom: true` 时对较小条码自动放大 |
| 双指手势缩放 | 已实现 | 扫码页内手动调整相机倍率 |
| 手电筒 | 已实现 | 扫码页右下角手动开关 |
| 成功震动 | 已实现 | 通过 `vibrate` 控制 |
| 自定义有效区域 | 已实现 | 通过 `scanRegion` 控制识别区域 |
| 二维码正方形区域 | 已实现 | `width` 与 `height` 使用相同值,锁定 1:1 |
| 横向条码区域 | 已实现 | 默认宽度较大、高度可调 |
| 条码框上下位置 | 已实现 | 支持上、中、下、自定义位置 |
| 条码框高度选择 | 已实现 | 原型页支持 24%、32%、40% 三档 |
| 全屏识别模式 | 已实现 | `scanRegion: { fullScreen: true }`,整画面识别且不显示扫描框 |
| 扫描框直角样式 | 已实现 | 当前不绘制圆角 |
---
## 2. 当前 API 概览
### 2.1 基础调用
```ts
import { openCodeScan } from '@/uni_modules/mas-code-scan'
openCodeScan({
smartZoom: true,
vibrate: true,
success: (res) => {
console.log(res.content, res.format)
},
fail: (err) => {
console.log(err.errCode, err.errMsg)
},
complete: () => {
console.log('扫码结束')
}
})
```
### 2.2 全屏识别
```ts
openCodeScan({
scanRegion: {
fullScreen: true
},
success: (res) => {
console.log(res.content)
}
})
```
全屏模式的行为:
- 整个相机预览区域均可识别;
- 不显示扫描框;
- 不显示扫描框遮罩;
- 不过滤扫描框外的条码;
- 不影响 `formats`、`smartZoom`、`vibrate`、`continuous` 等其他设置。
### 2.3 二维码正方形区域
```ts
openCodeScan({
formats: ['QR_CODE'],
scanRegion: {
width: 0.74,
height: 0.74,
centerX: 0.5,
centerY: 0.5
}
})
```
二维码区域要求:
- `width` 和 `height` 使用相同值;
- 严格保持 1:1 正方形比例;
- 扫描框使用直角,不使用圆角。
### 2.4 横向条码区域
```ts
openCodeScan({
formats: ['CODE_128', 'EAN_13'],
scanRegion: {
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
}
})
```
其中:
- `width` 控制横向宽度;
- `height` 控制扫描框内高度;
- `centerY` 控制扫描框上下位置;
- 目前原型页提供 24%、32%、40% 三档高度;
- 框内条码才会进入有效识别结果。
---
## 3. 建议优先增加的功能
以下功能对仓库、设备管理、物料盘点等场景最有价值。
### 3.1 手电筒 API 控制
当前已经有扫码页手电筒按钮,建议继续增加调用参数,用于控制打开扫码页时的默认状态。
建议接口:
```ts
torch?: 'auto' | 'on' | 'off'
```
建议行为:
| 值 | 行为 |
|---|---|
| `auto` | 使用系统或插件默认状态,不强制打开 |
| `on` | 打开扫码页后自动开启手电筒 |
| `off` | 打开扫码页后保持关闭 |
适用场景:弱光仓库、货架底部、设备背面标签、夜间扫码。
---
### 3.2 连续扫码去重策略
连续扫码时建议开放更明确的去重配置。
建议接口:
```ts
openCodeScan({
continuous: true,
duplicatePolicy: 'cooldown',
duplicateCooldown: 1500
})
```
建议支持:
| 策略 | 行为 |
|---|---|
| `allow` | 同一个码可以重复回调 |
| `ignore` | 同一次扫码会话中,相同内容只回调一次 |
| `cooldown` | 相同内容在指定时间内不重复回调 |
建议参数:
- `duplicatePolicy`:去重策略;
- `duplicateCooldown`:去重时间,单位毫秒;
- `maxResults`:连续扫码最多保存的结果数量。
这是批量盘点场景中最值得增加的功能之一。
---
### 3.3 多码识别策略
当前同屏多个条码时可以进入点选。建议增加自动选择策略,减少用户额外操作。
建议接口:
```ts
openCodeScan({
multiCodeMode: 'nearestCenter'
})
```
建议取值:
| 值 | 行为 |
|---|---|
| `select` | 保持当前交互,暂停画面并由用户点选 |
| `first` | 取 ML Kit 返回的第一个有效条码 |
| `nearestCenter` | 选择距离扫描框中心最近的条码 |
| `largest` | 选择画面中面积最大的条码 |
| `all` | 一次返回全部有效条码 |
建议默认使用 `nearestCenter`,更符合扫描框交互习惯。
---
### 3.4 扫码结果确认模式
目前单次扫码识别成功后会自动返回。建议增加确认模式,避免误扫。
建议接口:
```ts
openCodeScan({
resultMode: 'confirm'
})
```
建议取值:
| 值 | 行为 |
|---|---|
| `immediate` | 识别成功后立即返回,保持当前行为 |
| `confirm` | 显示结果,用户确认后返回 |
| `pause` | 识别成功后暂停相机,用户可确认或继续扫描 |
适用场景:设备绑定、物料入库、资产登记等不能容忍误扫的业务。
---
### 3.5 前后摄像头切换
建议支持默认摄像头和扫码页内切换。
建议接口:
```ts
openCodeScan({
camera: 'back'
})
```
建议取值:
- `back`:后置摄像头,默认;
- `front`:前置摄像头;
- `auto`:由插件选择可用摄像头。
扫码页可以增加一个左下角或右上角切换按钮。
---
## 4. 识别准确率和性能设置
### 4.1 稳定识别帧数
要求同一个条码连续出现在若干帧后才判定成功。
```ts
openCodeScan({
stableFrames: 2
})
```
优点:
- 减少模糊画面误触发;
- 减少条码刚进入画面时的误扫;
- 对连续扫码更稳定。
注意:数值过高会增加识别延迟,建议默认 1 或 2。
---
### 4.2 智能变焦参数
当前只有开关,后续可以细化为:
```ts
openCodeScan({
smartZoom: true,
smartZoomMaxRatio: 2.5,
smartZoomTriggerSize: 0.12
})
```
建议参数:
- `smartZoomMaxRatio`:最大自动变焦倍率;
- `smartZoomTriggerSize`:条码小于指定比例时触发;
- `smartZoomResetAfterSuccess`:识别成功后是否恢复倍率。
---
### 4.3 最小条码尺寸过滤
用于忽略过远或过小的无关条码。
```ts
openCodeScan({
minBarcodeWidth: 0.08,
minBarcodeHeight: 0.03
})
```
注意:该功能和 `scanRegion` 不同:
- `scanRegion` 解决“条码是否在有效区域内”;
- 最小尺寸过滤解决“条码是否足够大、是否值得识别”。
---
## 5. 扫描框和交互设置
### 5.1 扫描框视觉样式
目前扫描框已经支持尺寸、位置和直角样式。增加独立的视觉配置:
```ts
openCodeScan({
scanRegion: {
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
},
scanStyle: {
showMask: true,
showBeam: true,
borderColor: '#19be6b',
maskColor: 'rgba(0, 0, 0, 0.45)',
beamColor: '#12e880'
}
})
```
建议支持:
- `showMask`:是否显示框外遮罩;
- `showBeam`:是否显示扫描线;
- `borderColor`:边框颜色;
- `maskColor`:遮罩颜色和透明度;
- `beamColor`:扫描线颜色;
- `beamDuration`:扫描线移动周期;
- `cornerStyle`:完整边框或四角边框。
建议将这些属性放在 `scanStyle` 中,不要和识别区域参数混在一起。
---
### 5.2 扫码页提示文案
不同业务可以使用不同的引导语。
建议接口:
```ts
openCodeScan({
title: '设备绑定',
hint: '请将设备条码放入扫描框内'
})
```
建议支持:
- `title`:页面标题;
- `hint`:扫描框下方提示;
- `successHint`:识别成功提示;
- `emptyHint`:暂未识别到条码时的提示。
---
### 5.3 暂停与继续扫描
连续扫码可以增加暂停和继续按钮。
建议行为:
- 点击暂停后停止分析帧;
- 相机预览可以继续显示,也可以冻结画面;
- 点击继续后恢复分析;
- 连续扫码结果列表不清空。
- 实时帧中只有一条已解码条码时必须直接上报;ML Kit 额外返回的无内容候选框不能触发多码点选或跳过该单码。
建议接口:
```ts
openCodeScan({
continuous: true,
showPauseButton: true
})
```
---
## 6. 扫码结果增强
### 6.1 返回条码位置
当前结果主要返回内容和码制。后续可以返回条码在图像中的位置。
建议结果:
```ts
{
content: 'EQ-12345678',
format: 'CODE_128',
fromAlbum: false,
bounds: {
left: 120,
top: 360,
width: 480,
height: 96
}
}
```
用途:
- 调试扫描框映射;
- 保存条码位置;
- 多码场景二次判断;
- 识别结果可视化。
---
### 6.2 结果校验
建议在插件外层或调用参数中增加结果校验能力。
```ts
openCodeScan({
formats: ['CODE_128'],
validator: {
pattern: '^EQ-[A-Z0-9]{8}$',
message: '请扫描有效设备编码'
}
})
```
建议支持:
- 正则表达式;
- 固定前缀;
- 固定长度;
- 自定义校验回调;
- 校验失败后继续扫描,不关闭扫码页。
建议优先在业务层实现,避免把业务规则过度耦合到通用插件。
---
### 6.3 扫码状态回调
用于业务页面展示状态或进行调试。
建议接口:
```ts
openCodeScan({
onStatus: (status) => {
console.log(status)
}
})
```
建议状态:
- `opening`:正在打开扫码页;
- `cameraReady`:相机已准备完成;
- `scanning`:正在识别;
- `paused`:已暂停;
- `result`:识别到结果;
- `closed`:扫码页关闭。
---
## 7. 相册识图增强
### 7.1 多图批量识别
```ts
openCodeScan({
fromAlbum: true,
albumMultiple: true,
maxAlbumImages: 9
})
```
适用场景:
- 批量导入物料标签照片;
- 历史图片盘点;
- 一次处理多个设备照片。
### 7.2 图片裁剪后识别
相册选择图片后增加裁剪步骤:
- 用户手动裁剪识别区域;
- 使用当前扫描框比例作为裁剪建议;
- 裁剪后再交给 ML Kit 识别。
复杂照片中存在多个标签时,该功能可以明显提高识别准确率。
---
## 8. 调试和错误处理
### 8.1 调试模式
建议增加:
```ts
openCodeScan({
debug: true
})
```
调试信息可包括:
- 预览 View 尺寸;
- 实际扫描框像素坐标;
- 输入图像尺寸;
- 图像旋转角度;
- cropRect;
- 区域过滤前后的条码数量;
- 智能变焦触发记录;
- 每帧识别耗时。
正式环境默认关闭,避免性能损耗和敏感信息泄露。
### 8.2 更细的错误码
**状态:已实现。** 现有 `9021001 ~ 9021004` 的含义保持不变,新增错误码从 `9021005` 开始:
| 错误码 | 场景 | 处理方式 |
| --- | --- | --- |
| `9021005` | 相机被其他应用占用或当前不可用 | 关闭其他相机应用后重试 |
| `9021006` | 设备没有闪光灯 | 仅在扫码页提示,不中断扫码会话 |
| `9021007` | 相机初始化失败 | 检查设备相机状态,必要时重启应用 |
| `9021008` | 相册图片无法读取 | 重新选择本地图片 |
| `9021009` | 图片中没有有效条码 | 仅打开即相册且无可用相机回退时结束会话 |
| `9021010` | 指定码制不支持 | `formats` 非空且全部不受支持时返回 |
| `9021011` | 扫描超时 | 单次扫码传入 `scanTimeout: 1000 ~ 600000` 后启用;连续扫码不启用 |
错误码增加遵循兼容原则:不修改已有错误码的含义;致命错误走 `fail` 后 `complete`;无闪光灯不会影响相机扫码,因此只提示,不触发 `fail`。
---
## 9. 横屏和平板支持
当前 Android 扫码页固定竖屏。后续可以评估:
```ts
openCodeScan({
orientation: 'portrait'
})
```
建议取值:
- `portrait`:竖屏,当前默认行为;
- `landscape`:横屏;
- `auto`:跟随设备方向。
适用设备:
- 平板;
- 工业手持终端;
- 横向固定安装设备;
- 长条码集中扫描场景。
---
## 10. 推荐开发优先级
### 第一阶段:直接提升日常可用性
1. 手电筒默认状态 API;
2. 连续扫码去重策略;
3. 多码自动选择策略;
4. 扫码结果确认模式;
5. 前后摄像头切换。
### 第二阶段:提升业务适配能力
1. 扫码页提示文案;
2. 扫描状态回调;
3. 稳定识别帧数;
4. 扫描框视觉样式配置;
5. 结果位置 `bounds` 返回。
### 第三阶段:扩展复杂业务场景
1. 相册多图批量识别;
2. 图片裁剪后识别;
3. 横屏和平板支持;
4. 扫码结果校验器;
5. 调试模式和性能指标。
---
## 11. 开发注意事项
1. 新增功能应优先保持 `openCodeScan(options)` 的兼容性,不要破坏现有调用。
2. 识别区域参数和视觉样式参数应分开设计。
3. 全屏模式必须保持“整画面识别、不绘制扫描框、不进行区域过滤”的语义。
4. 二维码区域必须继续保持 1:1,不能因为屏幕比例或边界收敛发生变形。
5. 连续扫码新增去重策略时,不能削弱当前已有的基本去重行为。
6. 相册识图和实时相机识别应保持清晰区分,相册识图默认不受实时扫描框限制。
7. 新增 Android 原生能力后,应同步更新 UTS 类型定义、插件 README 和演示页。
8. 每次修改原生相机、图像坐标映射或区域过滤逻辑后,都需要使用 Android 真机或自定义调试基座验证。
9. 新增错误码时只能追加,不能改变已有错误码的含义。
10. 涉及权限、相机、闪光灯和设备方向的功能,需要至少验证主流 Android 设备和不同屏幕尺寸。
@@ -0,0 +1,498 @@
# mas-code-scan 自定义识别区域方案
## 1. 目标
在 Android 扫码页面中提供可配置的实时识别区域(ROI,Region of Interest):
- 摄像头打开后显示一个矩形扫描框;
- 只有条码位于矩形区域内时才允许识别成功;
- 扫描框外的条码不触发单次扫码成功;
- 支持自定义扫描框宽度、高度、水平位置和垂直位置;
- 支持不同屏幕尺寸和横竖屏适配;
- 不破坏现有的单次扫码、连续扫码、相册识图、多码点选、智能变焦和码制过滤能力。
## 2. 当前实现核对
### 2.1 现有接口
文件:`uni_modules/mas-code-scan/utssdk/interface.uts`
当前 `CodeScanOptions` 支持:
```ts
{
smartZoom?: boolean
formats?: string[]
vibrate?: boolean
continuous?: boolean
fromAlbum?: boolean
success?: (res) => void
fail?: (res) => void
complete?: (res) => void
}
```
当前没有识别区域配置字段。
### 2.2 预览层
文件:`uni_modules/mas-code-scan/utssdk/app-android/CodeCaptureActivity.kt`
- 使用 `PreviewView.ScaleType.FILL_CENTER` 显示相机预览;
- `ScanFrameDecorView` 覆盖在预览层上方绘制扫描动画;
- 预览层和装饰层都是全屏 `FrameLayout`;
- 触摸缩放绑定在 `PreviewView` 上。
文件:`uni_modules/mas-code-scan/utssdk/app-android/ScanFrameDecorView.kt`
当前扫描框是固定的正方形:
```kotlin
val frameWidth = ...
val frameHeight = frameWidth
```
它只负责画扫描线,不参与实际识别。
### 2.3 识别层
文件:`uni_modules/mas-code-scan/utssdk/app-android/CodeCaptureActivity.kt`
当前把完整的 `ImageProxy` 图像交给 ML Kit:
```kotlin
val inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees)
scanner.process(inputImage)
```
因此当前扫描框只是视觉提示,框外条码仍可能被识别。
## 3. 推荐接口设计
建议新增 `scanRegion`,使用相对比例配置,不使用固定像素作为公共接口。
```ts
openCodeScan({
scanRegion: {
width: 0.92,
height: 0.18,
centerX: 0.5,
centerY: 0.5
}
})
```
字段定义:
| 字段 | 类型 | 默认值 | 范围 | 含义 |
|---|---|---:|---|---|
| `fullScreen` | `boolean` | `false` | - | 为 `true` 时整画面识别,不绘制扫描框且不进行区域过滤 |
| `width` | `number` | `0.74` | `0.1 ~ 1` | 扫描框宽度,占预览宽度的比例 |
| `height` | `number` | `0.74` | `0.1 ~ 1` | 扫描框高度,占预览宽度的比例 |
| `centerX` | `number` | `0.5` | `0 ~ 1` | 扫描框中心的水平位置比例 |
| `centerY` | `number` | `0.5` | `0 ~ 1` | 扫描框中心的垂直位置比例 |
全屏识别示例:
```ts
openCodeScan({
scanRegion: { fullScreen: true }
})
```
二维码区域约束:
- `width` 与 `height` 使用相同值,严格锁定 `1:1` 正方形比例;
- 默认值为 `width: 0.74`、`height: 0.74`;
- 原生扫描框使用直角,不绘制圆角。
推荐默认值:
```ts
{
width: 0.74,
height: 0.74,
centerX: 0.5,
centerY: 0.5
}
```
业务页面可针对一维码使用横向区域,并通过 `centerY` 调整上下位置、通过 `height` 调整框高:
```ts
{
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
}
```
原型页提供三档横向条码框高度:
- 中:`0.24`(24%);
- 大:`0.32`(32%);
- 特大:`0.40`(40%)。
### 3.1 为什么使用比例
- 不依赖具体手机分辨率和 density;
- 适配不同 Android 设备;
- 与当前全屏预览布局一致;
- 后续可在页面中通过滑块或预设配置调整;
- 避免将前端 `rpx` 直接与原生像素混用。
## 4. 实现分层
### 4.1 UTS 接口层
修改:`utssdk/interface.uts`
新增:
```ts
export type ScanRegion = {
width?: number
height?: number
centerX?: number
centerY?: number
}
```
并在 `CodeScanOptions` 中增加:
```ts
scanRegion?: ScanRegion
```
要求:
- 字段全部可选;
- 不传时保持现有默认扫描框;
- 对非法值进行原生端裁剪和兜底;
- 不把 UI 颜色、文案等表现层参数与识别区域参数混在一起。
### 4.2 UTS 到 Android Intent
修改:`utssdk/app-android/index.uts`
新增一个 JSON 字符串 Extra,例如:
```text
mas_scan_region
```
将 `scanRegion` 序列化后传递给 `CodeCaptureActivity`。
推荐传递 JSON,而不是增加多个 Intent Extra,原因是:
- 字段集中;
- 后续增加圆角、边框颜色等非识别参数时更容易扩展;
- 保持接口和 Activity 参数数量可控。
### 4.3 Activity 参数解析
修改:`CodeCaptureActivity.kt`
新增:
```kotlin
private data class ScanRegionConfig(
val width: Float,
val height: Float,
val centerX: Float,
val centerY: Float
)
```
初始化时:
1. 读取 Intent Extra;
2. 解析 JSON;
3. 将数值限制在安全范围;
4. 对过小区域、越界中心点进行兜底;
5. 将同一份配置传给 `ScanFrameDecorView` 和识别过滤逻辑。
最终必须保证:
```text
视觉扫描框区域 == 实际有效识别区域
```
## 5. 坐标体系设计
这是本需求的核心难点。项目中至少存在三套坐标:
1. **屏幕/装饰层坐标**:`ScanFrameDecorView` 的 view 坐标;
2. **预览可视区域坐标**:`PreviewView` 中用户看到的相机画面;
3. **ImageProxy 图像坐标**:ML Kit `Barcode.boundingBox` 使用的图像坐标。
由于预览使用 `FILL_CENTER`,图像可能在屏幕两侧或上下被裁剪,不能简单按照屏幕宽高直接换算。
### 5.1 统一区域定义
推荐先在预览层坐标中计算扫描框:
```text
frameLeft = viewWidth * (centerX - width / 2)
frameTop = viewHeight * (centerY - height / 2)
frameRight = viewWidth * (centerX + width / 2)
frameBottom = viewHeight * (centerY + height / 2)
```
并限制在预览层范围内。
### 5.2 预览坐标映射到图像坐标
当前 `PreviewView` 使用 `FILL_CENTER`,映射应考虑:
```text
scale = max(viewWidth / rotatedImageWidth, viewHeight / rotatedImageHeight)
```
相机图像在屏幕上的实际显示尺寸为:
```text
displayWidth = rotatedImageWidth * scale
displayHeight = rotatedImageHeight * scale
offsetX = (viewWidth - displayWidth) / 2
offsetY = (viewHeight - displayHeight) / 2
```
扫描框转回旋转后的图像坐标:
```text
imageLeft = (frameLeft - offsetX) / scale
imageTop = (frameTop - offsetY) / scale
imageRight = (frameRight - offsetX) / scale
imageBottom = (frameBottom - offsetY) / scale
```
然后再根据 `rotationDegrees` 转换到 `ImageProxy` 原始坐标。
注意:实际代码需要以 `PreviewView` 的 `viewPort`、绑定的 UseCase 分辨率和设备方向为准验证,不能只凭静态公式上线。
## 6. 识别限制策略
### 6.1 第一阶段:结果过滤,推荐先实现
ML Kit 仍分析整帧,但只接受扫描区域内的条码:
```kotlin
val validBarcodes = barcodes.filter { barcode ->
val box = barcode.boundingBox ?: return@filter false
isBarcodeInsideScanRegion(box, imageProxy, rotationDegrees)
}
```
区域判断建议使用条码边界框中心点,默认规则:
```text
条码中心点位于扫描框内 => 允许识别
条码中心点位于扫描框外 => 忽略
```
对于严格业务场景,可以改为:
```text
条码边界框完全位于扫描框内 => 允许识别
```
本项目建议默认使用“中心点在区域内”,原因是:
- 用户将条码移入框内时体验更自然;
- 条码边缘轻微超出时不会频繁丢失;
- 对一维横向条码更友好。
### 6.2 多码识别
过滤应发生在多码判断之前:
```text
原始 barcodes
-> 过滤无 boundingBox 的结果
-> 过滤框外结果
-> 再执行单码 / 多码判断
```
这样框外多码不会触发点选页面,也不会影响智能变焦。
### 6.3 相册识图
实时摄像头的 `scanRegion` 不应默认限制相册识图:
- 相册图片没有实时预览框坐标;
- 用户选择的是整张图片;
- 继续保持相册识别整图的现有行为。
如果未来确实需要对相册图片裁剪,应单独设计 `albumRegion`,不要复用实时预览坐标。
### 6.4 识别前裁剪
第二阶段可选做真正的 ROI 图像裁剪,只将区域图像提交给 ML Kit。
暂不作为第一阶段实现,原因:
- 需要处理 ImageProxy YUV 裁剪;
- 需要处理旋转和 cropRect;
- 需要重新映射多码点选坐标;
- 可能影响智能变焦依据和现有连续扫码稳定性;
- 设备兼容性验证成本更高。
## 7. 扫描框视觉设计
修改:`ScanFrameDecorView.kt`
扫描框应从固定正方形改为配置矩形:
- 宽度由 `width` 决定;
- 高度由 `height` 决定;
- 位置由 `centerX`、`centerY` 决定;
- 扫描线只在矩形内部移动;
- 扫描线左右留出固定 inset;
- 其他区域可增加半透明遮罩,增强用户对有效范围的理解。
建议提供清晰的 UI 语义:
```text
框内:有效识别区域
框外:不识别
```
当前截图的横向条码场景推荐:
```ts
scanRegion: {
width: 0.96,
height: 0.16,
centerX: 0.5,
centerY: 0.5
}
```
## 8. 原型页面示例规划
文件:`pages/scan/prototype.vue`
原型页增加“识别区域”配置区,但不直接实现摄像头 UI。页面负责展示和传递配置:
### 8.1 预设
提供三个预设:
- 默认正方形:适合二维码;
- 横向条码:适合设备码、物料码;
- 自定义:通过宽度、高度、水平位置、垂直位置调整。
### 8.2 示例数据
```js
scanRegion: {
width: 0.96,
height: 0.16,
centerX: 0.5,
centerY: 0.5
}
```
### 8.3 原型页交互
- 使用滑块或步进按钮调整四个比例参数;
- 页面实时显示当前区域参数;
- 单次扫码、连续扫码都使用当前 `scanRegion`;
- 相册识图按钮不传实时扫描区域,继续整图识别;
- 在 Web 预览中展示“仅 Android App-Plus 支持原生扫码”的提示。
## 9. 兼容性策略
### 9.1 不传配置
现有调用全部保持兼容:
```ts
openCodeScan({
smartZoom: true,
vibrate: true
})
```
行为继续使用默认正方形区域。
### 9.2 旧构建包
如果前端代码传入 `scanRegion`,但运行的是旧插件包,旧原生代码会忽略未知 Extra,仍可正常扫码,但不会限制区域。因此必须重新制作自定义基座或正式 APK。
### 9.3 非 Android 平台
继续由 `#ifdef APP-PLUS` 控制插件导入和调用,Web、iOS、小程序不加载 Android UTS 实现。
### 9.4 参数安全
原生端必须再次校验参数,不能只依赖前端:
- `width`、`height` 限制在 `0.1 ~ 1`;
- `centerX`、`centerY` 限制在 `0 ~ 1`;
- 区域超出屏幕时进行边界收敛;
- 宽高过小导致不可用时回退默认值。
## 10. 实施顺序
### 阶段一:方案验证
1. 增加 `scanRegion` 接口定义;
2. 将参数传入 Android Activity;
3. 扫描框支持矩形和位置配置;
4. 根据条码中心点过滤框外结果;
5. 原型页提供横向条码预设;
6. 保持相册整图识别。
### 阶段二:真机验证
至少验证:
- 二维码默认正方形识别;
- 横向一维码识别;
- 框内识别成功;
- 框外完全不识别;
- 条码横跨边界时的行为;
- 多个条码一内一外时只处理框内条码;
- 连续扫码只回传框内结果;
- 智能变焦不会被框外条码触发;
- 横竖屏和不同分辨率设备;
- 相册识图不受实时区域影响;
- 关闭、取消、权限拒绝和页面销毁流程。
### 阶段三:可选优化
根据真机效果再决定是否:
- 改为识别前裁剪 ROI;
- 增加边框颜色、圆角、遮罩透明度配置;
- 增加“中心点在框内 / 完全包含”两种命中规则;
- 增加一维码专用默认模板。
## 11. 验收标准
功能验收:
- 扫描框宽高和位置可以通过 `openCodeScan` 参数调整;
- 用户看到的扫描框与实际识别区域一致;
- 框外条码不会触发成功回调;
- 框内条码可以正常触发成功回调;
- 多码点选只展示有效区域内的条码;
- 旧调用不传 `scanRegion` 时行为不变;
- 单次、连续、相册三种模式互不回归。
工程验收:
- UTS 接口类型、Intent Extra、Activity 解析三层字段一致;
- 不在页面中直接修改原生插件内部逻辑;
- 所有新增配置有默认值和越界保护;
- 真机重新构建自定义基座后验证;
- 不以 Web 预览结果代替 Android 真机验证。
## 12. 结论
本项目适合采用“**比例配置 + 原生扫描框绘制 + 条码结果区域过滤**”作为第一版实现。
该方案能满足截图所示的横向矩形识别场景,改动范围可控,且能保留当前插件已有能力。真正的图像裁剪 ROI 作为后续优化,不在第一版中引入,以降低旋转、坐标映射和多码点选的回归风险。
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main.js"></script>
</body>
</html>
+94
View File
@@ -0,0 +1,94 @@
import App from './App'
import uViewPro from '@/uni_modules/uview-pro'
import { applyAppTheme, getAppThemeMode } from '@/utils/theme.js'
const appThemeMixin = {
data() {
return {
themeMode: getAppThemeMode()
}
},
created() {
this.__onScanappThemeChange = (themeMode) => {
this.themeMode = themeMode
}
uni.$on('scanapp-theme-change', this.__onScanappThemeChange)
},
onShow() {
this.themeMode = getAppThemeMode()
applyAppTheme(this.themeMode)
},
beforeDestroy() {
uni.$off('scanapp-theme-change', this.__onScanappThemeChange)
},
beforeUnmount() {
uni.$off('scanapp-theme-change', this.__onScanappThemeChange)
}
}
applyAppTheme(getAppThemeMode())
// Android 原生 uni.showModal 的按钮顺序与 H5 不一致:确认按钮在左、取消按钮在右。
// 仅交换显示文本并还原回调语义,避免逐个页面修改弹窗逻辑,也确保所有弹窗统一生效。
// #ifdef APP-PLUS
const installAndroidModalButtonOrderFix = () => {
if (uni.__scanappAndroidModalButtonOrderFixed) return
const systemInfo = uni.getSystemInfoSync ? uni.getSystemInfoSync() : {}
if (systemInfo.platform !== 'android' || typeof uni.showModal !== 'function') return
const nativeShowModal = uni.showModal
const normalizeResult = (result) => {
if (!result || typeof result !== 'object') return result
return {
...result,
confirm: Boolean(result.cancel),
cancel: Boolean(result.confirm)
}
}
uni.showModal = (options = {}) => {
if (options.showCancel === false) return nativeShowModal.call(uni, options)
const confirmText = options.confirmText || '确定'
const cancelText = options.cancelText || '取消'
const nativeOptions = {
...options,
confirmText: cancelText,
cancelText: confirmText,
success: (result) => options.success?.(normalizeResult(result)),
fail: (error) => options.fail?.(error),
complete: (result) => options.complete?.(normalizeResult(result))
}
delete nativeOptions.__scanappNormalized
const request = nativeShowModal.call(uni, nativeOptions)
return request && typeof request.then === 'function' ? request.then(normalizeResult) : request
}
uni.__scanappAndroidModalButtonOrderFixed = true
}
installAndroidModalButtonOrderFix()
// #endif
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
Vue.mixin(appThemeMixin)
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import {
createSSRApp
} from 'vue'
export function createApp() {
const app = createSSRApp(App)
app.mixin(appThemeMixin)
app.use(uViewPro)
return {
app
}
}
// #endif
+79
View File
@@ -0,0 +1,79 @@
{
"name" : "scanapp",
"appid" : "__UNI__04C6DBA",
"description" : "",
"versionName" : "1.1.0",
"versionCode" : 110,
"transformPx" : false,
/* 5+App特有相关 */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* 模块配置 */
"modules" : {
"Camera" : {},
"Bluetooth" : {},
"Barcode" : {}
},
/* 应用发布信息 */
"distribute" : {
/* android打包配置 */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios打包配置 */
"ios" : {
"dSYMs" : false
},
/* SDK配置 */
"sdkConfigs" : {}
}
},
/* 快应用特有相关 */
"quickapp" : {},
/* 小程序特有相关 */
"mp-weixin" : {
"appid" : "wx2f4810c6b3da5e52",
"setting" : {
"urlCheck" : false,
"minified" : true
},
"usingComponents" : true,
"permission" : {}
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"vueVersion" : "3",
"locale" : "zh-Hans",
"fallbackLocale" : "zh-Hans"
}
+137
View File
@@ -0,0 +1,137 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^u-(.*)": "@/uni_modules/uview-pro/components/u-$1/u-$1.vue"
}
},
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页"
}
},
{
"path": "pages/auth/login",
"style": {
"navigationBarTitleText": "登录"
}
},
{
"path": "pages/auth/register",
"style": {
"navigationBarTitleText": "注册"
}
},
{
"path": "pages/entry/order",
"style": {
"navigationBarTitleText": "订单模式"
}
},
{
"path": "pages/entry/multi-machine",
"style": {
"navigationBarTitleText": "一单多机"
}
},
{
"path": "pages/entry/machine",
"style": {
"navigationBarTitleText": "整机模式"
}
},
{
"path": "pages/scan/prototype",
"style": {
"navigationBarTitleText": "UTS 扫码原型"
}
},
{
"path": "pages/query/index",
"style": {
"navigationBarTitleText": "查询"
}
},
{
"path": "pages/statistics/index",
"style": {
"navigationBarTitleText": "统计"
}
},
{
"path": "pages/exception/index",
"style": {
"navigationBarTitleText": "异常处理"
}
},
{
"path": "pages/repair/index",
"style": {
"navigationBarTitleText": "维修处理"
}
},
{
"path": "pages/repair/tasks",
"style": {
"navigationBarTitleText": "待维修订单"
}
},
{
"path": "pages/settings/index",
"style": {
"navigationBarTitleText": "设置"
}
},
{
"path": "pages/settings/feedback",
"style": {
"navigationBarTitleText": "问题反馈"
}
},
{
"path": "pages/settings/feedback-detail",
"style": {
"navigationBarTitleText": "反馈详情"
}
},
{
"path": "pages/settings/about",
"style": {
"navigationBarTitleText": "关于我们"
}
},
{
"path": "pages/settings/policy",
"style": {
"navigationBarTitleText": "协议详情"
}
},
{
"path": "pages/profile/index",
"style": {
"navigationBarTitleText": "个人资料"
}
},
{
"path": "pages/profile/edit",
"style": {
"navigationBarTitleText": "修改资料"
}
},
{
"path": "pages/profile/password",
"style": {
"navigationBarTitleText": "修改密码"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "首页",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F7F9FC"
},
"uniIdRouter": {}
}
+284
View File
@@ -0,0 +1,284 @@
<template>
<view class="auth-page" :class="`theme-${themeMode}`">
<view class="bg-circle bg-circle-primary"></view>
<view class="bg-circle bg-circle-light"></view>
<view class="brand-area">
<view class="brand-mark">
<u-icon name="scan" color="#ffffff" size="52" />
</view>
<text class="brand-title">扫码生产助手</text>
<text class="brand-subtitle">登录后开始扫码录入生产数据</text>
</view>
<view class="auth-panel">
<view class="panel-header">
<text class="panel-title">账号登录</text>
<text class="panel-desc">请输入账号和密码</text>
</view>
<u-form :model="form" :border-bottom="false" label-position="top" label-width="120">
<u-form-item prop="account" :border-bottom="false">
<view class="field-card">
<view class="field-icon">
<u-icon name="account-fill" color="#1677ff" size="36" />
</view>
<u-input v-model="form.account" clearable placeholder="请输入账号/手机号" placeholder-style="color: #a0a7b5;" />
</view>
</u-form-item>
<u-form-item prop="password" :border-bottom="false">
<view class="field-card">
<view class="field-icon">
<u-icon name="lock-fill" color="#1677ff" size="36" />
</view>
<u-input v-model="form.password" type="password" clearable password-icon placeholder="请输入密码" placeholder-style="color: #a0a7b5;" />
</view>
</u-form-item>
</u-form>
<view class="actions">
<u-button type="primary" shape="circle" size="large" :loading="loading" @click="handleLogin">登录</u-button>
</view>
<view class="footer-link">
<text>还没有账号?</text>
<text class="link" @tap="goRegister">立即注册</text>
</view>
</view>
</view>
</template>
<script>
import { login } from '@/api/auth.js'
export default {
data() {
return {
loading: false,
redirectUrl: '/pages/index/index',
form: {
account: '',
password: ''
}
}
},
onLoad(options = {}) {
if (options.redirect) {
this.redirectUrl = decodeURIComponent(options.redirect)
}
},
methods: {
async handleLogin() {
if (!this.form.account || !this.form.password) {
uni.showToast({
title: '请输入账号和密码',
icon: 'none'
})
return
}
this.loading = true
try {
const result = await login({
username: this.form.account,
password: this.form.password
})
const accessToken = result?.access_token || result?.token?.access_token
if (!accessToken) {
throw new Error('登录响应缺少访问令牌')
}
uni.setStorageSync('scanapp_token', accessToken)
if (result?.refresh_token || result?.token?.refresh_token) {
uni.setStorageSync('scanapp_refresh_token', result.refresh_token || result.token.refresh_token)
}
if (result?.user_info) {
uni.setStorageSync('scanapp_user', result.user_info)
}
uni.showToast({ title: '登录成功', icon: 'none' })
setTimeout(() => {
uni.redirectTo({ url: this.redirectUrl })
}, 300)
} catch (error) {
console.error('[scanapp] 登录失败', error)
} finally {
this.loading = false
}
},
goRegister() {
uni.navigateTo({
url: '/pages/auth/register'
})
}
}
}
</script>
<style lang="scss" scoped>
.auth-page {
position: relative;
overflow: hidden;
min-height: 100vh;
padding: 92rpx 40rpx 48rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eaf4ff 0%, #f6f8fc 50%, #ffffff 100%);
}
.bg-circle {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.bg-circle-primary {
top: -120rpx;
right: -90rpx;
width: 300rpx;
height: 300rpx;
background: rgba(22, 119, 255, 0.12);
}
.bg-circle-light {
top: 160rpx;
left: -140rpx;
width: 260rpx;
height: 260rpx;
background: rgba(53, 191, 163, 0.12);
}
.brand-area {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 64rpx;
}
.brand-mark {
display: flex;
align-items: center;
justify-content: center;
width: 120rpx;
height: 120rpx;
margin-bottom: 24rpx;
border-radius: 36rpx;
background: linear-gradient(135deg, #1677ff 0%, #35bfa3 100%);
box-shadow: 0 20rpx 46rpx rgba(22, 119, 255, 0.26);
}
.brand-title {
color: #17233d;
font-size: 38rpx;
font-weight: 700;
line-height: 1.35;
}
.brand-subtitle {
margin-top: 12rpx;
color: #6b778c;
font-size: 22rpx;
line-height: 1.5;
}
.auth-panel {
position: relative;
z-index: 1;
padding: 44rpx 32rpx 38rpx;
border: 1rpx solid rgba(255, 255, 255, 0.8);
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 22rpx 60rpx rgba(23, 35, 61, 0.1);
}
.panel-header {
margin-bottom: 24rpx;
}
.panel-title {
display: block;
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.4;
}
.panel-desc {
display: block;
margin-top: 8rpx;
color: #8a94a6;
font-size: 22rpx;
line-height: 1.5;
}
.actions {
margin-top: 36rpx;
}
.field-card {
display: flex;
align-items: center;
width: 100%;
height: 96rpx;
padding: 0 24rpx;
box-sizing: border-box;
border: 1rpx solid #edf1f7;
border-radius: 22rpx;
background: #f8fbff;
}
.field-icon {
display: flex;
align-items: center;
justify-content: center;
width: 52rpx;
height: 52rpx;
margin-right: 14rpx;
border-radius: 16rpx;
background: rgba(22, 119, 255, 0.1);
}
.footer-link {
display: flex;
justify-content: center;
align-items: center;
margin-top: 32rpx;
color: #8a94a6;
font-size: 22rpx;
}
.link {
margin-left: 8rpx;
color: #1677ff;
font-weight: 600;
}
.theme-dark.auth-page {
background: linear-gradient(180deg, #111827 0%, #151923 50%, #151923 100%);
}
.theme-dark .auth-panel {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .field-card {
border-color: #364152;
background: #1c2431;
}
.theme-dark .panel-title {
color: #f2f5fa;
}
.theme-dark .panel-desc,
.theme-dark .footer-link {
color: #aeb9ca;
}
.theme-dark :deep(.u-input__input) {
color: #f2f5fa !important;
}
</style>
+330
View File
@@ -0,0 +1,330 @@
<template>
<view class="auth-page" :class="`theme-${themeMode}`">
<view class="bg-circle bg-circle-primary"></view>
<view class="bg-circle bg-circle-light"></view>
<view class="brand-area">
<view class="brand-mark">
<u-icon name="edit-pen-fill" color="#ffffff" size="52" />
</view>
<text class="brand-title">创建新账号</text>
<text class="brand-subtitle">注册后可进行扫码、录入和查看统计</text>
</view>
<view class="auth-panel">
<view class="panel-header">
<text class="panel-title">账号注册</text>
<text class="panel-desc">请填写以下信息完成注册</text>
</view>
<u-form :model="form" :border-bottom="false" label-position="top" label-width="120">
<u-form-item prop="username" :border-bottom="false">
<view class="field-card">
<view class="field-icon">
<u-icon name="account-fill" color="#19be6b" size="36" />
</view>
<u-input v-model="form.username" clearable placeholder="请输入用户名" placeholder-style="color: #a0a7b5;" />
</view>
</u-form-item>
<u-form-item prop="email" :border-bottom="false">
<view class="field-card">
<view class="field-icon">
<u-icon name="email-fill" color="#19be6b" size="36" />
</view>
<u-input v-model="form.email" type="text" clearable placeholder="请输入邮箱" placeholder-style="color: #a0a7b5;" />
</view>
</u-form-item>
<u-form-item prop="password" :border-bottom="false">
<view class="field-card">
<view class="field-icon">
<u-icon name="lock-fill" color="#19be6b" size="36" />
</view>
<u-input v-model="form.password" type="password" clearable password-icon placeholder="请输入密码" placeholder-style="color: #a0a7b5;" />
</view>
</u-form-item>
<u-form-item prop="confirm_password" :border-bottom="false">
<view class="field-card">
<view class="field-icon">
<u-icon name="lock-fill" color="#19be6b" size="36" />
</view>
<u-input v-model="form.confirm_password" type="password" clearable password-icon placeholder="请再次输入密码" placeholder-style="color: #a0a7b5;" />
</view>
</u-form-item>
<u-form-item prop="captcha_code" :border-bottom="false">
<view class="code-field">
<view class="field-card code-input-card">
<view class="field-icon">
<u-icon name="coupon-fill" color="#19be6b" size="36" />
</view>
<u-input v-model="form.captcha_code" clearable placeholder="请输入图形验证码" placeholder-style="color: #a0a7b5;" />
</view>
<view class="captcha-box" @tap="refreshCaptcha">{{ captchaText }}</view>
</view>
</u-form-item>
</u-form>
<view class="actions">
<u-button type="primary" shape="circle" size="large" :loading="loading" @click="handleRegister">注册</u-button>
</view>
<view class="footer-link">
<text>已有账号?</text>
<text class="link" @tap="goLogin">返回登录</text>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
loading: false,
captchaText: '8K4P',
form: {
username: '',
email: '',
password: '',
confirm_password: '',
captcha_key: '',
captcha_code: ''
}
}
},
methods: {
handleRegister() {
if (!this.form.username || !this.form.email || !this.form.password || !this.form.confirm_password || !this.form.captcha_code) {
uni.showToast({
title: '请完整填写注册信息',
icon: 'none'
})
return
}
if (this.form.password !== this.form.confirm_password) {
uni.showToast({
title: '两次密码不一致',
icon: 'none'
})
return
}
this.loading = true
setTimeout(() => {
this.loading = false
uni.showToast({
title: '注册接口待接入',
icon: 'none'
})
}, 500)
},
refreshCaptcha() {
this.captchaText = Math.random().toString(36).slice(2, 6).toUpperCase()
},
goLogin() {
uni.navigateBack({
delta: 1
})
}
}
}
</script>
<style lang="scss" scoped>
.auth-page {
position: relative;
overflow: hidden;
min-height: 100vh;
padding: 56rpx 40rpx 48rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #ebfff7 0%, #f6f8fc 48%, #ffffff 100%);
}
.bg-circle {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.bg-circle-primary {
top: -130rpx;
right: -100rpx;
width: 310rpx;
height: 310rpx;
background: rgba(25, 190, 107, 0.14);
}
.bg-circle-light {
top: 190rpx;
left: -150rpx;
width: 280rpx;
height: 280rpx;
background: rgba(22, 119, 255, 0.1);
}
.brand-area {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 42rpx;
}
.brand-mark {
display: flex;
align-items: center;
justify-content: center;
width: 112rpx;
height: 112rpx;
margin-bottom: 24rpx;
border-radius: 34rpx;
background: linear-gradient(135deg, #19be6b 0%, #1677ff 100%);
box-shadow: 0 20rpx 46rpx rgba(25, 190, 107, 0.24);
}
.brand-title {
color: #17233d;
font-size: 38rpx;
font-weight: 700;
line-height: 1.35;
}
.brand-subtitle {
margin-top: 12rpx;
color: #6b778c;
font-size: 22rpx;
line-height: 1.5;
}
.auth-panel {
position: relative;
z-index: 1;
padding: 40rpx 32rpx 36rpx;
border: 1rpx solid rgba(255, 255, 255, 0.8);
border-radius: 32rpx;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 22rpx 60rpx rgba(23, 35, 61, 0.1);
}
.panel-header {
margin-bottom: 24rpx;
}
.panel-title {
display: block;
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.4;
}
.panel-desc {
display: block;
margin-top: 8rpx;
color: #8a94a6;
font-size: 22rpx;
line-height: 1.5;
}
.actions {
margin-top: 36rpx;
}
.field-card {
display: flex;
align-items: center;
width: 100%;
height: 92rpx;
padding: 0 22rpx;
box-sizing: border-box;
border: 1rpx solid #edf1f7;
border-radius: 22rpx;
background: #f8fbff;
}
.field-icon {
display: flex;
align-items: center;
justify-content: center;
width: 50rpx;
height: 50rpx;
margin-right: 12rpx;
border-radius: 16rpx;
background: rgba(25, 190, 107, 0.1);
}
.code-field {
display: flex;
align-items: center;
width: 100%;
gap: 0;
}
.code-input-card {
flex: 1;
min-width: 0;
}
.captcha-box {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 170rpx;
margin-left: 20rpx;
height: 92rpx;
border: 1rpx solid rgba(25, 190, 107, 0.16);
border-radius: 22rpx;
background: linear-gradient(135deg, #effff7 0%, #eef6ff 100%);
color: #19be6b;
font-size: 22rpx;
font-weight: 600;
letter-spacing: 4rpx;
}
.footer-link {
display: flex;
justify-content: center;
align-items: center;
margin-top: 32rpx;
color: #8a94a6;
font-size: 22rpx;
}
.link {
margin-left: 8rpx;
color: #19be6b;
font-weight: 600;
}
.theme-dark.auth-page {
background: linear-gradient(180deg, #111827 0%, #151923 50%, #151923 100%);
}
.theme-dark .auth-panel {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .field-card {
border-color: #364152;
background: #1c2431;
}
.theme-dark .captcha-box {
border-color: #36534c;
background: #1c2d2b;
}
.theme-dark .panel-title {
color: #f2f5fa;
}
.theme-dark .panel-desc,
.theme-dark .footer-link {
color: #aeb9ca;
}
.theme-dark :deep(.u-input__input) {
color: #f2f5fa !important;
}
</style>
+856
View File
@@ -0,0 +1,856 @@
<template>
<view class="entry-page machine-page" :class="`theme-${themeMode}`">
<view class="hero-card">
<view>
<text class="hero-title">整机模式</text>
<text class="hero-subtitle">扫描加工单和商品编码,自动使用当前登录用户编码完成录入</text>
</view>
<view class="hero-icon">
<u-icon name="scan" color="#ffffff" size="46" />
</view>
</view>
<view class="config-card">
<view class="card-title">当前配置</view>
<view class="chip-row">
<view class="select-chip" @tap="showPicker('process')">
<text class="chip-label">工序</text>
<text class="chip-value">{{ processLabel }}</text>
</view>
<view class="select-chip" @tap="showPicker('step')">
<text class="chip-label">步骤</text>
<text class="chip-value">{{ stepLabel }}</text>
</view>
</view>
</view>
<view class="form-card">
<view class="card-title">整机录入</view>
<u-form :model="form" :border-bottom="false" label-position="top">
<u-form-item :border-bottom="false" prop="processOrderNo">
<view class="field-card primary-field">
<view class="field-main">
<text class="field-label">加工单号</text>
<u-input
v-model="form.processOrderNo"
:input-mode="pdaMode ? 'none' : 'text'"
clearable
placeholder="请扫描或输入加工单号"
placeholder-style="color: #a0a7b5;"
@focus="handleMachineInputFocus"
/>
</view>
<view class="scan-button" @tap="scanToField('processOrderNo')">
<u-icon name="scan" color="#ffffff" size="36" />
</view>
</view>
</u-form-item>
<u-form-item :border-bottom="false" prop="productCode">
<view class="field-card">
<view class="field-main">
<text class="field-label">商品编码</text>
<u-input
v-model="form.productCode"
:input-mode="pdaMode ? 'none' : 'text'"
clearable
placeholder="请扫描或输入商品编码"
placeholder-style="color: #a0a7b5;"
@focus="handleMachineInputFocus"
/>
</view>
<view class="scan-button" @tap="scanToField('productCode')">
<u-icon name="scan" color="#ffffff" size="36" />
</view>
</view>
</u-form-item>
<u-form-item :border-bottom="false" prop="quantity">
<view class="quantity-card">
<view>
<text class="field-label">数量</text>
<text class="quantity-tip">默认 1,可手动调整</text>
</view>
<view class="quantity-control">
<view class="qty-button" @tap="decreaseQuantity">-</view>
<input v-model="form.quantity" class="qty-input" type="number" />
<view class="qty-button" @tap="increaseQuantity">+</view>
</view>
</view>
</u-form-item>
</u-form>
<view class="action-row">
<view class="submit-secondary" @tap="resetForm">重置</view>
<view class="submit-primary" :class="{ 'is-loading': submitting }" @tap="submitEntry">{{ submitting ? '提交中...' : '提交录入' }}</view>
</view>
</view>
<view class="stats-card">
<view class="stats-header">
<view>
<text class="card-title">今日统计</text>
<text class="card-subtitle">当前工序/步骤整机扫码量</text>
</view>
<view class="total-badge">{{ hideTotal ? '***' : statistics.todayTotal }}</view>
</view>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-value success">{{ statistics.successTotal }}</text>
<text class="stat-label">成功</text>
</view>
<view class="stat-item">
<text class="stat-value danger">{{ statistics.failedTotal }}</text>
<text class="stat-label">失败</text>
</view>
<view class="stat-item" @tap="hideTotal = !hideTotal">
<text class="stat-value">{{ hideTotal ? '***' : statistics.todayTotal }}</text>
<text class="stat-label">总数</text>
</view>
</view>
</view>
<view class="record-card">
<view class="card-title">最近记录</view>
<view v-for="item in recentRecords" :key="item.id" class="record-item">
<view>
<text class="record-main">{{ item.processOrderNo }}</text>
<text class="record-sub">商品:{{ item.productCode }} / 数量:{{ item.quantity }}</text>
<text class="record-sub">用户编码:{{ item.userCode }}</text>
</view>
<view class="record-status">成功</view>
</view>
</view>
</view>
</template>
<script>
// UTS 原生扫码插件仅在 App-Plus 真机/自定义基座中解析,Web 环境不加载。
// #ifdef APP-PLUS
import { openCodeScan } from '@/uni_modules/mas-code-scan'
// #endif
import { checkMemberUser, getDataEntryConfig, getProcessOptions, getStepOptions, getProductRecentRecords, getProductStatistics, submitProductRecord } from '@/api/produce/data-entry.js'
import { getScanRegionSetting } from '@/utils/scan-settings.js'
export default {
data() {
return {
submitting: false,
scanning: false,
pdaMode: false,
hideTotal: false,
currentUser: {
userCode: 'U001'
},
basicSettings: {},
specificRules: [],
processOptions: [],
stepOptions: [],
selectedProcessIndex: 0,
selectedStepIndex: 0,
form: {
processOrderNo: '',
productCode: '',
quantity: 1
},
statistics: {
todayTotal: 0,
successTotal: 0,
failedTotal: 0
},
recentRecords: []
}
},
onLoad() {
this.loadPdaMode()
this.loadPageData()
},
onShow() {
this.loadPdaMode()
},
computed: {
processLabel() {
const item = this.processOptions[this.selectedProcessIndex]
return item ? item.label : '请选择'
},
stepLabel() {
const item = this.stepOptions[this.selectedStepIndex]
return item ? item.label : '请选择'
}
},
methods: {
getCurrentUserCode() {
const user = uni.getStorageSync('scanapp_user') || {}
return user.username || user.nickname || 'U001'
},
loadPdaMode() {
const userCode = this.getCurrentUserCode()
const settings = uni.getStorageSync(`scanapp_settings_${userCode}`)
this.pdaMode = settings?.pdaMode === true
},
handleMachineInputFocus() {
// #ifdef APP-PLUS
try {
const activity = plus.android.runtimeMainActivity()
const focusedView = activity.getWindow().getDecorView().findFocus()
if (focusedView) {
plus.android.invoke(focusedView, 'setShowSoftInputOnFocus', !this.pdaMode)
}
if (this.pdaMode) {
uni.hideKeyboard()
}
} catch (error) {
if (this.pdaMode) uni.hideKeyboard()
}
// #endif
},
getSelectionStorageKey() {
return `scanapp_entry_machine_selection_${this.currentUser.userCode}`
},
getSavedSelection() {
const selection = uni.getStorageSync(this.getSelectionStorageKey())
return selection && typeof selection === 'object' ? selection : {}
},
findOptionIndex(options, value) {
const index = options.findIndex((item) => String(item.value) === String(value))
return index >= 0 ? index : 0
},
saveSelection() {
const processItem = this.processOptions[this.selectedProcessIndex]
const stepItem = this.stepOptions[this.selectedStepIndex]
uni.setStorageSync(this.getSelectionStorageKey(), {
processId: processItem?.value || '',
stepId: stepItem?.value || ''
})
},
async loadPageData() {
this.currentUser.userCode = this.getCurrentUserCode()
const savedSelection = this.getSavedSelection()
try {
const [processOptions, statistics, recent] = await Promise.all([
getProcessOptions(),
getProductStatistics({ user_code: this.currentUser.userCode }),
getProductRecentRecords({ user_code: this.currentUser.userCode, limit: 5 })
])
this.processOptions = Array.isArray(processOptions) ? processOptions : []
this.selectedProcessIndex = this.findOptionIndex(this.processOptions, savedSelection.processId)
this.statistics = {
todayTotal: Number(statistics?.today_total || 0),
successTotal: Number(statistics?.success_total || 0),
failedTotal: Number(statistics?.failed_total || 0)
}
this.recentRecords = Array.isArray(recent?.items) ? recent.items.map((item, index) => ({
id: item.id || index,
processOrderNo: item.process_order_no || '-',
productCode: item.product_code || '-',
quantity: Number(item.quantity || 1),
userCode: item.user_code || this.currentUser.userCode
})) : []
if (this.processOptions.length > 0) {
await this.loadStepOptions(this.processOptions[this.selectedProcessIndex].value, savedSelection.stepId)
}
} catch (error) {
console.error('[scanapp] 整机页数据加载失败', error)
}
},
async loadStepOptions(processId, stepId = '') {
try {
const options = await getStepOptions(processId)
this.stepOptions = Array.isArray(options) ? options : []
this.selectedStepIndex = this.findOptionIndex(this.stepOptions, stepId)
await this.loadDataEntryConfig()
this.saveSelection()
} catch (error) {
this.stepOptions = []
this.selectedStepIndex = 0
this.basicSettings = {}
this.specificRules = []
console.error('[scanapp] 整机步骤加载失败', error)
}
},
async loadDataEntryConfig() {
const processItem = this.processOptions[this.selectedProcessIndex]
const stepItem = this.stepOptions[this.selectedStepIndex]
if (!processItem || !stepItem) {
this.basicSettings = {}
this.specificRules = []
return
}
try {
const config = await getDataEntryConfig({
process_id: processItem.value,
step_id: stepItem.value,
apply_mode: 'machine',
process_order_no: this.form.processOrderNo.trim(),
product_code: this.form.productCode.trim(),
user_code: this.currentUser.userCode.trim(),
order_no: this.form.processOrderNo.trim()
})
this.basicSettings = config?.basic_setting || {}
this.specificRules = Array.isArray(config?.specific_rules) ? config.specific_rules : []
} catch (error) {
this.basicSettings = {}
this.specificRules = []
console.error('[scanapp] 整机规则加载失败', error)
}
},
matchSpecificRule(rule, valueMap) {
const matchField = rule.match_field || 'process_order_no'
const value = String(valueMap[matchField] || '').trim()
const pattern = String(rule.rule_pattern || '').trim()
if (!value || !pattern) return false
try {
return new RegExp(pattern).test(value)
} catch (error) {
return false
}
},
validateSpecificRules() {
const context = {
order_no: this.form.processOrderNo.trim(),
process_order_no: this.form.processOrderNo.trim(),
product_code: this.form.productCode.trim(),
user_code: this.currentUser.userCode.trim()
}
const blockedRule = this.specificRules.find((rule) => {
if (rule.apply_mode && rule.apply_mode !== 'machine' && rule.apply_mode !== 'both') return false
return Number(rule.rule_type) === 2 && this.matchSpecificRule(rule, context)
})
if (blockedRule) {
uni.showToast({ title: blockedRule.rule_name || '当前整机数据命中特定规则,禁止提交', icon: 'none' })
return false
}
return true
},
validatePattern(value, settingKey, label) {
const pattern = String(this.basicSettings[settingKey] || '').trim()
if (!pattern) return true
try {
if (!new RegExp(pattern).test(value)) {
uni.showToast({ title: `${label}格式不符合规则`, icon: 'none' })
return false
}
} catch (error) {
console.error(`[scanapp] ${label}规则格式错误`, error)
}
return true
},
showPicker(type) {
const list = type === 'process' ? this.processOptions : this.stepOptions
if (!list.length) {
uni.showToast({ title: '当前没有可选项', icon: 'none' })
return
}
uni.showActionSheet({
itemList: list.map((item) => item.label),
success: async (res) => {
if (type === 'process') {
this.selectedProcessIndex = res.tapIndex
await this.loadStepOptions(this.processOptions[res.tapIndex]?.value)
} else {
this.selectedStepIndex = res.tapIndex
await this.loadDataEntryConfig()
this.saveSelection()
}
}
})
},
scanToField(field) {
if (this.scanning || this.submitting) return
// #ifdef APP-PLUS
this.scanning = true
openCodeScan({
smartZoom: true,
vibrate: true,
scanRegion: getScanRegionSetting(),
success: async (result) => {
const value = String(result?.content || '').trim()
if (!value) return
this.form[field] = value
await this.loadDataEntryConfig()
},
fail: (error) => {
if (Number(error?.errCode) === 9021003) return
uni.showToast({
title: Number(error?.errCode) === 9021002 ? '未获得扫码权限' : '未识别到有效条码',
icon: 'none'
})
},
complete: () => {
this.scanning = false
}
})
// #endif
// #ifndef APP-PLUS
uni.showToast({ title: '请在 Android App 中使用扫码功能', icon: 'none' })
// #endif
},
increaseQuantity() {
this.form.quantity = Number(this.form.quantity || 1) + 1
},
decreaseQuantity() {
this.form.quantity = Math.max(1, Number(this.form.quantity || 1) - 1)
},
validateForm() {
if (!this.form.processOrderNo.trim()) {
uni.showToast({ title: '请输入加工单号', icon: 'none' })
return false
}
if (!this.form.productCode.trim()) {
uni.showToast({ title: '请输入商品编码', icon: 'none' })
return false
}
if (!Number(this.form.quantity) || Number(this.form.quantity) < 1) {
uni.showToast({ title: '数量必须大于 0', icon: 'none' })
return false
}
if (!this.processOptions[this.selectedProcessIndex]) {
uni.showToast({ title: '请选择工序', icon: 'none' })
return false
}
if (!this.stepOptions[this.selectedStepIndex]) {
uni.showToast({ title: '请选择步骤', icon: 'none' })
return false
}
if (!this.validatePattern(this.form.processOrderNo.trim(), 'process_order_no_regex', '加工单号')) return false
const minLength = Number(this.basicSettings.product_code_min_length || 0)
if (minLength > 0 && this.form.productCode.trim().length < minLength) {
uni.showToast({ title: `商品编码长度不能少于 ${minLength} 位`, icon: 'none' })
return false
}
const maxLength = Number(this.basicSettings.product_code_max_length || 0)
if (maxLength > 0 && this.form.productCode.trim().length > maxLength) {
uni.showToast({ title: `商品编码长度不能超过 ${maxLength} 位`, icon: 'none' })
return false
}
if (!this.validatePattern(this.form.productCode.trim(), 'product_code_regex', '商品编码')) return false
if (!this.validateSpecificRules()) return false
return true
},
async submitEntry() {
await this.loadDataEntryConfig()
if (!this.validateForm()) return
this.submitting = true
try {
const processItem = this.processOptions[this.selectedProcessIndex]
const stepItem = this.stepOptions[this.selectedStepIndex]
await submitProductRecord({
process_order_no: this.form.processOrderNo.trim(),
product_code: this.form.productCode.trim(),
quantity: Number(this.form.quantity) || 1,
user_code: this.currentUser.userCode,
process_id: processItem?.value,
step_id: stepItem?.value,
scan_result: 1
})
uni.showToast({ title: '整机录入成功', icon: 'success' })
this.resetForm()
await this.loadPageData()
} catch (error) {
console.error('[scanapp] 整机录入失败', error)
} finally {
this.submitting = false
}
},
resetForm() {
this.form.processOrderNo = ''
this.form.productCode = ''
this.form.quantity = 1
this.loadDataEntryConfig()
}
}
}
</script>
<style lang="scss" scoped>
.entry-page {
min-height: 100vh;
padding: 28rpx 24rpx 48rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #ebfff7 0%, #f4f6fb 38%, #f4f6fb 100%);
}
.hero-card,
.config-card,
.form-card,
.stats-card,
.record-card {
border: 1rpx solid rgba(255, 255, 255, 0.72);
border-radius: 28rpx;
background: #ffffff;
box-shadow: 0 16rpx 42rpx rgba(23, 35, 61, 0.06);
}
.hero-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 34rpx 30rpx;
background: linear-gradient(135deg, #35bfa3 0%, #1677ff 100%);
}
.hero-title,
.hero-subtitle,
.card-title,
.card-subtitle,
.field-label,
.quantity-tip,
.chip-label,
.chip-value,
.stat-value,
.stat-label,
.record-main,
.record-sub {
display: block;
}
.hero-title {
color: #ffffff;
font-size: 38rpx;
font-weight: 800;
line-height: 1.35;
}
.hero-subtitle {
margin-top: 10rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 22rpx;
line-height: 1.4;
}
.hero-icon {
display: flex;
align-items: center;
justify-content: center;
width: 86rpx;
height: 86rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.18);
}
.config-card,
.form-card,
.stats-card,
.record-card {
margin-top: 24rpx;
padding: 28rpx;
}
.card-title {
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.card-subtitle {
margin-top: 6rpx;
color: #8a94a6;
font-size: 20rpx;
line-height: 1.4;
}
.chip-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18rpx;
margin-top: 22rpx;
}
.select-chip {
padding: 20rpx;
border-radius: 22rpx;
background: #f8fbff;
}
.chip-label {
color: #8a94a6;
font-size: 20rpx;
}
.chip-value {
margin-top: 8rpx;
color: #17233d;
font-size: 26rpx;
font-weight: 700;
}
.field-card,
.quantity-card {
display: flex;
align-items: center;
width: 100%;
padding: 22rpx;
box-sizing: border-box;
border: 1rpx solid #edf1f7;
border-radius: 24rpx;
background: #f8fbff;
}
.primary-field {
border-color: rgba(53, 191, 163, 0.18);
background: #f5fffb;
}
.field-main {
flex: 1;
min-width: 0;
}
.field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6rpx;
}
.field-label {
margin-bottom: 6rpx;
color: #5f6b7a;
font-size: 22rpx;
font-weight: 600;
}
.quantity-tip {
color: #a0a7b5;
font-size: 20rpx;
}
.lock-pill {
padding: 6rpx 16rpx;
border-radius: 999rpx;
background: #eef2f7;
color: #8a94a6;
font-size: 20rpx;
}
.lock-pill.is-active {
background: rgba(25, 190, 107, 0.12);
color: #19be6b;
}
.scan-button {
display: flex;
align-items: center;
justify-content: center;
width: 76rpx;
height: 76rpx;
margin-left: 18rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #35bfa3 0%, #1677ff 100%);
box-shadow: 0 12rpx 26rpx rgba(53, 191, 163, 0.2);
}
.quantity-card {
justify-content: space-between;
}
.quantity-control {
display: flex;
align-items: center;
overflow: hidden;
border-radius: 18rpx;
background: #ffffff;
}
.qty-button {
display: flex;
align-items: center;
justify-content: center;
width: 58rpx;
height: 58rpx;
color: #1677ff;
font-size: 28rpx;
font-weight: 700;
}
.qty-input {
width: 80rpx;
height: 58rpx;
color: #17233d;
font-size: 24rpx;
font-weight: 700;
text-align: center;
}
.action-row {
display: flex;
align-items: center;
margin-top: 28rpx;
}
.submit-secondary,
.submit-primary {
display: flex;
align-items: center;
justify-content: center;
height: 86rpx;
border-radius: 20rpx;
font-size: 24rpx;
font-weight: 700;
line-height: 1;
}
.submit-secondary {
width: 176rpx;
background: #f2f4f8;
color: #5f6b7a;
flex: none;
}
.submit-primary {
margin-left: 14rpx;
flex: 1;
background: linear-gradient(135deg, #35bfa3 0%, #1677ff 100%);
color: #ffffff;
box-shadow: 0 14rpx 24rpx rgba(53, 191, 163, 0.18);
}
.submit-primary.is-loading {
opacity: 0.8;
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
}
.total-badge {
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: rgba(53, 191, 163, 0.1);
color: #19be6b;
font-size: 26rpx;
font-weight: 800;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
}
.stat-item {
display: flex;
align-items: center;
justify-content: center;
min-height: 112rpx;
border-radius: 22rpx;
background: #f8fbff;
flex-direction: column;
}
.stat-value {
color: #17233d;
font-size: 32rpx;
font-weight: 800;
}
.stat-value.success {
color: #19be6b;
}
.stat-value.danger {
color: #e91f3f;
}
.stat-label {
margin-top: 8rpx;
color: #8a94a6;
font-size: 20rpx;
}
.record-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22rpx 0;
border-bottom: 1rpx solid #edf1f7;
}
.record-item:last-child {
border-bottom: 0;
}
.record-main {
color: #17233d;
font-size: 26rpx;
font-weight: 700;
}
.record-sub {
margin-top: 6rpx;
color: #8a94a6;
font-size: 20rpx;
}
.record-status {
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(25, 190, 107, 0.12);
color: #19be6b;
font-size: 20rpx;
font-weight: 600;
}
.theme-dark.entry-page {
background: #151923;
}
.theme-dark .config-card,
.theme-dark .form-card,
.theme-dark .stats-card,
.theme-dark .record-card {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .select-chip,
.theme-dark .field-card,
.theme-dark .quantity-card,
.theme-dark .stat-item {
border-color: #364152;
background: #1c2431;
}
.theme-dark .quantity-control,
.theme-dark .qty-input {
background: #1c2431;
}
.theme-dark .qty-button,
.theme-dark .submit-secondary,
.theme-dark .lock-pill {
background: #293344;
}
.theme-dark .card-title,
.theme-dark .chip-value,
.theme-dark .field-label,
.theme-dark .record-main,
.theme-dark .stat-value,
.theme-dark .qty-input {
color: #f2f5fa;
}
.theme-dark .card-subtitle,
.theme-dark .chip-label,
.theme-dark .quantity-tip,
.theme-dark .record-sub,
.theme-dark .stat-label {
color: #aeb9ca;
}
.theme-dark .record-item {
border-color: #364152;
}
.theme-dark :deep(.u-input__input) {
color: #f2f5fa !important;
}
</style>
File diff suppressed because it is too large Load Diff
+791
View File
@@ -0,0 +1,791 @@
<template>
<view class="entry-page" :class="`theme-${themeMode}`">
<view class="hero-card">
<view>
<text class="hero-title">订单模式</text>
<text class="hero-subtitle">扫描订单号,自动使用当前登录用户编码完成录入</text>
</view>
<view class="hero-icon">
<u-icon name="order" color="#ffffff" size="46" />
</view>
</view>
<view class="config-card">
<view class="card-title">当前配置</view>
<view class="chip-row">
<view class="select-chip" @tap="showPicker('process')">
<text class="chip-label">工序</text>
<text class="chip-value">{{ processLabel }}</text>
</view>
<view class="select-chip" @tap="showPicker('step')">
<text class="chip-label">步骤</text>
<text class="chip-value">{{ stepLabel }}</text>
</view>
</view>
</view>
<view class="form-card">
<view class="form-title-row">
<view class="card-title">扫码录入</view>
<view class="scan-mode-inline">
<text class="scan-mode-title">连续扫码</text>
<u-switch v-model="continuousScan" size="34" active-color="#1677ff" inactive-color="#d6dbe5" />
</view>
</view>
<u-form :model="form" :border-bottom="false" label-position="top">
<u-form-item :border-bottom="false" prop="orderNo">
<view class="field-card primary-field">
<view class="field-main">
<text class="field-label">订单号</text>
<u-input
v-model="form.orderNo"
:input-mode="pdaMode ? 'none' : 'text'"
clearable
placeholder="请扫描或输入订单号"
placeholder-style="color: #a0a7b5;"
@focus="handleOrderInputFocus"
/>
</view>
<view class="scan-button" @tap="scanToField('orderNo')">
<u-icon name="scan" color="#ffffff" size="36" />
</view>
</view>
</u-form-item>
</u-form>
<view class="action-row">
<view class="submit-secondary" @tap="resetForm">重置</view>
<view class="submit-primary" :class="{ 'is-loading': submitting }" @tap="submitEntry">{{ submitting ? '提交中...' : '提交录入' }}</view>
</view>
</view>
<view class="stats-card">
<view class="stats-header">
<view>
<text class="card-title">今日统计</text>
<text class="card-subtitle">当前工序/步骤扫码量</text>
</view>
<view class="total-badge">{{ hideTotal ? '***' : statistics.todayTotal }}</view>
</view>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-value success">{{ statistics.successTotal }}</text>
<text class="stat-label">成功</text>
</view>
<view class="stat-item">
<text class="stat-value danger">{{ statistics.failedTotal }}</text>
<text class="stat-label">失败</text>
</view>
<view class="stat-item" @tap="hideTotal = !hideTotal">
<text class="stat-value">{{ hideTotal ? '***' : statistics.todayTotal }}</text>
<text class="stat-label">总数</text>
</view>
</view>
</view>
<view class="record-card">
<view class="card-title">最近记录</view>
<view v-for="item in recentRecords" :key="item.id" class="record-item">
<view>
<text class="record-main">{{ item.orderNo }}</text>
<text class="record-sub">用户编码:{{ item.userCode }}</text>
</view>
<view class="record-status">成功</view>
</view>
</view>
</view>
</template>
<script>
// UTS 原生扫码插件仅在 App-Plus 真机/自定义基座中解析,Web 环境不加载。
// #ifdef APP-PLUS
import { openCodeScan } from '@/uni_modules/mas-code-scan'
// #endif
import { getDataEntryConfig, getProcessOptions, getStepOptions, getOrderRecentRecords, getOrderStatistics, submitOrderRecord } from '@/api/produce/data-entry.js'
import { getScanRegionSetting } from '@/utils/scan-settings.js'
export default {
data() {
return {
submitting: false,
scanning: false,
pdaMode: false,
hideTotal: false,
continuousScan: false,
currentUser: {
userCode: 'U001'
},
basicSettings: {},
specificRules: [],
processOptions: [],
stepOptions: [],
selectedProcessIndex: 0,
selectedStepIndex: 0,
form: {
orderNo: ''
},
statistics: {
todayTotal: 0,
successTotal: 0,
failedTotal: 0
},
recentRecords: []
}
},
onLoad() {
this.loadPdaMode()
this.loadPageData()
},
onShow() {
this.loadPdaMode()
},
computed: {
processLabel() {
const item = this.processOptions[this.selectedProcessIndex]
return item ? item.label : '请选择'
},
stepLabel() {
const item = this.stepOptions[this.selectedStepIndex]
return item ? item.label : '请选择'
}
},
methods: {
getCurrentUserCode() {
const user = uni.getStorageSync('scanapp_user') || {}
return user.username || user.nickname || 'U001'
},
loadPdaMode() {
const userCode = this.getCurrentUserCode()
const settings = uni.getStorageSync(`scanapp_settings_${userCode}`)
this.pdaMode = settings?.pdaMode === true
},
handleOrderInputFocus() {
// #ifdef APP-PLUS
try {
const activity = plus.android.runtimeMainActivity()
const focusedView = activity.getWindow().getDecorView().findFocus()
if (focusedView) {
plus.android.invoke(focusedView, 'setShowSoftInputOnFocus', !this.pdaMode)
}
if (this.pdaMode) {
uni.hideKeyboard()
}
} catch (error) {
if (this.pdaMode) uni.hideKeyboard()
}
// #endif
},
getSelectionStorageKey() {
return `scanapp_entry_order_selection_${this.currentUser.userCode}`
},
getSavedSelection() {
const selection = uni.getStorageSync(this.getSelectionStorageKey())
return selection && typeof selection === 'object' ? selection : {}
},
findOptionIndex(options, value) {
const index = options.findIndex((item) => String(item.value) === String(value))
return index >= 0 ? index : 0
},
saveSelection() {
const processItem = this.processOptions[this.selectedProcessIndex]
const stepItem = this.stepOptions[this.selectedStepIndex]
uni.setStorageSync(this.getSelectionStorageKey(), {
processId: processItem?.value || '',
stepId: stepItem?.value || ''
})
},
async loadPageData() {
this.currentUser.userCode = this.getCurrentUserCode()
const savedSelection = this.getSavedSelection()
try {
const [processOptions, statistics, recent] = await Promise.all([
getProcessOptions(),
getOrderStatistics({ user_code: this.currentUser.userCode }),
getOrderRecentRecords({ user_code: this.currentUser.userCode, limit: 5 })
])
this.processOptions = Array.isArray(processOptions) ? processOptions : []
this.selectedProcessIndex = this.findOptionIndex(this.processOptions, savedSelection.processId)
this.statistics = {
todayTotal: Number(statistics?.today_total || 0),
successTotal: Number(statistics?.success_total || 0),
failedTotal: Number(statistics?.failed_total || 0)
}
this.recentRecords = Array.isArray(recent?.items) ? recent.items.map((item, index) => ({
id: item.id || index,
orderNo: item.order_no || '-',
userCode: item.user_code || this.currentUser.userCode
})) : []
if (this.processOptions.length > 0) {
await this.loadStepOptions(this.processOptions[this.selectedProcessIndex].value, savedSelection.stepId)
}
} catch (error) {
console.error('[scanapp] 订单页数据加载失败', error)
}
},
async loadStepOptions(processId, stepId = '') {
try {
const options = await getStepOptions(processId)
this.stepOptions = Array.isArray(options) ? options : []
this.selectedStepIndex = this.findOptionIndex(this.stepOptions, stepId)
await this.loadDataEntryConfig()
this.saveSelection()
} catch (error) {
this.stepOptions = []
this.selectedStepIndex = 0
this.basicSettings = {}
this.specificRules = []
console.error('[scanapp] 步骤加载失败', error)
}
},
async loadDataEntryConfig() {
const processItem = this.processOptions[this.selectedProcessIndex]
const stepItem = this.stepOptions[this.selectedStepIndex]
if (!processItem || !stepItem) {
this.basicSettings = {}
this.specificRules = []
return
}
try {
const config = await getDataEntryConfig({
process_id: processItem.value,
step_id: stepItem.value,
apply_mode: 'order',
order_no: this.form.orderNo.trim(),
user_code: this.currentUser.userCode.trim()
})
this.basicSettings = config?.basic_setting || {}
this.specificRules = Array.isArray(config?.specific_rules) ? config.specific_rules : []
} catch (error) {
this.basicSettings = {}
this.specificRules = []
console.error('[scanapp] 订单规则加载失败', error)
}
},
matchSpecificRule(rule, valueMap) {
const matchField = rule.match_field || 'order_no'
const value = String(valueMap[matchField] || '').trim()
const pattern = String(rule.rule_pattern || '').trim()
if (!value || !pattern) return false
try {
return new RegExp(pattern).test(value)
} catch (error) {
return false
}
},
validateSpecificRules() {
const context = {
order_no: this.form.orderNo.trim(),
user_code: this.currentUser.userCode.trim()
}
const blockedRule = this.specificRules.find((rule) => {
if (rule.apply_mode && rule.apply_mode !== 'order' && rule.apply_mode !== 'both') return false
if (Array.isArray(rule.step_ids) && rule.step_ids.length && !rule.step_ids.map(String).includes(String(this.stepOptions[this.selectedStepIndex]?.value))) return false
return Number(rule.rule_type) === 2 && this.matchSpecificRule(rule, context)
})
if (blockedRule) {
uni.showToast({ title: blockedRule.rule_name || '当前订单命中特定规则,禁止提交', icon: 'none' })
return false
}
return true
},
validatePattern(value, settingKey, label) {
const pattern = String(this.basicSettings[settingKey] || '').trim()
if (!pattern) return true
try {
if (!new RegExp(pattern).test(value)) {
uni.showToast({ title: `${label}格式不符合规则`, icon: 'none' })
return false
}
} catch (error) {
console.error(`[scanapp] ${label}规则格式错误`, error)
}
return true
},
showPicker(type) {
const list = type === 'process' ? this.processOptions : this.stepOptions
if (!list.length) {
uni.showToast({ title: '当前没有可选项', icon: 'none' })
return
}
uni.showActionSheet({
itemList: list.map((item) => item.label),
success: async (res) => {
if (type === 'process') {
this.selectedProcessIndex = res.tapIndex
await this.loadStepOptions(this.processOptions[res.tapIndex]?.value)
} else {
this.selectedStepIndex = res.tapIndex
await this.loadDataEntryConfig()
this.saveSelection()
}
}
})
},
startOrderScan() {
this.scanToField('orderNo')
},
scanToField(field) {
if (this.scanning || this.submitting) return
// #ifdef APP-PLUS
this.scanning = true
openCodeScan({
smartZoom: true,
vibrate: true,
scanRegion: getScanRegionSetting(),
success: async (result) => {
const value = String(result?.content || '').trim()
if (!value) return
this.form[field] = value
await this.submitEntry()
},
fail: (error) => {
if (Number(error?.errCode) === 9021003) return
uni.showToast({
title: Number(error?.errCode) === 9021002 ? '未获得扫码权限' : '未识别到有效条码',
icon: 'none'
})
},
complete: () => {
this.scanning = false
}
})
// #endif
// #ifndef APP-PLUS
uni.showToast({ title: '请在 Android App 中使用扫码功能', icon: 'none' })
// #endif
},
validateForm() {
if (!this.form.orderNo.trim()) {
uni.showToast({ title: '请输入订单号', icon: 'none' })
return false
}
if (!this.processOptions[this.selectedProcessIndex]) {
uni.showToast({ title: '请选择工序', icon: 'none' })
return false
}
if (!this.stepOptions[this.selectedStepIndex]) {
uni.showToast({ title: '请选择步骤', icon: 'none' })
return false
}
const minLength = Number(this.basicSettings.order_no_min_length || 0)
if (minLength > 0 && this.form.orderNo.trim().length < minLength) {
uni.showToast({ title: `订单号长度不能少于 ${minLength} 位`, icon: 'none' })
return false
}
if (!this.validatePattern(this.form.orderNo.trim(), 'order_no_regex', '订单号')) return false
if (!this.validateSpecificRules()) return false
return true
},
async submitEntry() {
if (this.submitting) return
this.submitting = true
try {
await this.loadDataEntryConfig()
if (!this.validateForm()) return
const processItem = this.processOptions[this.selectedProcessIndex]
const stepItem = this.stepOptions[this.selectedStepIndex]
await submitOrderRecord({
order_no: this.form.orderNo.trim(),
quantity: 1,
user_code: this.currentUser.userCode,
process_id: processItem?.value,
step_id: stepItem?.value,
scan_result: 1
})
uni.showToast({ title: '订单录入成功', icon: 'success' })
this.form.orderNo = ''
await this.loadPageData()
if (this.continuousScan) {
setTimeout(() => {
this.startOrderScan()
}, 350)
}
} catch (error) {
console.error('[scanapp] 订单录入失败', error)
} finally {
this.submitting = false
}
},
resetForm() {
this.form.orderNo = ''
this.loadDataEntryConfig()
}
}
}
</script>
<style lang="scss" scoped>
.entry-page {
min-height: 100vh;
padding: 28rpx 24rpx 48rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eaf4ff 0%, #f4f6fb 38%, #f4f6fb 100%);
}
.hero-card,
.config-card,
.form-card,
.stats-card,
.record-card {
border: 1rpx solid rgba(255, 255, 255, 0.72);
border-radius: 28rpx;
background: #ffffff;
box-shadow: 0 16rpx 42rpx rgba(23, 35, 61, 0.06);
}
.hero-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 34rpx 30rpx;
background: linear-gradient(135deg, #1677ff 0%, #35bfa3 100%);
}
.hero-title,
.hero-subtitle,
.card-title,
.card-subtitle,
.field-label,
.chip-label,
.chip-value,
.stat-value,
.stat-label,
.record-main,
.record-sub {
display: block;
}
.hero-title {
color: #ffffff;
font-size: 38rpx;
font-weight: 800;
line-height: 1.35;
}
.hero-subtitle {
margin-top: 10rpx;
color: rgba(255, 255, 255, 0.82);
font-size: 22rpx;
line-height: 1.4;
}
.hero-icon {
display: flex;
align-items: center;
justify-content: center;
width: 86rpx;
height: 86rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.18);
}
.config-card,
.form-card,
.stats-card,
.record-card {
margin-top: 24rpx;
padding: 28rpx;
}
.card-title {
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.form-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
}
.scan-mode-inline {
display: flex;
align-items: center;
gap: 0;
flex-shrink: 0;
}
.scan-mode-title {
display: block;
margin-right: 22rpx;
color: #6b778c;
font-size: 22rpx;
font-weight: 600;
line-height: 1.35;
}
.card-subtitle {
margin-top: 6rpx;
color: #8a94a6;
font-size: 20rpx;
line-height: 1.4;
}
.chip-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18rpx;
margin-top: 22rpx;
}
.select-chip {
padding: 20rpx;
border-radius: 22rpx;
background: #f8fbff;
}
.chip-label {
color: #8a94a6;
font-size: 20rpx;
}
.chip-value {
margin-top: 8rpx;
color: #17233d;
font-size: 26rpx;
font-weight: 700;
}
.field-card {
display: flex;
align-items: center;
width: 100%;
padding: 22rpx;
box-sizing: border-box;
border: 1rpx solid #edf1f7;
border-radius: 24rpx;
background: #f8fbff;
}
.primary-field {
border-color: rgba(22, 119, 255, 0.18);
background: #f5f9ff;
}
.field-main {
flex: 1;
min-width: 0;
}
.field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6rpx;
}
.field-label {
margin-bottom: 6rpx;
color: #5f6b7a;
font-size: 22rpx;
font-weight: 600;
}
.lock-pill {
padding: 6rpx 16rpx;
border-radius: 999rpx;
background: #eef2f7;
color: #8a94a6;
font-size: 20rpx;
}
.lock-pill.is-active {
background: rgba(25, 190, 107, 0.12);
color: #19be6b;
}
.scan-button {
display: flex;
align-items: center;
justify-content: center;
width: 76rpx;
height: 76rpx;
margin-left: 18rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #1677ff 0%, #35bfa3 100%);
box-shadow: 0 12rpx 26rpx rgba(22, 119, 255, 0.2);
}
.action-row {
display: flex;
align-items: center;
margin-top: 28rpx;
}
.submit-secondary,
.submit-primary {
display: flex;
align-items: center;
justify-content: center;
height: 86rpx;
border-radius: 20rpx;
font-size: 24rpx;
font-weight: 700;
line-height: 1;
}
.submit-secondary {
width: 176rpx;
background: #f2f4f8;
color: #5f6b7a;
flex: none;
}
.submit-primary {
margin-left: 14rpx;
flex: 1;
background: linear-gradient(135deg, #1677ff 0%, #35bfa3 100%);
color: #ffffff;
box-shadow: 0 14rpx 24rpx rgba(22, 119, 255, 0.18);
}
.submit-primary.is-loading {
opacity: 0.8;
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
}
.total-badge {
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: rgba(22, 119, 255, 0.1);
color: #1677ff;
font-size: 26rpx;
font-weight: 800;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
}
.stat-item {
display: flex;
align-items: center;
justify-content: center;
min-height: 112rpx;
border-radius: 22rpx;
background: #f8fbff;
flex-direction: column;
}
.stat-value {
color: #17233d;
font-size: 32rpx;
font-weight: 800;
}
.stat-value.success {
color: #19be6b;
}
.stat-value.danger {
color: #e91f3f;
}
.stat-label {
margin-top: 8rpx;
color: #8a94a6;
font-size: 20rpx;
}
.record-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22rpx 0;
border-bottom: 1rpx solid #edf1f7;
}
.record-item:last-child {
border-bottom: 0;
}
.record-main {
color: #17233d;
font-size: 26rpx;
font-weight: 700;
}
.record-sub {
margin-top: 6rpx;
color: #8a94a6;
font-size: 20rpx;
}
.record-status {
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(25, 190, 107, 0.12);
color: #19be6b;
font-size: 20rpx;
font-weight: 600;
}
.theme-dark.entry-page {
background: #151923;
}
.theme-dark .config-card,
.theme-dark .form-card,
.theme-dark .stats-card,
.theme-dark .record-card {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .select-chip,
.theme-dark .field-card,
.theme-dark .stat-item {
border-color: #364152;
background: #1c2431;
}
.theme-dark .quantity-control,
.theme-dark .qty-input {
background: #1c2431;
}
.theme-dark .qty-button,
.theme-dark .submit-secondary,
.theme-dark .lock-pill {
background: #293344;
}
.theme-dark .card-title,
.theme-dark .chip-value,
.theme-dark .field-label,
.theme-dark .record-main,
.theme-dark .stat-value {
color: #f2f5fa;
}
.theme-dark .card-subtitle,
.theme-dark .chip-label,
.theme-dark .scan-mode-title,
.theme-dark .record-sub,
.theme-dark .stat-label,
.theme-dark .field-label,
.theme-dark .quantity-tip {
color: #aeb9ca;
}
.theme-dark .record-item {
border-color: #364152;
}
.theme-dark :deep(.u-input__input) {
color: #f2f5fa !important;
}
</style>
File diff suppressed because it is too large Load Diff
+641
View File
@@ -0,0 +1,641 @@
<template>
<view class="home-page" :class="`theme-${themeMode}`">
<view class="home-hero">
<view>
<text class="hero-title">扫码工作台</text>
<text class="hero-subtitle">高效完成生产扫码与库存管理</text>
</view>
<view class="hero-action">
<u-icon name="bell-fill" color="#1677ff" size="38" />
</view>
</view>
<view class="user-card" @tap="openProfile">
<view class="avatar">
<u-icon name="account-fill" color="#ffffff" size="54" />
</view>
<view class="user-info">
<text class="user-account">{{ member.displayName }}</text>
<text class="user-name">用户名:{{ member.username }}</text>
</view>
<view class="user-plan-pill" :class="{ disabled: !member.enabled }">{{ member.levelName }}</view>
</view>
<view class="menu-panel">
<view class="workspace-header">
<text class="workspace-title">工作区</text>
</view>
<view v-for="(section, sectionIndex) in sections" :key="section.title" class="menu-group" :class="`menu-group-${sectionIndex}`">
<view class="menu-grid">
<view v-for="item in section.items" :key="item.title" class="menu-item" hover-class="menu-item--active" @tap="handleMenuTap(item)">
<view class="icon-wrap" :style="{ backgroundColor: item.bgColor }">
<u-icon :name="item.icon" color="#ffffff" size="42" />
</view>
<text class="menu-title">{{ item.title }}</text>
<text v-if="item.desc" class="menu-desc">{{ item.desc }}</text>
</view>
</view>
</view>
</view>
<view class="chart-panel">
<view class="chart-header">
<view>
<text class="chart-title">今日数据</text>
<text class="chart-subtitle">扫码与异常概览</text>
</view>
<view class="chart-badge">实时</view>
</view>
<view class="summary-grid">
<view v-for="item in summaries" :key="item.label" class="summary-card">
<text class="summary-value">{{ item.value }}</text>
<text class="summary-label">{{ item.label }}</text>
</view>
</view>
<view class="trend-card">
<view class="chart-section-title">扫码趋势</view>
<view class="bar-chart">
<view v-for="item in trendData" :key="item.label" class="bar-item">
<view class="bar-track">
<view class="bar-fill" :style="{ height: item.percent + '%', backgroundColor: item.color }"></view>
</view>
<text class="bar-label">{{ item.label }}</text>
</view>
</view>
</view>
<view class="progress-card">
<view class="chart-section-title">异常处理</view>
<view v-for="item in exceptionData" :key="item.label" class="progress-row">
<view class="progress-info">
<text class="progress-label">{{ item.label }}</text>
<text class="progress-value">{{ item.value }}%</text>
</view>
<view class="progress-track">
<view class="progress-fill" :style="{ width: item.value + '%', backgroundColor: item.color }"></view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { getUserInfo } from '@/api/member.js'
export default {
data() {
return {
member: {
displayName: '未登录',
username: '-',
levelName: '请先登录',
enabled: true
},
summaries: [
{ label: '订单扫码', value: '128' },
{ label: '整机扫码', value: '86' },
{ label: '异常待处理', value: '12' }
],
trendData: [
{ label: '09', percent: 42, color: '#1677ff' },
{ label: '10', percent: 68, color: '#35bfa3' },
{ label: '11', percent: 52, color: '#1677ff' },
{ label: '12', percent: 76, color: '#35bfa3' },
{ label: '13', percent: 45, color: '#1677ff' },
{ label: '14', percent: 88, color: '#35bfa3' }
],
exceptionData: [
{ label: '已处理', value: 72, color: '#19be6b' },
{ label: '待复核', value: 46, color: '#f7b733' },
{ label: '待处理', value: 28, color: '#e91f3f' }
],
sections: [
{
title: '扫码',
items: [
{ title: '订单模式', icon: 'order', bgColor: '#e91f3f', url: '/pages/entry/order' },
{ title: '一单多机', icon: 'grid-fill', bgColor: '#6b70e8', url: '/pages/entry/multi-machine' },
{ title: '整机模式', icon: 'scan', bgColor: '#1e9df2', url: '/pages/entry/machine' },
{ title: 'UTS 扫码原型', icon: 'scan', bgColor: '#1677ff', url: '/pages/scan/prototype' },
{ title: '异常处理', icon: 'warning-fill', bgColor: '#f7b733', url: '/pages/exception/index' },
{ title: '维修处理', icon: 'setting-fill', bgColor: '#35bfa3', url: '/pages/repair/index' }
]
},
{
title: '查询',
items: [
{ title: '查询', icon: 'search', bgColor: '#ef6a62', url: '/pages/query/index' },
{ title: '统计', icon: 'grid-fill', bgColor: '#1eb2e8', url: '/pages/statistics/index' },
{ title: '设置', icon: 'setting-fill', bgColor: '#9f86e8', url: '/pages/settings/index' }
]
}
]
}
},
onShow() {
this.loadMemberInfo()
},
methods: {
async loadMemberInfo() {
if (!this.isLoggedIn()) {
this.member = {
displayName: '未登录',
username: '-',
levelName: '请先登录',
enabled: true
}
return
}
try {
const profile = await getUserInfo()
uni.setStorageSync('scanapp_user', profile)
this.member = {
displayName: profile.nickname || profile.email || profile.username || '-',
username: profile.username || profile.email || '-',
levelName: profile.level_name || (profile.level && profile.level.name) || (Number(profile.enabled) === 0 ? '账号已停用' : '会员用户'),
enabled: Number(profile.enabled) !== 0
}
} catch (error) {
console.error('[scanapp] 获取首页会员信息失败', error)
}
},
isLoggedIn() {
return Boolean(uni.getStorageSync('scanapp_token'))
},
requireLogin(redirectUrl) {
if (this.isLoggedIn()) {
uni.navigateTo({
url: redirectUrl
})
return
}
uni.showModal({
title: '需要登录',
content: '登录后可访问个人资料',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
uni.navigateTo({
url: `/pages/auth/login?redirect=${encodeURIComponent(redirectUrl)}`
})
}
}
})
},
openProfile() {
this.requireLogin('/pages/profile/index')
},
handleMenuTap(item) {
if (item.url) {
uni.navigateTo({
url: item.url
})
return
}
uni.showToast({
title: `${item.title}功能待接入`,
icon: 'none'
})
}
}
}
</script>
<style lang="scss" scoped>
.home-page {
min-height: 100vh;
padding: 34rpx 24rpx 48rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eaf4ff 0%, #f4f6fb 34%, #f4f6fb 100%);
}
.home-hero {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 8rpx 30rpx;
}
.hero-title,
.hero-subtitle {
display: block;
}
.hero-title {
color: #17233d;
font-size: 38rpx;
font-weight: 800;
line-height: 1.35;
}
.hero-subtitle {
margin-top: 8rpx;
color: #7d8798;
font-size: 22rpx;
line-height: 1.4;
}
.hero-action {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 12rpx 32rpx rgba(23, 35, 61, 0.08);
}
.user-card {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
padding: 36rpx 28rpx;
border: 1rpx solid rgba(255, 255, 255, 0.72);
border-radius: 28rpx;
background: linear-gradient(135deg, #ffffff 0%, #f8fbff 100%);
box-shadow: 0 18rpx 44rpx rgba(23, 35, 61, 0.08);
}
.user-card::after {
position: absolute;
top: -80rpx;
right: -70rpx;
width: 180rpx;
height: 180rpx;
border-radius: 50%;
background: rgba(22, 119, 255, 0.08);
content: '';
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
margin-right: 24rpx;
border-radius: 50%;
background: linear-gradient(135deg, #35bfa3 0%, #1677ff 100%);
box-shadow: 0 12rpx 28rpx rgba(53, 191, 163, 0.28);
}
.user-info {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
}
.user-account {
color: #111111;
font-size: 32rpx;
font-weight: 700;
line-height: 1.35;
}
.user-name {
margin-top: 8rpx;
color: #8b8f98;
font-size: 22rpx;
line-height: 1.35;
}
.user-plan-pill {
flex: none;
margin-left: 20rpx;
padding: 6rpx 18rpx;
border-radius: 999rpx;
background: rgba(25, 190, 107, 0.1);
color: #19be6b;
font-size: 20rpx;
font-weight: 600;
line-height: 1.4;
}
.user-plan-pill.disabled {
background: rgba(233, 31, 63, 0.1);
color: #e91f3f;
}
.menu-panel {
margin-top: 34rpx;
overflow: hidden;
padding: 18rpx;
border: 1rpx solid rgba(255, 255, 255, 0.72);
border-radius: 28rpx;
background: #ffffff;
box-shadow: 0 16rpx 42rpx rgba(23, 35, 61, 0.06);
}
.workspace-header {
padding: 6rpx 8rpx 20rpx;
}
.workspace-title {
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.menu-group {
overflow: hidden;
border-radius: 24rpx;
}
.menu-group + .menu-group {
margin-top: 18rpx;
}
.menu-group-0 {
background: linear-gradient(135deg, rgba(22, 119, 255, 0.08) 0%, rgba(233, 31, 63, 0.05) 100%);
}
.menu-group-1 {
background: linear-gradient(135deg, rgba(25, 190, 107, 0.08) 0%, rgba(159, 134, 232, 0.06) 100%);
}
.menu-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.menu-item {
display: flex;
align-items: center;
justify-content: center;
min-height: 196rpx;
padding: 26rpx 10rpx;
box-sizing: border-box;
border-right: 1rpx solid #edf0f5;
border-bottom: 1rpx solid #edf0f5;
flex-direction: column;
}
.menu-item--active {
background: #f8fbff;
}
.menu-item:nth-child(3n) {
border-right: 0;
}
.menu-item:nth-last-child(-n + 3) {
border-bottom: 0;
}
.icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
margin-bottom: 28rpx;
border-radius: 50%;
box-shadow: 0 10rpx 24rpx rgba(23, 35, 61, 0.12);
}
.menu-title {
color: #111111;
font-size: 28rpx;
line-height: 1.35;
text-align: center;
}
.menu-desc {
margin-top: 4rpx;
color: #9a9fa8;
font-size: 20rpx;
line-height: 1.25;
text-align: center;
}
.chart-panel {
margin-top: 34rpx;
padding: 28rpx;
border: 1rpx solid rgba(255, 255, 255, 0.72);
border-radius: 28rpx;
background: #ffffff;
box-shadow: 0 16rpx 42rpx rgba(23, 35, 61, 0.06);
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.chart-title,
.chart-subtitle {
display: block;
}
.chart-title {
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.chart-subtitle {
margin-top: 6rpx;
color: #8a94a6;
font-size: 20rpx;
line-height: 1.4;
}
.chart-badge {
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(22, 119, 255, 0.1);
color: #1677ff;
font-size: 20rpx;
font-weight: 600;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
margin-bottom: 22rpx;
}
.summary-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 112rpx;
border-radius: 22rpx;
background: #f8fbff;
}
.summary-value {
color: #17233d;
font-size: 32rpx;
font-weight: 800;
line-height: 1.3;
}
.summary-label {
margin-top: 8rpx;
color: #8a94a6;
font-size: 20rpx;
line-height: 1.3;
}
.trend-card,
.progress-card {
padding: 24rpx;
border-radius: 24rpx;
background: #f8fbff;
}
.progress-card {
margin-top: 20rpx;
}
.chart-section-title {
margin-bottom: 22rpx;
color: #17233d;
font-size: 26rpx;
font-weight: 700;
}
.bar-chart {
display: flex;
align-items: flex-end;
justify-content: space-between;
height: 210rpx;
}
.bar-item {
display: flex;
align-items: center;
flex: 1;
flex-direction: column;
}
.bar-track {
display: flex;
align-items: flex-end;
width: 28rpx;
height: 156rpx;
overflow: hidden;
border-radius: 999rpx;
background: #eaf0f8;
}
.bar-fill {
width: 100%;
border-radius: 999rpx;
}
.bar-label {
margin-top: 14rpx;
color: #8a94a6;
font-size: 20rpx;
}
.progress-row + .progress-row {
margin-top: 20rpx;
}
.progress-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
.progress-label,
.progress-value {
font-size: 22rpx;
line-height: 1.35;
}
.progress-label {
color: #5f6b7a;
}
.progress-value {
color: #17233d;
font-weight: 700;
}
.progress-track {
height: 14rpx;
overflow: hidden;
border-radius: 999rpx;
background: #eaf0f8;
}
.progress-fill {
height: 100%;
border-radius: 999rpx;
}
.theme-dark.home-page {
background: linear-gradient(180deg, #111827 0%, #151923 34%, #151923 100%);
}
.theme-dark .hero-action {
background: #222936;
box-shadow: none;
}
.theme-dark .user-card,
.theme-dark .menu-panel,
.theme-dark .chart-panel {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .menu-group-0,
.theme-dark .menu-group-1 {
background: #1c2431;
}
.theme-dark .menu-item {
border-color: #364152;
}
.theme-dark .menu-item--active,
.theme-dark .summary-card,
.theme-dark .trend-card,
.theme-dark .progress-card {
background: #1c2431;
}
.theme-dark .user-account,
.theme-dark .menu-title,
.theme-dark .workspace-title,
.theme-dark .chart-title,
.theme-dark .chart-section-title,
.theme-dark .summary-value,
.theme-dark .progress-value {
color: #f2f5fa;
}
.theme-dark .hero-subtitle,
.theme-dark .user-name,
.theme-dark .menu-desc,
.theme-dark .chart-subtitle,
.theme-dark .summary-label,
.theme-dark .bar-label,
.theme-dark .progress-label {
color: #aeb9ca;
}
.theme-dark .bar-track,
.theme-dark .progress-track {
background: #364152;
}
</style>
+325
View File
@@ -0,0 +1,325 @@
<template>
<view class="profile-form-page" :class="`theme-${themeMode}`">
<view class="form-card">
<view class="form-header">
<text class="form-title">修改资料</text>
<text class="form-desc">用户名、邮箱和手机号为账号信息,暂不支持修改</text>
</view>
<u-form :model="form" :border-bottom="false" label-position="top" label-width="120">
<u-form-item label="用户名" :border-bottom="false">
<view class="field-card locked">
<text class="field-text locked-text">{{ form.username || '-' }}</text>
</view>
</u-form-item>
<u-form-item label="邮箱" :border-bottom="false">
<view class="field-card locked">
<text class="field-text locked-text">{{ form.email || '-' }}</text>
</view>
</u-form-item>
<u-form-item label="手机号" :border-bottom="false">
<view class="field-card locked">
<text class="field-text locked-text">{{ form.phone || '暂未绑定手机号' }}</text>
</view>
</u-form-item>
<u-form-item label="昵称" prop="nickname" required :border-bottom="false">
<view class="field-card">
<u-input v-model="form.nickname" clearable placeholder="请输入昵称" placeholder-style="color: #a0a7b5;" />
</view>
</u-form-item>
<u-form-item label="性别" prop="gender" :border-bottom="false">
<view class="gender-options">
<view
v-for="item in genderOptions"
:key="item.value"
class="gender-option"
:class="{ active: form.gender === item.value }"
@tap="form.gender = item.value"
>
<view class="gender-dot"></view>
<text>{{ item.label }}</text>
</view>
</view>
</u-form-item>
<u-form-item label="生日" prop="birthday" :border-bottom="false">
<picker mode="date" :value="form.birthday || today" start="1900-01-01" :end="today" @change="handleBirthdayChange">
<view class="field-card picker-card">
<text class="field-text" :class="{ placeholder: !form.birthday }">{{ form.birthday || '请选择生日' }}</text>
<u-icon name="calendar" color="#a0a7b5" size="24" />
</view>
</picker>
</u-form-item>
<u-form-item label="个人简介" prop="bio" :border-bottom="false">
<view class="textarea-card">
<textarea v-model="form.bio" maxlength="120" placeholder="请输入个人简介" placeholder-style="color: #a0a7b5;" />
</view>
</u-form-item>
</u-form>
</view>
<view class="bottom-actions">
<u-button type="primary" shape="circle" size="large" :loading="submitting" @click="submitForm">保存资料</u-button>
</view>
</view>
</template>
<script>
import { getUserInfo, updateUserInfo } from '@/api/member.js'
export default {
data() {
return {
submitting: false,
today: '',
form: {
username: '',
email: '',
phone: '',
nickname: '',
gender: 0,
birthday: '',
bio: ''
},
genderOptions: [
{ label: '男', value: 1 },
{ label: '女', value: 2 },
{ label: '其他', value: 0 }
]
}
},
onLoad() {
this.today = this.getToday()
this.loadProfile()
},
methods: {
getToday() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
},
async loadProfile() {
try {
const profile = await getUserInfo()
this.form = {
username: profile.username || '',
email: profile.email || '',
phone: profile.phone || '',
nickname: profile.nickname || profile.username || '',
gender: Number(profile.gender || 0),
birthday: profile.birthday || '',
bio: profile.bio || ''
}
} catch (error) {
console.error('[scanapp] 加载资料失败', error)
}
},
handleBirthdayChange(e) {
this.form.birthday = e.detail.value
},
validateForm() {
if (!this.form.nickname.trim()) {
uni.showToast({ title: '请输入昵称', icon: 'none' })
return false
}
return true
},
async submitForm() {
if (this.submitting || !this.validateForm()) {
return
}
this.submitting = true
try {
await updateUserInfo({
nickname: this.form.nickname.trim(),
gender: this.form.gender,
birthday: this.form.birthday,
bio: this.form.bio
})
const profile = await getUserInfo()
uni.setStorageSync('scanapp_user', profile)
uni.showToast({ title: '资料已保存', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 400)
} catch (error) {
console.error('[scanapp] 保存资料失败', error)
} finally {
this.submitting = false
}
}
}
}
</script>
<style lang="scss" scoped>
.profile-form-page {
min-height: 100vh;
padding: 28rpx 24rpx 48rpx;
box-sizing: border-box;
background: #f4f6fb;
}
.form-card {
padding: 34rpx 30rpx;
border: 1rpx solid rgba(255, 255, 255, 0.75);
border-radius: 28rpx;
background: #ffffff;
box-shadow: 0 16rpx 40rpx rgba(23, 35, 61, 0.06);
}
.form-header {
margin-bottom: 24rpx;
}
.form-title,
.form-desc,
.field-text {
display: block;
}
.form-title {
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.form-desc {
margin-top: 10rpx;
color: #8a94a6;
font-size: 22rpx;
line-height: 1.45;
}
.field-card {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 88rpx;
padding: 0 24rpx;
box-sizing: border-box;
border: 1rpx solid #edf1f7;
border-radius: 22rpx;
background: #f8fbff;
}
.field-card.locked {
border-color: #edf0f5;
background: #f4f6fb;
}
.field-card.picker-card {
padding-right: 20rpx;
}
.field-text {
color: #17233d;
font-size: 22rpx;
line-height: 1.4;
}
.field-text.locked-text,
.field-text.placeholder {
color: #a0a7b5;
}
.gender-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: 16rpx;
width: 100%;
}
.gender-option {
display: flex;
align-items: center;
justify-content: center;
height: 76rpx;
border: 1rpx solid #edf1f7;
border-radius: 20rpx;
background: #f8fbff;
color: #6b778c;
font-size: 22rpx;
font-weight: 600;
}
.gender-dot {
width: 14rpx;
height: 14rpx;
margin-right: 10rpx;
border: 3rpx solid #c5ccda;
border-radius: 50%;
box-sizing: border-box;
}
.gender-option.active {
border-color: #1677ff;
background: rgba(22, 119, 255, 0.1);
color: #1677ff;
}
.gender-option.active .gender-dot {
border-color: #1677ff;
background: #1677ff;
box-shadow: 0 0 0 4rpx rgba(22, 119, 255, 0.16);
}
.textarea-card {
width: 100%;
min-height: 156rpx;
padding: 20rpx 24rpx;
box-sizing: border-box;
border: 1rpx solid #edf1f7;
border-radius: 22rpx;
background: #f8fbff;
}
.textarea-card textarea {
width: 100%;
height: 116rpx;
color: #17233d;
font-size: 22rpx;
line-height: 1.5;
}
.bottom-actions {
margin-top: 36rpx;
}
.theme-dark.profile-form-page {
background: #151923;
}
.theme-dark .form-card,
.theme-dark .field-card,
.theme-dark .gender-option,
.theme-dark .textarea-card {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .field-card.locked {
background: #1c2431;
}
.theme-dark .form-title,
.theme-dark .field-text,
.theme-dark .textarea-card textarea {
color: #f2f5fa;
}
.theme-dark .form-desc,
.theme-dark .field-text.locked-text,
.theme-dark .field-text.placeholder,
.theme-dark .gender-option {
color: #aeb9ca;
}
.theme-dark :deep(textarea),
.theme-dark :deep(.u-input__input) {
color: #f2f5fa !important;
}
</style>
+384
View File
@@ -0,0 +1,384 @@
<template>
<view class="profile-page" :class="`theme-${themeMode}`">
<view class="profile-header">
<view class="profile-top">
<view class="avatar-wrap">
<u-icon name="account-fill" color="#ffffff" size="52" />
</view>
<view class="profile-meta">
<text class="profile-name">{{ user.name }}</text>
<text class="profile-account">{{ user.account }}</text>
</view>
<text class="profile-role">{{ user.role }}</text>
</view>
<view class="profile-stat-row">
<view class="stat-item" v-for="item in stats" :key="item.label">
<text class="stat-value">{{ item.value }}</text>
<text class="stat-label">{{ item.label }}</text>
</view>
</view>
</view>
<view class="profile-card">
<view class="card-title">基本信息</view>
<view class="info-list">
<view class="info-row">
<text class="info-label">手机号</text>
<text class="info-value">{{ user.phone }}</text>
</view>
<view class="info-row">
<text class="info-label">所属组织</text>
<text class="info-value">{{ user.department }}</text>
</view>
<view class="info-row">
<text class="info-label">账号状态</text>
<text class="info-value success">{{ user.status }}</text>
</view>
</view>
</view>
<view class="profile-card">
<view class="card-title">快捷操作</view>
<view class="action-list">
<view class="action-item" @tap="handleAction('修改资料')">
<view class="action-left">
<u-icon name="edit-pen" color="#2f6cf6" size="30" />
<text>修改资料</text>
</view>
<u-icon name="arrow-right" color="#c1c7d0" size="24" />
</view>
<view class="action-item" @tap="handleAction('修改密码')">
<view class="action-left">
<u-icon name="lock-fill" color="#f7b733" size="30" />
<text>修改密码</text>
</view>
<u-icon name="arrow-right" color="#c1c7d0" size="24" />
</view>
<view class="action-item" @tap="handleAction('切换账号')">
<view class="action-left">
<u-icon name="account" color="#35bfa3" size="30" />
<text>切换账号</text>
</view>
<u-icon name="arrow-right" color="#c1c7d0" size="24" />
</view>
<view class="action-item danger" @tap="handleAction('退出登录')">
<view class="action-left">
<u-icon name="logout" color="#ef4f4f" size="30" />
<text>退出登录</text>
</view>
<u-icon name="arrow-right" color="#c1c7d0" size="24" />
</view>
</view>
</view>
</view>
</template>
<script>
import { getUserInfo } from '@/api/member.js'
export default {
data() {
return {
user: {
name: '-',
account: '-',
role: '会员用户',
phone: '-',
department: '-',
status: '正常'
},
stats: [
{ label: '今日扫码', value: '-' },
{ label: '异常处理', value: '-' },
{ label: '累计天数', value: '-' }
]
}
},
onShow() {
this.loadUserInfo()
},
methods: {
async loadUserInfo() {
try {
const profile = await getUserInfo()
uni.setStorageSync('scanapp_user', profile)
this.user = {
name: profile.nickname || profile.username || '-',
account: profile.username || profile.email || '-',
role: '会员用户',
phone: profile.phone || '-',
department: profile.department_name || '-',
status: Number(profile.enabled) === 0 ? '已停用' : '正常'
}
} catch (error) {
console.error('[scanapp] 获取用户信息失败', error)
}
},
handleAction(title) {
const actionMap = {
'修改资料': this.goEditProfile,
'修改密码': this.goChangePassword,
'切换账号': this.switchAccount,
'退出登录': this.logout
}
const action = actionMap[title]
if (action) {
action()
}
},
goEditProfile() {
uni.navigateTo({
url: '/pages/profile/edit'
})
},
goChangePassword() {
uni.navigateTo({
url: '/pages/profile/password'
})
},
switchAccount() {
uni.showModal({
title: '切换账号',
content: '将清除当前登录状态并返回登录页,是否继续?',
confirmText: '切换',
success: (res) => {
if (res.confirm) {
this.clearLoginState()
uni.reLaunch({ url: '/pages/auth/login' })
}
}
})
},
logout() {
uni.showModal({
title: '退出登录',
content: '确认退出当前账号?',
confirmText: '退出',
success: (res) => {
if (res.confirm) {
this.clearLoginState()
uni.reLaunch({ url: '/pages/auth/login' })
}
}
})
},
clearLoginState() {
uni.removeStorageSync('scanapp_token')
uni.removeStorageSync('scanapp_refresh_token')
uni.removeStorageSync('scanapp_user')
}
}
}
</script>
<style lang="scss" scoped>
.profile-page {
min-height: 100vh;
padding: 28rpx 24rpx 48rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eef6ff 0%, #f4f6fb 36%, #f4f6fb 100%);
}
.profile-header,
.profile-card {
padding: 38rpx 34rpx;
border: 1rpx solid rgba(255, 255, 255, 0.75);
border-radius: 28rpx;
background: #ffffff;
box-shadow: 0 16rpx 40rpx rgba(23, 35, 61, 0.06);
}
.profile-card {
margin-top: 30rpx;
}
.profile-top {
display: flex;
align-items: center;
gap: 0;
}
.profile-meta {
flex: 1;
min-width: 0;
margin-left: 36rpx;
}
.avatar-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 92rpx;
height: 92rpx;
border-radius: 50%;
background: linear-gradient(135deg, #35bfa3 0%, #1677ff 100%);
box-shadow: 0 12rpx 28rpx rgba(53, 191, 163, 0.28);
flex: none;
}
.profile-meta {
flex: 1;
min-width: 0;
}
.profile-name {
display: block;
color: #17233d;
font-size: 28rpx;
font-weight: 800;
line-height: 1.35;
}
.profile-account,
.profile-role,
.info-label,
.info-value,
.action-item text {
display: block;
}
.profile-account {
margin-top: 14rpx;
color: #7d8798;
font-size: 20rpx;
line-height: 1.4;
}
.profile-role {
display: inline-flex;
align-items: center;
flex: none;
margin-left: 24rpx;
padding: 6rpx 16rpx;
border-radius: 999rpx;
background: rgba(25, 190, 107, 0.1);
color: #19be6b;
font-size: 18rpx;
font-weight: 600;
line-height: 1.35;
}
.profile-stat-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16rpx;
margin-top: 34rpx;
}
.stat-item {
padding: 24rpx 12rpx;
border-radius: 20rpx;
background: #f7f9fc;
text-align: center;
}
.stat-value {
display: block;
color: #1677ff;
font-size: 28rpx;
font-weight: 800;
line-height: 1.35;
}
.stat-label {
display: block;
margin-top: 10rpx;
color: #8a94a6;
font-size: 18rpx;
line-height: 1.35;
}
.card-title {
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.info-list {
margin-top: 26rpx;
}
.info-row,
.action-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 26rpx 0;
border-bottom: 1rpx solid #eef1f6;
}
.info-row:last-child,
.action-item:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.info-label {
color: #8a94a6;
font-size: 20rpx;
line-height: 1.35;
}
.info-value {
color: #17233d;
font-size: 22rpx;
font-weight: 600;
line-height: 1.35;
text-align: right;
}
.info-value.success {
color: #19be6b;
}
.action-list {
margin-top: 26rpx;
}
.action-left {
display: flex;
align-items: center;
gap: 18rpx;
color: #17233d;
font-size: 22rpx;
font-weight: 600;
line-height: 1.35;
}
.action-item.danger .action-left {
color: #ef4f4f;
}
.theme-dark.profile-page {
background: #151923;
}
.theme-dark .profile-header,
.theme-dark .profile-card {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .stat-item {
background: #1c2431;
}
.theme-dark .profile-name,
.theme-dark .card-title,
.theme-dark .info-value,
.theme-dark .action-left {
color: #f2f5fa;
}
.theme-dark .profile-account,
.theme-dark .info-label,
.theme-dark .stat-label {
color: #aeb9ca;
}
.theme-dark .info-row,
.theme-dark .action-item {
border-color: #364152;
}
</style>
+205
View File
@@ -0,0 +1,205 @@
<template>
<view class="password-page" :class="`theme-${themeMode}`">
<view class="form-card">
<view class="form-header">
<text class="form-title">修改密码</text>
<text class="form-desc">修改成功后需要重新登录</text>
</view>
<u-form :model="form" :border-bottom="false" label-position="top" label-width="120">
<u-form-item label="原密码" prop="oldPassword" :border-bottom="false">
<view class="field-card">
<u-input v-model="form.oldPassword" type="password" clearable password-icon placeholder="请输入原密码" placeholder-style="color: #a0a7b5;" />
</view>
</u-form-item>
<u-form-item label="新密码" prop="newPassword" :border-bottom="false">
<view class="field-card">
<u-input v-model="form.newPassword" type="password" clearable password-icon placeholder="请输入新密码" placeholder-style="color: #a0a7b5;" />
</view>
</u-form-item>
<u-form-item label="确认新密码" prop="confirmPassword" :border-bottom="false">
<view class="field-card">
<u-input v-model="form.confirmPassword" type="password" clearable password-icon placeholder="请再次输入新密码" placeholder-style="color: #a0a7b5;" />
</view>
</u-form-item>
</u-form>
</view>
<view class="tips-card">
<text class="tips-title">密码要求</text>
<text class="tips-text">长度不少于 6 位,建议同时包含字母、数字或符号。</text>
</view>
<view class="bottom-actions">
<u-button type="primary" shape="circle" size="large" :loading="submitting" @click="submitForm">确认修改</u-button>
</view>
</view>
</template>
<script>
import { changePassword } from '@/api/member.js'
export default {
data() {
return {
submitting: false,
form: {
oldPassword: '',
newPassword: '',
confirmPassword: ''
}
}
},
methods: {
validateForm() {
if (!this.form.oldPassword) {
uni.showToast({ title: '请输入原密码', icon: 'none' })
return false
}
if (!this.form.newPassword || this.form.newPassword.length < 6) {
uni.showToast({ title: '新密码至少 6 位', icon: 'none' })
return false
}
if (this.form.newPassword !== this.form.confirmPassword) {
uni.showToast({ title: '两次新密码不一致', icon: 'none' })
return false
}
if (this.form.oldPassword === this.form.newPassword) {
uni.showToast({ title: '新密码不能与原密码相同', icon: 'none' })
return false
}
return true
},
async submitForm() {
if (this.submitting || !this.validateForm()) {
return
}
this.submitting = true
try {
await changePassword({
old_password: this.form.oldPassword,
new_password: this.form.newPassword
})
uni.removeStorageSync('scanapp_token')
uni.removeStorageSync('scanapp_refresh_token')
uni.removeStorageSync('scanapp_user')
uni.showToast({ title: '密码已修改,请重新登录', icon: 'none' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/auth/login' })
}, 700)
} catch (error) {
console.error('[scanapp] 修改密码失败', error)
} finally {
this.submitting = false
}
}
}
}
</script>
<style lang="scss" scoped>
.password-page {
min-height: 100vh;
padding: 28rpx 24rpx 48rpx;
box-sizing: border-box;
background: #f4f6fb;
}
.form-card,
.tips-card {
padding: 34rpx 30rpx;
border-radius: 28rpx;
background: #ffffff;
box-shadow: 0 16rpx 40rpx rgba(23, 35, 61, 0.06);
}
.form-header {
margin-bottom: 24rpx;
}
.form-title,
.form-desc,
.tips-title,
.tips-text {
display: block;
}
.form-title {
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.form-desc {
margin-top: 10rpx;
color: #8a94a6;
font-size: 22rpx;
line-height: 1.45;
}
.field-card {
display: flex;
align-items: center;
width: 100%;
height: 88rpx;
padding: 0 24rpx;
box-sizing: border-box;
border: 1rpx solid #edf1f7;
border-radius: 22rpx;
background: #f8fbff;
}
.tips-card {
margin-top: 24rpx;
background: #f8fbff;
}
.tips-title {
color: #17233d;
font-size: 24rpx;
font-weight: 700;
line-height: 1.4;
}
.tips-text {
margin-top: 10rpx;
color: #6b778c;
font-size: 22rpx;
line-height: 1.55;
}
.bottom-actions {
margin-top: 36rpx;
}
.theme-dark.password-page {
background: #151923;
}
.theme-dark .form-card,
.theme-dark .tips-card,
.theme-dark .field-card {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .tips-card,
.theme-dark .field-card {
background: #1c2431;
}
.theme-dark .form-title,
.theme-dark .tips-title {
color: #f2f5fa;
}
.theme-dark .form-desc,
.theme-dark .tips-text {
color: #aeb9ca;
}
.theme-dark :deep(.u-input__input) {
color: #f2f5fa !important;
}
</style>
File diff suppressed because it is too large Load Diff
+980
View File
@@ -0,0 +1,980 @@
<template>
<view class="repair-page" :class="`theme-${themeMode}`">
<view class="repair-hero">
<view class="hero-copy">
<text class="hero-tag">维修工作台</text>
<text class="hero-title">维修处理</text>
<text class="hero-subtitle">扫描设备,快速完成故障判断与维修记录</text>
</view>
<view class="hero-icon">
<u-icon name="setting-fill" color="#ffffff" size="46" />
</view>
</view>
<view class="summary-grid">
<view v-for="item in summaries" :key="item.label" class="summary-item" @tap="handleSummaryTap(item)">
<text class="summary-value" :class="item.type">{{ item.value }}</text>
<text class="summary-label">{{ item.label }}</text>
<view v-if="item.action === 'pending'" class="summary-arrow">
<u-icon name="arrow-right" color="#1677ff" size="16" />
</view>
</view>
</view>
<view class="section-card scan-card">
<view class="section-header">
<view>
<text class="section-title">1. 扫描或录入设备</text>
<text class="section-desc">支持订单号、整机编码或设备条码</text>
</view>
<text class="required-tag">必填</text>
</view>
<view class="scan-input-row">
<view class="scan-input">
<u-icon name="search" color="#9aa4b4" size="32" />
<u-input v-model="form.deviceNo" :input-mode="pdaMode ? 'none' : 'text'" placeholder="请输入订单号 / 整机编码" placeholder-style="color: #a0a7b5;" @focus="handleScanInputFocus" @blur="loadRepairTask" />
</view>
<view class="scan-button" @tap="scanDevice">
<u-icon name="scan" color="#ffffff" size="30" />
<text>扫码</text>
</view>
</view>
<view v-if="repairTask" class="device-found">
<view class="device-found-icon"><u-icon name="checkmark" color="#ffffff" size="28" /></view>
<view class="device-found-info">
<text class="device-found-title">已识别维修对象</text>
<text class="device-found-value">{{ repairTask.deviceNo }}</text>
</view>
<text class="device-found-status">{{ repairTask.statusName }}</text>
</view>
</view>
<view class="section-card">
<view class="section-header">
<view>
<text class="section-title">2. 异常信息</text>
<text class="section-desc">来自异常提报的维修任务</text>
</view>
<text class="order-time">{{ repairTask ? repairTask.reportedDate : '待扫描设备' }}</text>
</view>
<view v-if="repairTask" class="exception-info">
<view class="info-row"><text class="info-label">异常类型</text><text class="info-value">{{ repairTask.typeName || '-' }}</text></view>
<view class="info-row"><text class="info-label">异常原因</text><text class="info-value">{{ repairTask.reasonText || '-' }}</text></view>
<view class="info-row"><text class="info-label">提报人员</text><text class="info-value">{{ repairTask.reporterText || '-' }}</text></view>
</view>
<view v-else class="exception-empty">扫描或录入设备后加载异常任务</view>
</view>
<view class="section-card">
<view class="section-header">
<view>
<text class="section-title">3. 维修判定</text>
<text class="section-desc">选择本次维修的不良类型</text>
</view>
<text class="required-tag">必选</text>
</view>
<view class="result-grid">
<view v-for="item in resultOptions" :key="item.value" class="result-item" :class="{ active: form.result === item.value }" @tap="selectRepairResult(item.value)">
<view class="result-icon" :style="{ backgroundColor: form.result === item.value ? item.color : '#f2f5fa' }">
<u-icon :name="item.icon" :color="form.result === item.value ? '#ffffff' : item.color" size="32" />
</view>
<text class="result-label">{{ item.label }}</text>
<text class="result-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<view v-if="form.result === 'assembly_mismatch'" class="section-card">
<view class="section-header">
<view>
<text class="section-title">4. 搭配错误分类</text>
<text class="section-desc">选择与本次异常对应的物料分类</text>
</view>
<text class="required-tag">必选</text>
</view>
<view v-if="loadingMaterialCategories" class="repair-actions-tip">正在加载物料分类...</view>
<view v-else-if="!mismatchCategoryOptions.length" class="repair-actions-tip">暂无可用物料分类</view>
<view v-else class="repair-action-list">
<view
v-for="item in mismatchCategoryOptions"
:key="item.id"
class="repair-action-item"
:class="{ active: isMismatchCategorySelected(item.id) }"
@tap="toggleMismatchCategory(item)"
>
<u-icon :name="isMismatchCategorySelected(item.id) ? 'checkmark-circle-fill' : 'plus-circle'" :color="isMismatchCategorySelected(item.id) ? '#1677ff' : '#9aa4b4'" size="28" />
<text>{{ item.category_name }}</text>
</view>
</view>
</view>
<view v-if="form.result === 'operation_defect'" class="section-card responsibility-card">
<view class="section-header">
<view>
<text class="section-title">4. 作业不良责任</text>
<text class="section-desc">选择责任人员,工序步骤随人员记录一并保存</text>
</view>
<text class="required-tag">必选</text>
</view>
<view v-if="loadingOrderRecords" class="repair-actions-tip">正在加载历史扫码记录...</view>
<view v-else-if="!orderScanRecords.length" class="repair-actions-tip">当前订单暂无可定责的历史扫码记录</view>
<view v-else>
<text class="responsibility-title">责任人员</text>
<view class="responsibility-list">
<view v-for="item in orderScanRecords" :key="`operator-${item.id}`" class="responsibility-item" :class="{ active: isResponsibleOperatorSelected(item.id) }" @tap="toggleResponsibleOperator(item.id)">
<u-icon :name="isResponsibleOperatorSelected(item.id) ? 'checkmark-circle-fill' : 'plus-circle'" :color="isResponsibleOperatorSelected(item.id) ? '#1677ff' : '#9aa4b4'" size="28" />
<view><text class="responsibility-name">{{ item.operator || item.userCode || '未记录人员' }}</text><text class="responsibility-meta">{{ item.processName || '未设置工序' }} · {{ item.stepName || '未设置步骤' }}</text></view>
</view>
</view>
</view>
</view>
<view class="section-card">
<view class="section-header repair-record-header">
<view>
<text class="section-title">{{ form.result === 'material_defect' ? '4. 维修记录' : '5. 维修记录' }}</text>
<text class="section-desc">记录处理过程,便于后续追溯</text>
</view>
<view class="parts-actions">
<text class="parts-action scan-part-action" @tap="scanPart">扫码</text>
<text class="parts-action input-part-action" @tap="inputPart">输入</text>
</view>
</view>
<view class="parts-header">
<text class="parts-title">更换备件</text>
</view>
<view v-for="(part, index) in form.parts" :key="part.id" class="part-row">
<view class="part-index">{{ index + 1 }}</view>
<view class="part-info">
<text class="part-name">{{ part.name }}</text>
<text class="part-code">{{ part.code }}</text>
<text v-if="part.categoryName" class="part-category">{{ part.categoryName }}</text>
<view class="part-source-options">
<view
v-for="item in partSourceOptions"
:key="item.value"
class="part-source-option"
:class="{ active: part.source === item.value }"
@tap="part.source = item.value"
>
{{ item.label }}
</view>
</view>
<view class="part-fault-entry" @tap="openFaultPointPopup(part)">
<text class="part-fault-entry-label">故障点</text>
<text class="part-fault-entry-value">{{ getPartFaultSummary(part) || '请选择' }}</text>
<u-icon name="arrow-right" color="#9aa4b4" size="20" />
</view>
</view>
<view class="part-stepper">
<view class="stepper-button" @tap.stop="changePartQuantity(part, -1)">
<u-icon name="minus" color="#56647a" size="22" />
</view>
<text class="stepper-value">{{ part.quantity }}</text>
<view class="stepper-button" @tap.stop="changePartQuantity(part, 1)">
<u-icon name="plus" color="#1677ff" size="22" />
</view>
</view>
<view class="part-remove" @tap.stop="removePart(index)">
<u-icon name="trash" color="#ef625f" size="30" />
</view>
</view>
<view class="repair-actions-tip">每个备件分别选择对应分类的故障点</view>
<view v-if="form.remark" class="repair-action-summary">{{ form.remark }}</view>
</view>
<u-popup v-model="partInputVisible" mode="center" border-radius="20">
<view class="part-input-dialog">
<text class="part-input-title">输入商品编码</text>
<u-input v-model="partInputCode" :input-mode="pdaMode ? 'none' : 'text'" placeholder="请输入商品编码" border @focus="handleScanInputFocus" />
<view class="part-input-actions">
<view class="part-input-cancel" @tap="closePartInput">取消</view>
<view class="part-input-confirm" @tap="confirmPartInput">确定</view>
</view>
</view>
</u-popup>
<u-popup v-model="faultPointPopupVisible" mode="bottom" border-radius="24">
<view class="fault-popup">
<view class="fault-popup-header">
<view>
<text class="fault-popup-title">选择故障点</text>
<text class="fault-popup-subtitle">{{ currentFaultPart ? currentFaultPart.name : '' }}</text>
</view>
<text class="fault-popup-close" @tap="closeFaultPointPopup">关闭</text>
</view>
<view v-if="loadingFaultPoints" class="repair-actions-tip">正在加载故障描述...</view>
<view v-else-if="!currentFaultPartOptions.length" class="repair-actions-tip">当前备件分类暂无故障描述</view>
<scroll-view v-else scroll-y class="fault-popup-scroll">
<view class="repair-action-list fault-popup-list">
<view
v-for="item in currentFaultPartOptions"
:key="item.id"
class="repair-action-item"
:class="{ active: isFaultPointSelected(item.id) }"
@tap="toggleFaultPoint(item)"
>
<u-icon
:name="isFaultPointSelected(item.id) ? 'checkmark-circle-fill' : 'plus-circle'"
:color="isFaultPointSelected(item.id) ? '#1677ff' : '#9aa4b4'"
size="28"
/>
<text>{{ item.description }}</text>
</view>
</view>
</scroll-view>
<view class="fault-popup-footer">
<view class="fault-popup-button fault-popup-clear" @tap="clearCurrentPartFaults">清空</view>
<view class="fault-popup-button fault-popup-confirm" @tap="closeFaultPointPopup">完成</view>
</view>
</view>
</u-popup>
<view class="section-card evidence-card">
<view class="section-header">
<view>
<text class="section-title">{{ form.result === 'material_defect' ? '5. 维修凭证' : '6. 维修凭证' }}</text>
<text class="section-desc">可上传维修完成后的现场照片</text>
</view>
<text class="optional-tag">选填</text>
</view>
<view class="evidence-row">
<view v-if="form.images.length < 4" class="upload-box" @tap="chooseImages">
<u-icon name="camera-fill" color="#ff8a3d" size="38" />
<text>拍照上传</text>
</view>
<view v-for="(image, index) in form.images" :key="image" class="evidence-image">
<image :src="image" mode="aspectFill" />
<text class="upload-label">图片{{ index + 1 }}</text>
<view class="upload-delete" @tap.stop="removeImage(index)">
<u-icon name="close" color="#ffffff" size="20" />
</view>
</view>
</view>
</view>
<view class="bottom-actions">
<view class="draft-button" @tap="saveDraft">保存草稿</view>
<view class="complete-button" @tap="completeRepair">完成维修</view>
</view>
</view>
</template>
<script>
// UTS 原生扫码插件仅在 App-Plus 真机/自定义基座中解析,Web 环境不加载。
// #ifdef APP-PLUS
import { openCodeScan } from '@/uni_modules/mas-code-scan'
// #endif
import { getHardwareFaultDescriptionOptions, getOrderRecentRecords, getScanAppMaterialCategories, lookupMaterial } from '@/api/produce/data-entry.js'
import { getRepairStatistics, getRepairTask, submitRepairRecord } from '@/api/produce/exception.js'
import { uploadImage } from '@/api/upload.js'
import { getScanRegionSetting } from '@/utils/scan-settings.js'
export default {
data() {
return {
partInputVisible: false,
partInputCode: '',
scanning: false,
pdaMode: false,
loadingRepairTask: false,
loadingOrderRecords: false,
submitting: false,
repairTask: null,
orderScanRecords: [],
loadingFaultPoints: false,
loadingMaterialCategories: false,
faultPointRequestKey: '',
faultPointPopupVisible: false,
currentFaultPartId: '',
faultPointOptions: [],
summaries: [
{ label: '待维修', value: 0, type: 'warning', action: 'pending' },
{ label: '处理中', value: 0, type: 'primary' },
{ label: '今日完成', value: 0, type: 'success' }
],
resultOptions: [
{ label: '物料不良', value: 'material_defect', desc: '物料本身存在异常', icon: 'bag-fill', color: '#ef625f' },
{ label: '作业不良', value: 'operation_defect', desc: '作业过程导致异常', icon: 'edit-pen-fill', color: '#f2a93b' },
{ label: '搭配错误', value: 'assembly_mismatch', desc: '物料搭配或装配错误', icon: 'grid-fill', color: '#2f6cf6' }
],
mismatchCategoryOptions: [],
partSourceOptions: [
{ label: '新料', value: 'new' },
{ label: '售后', value: 'after_sales' },
{ label: '返修', value: 'refurbished' }
],
form: {
deviceNo: '',
result: 'material_defect',
remark: '',
parts: [],
responsibleOperatorIds: [],
mismatchCategoryIds: [],
images: [],
status: ''
}
}
},
onLoad(options) {
this.loadPdaMode()
this.loadMaterialCategories()
this.loadRepairStatistics()
const deviceNo = String(options?.device_no || '').trim()
if (deviceNo) {
this.form.deviceNo = deviceNo
this.loadRepairTask()
}
},
onShow() {
this.loadPdaMode()
},
computed: {
currentFaultPart() {
return this.form.parts.find((part) => part.id === this.currentFaultPartId) || null
},
currentFaultPartOptions() {
if (!this.currentFaultPart) return []
return this.faultPointOptions.filter((item) => item.categoryId === String(this.currentFaultPart.categoryId || ''))
}
},
methods: {
getCurrentUserCode() {
const user = uni.getStorageSync('scanapp_user') || {}
return user.username || user.nickname || 'U001'
},
loadPdaMode() {
const settings = uni.getStorageSync(`scanapp_settings_${this.getCurrentUserCode()}`)
this.pdaMode = settings?.pdaMode === true
},
handleScanInputFocus() {
// #ifdef APP-PLUS
try {
const activity = plus.android.runtimeMainActivity()
const focusedView = activity.getWindow().getDecorView().findFocus()
if (focusedView) plus.android.invoke(focusedView, 'setShowSoftInputOnFocus', !this.pdaMode)
if (this.pdaMode) uni.hideKeyboard()
} catch (error) {
if (this.pdaMode) uni.hideKeyboard()
}
// #endif
},
handleSummaryTap(item) {
if (item.action !== 'pending') return
uni.navigateTo({ url: '/pages/repair/tasks' })
},
async loadRepairStatistics() {
try {
const user = uni.getStorageSync('scanapp_user') || {}
const userCode = String(user.username || user.user_code || '').trim()
const statistics = await getRepairStatistics({ user_code: userCode })
const data = statistics?.data || statistics || {}
this.summaries = [
{ label: '待维修', value: Number(data.pending || 0), type: 'warning', action: 'pending' },
{ label: '处理中', value: Number(data.processing || 0), type: 'primary' },
{ label: '今日完成', value: Number(data.completed_today || 0), type: 'success' }
]
} catch (error) {
this.summaries = [
{ label: '待维修', value: 0, type: 'warning', action: 'pending' },
{ label: '处理中', value: 0, type: 'primary' },
{ label: '今日完成', value: 0, type: 'success' }
]
}
},
async loadMaterialCategories() {
this.loadingMaterialCategories = true
try {
const items = await getScanAppMaterialCategories()
this.mismatchCategoryOptions = (Array.isArray(items) ? items : []).map((item) => ({
id: String(item.id || ''),
category_name: String(item.category_name || item.name || '')
})).filter((item) => item.id && item.category_name)
const availableIds = new Set(this.mismatchCategoryOptions.map((item) => item.id))
this.form.mismatchCategoryIds = this.form.mismatchCategoryIds.filter((id) => availableIds.has(id))
} catch (error) {
this.mismatchCategoryOptions = []
this.form.mismatchCategoryIds = []
uni.showToast({ title: '物料分类加载失败,请稍后重试', icon: 'none' })
} finally {
this.loadingMaterialCategories = false
}
},
async loadRepairTask() {
const deviceNo = this.form.deviceNo.trim()
this.repairTask = null
this.orderScanRecords = []
this.form.responsibleOperatorIds = []
if (!deviceNo || this.loadingRepairTask) return
this.loadingRepairTask = true
try {
const task = await getRepairTask(deviceNo)
if (this.form.deviceNo.trim() !== deviceNo) return
this.form.deviceNo = String(task.device_no || deviceNo)
this.repairTask = {
id: String(task.id || ''),
deviceNo: String(task.device_no || deviceNo),
typeName: String(task.type_name || ''),
reasonText: [task.reason_name, task.issue_name].filter(Boolean).join(' · '),
reporterText: task.reporter ? `${task.reporter}${task.user_code ? `(${task.user_code})` : ''}` : String(task.user_code || ''),
reportedDate: String(task.reported_date || ''),
statusName: String(task.status_name || '待维修')
}
await this.loadOrderScanRecords(this.repairTask.deviceNo)
} catch (error) {
if (this.form.deviceNo.trim() === deviceNo) this.repairTask = null
} finally {
this.loadingRepairTask = false
}
},
async loadOrderScanRecords(orderNo) {
this.loadingOrderRecords = true
try {
const result = await getOrderRecentRecords({ order_no: String(orderNo || '').trim(), limit: 50 })
const items = Array.isArray(result?.items) ? result.items : []
this.orderScanRecords = items.map((item) => ({
id: String(item.id || ''),
processName: String(item.process_name || item.process?.process_name || ''),
stepName: String(item.step_name || item.step?.step_name || ''),
operator: String(item.operator || ''),
userCode: String(item.user_code || '')
})).filter((item) => item.id)
} catch (error) {
this.orderScanRecords = []
} finally {
this.loadingOrderRecords = false
}
},
selectRepairResult(result) {
this.form.result = result
if (result !== 'operation_defect') this.form.responsibleOperatorIds = []
if (result !== 'assembly_mismatch') this.form.mismatchCategoryIds = []
},
isMismatchCategorySelected(categoryId) {
return this.form.mismatchCategoryIds.includes(String(categoryId))
},
toggleMismatchCategory(category) {
const id = String(category.id)
const ids = this.form.mismatchCategoryIds
this.form.mismatchCategoryIds = ids.includes(id) ? ids.filter((item) => item !== id) : [...ids, id]
},
isResponsibleOperatorSelected(scanId) {
return this.form.responsibleOperatorIds.includes(String(scanId))
},
toggleResponsibleOperator(scanId) {
const id = String(scanId)
const ids = this.form.responsibleOperatorIds
this.form.responsibleOperatorIds = ids.includes(id) ? ids.filter((item) => item !== id) : [...ids, id]
},
scanDevice() {
if (this.scanning || this.submitting) return
// #ifdef APP-PLUS
this.scanning = true
openCodeScan({
smartZoom: true,
vibrate: true,
scanRegion: getScanRegionSetting(),
success: async (result) => {
const value = String(result?.content || '').trim()
if (!value) return
this.form.deviceNo = value
await this.loadRepairTask()
},
fail: (error) => this.handleScanFail(error),
complete: () => {
this.scanning = false
}
})
// #endif
// #ifndef APP-PLUS
uni.showToast({ title: '请在 Android App 中使用扫码功能', icon: 'none' })
// #endif
},
handleScanFail(error) {
const code = Number(error?.errCode)
if (code === 9021003) return
const messageMap = {
9021001: '当前环境不支持扫码,请在 Android 自定义调试基座或正式安装包中使用',
9021002: '相机权限被拒绝,请到系统设置中开启相机权限',
9021004: '未获取到有效扫码结果,请重试'
}
uni.showToast({ title: messageMap[code] || String(error?.errMsg || '扫码插件调用异常'), icon: 'none' })
},
chooseImages() {
if (this.submitting || this.form.images.length >= 4) return
uni.chooseImage({
count: 4 - this.form.images.length,
sizeType: ['compressed'],
sourceType: ['camera', 'album'],
success: (res) => {
this.form.images = [...this.form.images, ...res.tempFilePaths].slice(0, 4)
}
})
},
removeImage(index) {
if (this.submitting) return
this.form.images.splice(index, 1)
},
getFaultDescriptionById(id) {
return this.faultPointOptions.find((item) => item.id === id)?.description || ''
},
getPartFaultSummary(part) {
return (part.selectedFaultPointIds || [])
.map((id) => this.getFaultDescriptionById(id))
.filter(Boolean)
.join(';')
},
openFaultPointPopup(part) {
if (!part.categoryId) {
uni.showToast({ title: '当前备件缺少分类信息', icon: 'none' })
return
}
this.currentFaultPartId = part.id
this.faultPointPopupVisible = true
},
closeFaultPointPopup() {
this.faultPointPopupVisible = false
this.currentFaultPartId = ''
},
isFaultPointSelected(faultPointId) {
return !!this.currentFaultPart && (this.currentFaultPart.selectedFaultPointIds || []).includes(faultPointId)
},
toggleFaultPoint(faultPoint) {
if (!this.currentFaultPart) return
const selectedFaultPointIds = this.currentFaultPart.selectedFaultPointIds || []
const index = selectedFaultPointIds.indexOf(faultPoint.id)
if (index === -1) {
selectedFaultPointIds.push(faultPoint.id)
} else {
selectedFaultPointIds.splice(index, 1)
}
this.currentFaultPart.selectedFaultPointIds = [...selectedFaultPointIds]
this.syncFaultPointRemark()
},
clearCurrentPartFaults() {
if (!this.currentFaultPart) return
this.currentFaultPart.selectedFaultPointIds = []
this.syncFaultPointRemark()
},
syncFaultPointRemark() {
const summaries = this.form.parts
.map((part, index) => {
const summary = this.getPartFaultSummary(part)
if (!summary) return ''
return `备件${index + 1}:${summary}`
})
.filter(Boolean)
this.form.remark = summaries.join(';')
},
async reloadFaultPointOptions() {
const categoryIds = [...new Set(this.form.parts.map((part) => String(part.categoryId || '')).filter(Boolean))].sort()
const requestKey = categoryIds.join(',')
this.faultPointRequestKey = requestKey
if (!categoryIds.length) {
this.faultPointOptions = []
this.form.parts = this.form.parts.map((part) => ({
...part,
selectedFaultPointIds: []
}))
this.syncFaultPointRemark()
return
}
this.loadingFaultPoints = true
try {
const items = await getHardwareFaultDescriptionOptions(categoryIds)
if (this.faultPointRequestKey !== requestKey) return
this.faultPointOptions = (Array.isArray(items) ? items : []).map((item) => ({
id: String(item.id),
categoryId: String(item.category_id),
description: String(item.description || ''),
useCount: Number(item.use_count || 0)
}))
const availableIds = new Set(this.faultPointOptions.map((item) => item.id))
this.form.parts = this.form.parts.map((part) => ({
...part,
selectedFaultPointIds: (part.selectedFaultPointIds || []).filter((id) => availableIds.has(id))
}))
this.syncFaultPointRemark()
} catch (error) {
if (this.faultPointRequestKey === requestKey) this.faultPointOptions = []
} finally {
if (this.faultPointRequestKey === requestKey) this.loadingFaultPoints = false
}
},
async removePart(index) {
if (this.form.parts[index] && this.form.parts[index].id === this.currentFaultPartId) {
this.closeFaultPointPopup()
}
this.form.parts.splice(index, 1)
await this.reloadFaultPointOptions()
},
changePartQuantity(part, delta) {
const current = Math.max(1, Number.parseInt(part.quantity, 10) || 1)
part.quantity = String(Math.max(1, current + delta))
},
async addPartByCode(code) {
const productCode = String(code || '').trim()
if (!productCode) return
if (this.form.parts.some((part) => part.code === productCode)) {
uni.showToast({ title: '该物料已添加', icon: 'none' })
return
}
try {
const material = await lookupMaterial(productCode)
this.form.parts.push({
id: `${Date.now()}-${productCode}`,
name: material.product_name,
code: material.product_code || productCode,
categoryId: String(material.category_id || ''),
categoryName: String(material.category_name || ''),
selectedFaultPointIds: [],
source: 'new',
quantity: '1'
})
await this.reloadFaultPointOptions()
} catch (error) {
// 请求层已显示服务端错误提示,避免重复弹出。
}
},
scanPart() {
if (this.scanning || this.submitting) return
// #ifdef APP-PLUS
this.scanning = true
openCodeScan({
smartZoom: true,
vibrate: true,
scanRegion: getScanRegionSetting(),
success: (result) => {
const value = String(result?.content || '').trim()
if (value) this.addPartByCode(value)
},
fail: (error) => this.handleScanFail(error),
complete: () => {
this.scanning = false
}
})
// #endif
// #ifndef APP-PLUS
uni.showToast({ title: '请在 Android App 中使用扫码功能', icon: 'none' })
// #endif
},
inputPart() {
this.partInputCode = ''
this.partInputVisible = true
},
closePartInput() {
this.partInputVisible = false
},
async confirmPartInput() {
const productCode = this.partInputCode.trim()
if (!productCode) {
uni.showToast({ title: '请输入商品编码', icon: 'none' })
return
}
this.partInputVisible = false
await this.addPartByCode(productCode)
},
saveDraft() {
uni.showToast({ title: '维修草稿已保存', icon: 'success' })
},
completeRepair() {
if (this.submitting) return
if (!this.form.deviceNo.trim()) {
uni.showToast({ title: '请先扫描或录入设备', icon: 'none' })
return
}
if (!this.repairTask?.id) {
uni.showToast({ title: '请先加载有效的维修任务', icon: 'none' })
return
}
if (!this.form.result) {
uni.showToast({ title: '请选择维修判定', icon: 'none' })
return
}
if (this.form.result === 'operation_defect' && !this.form.responsibleOperatorIds.length) {
uni.showToast({ title: '请选择责任人员', icon: 'none' })
return
}
if (this.form.result === 'assembly_mismatch' && !this.form.mismatchCategoryIds.length) {
uni.showToast({ title: '请选择搭配错误分类', icon: 'none' })
return
}
const user = uni.getStorageSync('scanapp_user') || {}
const userCode = String(user.username || user.user_code || '').trim()
if (!userCode) {
uni.showToast({ title: '登录信息已失效,请重新登录', icon: 'none' })
return
}
uni.showModal({
title: '确认完成维修',
content: `确定提交设备 ${this.form.deviceNo} 的维修记录吗?`,
confirmText: '确认完成',
success: async (res) => {
if (!res.confirm || this.submitting) return
this.submitting = true
try {
const uploadedImages = []
for (const filePath of this.form.images) {
uploadedImages.push(await uploadImage(filePath, 'repair'))
}
const payload = {
exception_id: this.repairTask.id,
user_code: userCode,
repair_result: this.form.result,
images: uploadedImages,
parts: this.form.parts.map((part) => ({
product_code: String(part.code || '').trim(),
source: part.source || 'new',
quantity: Math.max(1, Number.parseInt(part.quantity, 10) || 1),
fault_description_ids: (part.selectedFaultPointIds || []).map((id) => String(id))
})),
operation_fault_steps: this.form.responsibleOperatorIds,
responsible_operators: this.form.responsibleOperatorIds,
remark: this.form.remark.trim()
}
if (this.form.result === 'assembly_mismatch') {
payload.category_ids = this.form.mismatchCategoryIds
}
await submitRepairRecord(payload)
uni.showToast({ title: '维修记录已完成', icon: 'success' })
this.repairTask = null
this.orderScanRecords = []
this.faultPointOptions = []
this.form.mismatchCategoryIds = []
this.form = {
deviceNo: '',
result: 'material_defect',
remark: '',
parts: [],
responsibleOperatorIds: [],
mismatchCategoryIds: [],
images: []
}
this.loadRepairStatistics()
} catch (error) {
console.error('[scanapp] 维修记录提交失败', error)
} finally {
this.submitting = false
}
}
})
}
}
}
</script>
<style lang="scss" scoped>
.repair-page {
min-height: 100vh;
padding: 24rpx 24rpx 210rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eaf4ff 0%, #f4f6fb 24%, #f4f6fb 100%);
}
.repair-hero {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 8rpx 28rpx;
}
.hero-copy text { display: block; }
.hero-tag { color: #1677ff; font-size: 20rpx; line-height: 1.35; }
.hero-title { margin-top: 6rpx; color: #17233d; font-size: 38rpx; font-weight: 800; line-height: 1.35; }
.hero-subtitle { margin-top: 8rpx; color: #7d899d; font-size: 20rpx; line-height: 1.4; }
.hero-icon { display: flex; align-items: center; justify-content: center; width: 88rpx; height: 88rpx; border-radius: 28rpx; background: linear-gradient(135deg, #2f6cf6 0%, #35bfa3 100%); box-shadow: 0 16rpx 28rpx rgba(47, 108, 246, 0.2); }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12rpx; margin-bottom: 18rpx; }
.summary-item { position: relative; padding: 18rpx 12rpx; border-radius: 20rpx; background: #ffffff; text-align: center; box-shadow: 0 8rpx 24rpx rgba(36, 59, 92, 0.05); }
.summary-arrow { position: absolute; top: 50%; right: 12rpx; display: flex; align-items: center; justify-content: center; width: 34rpx; height: 34rpx; border-radius: 50%; background: #edf4ff; transform: translateY(-50%); }
.summary-value { display: block; font-size: 32rpx; font-weight: 800; line-height: 1.2; }
.summary-value.warning { color: #f2a93b; }
.summary-value.primary { color: #1677ff; }
.summary-value.success { color: #26b36c; }
.summary-label { display: block; margin-top: 8rpx; color: #8a94a6; font-size: 18rpx; line-height: 1.35; }
.section-card { margin-bottom: 18rpx; padding: 24rpx; border-radius: 24rpx; background: #ffffff; box-shadow: 0 8rpx 24rpx rgba(36, 59, 92, 0.05); }
.section-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14rpx; }
.section-title, .section-desc { display: block; }
.section-title { color: #17233d; font-size: 28rpx; font-weight: 700; line-height: 1.35; }
.section-desc { margin-top: 8rpx; color: #9aa4b4; font-size: 20rpx; line-height: 1.35; }
.required-tag, .optional-tag, .order-time { flex: none; font-size: 18rpx; line-height: 1.35; }
.required-tag { padding: 6rpx 12rpx; border-radius: 999rpx; color: #1677ff; background: #edf4ff; }
.optional-tag, .order-time { color: #9aa4b4; }
.scan-input-row { display: flex; align-items: center; margin-top: 18rpx; }
.scan-input { display: flex; align-items: center; flex: 1; min-width: 0; padding: 0 14rpx; border: 1rpx solid #e7edf5; border-radius: 18rpx; background: #f8fbff; }
.scan-button { display: flex; align-items: center; justify-content: center; gap: 8rpx; width: 116rpx; height: 78rpx; margin-left: 14rpx; border-radius: 18rpx; color: #ffffff; background: linear-gradient(135deg, #2f6cf6 0%, #35bfa3 100%); font-size: 22rpx; flex: none; }
.device-found { display: flex; align-items: center; margin-top: 16rpx; padding: 16rpx; border-radius: 18rpx; background: #f0fbf6; }
.device-found-icon { display: flex; align-items: center; justify-content: center; width: 46rpx; height: 46rpx; margin-right: 12rpx; border-radius: 50%; background: #26b36c; }
.device-found-info { flex: 1; min-width: 0; }
.device-found-title, .device-found-value { display: block; }
.device-found-title { color: #26b36c; font-size: 18rpx; line-height: 1.35; }
.device-found-value { margin-top: 4rpx; color: #17233d; font-size: 24rpx; font-weight: 700; word-break: break-all; }
.device-found-status { color: #f2a93b; font-size: 18rpx; }
.exception-info { margin-top: 18rpx; padding: 4rpx 18rpx; border-radius: 18rpx; background: #f8fafc; }
.exception-empty { margin-top: 18rpx; padding: 24rpx 18rpx; border-radius: 18rpx; color: #9aa4b4; background: #f8fafc; font-size: 20rpx; text-align: center; }
.info-row { display: flex; align-items: center; justify-content: space-between; padding: 16rpx 0; border-bottom: 1rpx solid #edf0f5; }
.info-row:last-child { border-bottom: 0; }
.info-label { color: #8a94a6; font-size: 20rpx; }
.info-value { max-width: 62%; color: #2b3853; font-size: 22rpx; font-weight: 600; text-align: right; word-break: break-all; }
.result-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12rpx; margin-top: 18rpx; }
.result-item { padding: 16rpx 8rpx; border: 2rpx solid #edf0f5; border-radius: 18rpx; text-align: center; }
.result-item.active { border-color: #2f6cf6; background: #f4f8ff; }
.result-icon { display: flex; align-items: center; justify-content: center; width: 56rpx; height: 56rpx; margin: 0 auto 10rpx; border-radius: 18rpx; }
.result-label { display: block; color: #2b3853; font-size: 22rpx; font-weight: 700; line-height: 1.35; }
.result-desc { display: block; margin-top: 6rpx; color: #9aa4b4; font-size: 18rpx; line-height: 1.35; }
.repair-actions-tip { margin-top: 18rpx; color: #9aa4b4; font-size: 20rpx; }
.responsibility-title { display: block; margin-top: 20rpx; color: #2b3853; font-size: 22rpx; font-weight: 700; }
.responsibility-list { display: flex; flex-wrap: wrap; margin-top: 12rpx; }
.responsibility-item { display: flex; align-items: center; min-width: 44%; margin: 0 12rpx 12rpx 0; padding: 14rpx; border: 2rpx solid #e7edf5; border-radius: 14rpx; color: #56647a; background: #f8fafc; font-size: 20rpx; }
.responsibility-item.active { border-color: #1677ff; color: #1677ff; background: #edf4ff; }
.responsibility-item > view { margin-left: 8rpx; min-width: 0; }
.responsibility-name, .responsibility-meta { display: block; }
.responsibility-name { color: #2b3853; font-size: 20rpx; font-weight: 600; line-height: 1.35; }
.responsibility-meta { margin-top: 4rpx; color: #9aa4b4; font-size: 17rpx; line-height: 1.35; }
.repair-action-list { display: flex; flex-wrap: wrap; margin-top: 12rpx; }
.repair-action-item { display: flex; align-items: center; min-height: 64rpx; margin: 0 12rpx 12rpx 0; padding: 0 18rpx; border: 2rpx solid #e7edf5; border-radius: 14rpx; color: #56647a; background: #f8fafc; font-size: 22rpx; flex-shrink: 0; }
.repair-action-item text { margin-left: 8rpx; }
.repair-action-item.active { border-color: #1677ff; color: #1677ff; background: #edf4ff; }
.repair-action-summary { margin-top: 2rpx; padding: 16rpx; border-radius: 14rpx; color: #2b3853; background: #f8fbff; font-size: 20rpx; line-height: 1.5; word-break: break-all; }
.fault-popup { max-height: 78vh; padding: 28rpx 24rpx calc(24rpx + env(safe-area-inset-bottom)); box-sizing: border-box; background: #ffffff; }
.fault-popup-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16rpx; }
.fault-popup-title, .fault-popup-subtitle { display: block; }
.fault-popup-title { color: #17233d; font-size: 28rpx; font-weight: 700; line-height: 1.35; }
.fault-popup-subtitle { margin-top: 8rpx; color: #7d899d; font-size: 20rpx; line-height: 1.35; }
.fault-popup-close { color: #1677ff; font-size: 22rpx; line-height: 1.35; flex-shrink: 0; }
.fault-popup-scroll { max-height: 48vh; margin-top: 20rpx; }
.fault-popup-list { margin-top: 0; }
.fault-popup-footer { display: flex; align-items: center; margin-top: 12rpx; }
.fault-popup-button { display: flex; align-items: center; justify-content: center; height: 78rpx; border-radius: 18rpx; font-size: 24rpx; font-weight: 700; }
.fault-popup-clear { width: 30%; color: #56647a; background: #f0f3f8; }
.fault-popup-confirm { flex: 1; margin-left: 14rpx; color: #ffffff; background: #1677ff; }
.repair-record-header { align-items: center; }
.parts-header { margin-top: 24rpx; }
.parts-title { color: #2b3853; font-size: 24rpx; font-weight: 700; }
.part-source-options { display: flex; align-items: center; width: fit-content; margin-top: 10rpx; overflow: hidden; border: 1rpx solid #dce6f3; border-radius: 12rpx; }
.part-source-option { min-width: 88rpx; padding: 8rpx 14rpx; color: #7d899d; background: #ffffff; font-size: 18rpx; line-height: 1.35; text-align: center; }
.part-source-option + .part-source-option { border-left: 1rpx solid #dce6f3; }
.part-source-option.active { color: #ffffff; background: #1677ff; }
.parts-actions { display: flex; align-items: center; flex: none; }
.parts-action { display: flex; align-items: center; justify-content: center; width: 100rpx; height: 64rpx; border-radius: 16rpx; font-size: 24rpx; flex-shrink: 0; }
.scan-part-action { color: #ffffff; background: #1677ff; }
.input-part-action { margin-left: 16rpx; color: #1677ff; background: #edf4ff; }
.part-row { display: flex; align-items: center; margin-top: 12rpx; padding: 12rpx; border-radius: 16rpx; background: #f8fafc; }
.part-index { display: flex; align-items: center; justify-content: center; width: 42rpx; height: 42rpx; margin-right: 12rpx; border-radius: 50%; color: #1677ff; background: #eaf2ff; font-size: 20rpx; }
.part-info { flex: 1; min-width: 0; }
.part-name, .part-code, .part-category { display: block; }
.part-name { color: #2b3853; font-size: 22rpx; font-weight: 600; }
.part-code { margin-top: 4rpx; color: #9aa4b4; font-size: 18rpx; }
.part-category { margin-top: 4rpx; color: #1677ff; font-size: 18rpx; }
.part-fault-entry { display: flex; align-items: center; margin-top: 12rpx; padding: 14rpx 16rpx; border-radius: 14rpx; background: #ffffff; }
.part-fault-entry-label { color: #7d899d; font-size: 20rpx; line-height: 1.35; flex-shrink: 0; }
.part-fault-entry-value { margin: 0 12rpx; color: #2b3853; font-size: 20rpx; line-height: 1.4; flex: 1; min-width: 0; word-break: break-all; }
.part-stepper { display: flex; align-items: center; width: 172rpx; height: 52rpx; overflow: hidden; border: 1rpx solid #dce6f3; border-radius: 12rpx; background: #ffffff; flex: none; }
.stepper-button { display: flex; align-items: center; justify-content: center; width: 52rpx; height: 52rpx; background: #f8fafc; flex: none; }
.stepper-button + .stepper-value { border-left: 1rpx solid #dce6f3; }
.stepper-value { display: flex; align-items: center; justify-content: center; height: 52rpx; color: #2b3853; font-size: 22rpx; font-weight: 600; flex: 1; }
.stepper-value + .stepper-button { border-left: 1rpx solid #dce6f3; }
.part-remove { display: flex; align-items: center; justify-content: center; width: 52rpx; height: 52rpx; margin-left: 10rpx; flex: none; }
.part-input-dialog { width: 580rpx; padding: 32rpx; box-sizing: border-box; background: #ffffff; }
.part-input-title { display: block; margin-bottom: 24rpx; color: #17233d; font-size: 28rpx; font-weight: 700; }
.part-input-actions { display: flex; justify-content: flex-end; margin-top: 28rpx; }
.part-input-cancel, .part-input-confirm { padding: 14rpx 28rpx; border-radius: 12rpx; font-size: 24rpx; flex-shrink: 0; }
.part-input-cancel { color: #56647a; background: #f0f3f8; }
.part-input-confirm { margin-left: 16rpx; color: #ffffff; background: #1677ff; }
.evidence-row { display: flex; flex-wrap: nowrap; align-items: flex-start; overflow-x: auto; margin-top: 18rpx; padding-bottom: 4rpx; }
.evidence-row > * { margin-right: 14rpx; margin-bottom: 14rpx; }
.upload-box, .evidence-image { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 144rpx; height: 144rpx; border-radius: 20rpx; font-size: 20rpx; }
.upload-box { border: 2rpx dashed #ffb47e; color: #ff8a3d; background: #fffaf6; }
.evidence-image { position: relative; overflow: hidden; color: #ffffff; background: #b7c6d8; }
.evidence-image image { width: 100%; height: 100%; }
.upload-label { position: absolute; right: 8rpx; bottom: 8rpx; padding: 4rpx 8rpx; border-radius: 8rpx; background: rgba(0, 0, 0, 0.45); font-size: 18rpx; line-height: 1.2; }
.upload-delete { position: absolute; top: 8rpx; right: 8rpx; display: flex; align-items: center; justify-content: center; width: 34rpx; height: 34rpx; border-radius: 50%; background: rgba(0, 0, 0, 0.55); }
.bottom-actions { position: fixed; right: 0; bottom: 0; left: 0; z-index: 10; display: flex; padding: 22rpx 24rpx calc(22rpx + env(safe-area-inset-bottom)); background: rgba(255, 255, 255, 0.98); box-shadow: 0 -8rpx 24rpx rgba(36, 59, 92, 0.08); }
.draft-button, .complete-button { display: flex; align-items: center; justify-content: center; height: 82rpx; border-radius: 20rpx; font-size: 24rpx; font-weight: 700; flex-shrink: 0; }
.draft-button { width: 30%; color: #56647a; background: #f0f3f8; }
.complete-button { flex: 1; margin-left: 14rpx; color: #ffffff; background: linear-gradient(135deg, #2f6cf6 0%, #35bfa3 100%); }
.theme-dark.repair-page {
background: #151923;
}
.theme-dark .summary-item,
.theme-dark .section-card,
.theme-dark .evidence-panel,
.theme-dark .remark-panel,
.theme-dark .fault-popup,
.theme-dark .part-input-dialog,
.theme-dark .bottom-actions {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .scan-input,
.theme-dark .device-found,
.theme-dark .exception-info,
.theme-dark .exception-empty,
.theme-dark .result-item,
.theme-dark .responsibility-item,
.theme-dark .repair-action-item,
.theme-dark .repair-action-summary,
.theme-dark .part-row,
.theme-dark .part-fault-entry,
.theme-dark .part-source-option,
.theme-dark .upload-box {
border-color: #364152;
background: #1c2431;
}
.theme-dark .section-title,
.theme-dark .device-found-value,
.theme-dark .info-value,
.theme-dark .result-label,
.theme-dark .responsibility-title,
.theme-dark .responsibility-name,
.theme-dark .part-name,
.theme-dark .part-fault-entry-value,
.theme-dark .fault-popup-title,
.theme-dark .part-input-title {
color: #f2f5fa;
}
.theme-dark .section-desc,
.theme-dark .info-label,
.theme-dark .result-desc,
.theme-dark .responsibility-meta,
.theme-dark .repair-actions-tip,
.theme-dark .part-code,
.theme-dark .part-category,
.theme-dark .part-fault-entry-label,
.theme-dark .fault-popup-subtitle {
color: #aeb9ca;
}
.theme-dark .info-row,
.theme-dark .record-item,
.theme-dark .part-source-option + .part-source-option,
.theme-dark .stepper-button + .stepper-value,
.theme-dark .stepper-value + .stepper-button {
border-color: #364152;
}
.theme-dark .part-stepper,
.theme-dark .stepper-button {
border-color: #364152;
background: #1c2431;
}
.theme-dark :deep(.u-input__input),
.theme-dark :deep(textarea) {
color: #f2f5fa !important;
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<template>
<view class="repair-tasks-page" :class="`theme-${themeMode}`">
<view class="page-header">
<view>
<text class="page-title">待维修订单</text>
<text class="page-subtitle">选择订单查看异常详情并进入维修操作</text>
</view>
<view class="task-count">{{ tasks.length }} 条</view>
</view>
<view v-if="loading" class="loading-card">
<u-loading mode="circle" color="#1677ff" />
<text>正在加载待维修订单</text>
</view>
<view v-else-if="tasks.length" class="task-list">
<view v-for="task in tasks" :key="task.id" class="task-card" @tap="openTaskDetail(task)">
<view class="task-card-header">
<view class="task-no-wrap">
<u-icon name="file-text" color="#1677ff" size="28" />
<text class="task-no">{{ task.deviceNo }}</text>
</view>
<text class="task-status">待维修</text>
</view>
<view class="task-main-row"><text>异常类型</text><text>{{ task.typeName || '-' }}</text></view>
<view class="task-main-row"><text>异常原因</text><text>{{ task.reasonText || '-' }}</text></view>
<view class="task-footer">
<text>{{ task.reportedDate || '-' }}</text>
<text>{{ task.reporterText || '-' }}</text>
</view>
</view>
</view>
<u-empty v-else mode="data" text="暂无待维修订单" />
<view v-if="selectedTask" class="detail-mask" @tap="closeTaskDetail">
<view class="detail-sheet" @tap.stop>
<view class="sheet-handle"></view>
<view class="detail-header">
<text>待维修订单详情</text>
<u-icon name="close" color="#7d8798" size="30" @tap="closeTaskDetail" />
</view>
<text class="detail-device-no">{{ selectedTask.deviceNo }}</text>
<view class="detail-list">
<view class="detail-item"><text>异常类型</text><text>{{ selectedTask.typeName || '-' }}</text></view>
<view class="detail-item"><text>异常原因</text><text>{{ selectedTask.reasonText || '-' }}</text></view>
<view class="detail-item"><text>提报人员</text><text>{{ selectedTask.reporterText || '-' }}</text></view>
<view class="detail-item"><text>提报时间</text><text>{{ selectedTask.reportedDate || '-' }}</text></view>
</view>
<view class="repair-button" @tap="startRepair">开始维修</view>
</view>
</view>
</view>
</template>
<script>
import { getRepairTasks } from '@/api/produce/exception.js'
export default {
data() {
return {
loading: false,
tasks: [],
selectedTask: null
}
},
onLoad() {
this.loadTasks()
},
onPullDownRefresh() {
this.loadTasks().finally(() => uni.stopPullDownRefresh())
},
methods: {
async loadTasks() {
this.loading = true
try {
const result = await getRepairTasks({ limit: 100 })
const items = Array.isArray(result) ? result : (Array.isArray(result?.data) ? result.data : [])
this.tasks = items.map((item) => ({
id: String(item.id || ''),
deviceNo: String(item.device_no || ''),
typeName: String(item.type_name || ''),
reasonText: [item.reason_name, item.issue_name].filter(Boolean).join(' · '),
reporterText: item.reporter ? `${item.reporter}${item.user_code ? `(${item.user_code})` : ''}` : String(item.user_code || ''),
reportedDate: String(item.reported_date || '')
})).filter((item) => item.id && item.deviceNo)
} catch (error) {
this.tasks = []
} finally {
this.loading = false
}
},
openTaskDetail(task) {
this.selectedTask = task
},
closeTaskDetail() {
this.selectedTask = null
},
startRepair() {
if (!this.selectedTask?.deviceNo) return
uni.navigateTo({
url: `/pages/repair/index?device_no=${encodeURIComponent(this.selectedTask.deviceNo)}`
})
}
}
}
</script>
<style lang="scss" scoped>
.repair-tasks-page { min-height: 100vh; padding: 24rpx; box-sizing: border-box; background: #f4f6fb; }
.page-header { display: flex; align-items: flex-start; justify-content: space-between; padding: 8rpx 8rpx 26rpx; }
.page-title, .page-subtitle { display: block; }
.page-title { color: #17233d; font-size: 38rpx; font-weight: 800; line-height: 1.35; }
.page-subtitle { margin-top: 8rpx; color: #7d899d; font-size: 20rpx; line-height: 1.4; }
.task-count { padding: 8rpx 14rpx; border-radius: 999rpx; color: #1677ff; background: #eaf2ff; font-size: 20rpx; line-height: 1.35; }
.loading-card { display: flex; flex-direction: column; align-items: center; padding: 80rpx 0; color: #7d899d; font-size: 20rpx; }
.loading-card text { margin-top: 18rpx; }
.task-card { margin-bottom: 16rpx; padding: 22rpx; border-radius: 22rpx; background: #ffffff; box-shadow: 0 8rpx 24rpx rgba(36, 59, 92, 0.05); }
.task-card-header, .task-no-wrap, .task-footer, .detail-header, .detail-item { display: flex; align-items: center; }
.task-card-header, .detail-header, .detail-item { justify-content: space-between; }
.task-no-wrap { min-width: 0; }
.task-no { margin-left: 10rpx; color: #17233d; font-size: 26rpx; font-weight: 700; word-break: break-all; }
.task-status { padding: 6rpx 12rpx; border-radius: 999rpx; color: #e58b12; background: #fff5e8; font-size: 18rpx; line-height: 1.35; flex-shrink: 0; }
.task-main-row { display: flex; justify-content: space-between; margin-top: 18rpx; color: #7d899d; font-size: 20rpx; line-height: 1.45; }
.task-main-row text:last-child { max-width: 68%; color: #2b3853; text-align: right; word-break: break-all; }
.task-footer { justify-content: space-between; margin-top: 20rpx; padding-top: 16rpx; border-top: 1rpx solid #edf0f5; color: #9aa4b4; font-size: 18rpx; }
.detail-mask { position: fixed; z-index: 99; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: flex-end; background: rgba(22, 35, 61, 0.45); }
.detail-sheet { width: 100%; padding: 18rpx 24rpx calc(30rpx + env(safe-area-inset-bottom)); box-sizing: border-box; border-radius: 28rpx 28rpx 0 0; background: #ffffff; }
.sheet-handle { width: 72rpx; height: 8rpx; margin: 0 auto 22rpx; border-radius: 999rpx; background: #dce3ee; }
.detail-header { color: #17233d; font-size: 28rpx; font-weight: 700; }
.detail-device-no { display: block; margin-top: 22rpx; color: #1677ff; font-size: 32rpx; font-weight: 800; word-break: break-all; }
.detail-list { margin-top: 18rpx; padding: 2rpx 18rpx; border-radius: 18rpx; background: #f8fafc; }
.detail-item { padding: 18rpx 0; border-bottom: 1rpx solid #e8edf4; color: #7d899d; font-size: 20rpx; line-height: 1.4; }
.detail-item:last-child { border-bottom: 0; }
.detail-item text:last-child { max-width: 64%; color: #2b3853; text-align: right; word-break: break-all; }
.repair-button { display: flex; align-items: center; justify-content: center; height: 84rpx; margin-top: 24rpx; border-radius: 18rpx; color: #ffffff; background: #1677ff; font-size: 24rpx; font-weight: 700; }
.theme-dark.repair-tasks-page {
background: #151923;
}
.theme-dark .task-card,
.theme-dark .detail-sheet {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .task-no,
.theme-dark .task-main-row text:last-child,
.theme-dark .detail-header,
.theme-dark .detail-item text:last-child {
color: #f2f5fa;
}
.theme-dark .page-subtitle,
.theme-dark .loading-card,
.theme-dark .task-main-row,
.theme-dark .task-footer,
.theme-dark .detail-item {
color: #aeb9ca;
}
.theme-dark .task-footer,
.theme-dark .detail-item {
border-color: #364152;
}
.theme-dark .detail-list {
background: #1c2431;
}
</style>
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
<template>
<view class="about-page" :class="`theme-${themeMode}`">
<view class="hero-card">
<view class="hero-logo">
<u-icon name="info-circle-fill" color="#ffffff" size="46" />
</view>
<view class="hero-content">
<text class="hero-tag">APP 原型</text>
<text class="hero-title">关于我们</text>
<text class="hero-subtitle">扫描、提报、维修与统计的一体化现场作业助手</text>
</view>
</view>
<view class="section-card">
<text class="section-title">产品信息</text>
<view class="info-list">
<view class="info-row">
<text class="info-label">产品名称</text>
<text class="info-value">ScanApp</text>
</view>
<view class="info-row">
<text class="info-label">适用场景</text>
<text class="info-value">扫码录入 / 异常处理 / 维修协同</text>
</view>
<view class="info-row">
<text class="info-label">当前版本</text>
<text class="info-value">v{{ appVersion }}</text>
</view>
</view>
</view>
<view class="section-card">
<text class="section-title">协议与资质</text>
<view class="nav-list">
<view v-for="item in policyItems" :key="item.title" class="nav-row" :class="{ tappable: item.readable }" @tap="item.readable && handlePolicyTap(item)">
<text class="nav-title">{{ item.title }}</text>
<view class="nav-right">
<text v-if="item.value" class="nav-value">{{ item.value }}</text>
<u-icon v-if="item.readable" class="nav-arrow" name="arrow-right" color="#c1c7d0" size="24" />
</view>
</view>
</view>
</view>
<view class="section-card">
<text class="section-title">联系与支持</text>
<view class="contact-stack">
<view v-for="item in contactItems" :key="item.label" class="contact-block">
<text class="contact-label">{{ item.label }}</text>
<view class="email-row">
<text class="email-value">{{ item.email || '未配置' }}</text>
<view v-if="item.email" class="copy-btn" @tap="copyEmail(item.email)">复制</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { getCurrentAppVersion } from '@/api/app-update.js'
import { getConfigByCode } from '@/api/system-config.js'
export default {
data() {
return {
appVersion: getCurrentAppVersion(),
policyItems: [
{ title: '用户服务协议', value: '', content: '', readable: true },
{ title: '隐私政策', value: '', content: '', readable: true },
{ title: '备案号', value: '', content: '', readable: false }
],
contactItems: [
{ label: '违法和不良信息举报邮箱', email: '' },
{ label: '商务合作', email: '' }
],
}
},
onLoad() {
this.loadAboutConfig()
},
onShow() {
uni.setNavigationBarColor({
frontColor: this.themeMode === 'dark' ? '#ffffff' : '#000000',
backgroundColor: this.themeMode === 'dark' ? '#151923' : '#eef6ff'
})
},
methods: {
async loadAboutConfig() {
try {
const config = await getConfigByCode('app_about_setting')
this.policyItems = [
{ title: '用户服务协议', value: config.user_service_agreement ? '查看' : '', content: config.user_service_agreement || '', readable: true },
{ title: '隐私政策', value: config.privacy_policy ? '查看' : '', content: config.privacy_policy || '', readable: true },
{ title: '备案号', value: config.icp_number || '', content: '', readable: false }
]
this.contactItems = [
{ label: '违法和不良信息举报邮箱', email: config.report_email || '' },
{ label: '商务合作', email: config.business_contact || '' }
]
} catch (error) {
console.error('[scanapp] 获取关于我们配置失败', error)
}
},
handlePolicyTap(item) {
if (!item.content) {
uni.showToast({ title: '暂未配置', icon: 'none' })
return
}
uni.setStorageSync('scanapp_policy_detail', {
title: item.title,
content: item.content
})
uni.navigateTo({ url: '/pages/settings/policy' })
},
copyEmail(email) {
uni.setClipboardData({
data: email,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
}
})
}
}
}
</script>
<style lang="scss" scoped>
.about-page {
min-height: 100vh;
padding: 28rpx 24rpx 48rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eef6ff 0%, #f4f6fb 36%, #f4f6fb 100%);
}
.hero-card,
.section-card {
padding: 34rpx;
border-radius: 28rpx;
border: 1rpx solid rgba(255, 255, 255, 0.75);
background: #ffffff;
box-shadow: 0 16rpx 40rpx rgba(23, 35, 61, 0.06);
}
.section-card {
margin-top: 28rpx;
}
.hero-card {
display: flex;
align-items: center;
}
.hero-content {
min-width: 0;
margin-left: 24rpx;
}
.hero-logo {
display: flex;
align-items: center;
justify-content: center;
width: 96rpx;
height: 96rpx;
border-radius: 28rpx;
background: linear-gradient(135deg, #1677ff 0%, #35bfa3 100%);
flex: none;
}
.hero-tag,
.hero-subtitle,
.info-label,
.feature-desc,
.contact-label,
.contact-value,
.nav-title,
.nav-value,
.email-value {
display: block;
}
.hero-tag {
color: #1677ff;
font-size: 20rpx;
font-weight: 700;
}
.hero-title,
.section-title,
.feature-title {
display: block;
color: #17233d;
font-weight: 800;
}
.hero-title {
margin-top: 10rpx;
font-size: 34rpx;
}
.hero-subtitle {
margin-top: 10rpx;
color: #7d8798;
font-size: 20rpx;
line-height: 1.5;
}
.section-title {
font-size: 26rpx;
margin-bottom: 18rpx;
}
.info-row,
.contact-item {
display: flex;
justify-content: space-between;
padding: 18rpx 0;
border-bottom: 1rpx solid #edf1f6;
}
.info-value,
.contact-value {
min-width: 0;
margin-left: 20rpx;
}
.info-row:last-child,
.contact-item:last-child {
border-bottom: 0;
}
.info-label,
.contact-label {
color: #7d8798;
font-size: 22rpx;
}
.info-value,
.contact-value {
color: #17233d;
font-size: 22rpx;
font-weight: 600;
text-align: right;
}
.feature-grid {
display: grid;
grid-template-columns: 1fr;
gap: 14rpx;
}
.feature-item {
padding: 22rpx;
border-radius: 20rpx;
background: #f8fafc;
}
.feature-title {
font-size: 24rpx;
}
.feature-desc {
margin-top: 8rpx;
color: #7d8798;
font-size: 20rpx;
line-height: 1.55;
}
.nav-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #edf1f6;
}
.nav-title {
min-width: 0;
flex: 1;
}
.nav-row:last-child {
border-bottom: 0;
}
.nav-title {
color: #17233d;
font-size: 28rpx;
font-weight: 600;
line-height: 1.45;
}
.nav-right {
display: flex;
align-items: center;
flex: none;
margin-left: 20rpx;
}
.nav-value {
flex-shrink: 1;
min-width: 0;
}
.nav-arrow {
flex: none;
margin-left: 12rpx;
}
.nav-value {
color: #5f6b7a;
font-size: 22rpx;
line-height: 1.45;
text-align: right;
}
.contact-stack {
display: flex;
flex-direction: column;
gap: 22rpx;
}
.contact-block + .contact-block {
padding-top: 6rpx;
}
.contact-label {
margin-bottom: 14rpx;
color: #17233d;
font-size: 26rpx;
font-weight: 600;
line-height: 1.45;
}
.email-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22rpx 24rpx;
border-radius: 20rpx;
background: #f8fafc;
}
.email-value {
flex: 1;
min-width: 0;
margin-right: 18rpx;
}
.copy-btn {
flex: none;
}
.email-value {
flex: 1;
min-width: 0;
color: #8f98a8;
font-size: 22rpx;
line-height: 1.45;
word-break: break-all;
}
.copy-btn {
flex: none;
padding: 0 8rpx;
color: #7d8798;
font-size: 24rpx;
font-weight: 600;
line-height: 1.45;
}
.tappable {
cursor: pointer;
}
.theme-dark.about-page {
background: #151923;
}
.theme-dark .policy-fullscreen {
background: #151923;
}
.theme-dark .policy-header {
border-color: #364152;
}
.theme-dark .policy-back,
.theme-dark .policy-header-title,
.theme-dark .policy-title {
color: #f2f5fa;
}
.theme-dark .policy-content {
color: #c4cede;
}
.theme-dark .hero-card,
.theme-dark .section-card {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .feature-item,
.theme-dark .email-row {
background: #1c2431;
}
.theme-dark .hero-title,
.theme-dark .section-title,
.theme-dark .feature-title,
.theme-dark .info-value,
.theme-dark .contact-value,
.theme-dark .nav-title,
.theme-dark .contact-label {
color: #f2f5fa;
}
.theme-dark .hero-subtitle,
.theme-dark .info-label,
.theme-dark .feature-desc,
.theme-dark .nav-value,
.theme-dark .email-value,
.theme-dark .copy-btn {
color: #aeb9ca;
}
.theme-dark .info-row,
.theme-dark .contact-item,
.theme-dark .nav-row {
border-color: #364152;
}
</style>
@@ -0,0 +1,264 @@
<template>
<view class="feedback-detail-page" :class="`theme-${themeMode}`">
<view v-if="loading" class="loading-state">正在加载反馈详情…</view>
<view v-else-if="feedback" class="detail-card">
<view class="detail-header">
<view>
<text class="detail-type">{{ getTypeLabel(feedback.type) }}</text>
<text class="detail-time">{{ feedback.created_date || '时间未知' }}</text>
</view>
<u-icon name="chat" color="#ff8a3d" size="40" />
</view>
<view class="detail-section">
<text class="detail-label">问题描述</text>
<text class="detail-content">{{ feedback.content || '/' }}</text>
</view>
<view class="detail-section">
<text class="detail-label">联系方式</text>
<text class="detail-content">{{ feedback.contact || '未填写' }}</text>
</view>
<view v-if="imageList.length" class="detail-section">
<text class="detail-label">截图附件</text>
<view class="image-list">
<image v-for="image in imageList" :key="image" :src="image" mode="aspectFill" @tap="previewImage(image)" />
</view>
</view>
<view v-if="feedback.reply_content" class="reply-card">
<view class="reply-header">
<text class="reply-title">管理员回复</text>
<text class="reply-time">{{ feedback.reply_date || '' }}</text>
</view>
<text class="reply-content">{{ feedback.reply_content }}</text>
<text v-if="feedback.reply_name" class="reply-admin">回复人:{{ feedback.reply_name }}</text>
</view>
<view v-else class="pending-reply">该反馈暂未收到回复,请耐心等待。</view>
</view>
<u-empty v-else mode="data" text="反馈记录不存在" icon="data" />
</view>
</template>
<script>
import { getMyFeedbackDetail } from '@/api/feedback.js'
export default {
data() {
return {
loading: true,
feedback: null,
feedbackId: ''
}
},
computed: {
imageList() {
return Array.isArray(this.feedback?.images)
? this.feedback.images.filter((image) => typeof image === 'string' && image)
: []
}
},
onLoad(options) {
this.feedbackId = String(options?.id || '')
this.loadFeedbackDetail()
},
methods: {
getTypeLabel(type) {
return {
bug: '功能异常',
suggestion: '体验建议',
correction: '内容纠错'
}[type] || '问题反馈'
},
async loadFeedbackDetail() {
if (!this.feedbackId) {
this.loading = false
return
}
this.loading = true
try {
this.feedback = await getMyFeedbackDetail(this.feedbackId)
} catch (error) {
console.error('[scanapp] 获取反馈详情失败', error)
this.feedback = null
} finally {
this.loading = false
}
},
previewImage(current) {
uni.previewImage({
current,
urls: this.imageList
})
}
}
}
</script>
<style lang="scss" scoped>
.feedback-detail-page {
min-height: 100vh;
padding: 28rpx 24rpx 48rpx;
box-sizing: border-box;
background: #f4f6fb;
}
.loading-state {
padding: 80rpx 0;
color: #7d8798;
font-size: 22rpx;
text-align: center;
}
.detail-card {
padding: 34rpx;
border-radius: 28rpx;
background: #ffffff;
box-shadow: 0 16rpx 40rpx rgba(23, 35, 61, 0.06);
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 28rpx;
border-bottom: 1rpx solid #edf0f5;
}
.detail-type,
.detail-time,
.detail-label,
.detail-content {
display: block;
}
.detail-type {
color: #ff8a3d;
font-size: 28rpx;
font-weight: 800;
}
.detail-time {
margin-top: 10rpx;
color: #7d8798;
font-size: 20rpx;
}
.detail-section {
padding-top: 28rpx;
}
.detail-label {
color: #7d8798;
font-size: 20rpx;
}
.detail-content {
margin-top: 12rpx;
color: #17233d;
font-size: 24rpx;
line-height: 1.6;
word-break: break-all;
}
.image-list {
display: flex;
flex-wrap: wrap;
margin-top: 16rpx;
}
.image-list image {
width: 180rpx;
height: 180rpx;
margin-right: 14rpx;
margin-bottom: 14rpx;
border-radius: 18rpx;
}
.reply-card {
margin-top: 32rpx;
padding: 24rpx;
border-radius: 18rpx;
background: #fff8f2;
}
.reply-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.reply-title {
color: #d86c2d;
font-size: 24rpx;
font-weight: 700;
}
.reply-time,
.reply-admin {
color: #8b735f;
font-size: 20rpx;
}
.reply-content {
display: block;
margin-top: 14rpx;
color: #3e3128;
font-size: 24rpx;
line-height: 1.6;
word-break: break-all;
}
.reply-admin {
display: block;
margin-top: 14rpx;
}
.pending-reply {
margin-top: 32rpx;
padding: 24rpx;
border-radius: 18rpx;
color: #7d8798;
background: #f3f5f8;
font-size: 22rpx;
}
.theme-dark.feedback-detail-page {
background: #151923;
}
.theme-dark .detail-card {
background: #222936;
box-shadow: none;
}
.theme-dark .detail-header {
border-color: #364152;
}
.theme-dark .detail-content {
color: #f2f5fa;
}
.theme-dark .detail-label,
.theme-dark .detail-time,
.theme-dark .loading-state {
color: #aeb9ca;
}
.theme-dark .reply-card {
background: #34291f;
}
.theme-dark .reply-content {
color: #f2e6da;
}
.theme-dark .reply-time,
.theme-dark .reply-admin,
.theme-dark .pending-reply {
color: #c7b8a8;
}
.theme-dark .pending-reply {
background: #1c2431;
}
</style>
+647
View File
@@ -0,0 +1,647 @@
<template>
<view class="feedback-page" :class="`theme-${themeMode}`">
<view class="hero-card">
<view>
<text class="hero-tag">意见收集</text>
<text class="hero-title">问题反馈</text>
<text class="hero-subtitle">提交使用建议、问题描述或异常截图,我们会尽快跟进</text>
</view>
<view class="hero-icon">
<u-icon name="email" color="#ffffff" size="44" />
</view>
</view>
<view class="section-card">
<view class="section-header">
<text class="section-title">反馈内容</text>
</view>
<view class="field-card">
<text class="field-label">反馈类型</text>
<view class="chip-row">
<view
v-for="item in typeOptions"
:key="item.value"
class="chip"
:class="{ 'is-active': form.type === item.value }"
@tap="form.type = item.value"
>
{{ item.label }}
</view>
</view>
</view>
<view class="field-card">
<text class="field-label">问题描述</text>
<view class="textarea-wrap">
<textarea v-model="form.content" placeholder="请描述遇到的问题、操作步骤或建议" placeholder-class="textarea-placeholder" maxlength="500" />
</view>
</view>
<view class="field-card">
<text class="field-label">联系方式(选填)</text>
<u-input v-model="form.contact" placeholder="手机号 / 微信 / 邮箱" :border="false" />
</view>
<view class="field-card">
<text class="field-label">截图附件</text>
<view class="upload-row">
<view v-if="form.images.length < 3" class="upload-box" @tap="pickImage" :class="{ 'is-disabled': uploading || form.images.length >= 3 }">
<u-icon name="plus" color="#ff9d42" size="34" />
<text>{{ uploading ? '上传中' : '添加截图' }}</text>
</view>
<view v-for="(img, index) in form.images" :key="img" class="upload-preview">
<image :src="img" mode="aspectFill" />
<text class="upload-label">图片{{ index + 1 }}</text>
<view class="upload-delete" @tap.stop="removeImage(index)">
<u-icon name="close" color="#ffffff" size="20" />
</view>
</view>
</view>
</view>
</view>
<view class="tips-card">
<text class="tips-title">提交说明</text>
<text class="tips-text">1. 请优先说明页面名称和操作路径;2. 如果是扫码异常,请附上单号或二维码截图;3. 反馈会保存在本机历史记录中。</text>
</view>
<view class="bottom-actions">
<view class="secondary-button" @tap="resetForm">清空</view>
<view class="primary-button" :class="{ 'is-disabled': submitting }" @tap="submitFeedback">{{ submitting ? '提交中' : '提交反馈' }}</view>
</view>
<view class="history-card">
<view class="history-header">
<view>
<text class="section-title">我的反馈</text>
<text class="history-subtitle">仅展示当前账号已提交的问题</text>
</view>
<view class="refresh-button" :class="{ 'is-disabled': historyLoading }" @tap="loadFeedbackList">
<u-icon name="reload" color="#ff8a3d" size="28" />
<text>刷新</text>
</view>
</view>
<view v-if="historyLoading" class="history-loading">正在加载反馈记录…</view>
<view v-else-if="feedbackList.length" class="feedback-list">
<view v-for="item in feedbackList" :key="item.id" class="feedback-record" @tap="goFeedbackDetail(item.id)">
<view class="record-main">
<view class="record-title-row">
<text class="record-type">{{ getTypeLabel(item.type) }}</text>
<text class="record-time">{{ item.created_date || '时间未知' }}</text>
</view>
<text class="record-content">{{ item.content }}</text>
</view>
<u-icon name="arrow-right" color="#9aa5b5" size="28" />
</view>
</view>
<u-empty v-else mode="list" text="暂无已提交的反馈" icon="list" />
</view>
</view>
</template>
<script>
import { getMyFeedbackList, submitFeedback as createFeedback } from '@/api/feedback.js'
import { uploadImage } from '@/api/upload.js'
export default {
data() {
return {
uploading: false,
submitting: false,
historyLoading: false,
feedbackList: [],
form: {
type: 'bug',
content: '',
contact: '',
images: []
},
typeOptions: [
{ label: '功能异常', value: 'bug' },
{ label: '体验建议', value: 'suggestion' },
{ label: '内容纠错', value: 'correction' }
]
}
},
onShow() {
this.loadFeedbackList()
},
methods: {
getTypeLabel(type) {
return this.typeOptions.find((item) => item.value === type)?.label || '问题反馈'
},
async loadFeedbackList() {
if (this.historyLoading) {
return
}
this.historyLoading = true
try {
const result = await getMyFeedbackList({ page: 1, limit: 10 })
this.feedbackList = Array.isArray(result?.items) ? result.items : []
} catch (error) {
console.error('[scanapp] 获取反馈记录失败', error)
this.feedbackList = []
} finally {
this.historyLoading = false
}
},
goFeedbackDetail(id) {
if (!id) {
return
}
uni.navigateTo({
url: `/pages/settings/feedback-detail?id=${encodeURIComponent(id)}`
})
},
pickImage() {
if (this.uploading || this.submitting || this.form.images.length >= 3) {
return
}
uni.chooseImage({
count: 3 - this.form.images.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: ({ tempFilePaths = [] }) => {
this.form.images = [...this.form.images, ...tempFilePaths].slice(0, 3)
}
})
},
removeImage(index) {
if (this.uploading || this.submitting) return
this.form.images.splice(index, 1)
},
resetForm() {
if (this.submitting || this.uploading) {
return
}
this.form.content = ''
this.form.contact = ''
this.form.images = []
this.form.type = 'bug'
},
async submitFeedback() {
if (this.submitting || this.uploading) {
return
}
if (!this.form.content.trim()) {
uni.showToast({ title: '请输入问题描述', icon: 'none' })
return
}
this.submitting = true
try {
this.uploading = true
const uploadedImages = []
for (const filePath of this.form.images) {
uploadedImages.push(await uploadImage(filePath, 'feedback'))
}
await createFeedback({
type: this.form.type,
content: this.form.content.trim(),
contact: this.form.contact.trim(),
images: uploadedImages,
source: 'app'
})
uni.showToast({ title: '反馈已提交', icon: 'none' })
this.form.content = ''
this.form.contact = ''
this.form.images = []
this.form.type = 'bug'
await this.loadFeedbackList()
} catch (error) {
console.error('[scanapp] 提交反馈失败', error)
} finally {
this.uploading = false
this.submitting = false
}
}
}
}
</script>
<style lang="scss" scoped>
.feedback-page {
min-height: 100vh;
padding: 28rpx 24rpx 48rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eef6ff 0%, #f4f6fb 36%, #f4f6fb 100%);
}
.hero-card,
.section-card,
.tips-card {
padding: 34rpx;
border-radius: 28rpx;
border: 1rpx solid rgba(255, 255, 255, 0.75);
background: #ffffff;
box-shadow: 0 16rpx 40rpx rgba(23, 35, 61, 0.06);
}
.section-card,
.tips-card {
margin-top: 28rpx;
}
.hero-card {
display: flex;
align-items: center;
justify-content: space-between;
}
.hero-tag,
.section-subtitle,
.tips-text {
display: block;
}
.hero-tag {
color: #ff8a3d;
font-size: 20rpx;
font-weight: 700;
}
.hero-title,
.section-title {
display: block;
margin-top: 10rpx;
color: #17233d;
font-size: 28rpx;
font-weight: 800;
}
.hero-subtitle,
.section-subtitle {
margin-top: 10rpx;
color: #7d8798;
font-size: 20rpx;
line-height: 1.5;
}
.hero-icon {
display: flex;
align-items: center;
justify-content: center;
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #ff9d42 0%, #ff7048 100%);
}
.section-header {
margin-bottom: 20rpx;
}
.field-card + .field-card {
margin-top: 22rpx;
}
.field-card {
padding: 24rpx;
border-radius: 22rpx;
background: #f8fafc;
}
.field-label {
display: block;
margin-bottom: 16rpx;
color: #17233d;
font-size: 24rpx;
font-weight: 700;
}
.chip-row {
display: flex;
flex-wrap: wrap;
}
.chip-row .chip {
display: flex;
flex: 0 0 auto;
margin-right: 24rpx;
margin-bottom: 16rpx;
}
.chip {
padding: 16rpx 22rpx;
border-radius: 18rpx;
background: #e9edf4;
color: #5f6b7a;
font-size: 22rpx;
font-weight: 600;
}
.chip.is-active {
background: linear-gradient(135deg, #ff9d42 0%, #ff7d47 100%);
color: #ffffff;
}
.textarea-wrap {
padding: 18rpx;
border-radius: 20rpx;
background: #ffffff;
}
textarea {
width: 100%;
min-height: 220rpx;
color: #17233d;
font-size: 24rpx;
line-height: 1.6;
}
.upload-row {
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
overflow-x: auto;
padding-bottom: 4rpx;
}
.upload-row > * {
margin-right: 14rpx;
margin-bottom: 14rpx;
}
.upload-box,
.upload-preview {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 144rpx;
height: 144rpx;
border-radius: 20rpx;
overflow: hidden;
}
.upload-box {
flex-direction: column;
border: 2rpx dashed #ffb47e;
background: #fff8f2;
color: #ff8a3d;
font-size: 20rpx;
}
.upload-box text {
margin-top: 8rpx;
}
.upload-preview image {
width: 100%;
height: 100%;
}
.upload-label {
position: absolute;
right: 8rpx;
bottom: 8rpx;
padding: 4rpx 8rpx;
border-radius: 8rpx;
color: #ffffff;
background: rgba(0, 0, 0, 0.45);
font-size: 18rpx;
line-height: 1.2;
}
.upload-delete {
position: absolute;
top: 8rpx;
right: 8rpx;
display: flex;
align-items: center;
justify-content: center;
width: 34rpx;
height: 34rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
}
.tips-title {
display: block;
color: #17233d;
font-size: 26rpx;
font-weight: 800;
}
.tips-text {
margin-top: 12rpx;
color: #7d8798;
font-size: 22rpx;
line-height: 1.6;
}
.bottom-actions {
display: flex;
margin-top: 28rpx;
}
.bottom-actions .primary-button {
margin-left: 14rpx;
}
.bottom-actions .secondary-button {
flex-shrink: 0;
}
.secondary-button,
.primary-button {
display: flex;
align-items: center;
justify-content: center;
height: 84rpx;
border-radius: 20rpx;
font-size: 24rpx;
font-weight: 700;
}
.secondary-button {
width: 32%;
background: #f0f3f8;
color: #56647a;
}
.primary-button {
flex: 1;
background: linear-gradient(135deg, #ff9d42 0%, #ff7048 100%);
color: #ffffff;
}
.upload-box.is-disabled,
.primary-button.is-disabled,
.refresh-button.is-disabled {
opacity: 0.6;
}
.history-card {
margin-top: 28rpx;
padding: 34rpx;
border-radius: 28rpx;
background: #ffffff;
box-shadow: 0 16rpx 40rpx rgba(23, 35, 61, 0.06);
}
.history-header,
.feedback-record,
.record-title-row,
.refresh-button {
display: flex;
align-items: center;
}
.history-header {
justify-content: space-between;
}
.history-subtitle {
display: block;
margin-top: 10rpx;
color: #7d8798;
font-size: 20rpx;
}
.refresh-button {
padding: 12rpx 16rpx;
border-radius: 16rpx;
background: #fff8f2;
color: #ff8a3d;
font-size: 20rpx;
}
.refresh-button text {
margin-left: 8rpx;
}
.history-loading {
padding: 40rpx 0;
color: #7d8798;
font-size: 22rpx;
text-align: center;
}
.feedback-list {
margin-top: 20rpx;
}
.feedback-record {
justify-content: space-between;
padding: 22rpx 0;
border-bottom: 1rpx solid #edf0f5;
}
.feedback-record:last-child {
border-bottom: 0;
}
.record-main {
min-width: 0;
flex: 1;
}
.record-title-row {
justify-content: space-between;
}
.record-type {
color: #17233d;
font-size: 24rpx;
font-weight: 700;
}
.record-time {
margin-left: 20rpx;
color: #7d8798;
font-size: 18rpx;
flex-shrink: 0;
}
.record-content {
display: block;
margin-top: 10rpx;
margin-right: 20rpx;
color: #5f6b7a;
font-size: 22rpx;
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.theme-dark.feedback-page {
background: #151923;
}
.theme-dark .hero-card,
.theme-dark .section-card,
.theme-dark .tips-card,
.theme-dark .history-card {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .field-card,
.theme-dark .textarea-wrap,
.theme-dark .upload-box {
border-color: #364152;
background: #1c2431;
}
.theme-dark .hero-title,
.theme-dark .section-title,
.theme-dark .field-label,
.theme-dark .tips-title,
.theme-dark textarea {
color: #f2f5fa;
}
.theme-dark .hero-subtitle,
.theme-dark .section-subtitle,
.theme-dark .tips-text,
.theme-dark .history-subtitle,
.theme-dark .history-loading,
.theme-dark .record-time,
.theme-dark .record-content,
.theme-dark .chip,
.theme-dark .upload-box {
color: #aeb9ca;
}
.theme-dark .record-type {
color: #f2f5fa;
}
.theme-dark .feedback-record {
border-color: #364152;
}
.theme-dark .refresh-button {
background: #34291f;
}
.theme-dark .chip {
background: #1a2230;
}
.theme-dark .chip.is-active {
background: linear-gradient(135deg, #a85d2f 0%, #8b4d35 100%);
color: #ffffff;
}
.theme-dark textarea::placeholder,
.theme-dark :deep(.u-input__input)::placeholder {
color: #8996aa;
}
.theme-dark .secondary-button {
background: #293344;
color: #d7deea;
}
.theme-dark .primary-button {
background: linear-gradient(135deg, #ff9d42 0%, #ff7048 100%);
}
</style>
+708
View File
@@ -0,0 +1,708 @@
<template>
<view class="settings-page" :class="`theme-${themeMode}`">
<view class="page-accent"></view>
<view class="settings-card">
<view class="card-header">
<text class="card-title">基础配置</text>
<text class="card-subtitle">扫码与反馈</text>
</view>
<view class="setting-row tappable" @tap="selectScanInterval">
<view class="setting-main">
<text class="setting-label">连续扫码时间间隔</text>
</view>
<view class="setting-right interval-right">
<view class="value-line">
<text class="setting-value">{{ settings.scanInterval }}s</text>
<u-icon name="arrow-right" color="#d0d6e2" size="24" />
</view>
</view>
</view>
<view class="setting-row tappable" @tap="selectScanRegionPreset">
<view class="setting-main">
<text class="setting-label">有效识别区域</text>
<text class="setting-desc">{{ scanRegionSummary }}</text>
</view>
<view class="setting-right">
<text class="setting-value">{{ scanRegionPresetLabel }}</text>
<u-icon name="arrow-right" color="#d0d6e2" size="24" />
</view>
</view>
<view v-if="settings.scanRegionPreset === 'barcode'" class="setting-row tappable" @tap="selectScanRegionPosition">
<view class="setting-main">
<text class="setting-label">横向条码框位置</text>
</view>
<view class="setting-right">
<text class="setting-value">{{ scanRegionPositionLabel }}</text>
<u-icon name="arrow-right" color="#d0d6e2" size="24" />
</view>
</view>
<view v-if="settings.scanRegionPreset === 'barcode'" class="setting-row tappable" @tap="selectScanRegionHeight">
<view class="setting-main">
<text class="setting-label">扫描框高度</text>
</view>
<view class="setting-right">
<text class="setting-value">{{ scanRegionHeightLabel }}</text>
<u-icon name="arrow-right" color="#d0d6e2" size="24" />
</view>
</view>
<view v-if="settings.scanRegionPreset === 'barcode' && settings.scanRegionPosition === 'custom'" class="setting-row tappable" @tap="adjustCustomScanRegion">
<view class="setting-main">
<text class="setting-label">自定义条码框位置</text>
</view>
<view class="setting-right">
<text class="setting-value">{{ regionPercent(settings.scanRegion.centerY) }}%</text>
<u-icon name="arrow-right" color="#d0d6e2" size="24" />
</view>
</view>
<view class="setting-row">
<view class="setting-main">
<text class="setting-label">成功提示音</text>
</view>
<u-switch v-model="settings.successSound" active-color="#2f6cf6" />
</view>
<view class="setting-row">
<view class="setting-main">
<text class="setting-label">失败提示音</text>
</view>
<u-switch v-model="settings.failSound" active-color="#ef4f4f" />
</view>
<view class="setting-row">
<view class="setting-main">
<text class="setting-label">震动反馈</text>
</view>
<u-switch v-model="settings.vibration" active-color="#35bfa3" />
</view>
<view class="setting-row">
<view class="setting-main">
<text class="setting-label">PDA 模式</text>
</view>
<u-switch v-model="settings.pdaMode" active-color="#1677ff" />
</view>
</view>
<view class="settings-card">
<view class="card-header">
<text class="card-title">识别处理</text>
<text class="card-subtitle">扫码识别与界面偏好</text>
</view>
<view class="setting-row tappable" @tap="selectTheme">
<view class="setting-main">
<text class="setting-label">主题模式</text>
</view>
<view class="setting-right">
<text class="setting-value">{{ settings.theme === 'light' ? '浅色' : '深色' }}</text>
<u-icon name="arrow-right" color="#d0d6e2" size="24" />
</view>
</view>
<view class="setting-row tappable" @tap="selectLanguage">
<view class="setting-main">
<text class="setting-label">语言</text>
</view>
<view class="setting-right">
<text class="setting-value">{{ settings.language === 'zh' ? '中文' : 'EN' }}</text>
<u-icon name="arrow-right" color="#d0d6e2" size="24" />
</view>
</view>
<view class="setting-row">
<view class="setting-main">
<text class="setting-label">自动保存草稿</text>
</view>
<u-switch v-model="settings.autoSave" active-color="#2f6cf6" />
</view>
<view class="setting-row tappable" @tap="selectUploadMode">
<view class="setting-main">
<text class="setting-label">图片上传方式</text>
</view>
<view class="setting-right">
<text class="setting-value">{{ settings.uploadMode === 'wifi' ? '仅 Wi-Fi' : '任意网络' }}</text>
<u-icon name="arrow-right" color="#d0d6e2" size="24" />
</view>
</view>
</view>
<view class="settings-card">
<view class="card-header">
<text class="card-title">软件维护</text>
<text class="card-subtitle">本地与维护</text>
</view>
<view class="setting-row tappable" @tap="selectCacheDays">
<view class="setting-main">
<text class="setting-label">历史缓存保留</text>
</view>
<view class="setting-right">
<text class="setting-value">{{ settings.cacheDays }} 天</text>
<u-icon name="arrow-right" color="#d0d6e2" size="24" />
</view>
</view>
<view class="setting-row tappable" @tap="checkForUpdate">
<view class="setting-main">
<text class="setting-label">检查更新</text>
</view>
<view class="setting-right">
<text class="setting-value">v{{ currentVersion }}</text>
<u-icon name="arrow-right" color="#c1c7d0" size="24" />
</view>
</view>
<view class="setting-row tappable" @tap="clearBusinessCache">
<view class="setting-main">
<text class="setting-label">清理缓存</text>
</view>
<u-icon name="arrow-right" color="#c1c7d0" size="24" />
</view>
<view class="setting-row tappable" @tap="resetSettings">
<view class="setting-main">
<text class="setting-label">恢复默认设置</text>
</view>
<u-icon name="arrow-right" color="#c1c7d0" size="24" />
</view>
</view>
<view class="settings-card">
<view class="card-header">
<text class="card-title">帮助与支持</text>
<text class="card-subtitle">问题反馈与应用信息</text>
</view>
<view class="setting-row tappable" @tap="goFeedback">
<view class="setting-main">
<text class="setting-label">问题反馈</text>
</view>
<u-icon name="arrow-right" color="#d0d6e2" size="24" />
</view>
<view class="setting-row tappable" @tap="goAbout">
<view class="setting-main">
<text class="setting-label">关于我们</text>
</view>
<u-icon name="arrow-right" color="#d0d6e2" size="24" />
</view>
</view>
<view class="footer-tip">设置会自动保存在本机,切换账号后设置相互独立</view>
</view>
</template>
<script>
import {
compareVersions,
getCurrentAppVersion,
getLatestRelease,
getReleaseUpdateInfo
} from '@/api/app-update.js'
import { applyAppTheme } from '@/utils/theme.js'
import { getScanappSettings, getScanappSettingsStorageKey } from '@/utils/scan-settings.js'
export default {
data() {
const defaultSettings = {
scanInterval: 1,
successSound: true,
failSound: true,
vibration: true,
pdaMode: false,
theme: 'light',
language: 'zh',
autoSave: true,
cacheDays: 30,
uploadMode: 'wifi',
scanRegionPreset: 'fullscreen',
scanRegionPosition: 'middle',
scanRegionHeight: 'medium',
scanRegion: { fullScreen: true }
}
return {
settingsStorageKey: getScanappSettingsStorageKey(),
isLoadingSettings: true,
intervalOptions: [0.3, 0.5, 1, 1.5, 2, 3, 5],
cacheDayOptions: [7, 15, 30, 60, 90],
scanRegionPresets: [
{ key: 'fullscreen', label: '全屏识别', description: '整画面均可识别,不显示扫描框' },
{ key: 'qrcode', label: '二维码正方形框', description: '适合二维码与方形标签' },
{ key: 'barcode', label: '横向条码框', description: '适合商品条码,可调整位置和高度' }
],
scanRegionPositions: [
{ key: 'top', label: '上方', centerY: 0.28 },
{ key: 'middle', label: '居中', centerY: 0.5 },
{ key: 'bottom', label: '下方', centerY: 0.72 },
{ key: 'custom', label: '自定义' }
],
scanRegionHeights: [
{ key: 'medium', label: '中(24%)', height: 0.24 },
{ key: 'large', label: '大(32%)', height: 0.32 },
{ key: 'xlarge', label: '特大(40%)', height: 0.4 }
],
defaultSettings,
settings: { ...defaultSettings, scanRegion: { ...defaultSettings.scanRegion } },
checkingUpdate: false,
currentVersion: getCurrentAppVersion()
}
},
onLoad() {
this.settingsStorageKey = getScanappSettingsStorageKey()
this.loadSettings()
this.isLoadingSettings = false
},
watch: {
settings: {
deep: true,
handler() {
if (this.isLoadingSettings) return
this.saveSettings()
}
}
},
methods: {
loadSettings() {
const savedSettings = getScanappSettings()
if (savedSettings && typeof savedSettings === 'object') {
const preset = savedSettings.scanRegionPreset || (savedSettings.scanRegion?.fullScreen ? 'fullscreen' : 'barcode')
const savedRegion = savedSettings.scanRegion || {}
let scanRegion = { ...this.defaultSettings.scanRegion }
if (preset === 'fullscreen') {
scanRegion = { fullScreen: true }
} else if (preset === 'qrcode') {
scanRegion = { width: 0.74, height: 0.74, centerX: 0.5, centerY: 0.5 }
} else {
scanRegion = {
width: Number(savedRegion.width) || 0.96,
height: Number(savedRegion.height) || 0.24,
centerX: Number.isFinite(Number(savedRegion.centerX)) ? Number(savedRegion.centerX) : 0.5,
centerY: Number.isFinite(Number(savedRegion.centerY)) ? Number(savedRegion.centerY) : 0.5
}
}
this.settings = {
...this.defaultSettings,
...savedSettings,
scanRegionPreset: preset,
scanRegion
}
}
applyAppTheme(this.settings.theme)
},
saveSettings() {
uni.setStorageSync(this.settingsStorageKey, { ...this.settings })
},
checkForUpdate() {
if (this.checkingUpdate) return
this.checkingUpdate = true
uni.showLoading({ title: '正在检查更新', mask: true })
getLatestRelease()
.then((release) => {
const currentVersion = getCurrentAppVersion()
const updateInfo = getReleaseUpdateInfo(release)
if (compareVersions(updateInfo.latestVersion, currentVersion) <= 0) {
uni.showModal({
title: '检查更新',
content: `当前版本 v${currentVersion},已是最新版本`,
showCancel: false,
confirmText: '知道了'
})
return
}
const content = `发现新版本 v${updateInfo.latestVersion}\n\n${updateInfo.updateNote}${updateInfo.apkUrl ? '\n\n是否立即下载并安装?' : '\n\n更新包暂未发布,请稍后再试。'}`
uni.showModal({
title: '发现新版本',
content,
confirmText: updateInfo.apkUrl ? '立即更新' : '知道了',
showCancel: Boolean(updateInfo.apkUrl),
cancelText: '暂不更新',
success: (modalResult) => {
if (modalResult.confirm && updateInfo.apkUrl) {
this.downloadAndInstallApk(updateInfo.apkUrl)
}
}
})
})
.catch((error) => {
uni.showModal({
title: '检查更新失败',
content: error.message || '无法获取最新版本信息,请稍后重试',
showCancel: false,
confirmText: '知道了'
})
})
.finally(() => {
this.checkingUpdate = false
uni.hideLoading()
})
},
downloadAndInstallApk(downloadUrl) {
// #ifdef APP-PLUS
if (!downloadUrl) return
uni.showLoading({ title: '正在下载更新包', mask: true })
uni.downloadFile({
url: downloadUrl,
success: (result) => {
if (result.statusCode !== 200) {
uni.showToast({ title: '更新包下载失败', icon: 'none' })
return
}
plus.runtime.install(result.tempFilePath, {}, () => {}, (error) => {
uni.showModal({ title: '安装失败', content: error.message || '请允许安装未知来源应用后重试', showCancel: false })
})
},
fail: () => uni.showToast({ title: '更新包下载失败,请检查网络', icon: 'none' }),
complete: () => uni.hideLoading()
})
// #endif
// #ifndef APP-PLUS
this.openReleasePage(downloadUrl)
// #endif
},
openReleasePage(url) {
const releaseUrl = url || 'https://gitee.com/li-hua-88/scanapp/releases/latest'
// #ifdef APP-PLUS
plus.runtime.openURL(releaseUrl)
// #endif
// #ifndef APP-PLUS
window.location.href = releaseUrl
// #endif
},
clearBusinessCache() {
uni.showModal({
title: '清理缓存',
content: '将清理扫码业务缓存,保留登录状态和当前设置,是否继续?',
confirmText: '清理',
confirmColor: '#1677ff',
success: (res) => {
if (!res.confirm) return
const protectedKeys = new Set(['scanapp_token', 'scanapp_refresh_token', 'scanapp_user'])
const storageInfo = uni.getStorageInfoSync()
;(storageInfo.keys || []).forEach((key) => {
const isSettingsKey = key === 'scanapp_settings' || key.indexOf('scanapp_settings_') === 0
if (key.indexOf('scanapp_') === 0 && !protectedKeys.has(key) && !isSettingsKey) {
uni.removeStorageSync(key)
}
})
uni.showToast({ title: '缓存已清理', icon: 'success' })
}
})
},
resetSettings() {
uni.showModal({
title: '恢复默认设置',
content: '所有设置项将恢复为默认值,是否继续?',
confirmText: '恢复',
confirmColor: '#1677ff',
success: (res) => {
if (!res.confirm) return
this.settings = { ...this.defaultSettings, scanRegion: { ...this.defaultSettings.scanRegion } }
this.saveSettings()
applyAppTheme(this.settings.theme)
uni.showToast({ title: '已恢复默认设置', icon: 'success' })
}
})
},
selectScanInterval() {
this.showSettingOptions('连续扫码时间间隔', this.intervalOptions.map((item) => `${item}S`), (index) => {
this.settings.scanInterval = this.intervalOptions[index]
})
},
selectScanRegionPreset() {
this.showSettingOptions('有效识别区域', this.scanRegionPresets.map((item) => item.label), (index) => {
const preset = this.scanRegionPresets[index]
if (!preset) return
this.settings.scanRegionPreset = preset.key
if (preset.key === 'fullscreen') {
this.settings.scanRegion = { fullScreen: true }
return
}
if (preset.key === 'qrcode') {
this.settings.scanRegion = { width: 0.74, height: 0.74, centerX: 0.5, centerY: 0.5 }
return
}
const position = this.scanRegionPositions.find((item) => item.key === this.settings.scanRegionPosition) || this.scanRegionPositions[1]
const height = this.scanRegionHeights.find((item) => item.key === this.settings.scanRegionHeight) || this.scanRegionHeights[0]
this.settings.scanRegion = { width: 0.96, height: height.height, centerX: 0.5, centerY: position.centerY || 0.5 }
})
},
selectScanRegionPosition() {
this.showSettingOptions('横向条码框位置', this.scanRegionPositions.map((item) => item.label), (index) => {
const position = this.scanRegionPositions[index]
if (!position) return
this.settings.scanRegionPosition = position.key
if (position.key !== 'custom') this.settings.scanRegion.centerY = position.centerY
})
},
selectScanRegionHeight() {
this.showSettingOptions('扫描框高度', this.scanRegionHeights.map((item) => item.label), (index) => {
const height = this.scanRegionHeights[index]
if (!height) return
this.settings.scanRegionHeight = height.key
this.settings.scanRegion.height = height.height
const minCenterY = height.height / 2
this.settings.scanRegion.centerY = Math.max(minCenterY, Math.min(1 - minCenterY, Number(this.settings.scanRegion.centerY || 0.5)))
})
},
adjustCustomScanRegion() {
this.showSettingOptions('调整自定义位置', ['上移 5%', '下移 5%'], (index) => {
const delta = index === 0 ? -0.05 : 0.05
const height = Number(this.settings.scanRegion.height || 0.24)
const minCenterY = height / 2
const maxCenterY = 1 - minCenterY
this.settings.scanRegion.centerY = Math.max(minCenterY, Math.min(maxCenterY, Number((Number(this.settings.scanRegion.centerY || 0.5) + delta).toFixed(2))))
})
},
regionPercent(value) {
return Math.round(Number(value || 0) * 100)
},
selectTheme() {
this.showSettingOptions('主题模式', ['浅色模式', '深色模式'], (index) => {
this.settings.theme = index === 0 ? 'light' : 'dark'
applyAppTheme(this.settings.theme)
})
},
selectLanguage() {
this.showSettingOptions('语言', ['简体中文', 'English'], (index) => {
this.settings.language = index === 0 ? 'zh' : 'en'
})
},
selectUploadMode() {
this.showSettingOptions('图片上传方式', ['仅 Wi-Fi', '任意网络'], (index) => {
this.settings.uploadMode = index === 0 ? 'wifi' : 'all'
})
},
selectCacheDays() {
this.showSettingOptions('历史缓存保留', this.cacheDayOptions.map((item) => `${item} 天`), (index) => {
this.settings.cacheDays = this.cacheDayOptions[index]
})
},
showSettingOptions(title, itemList, onSelect) {
uni.showActionSheet({
title,
itemList,
success: (res) => onSelect(res.tapIndex)
})
},
goFeedback() {
uni.navigateTo({
url: '/pages/settings/feedback'
})
},
goAbout() {
uni.navigateTo({
url: '/pages/settings/about'
})
},
toggleAutoSave() {
this.settings.autoSave = !this.settings.autoSave
},
showMockToast(title) {
uni.showToast({ title, icon: 'none' })
}
},
computed: {
scanRegionPresetLabel() {
return this.scanRegionPresets.find((item) => item.key === this.settings.scanRegionPreset)?.label || '横向条码框'
},
scanRegionPositionLabel() {
return this.scanRegionPositions.find((item) => item.key === this.settings.scanRegionPosition)?.label || '居中'
},
scanRegionHeightLabel() {
return this.scanRegionHeights.find((item) => item.key === this.settings.scanRegionHeight)?.label || '中(24%)'
},
scanRegionSummary() {
if (this.settings.scanRegionPreset === 'fullscreen') return '整画面识别,不显示扫描框'
const region = this.settings.scanRegion || {}
return `宽 ${this.regionPercent(region.width)}% · 高 ${this.regionPercent(region.height)}% · 中心 ${this.regionPercent(region.centerX)}%, ${this.regionPercent(region.centerY)}%`
}
}
}
</script>
<style lang="scss" scoped>
.settings-page {
min-height: 100vh;
padding: 0 22rpx 40rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eaf2ff 0%, #f6f8fd 18%, #f4f7fb 100%);
}
.page-accent {
height: 10rpx;
margin: 0 -22rpx 18rpx;
background: linear-gradient(90deg, #2f6cf6 0%, #35bfa3 100%);
}
.settings-card {
margin-top: 18rpx;
padding: 30rpx 26rpx;
border-radius: 30rpx;
background: #ffffff;
box-shadow: 0 14rpx 32rpx rgba(24, 37, 68, 0.06);
}
.card-header {
margin-bottom: 14rpx;
}
.card-title {
display: block;
color: #8a94a6;
font-size: 24rpx;
font-weight: 700;
line-height: 1.35;
}
.card-subtitle {
display: block;
margin-top: 6rpx;
color: #c1c7d0;
font-size: 18rpx;
line-height: 1.35;
}
.setting-row,
.action-list .setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 20rpx 0;
border-bottom: 1rpx solid #eef1f6;
}
.setting-row:last-child,
.action-list .setting-row:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.setting-main {
min-width: 0;
flex: 1;
}
.setting-label {
display: block;
color: #17233d;
font-size: 26rpx;
font-weight: 700;
line-height: 1.35;
}
.setting-desc {
display: block;
margin-top: 10rpx;
color: #8f98a8;
font-size: 18rpx;
line-height: 1.45;
}
.setting-right {
display: flex;
align-items: center;
gap: 10rpx;
flex: none;
}
.setting-row :deep(.u-switch) {
transform: scale(0.82);
transform-origin: right center;
}
.value-line {
display: flex;
align-items: center;
gap: 8rpx;
}
.setting-value {
color: #5f6b7a;
font-size: 22rpx;
font-weight: 700;
line-height: 1.35;
}
.tappable {
cursor: pointer;
}
.footer-tip {
margin-top: 18rpx;
padding: 0 8rpx;
color: #9aa4b4;
font-size: 18rpx;
line-height: 1.45;
text-align: center;
}
.theme-dark {
background: #151923;
}
.theme-dark .settings-card {
background: #222936;
box-shadow: none;
}
.theme-dark .card-title,
.theme-dark .setting-desc,
.theme-dark .footer-tip {
color: #aeb9ca;
}
.theme-dark .card-subtitle {
color: #718096;
}
.theme-dark .setting-label {
color: #f2f5fa;
}
.theme-dark .setting-value {
color: #c6d0df;
}
.theme-dark .setting-row,
.theme-dark .action-list .setting-row {
border-bottom-color: #343e4d;
}
.theme-dark.settings-page {
background: #151923;
}
.theme-dark .settings-card {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .card-title,
.theme-dark .setting-label {
color: #f2f5fa;
}
.theme-dark .card-subtitle,
.theme-dark .setting-desc,
.theme-dark .footer-tip {
color: #aeb9ca;
}
.theme-dark .setting-value {
color: #d7deea;
}
.theme-dark .setting-row,
.theme-dark .action-list .setting-row {
border-color: #364152;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<template>
<view class="policy-page" :class="`theme-${themeMode}`">
<scroll-view scroll-y class="policy-scroll">
<view class="policy-paper">
<text class="policy-title">{{ title }}</text>
<text class="policy-content">{{ content }}</text>
</view>
</scroll-view>
</view>
</template>
<script>
const POLICY_STORAGE_KEY = 'scanapp_policy_detail'
export default {
data() {
return {
title: '',
content: ''
}
},
onLoad() {
const policy = uni.getStorageSync(POLICY_STORAGE_KEY) || {}
this.title = policy.title || '协议详情'
this.content = policy.content || ''
uni.setNavigationBarTitle({ title: this.title })
uni.setNavigationBarColor({
frontColor: this.themeMode === 'dark' ? '#ffffff' : '#000000',
backgroundColor: this.themeMode === 'dark' ? '#151923' : '#ffffff'
})
}
}
</script>
<style lang="scss" scoped>
.policy-page {
min-height: 100vh;
background: #ffffff;
}
.policy-scroll {
height: 100vh;
box-sizing: border-box;
}
.policy-paper {
padding: 44rpx 36rpx 80rpx;
box-sizing: border-box;
}
.policy-title {
display: block;
margin-bottom: 34rpx;
color: #17233d;
font-size: 38rpx;
font-weight: 800;
line-height: 1.4;
}
.policy-content {
display: block;
color: #3f4b5d;
font-size: 28rpx;
line-height: 1.9;
white-space: pre-wrap;
word-break: break-word;
}
.theme-dark.policy-page {
background: #151923;
}
.theme-dark .policy-title {
color: #f2f5fa;
}
.theme-dark .policy-content {
color: #c4cede;
}
</style>
+615
View File
@@ -0,0 +1,615 @@
<template>
<view class="statistics-page" :class="`theme-${themeMode}`">
<view class="hero-card">
<view class="hero-main">
<text class="hero-title">个人统计</text>
<text class="hero-subtitle">查看当前登录用户的扫码与处理数据</text>
</view>
<view class="hero-avatar">
<u-icon name="account-fill" color="#ffffff" size="44" />
</view>
</view>
<view class="profile-card">
<view class="profile-row">
<view class="profile-badge">{{ profile.userCode }}</view>
<view class="profile-meta">
<text class="profile-name">{{ profile.name }}</text>
<text class="profile-desc">岗位:{{ profile.position }} · 班组:{{ profile.team }}</text>
</view>
</view>
<view class="profile-extra">
<view class="profile-chip">{{ profile.department }}</view>
<view class="profile-chip success">{{ profile.level }}</view>
</view>
</view>
<view class="metric-card">
<view class="section-header">
<text class="section-title">核心指标</text>
<text class="section-subtitle">近 30 天累计</text>
</view>
<view class="metric-grid">
<view v-for="item in metrics" :key="item.label" class="metric-item">
<text class="metric-value" :class="item.type">{{ item.value }}</text>
<text class="metric-label">{{ item.label }}</text>
</view>
</view>
</view>
<view class="trend-card">
<view class="section-header">
<text class="section-title">个人趋势</text>
<text class="section-subtitle">最近 7 天</text>
</view>
<view class="bar-chart">
<view v-for="item in trendData" :key="item.label" class="bar-item">
<view class="bar-track">
<view class="bar-fill" :style="{ height: item.percent + '%', backgroundColor: item.color }"></view>
</view>
<text class="bar-label">{{ item.label }}</text>
</view>
</view>
</view>
<view class="record-card">
<view class="section-header">
<text class="section-title">最近明细</text>
<text class="section-subtitle">最新 5 条</text>
</view>
<view v-for="item in recentRecords" :key="item.id" class="record-item">
<view class="record-top">
<view>
<text class="record-main">{{ item.title }}</text>
<text class="record-sub">{{ item.subtitle }}</text>
</view>
<view class="record-pill" :class="item.statusClass">{{ item.status }}</view>
</view>
<view class="record-bottom">
<text>{{ item.time }}</text>
<text>{{ item.count }}</text>
</view>
</view>
</view>
<view class="split-card">
<view class="split-section">
<view class="section-header compact">
<text class="section-title">扫码类型占比</text>
</view>
<view v-for="item in typeRate" :key="item.label" class="progress-row">
<view class="progress-info">
<text class="progress-label">{{ item.label }}</text>
<text class="progress-value">{{ item.value }}%</text>
</view>
<view class="progress-track">
<view class="progress-fill" :style="{ width: item.value + '%', backgroundColor: item.color }"></view>
</view>
</view>
</view>
<view class="split-section">
<view class="section-header compact">
<text class="section-title">异常统计</text>
</view>
<view v-for="item in exceptionData" :key="item.label" class="progress-row">
<view class="progress-info">
<text class="progress-label">{{ item.label }}</text>
<text class="progress-value">{{ item.value }}%</text>
</view>
<view class="progress-track">
<view class="progress-fill" :style="{ width: item.value + '%', backgroundColor: item.color }"></view>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { getUserInfo } from '@/api/member.js'
import { getOrderRecentRecords, getOrderStatistics, getProductRecentRecords, getProductStatistics } from '@/api/produce/data-entry.js'
export default {
data() {
return {
profile: {
userCode: 'U001',
name: '-',
position: '-',
team: '-',
department: '-',
level: '-'
},
metrics: [
{ label: '今日扫码', value: '0', type: 'primary' },
{ label: '本月累计', value: '0', type: 'success' },
{ label: '成功率', value: '0%', type: 'success' },
{ label: '异常数', value: '0', type: 'danger' }
],
trendData: [],
recentRecords: [],
typeRate: [
{ label: '订单模式', value: 0, color: '#2f6cf6' },
{ label: '整机模式', value: 0, color: '#35bfa3' },
{ label: '异常记录', value: 0, color: '#f7b733' }
],
exceptionData: [
{ label: '已处理', value: 0, color: '#19be6b' },
{ label: '待复核', value: 0, color: '#f7b733' },
{ label: '待处理', value: 0, color: '#e91f3f' }
]
}
},
onLoad() {
this.loadPageData()
},
methods: {
getCurrentUserCode() {
const user = uni.getStorageSync('scanapp_user') || {}
return user.username || user.nickname || 'U001'
},
async loadPageData() {
const userCode = this.getCurrentUserCode()
try {
const [profile, orderStats, productStats, orderRecent, productRecent] = await Promise.all([
getUserInfo(),
getOrderStatistics({ user_code: userCode }),
getProductStatistics({ user_code: userCode }),
getOrderRecentRecords({ user_code: userCode, limit: 3 }),
getProductRecentRecords({ user_code: userCode, limit: 3 })
])
this.profile = {
userCode,
name: profile.nickname || profile.username || '-',
position: profile.position || profile.role_name || '-',
team: profile.team_name || profile.group_name || '-',
department: profile.department_name || '-',
level: Number(profile.enabled) === 0 ? '已停用' : '稳定输出'
}
const todayTotal = Number(orderStats?.today_total || 0) + Number(productStats?.today_total || 0)
const successTotal = Number(orderStats?.success_total || 0) + Number(productStats?.success_total || 0)
const failedTotal = Number(orderStats?.failed_total || 0) + Number(productStats?.failed_total || 0)
const successRate = todayTotal > 0 ? ((successTotal / todayTotal) * 100).toFixed(1) : '0.0'
this.metrics = [
{ label: '今日扫码', value: String(todayTotal), type: 'primary' },
{ label: '本月累计', value: String(todayTotal), type: 'success' },
{ label: '成功率', value: `${successRate}%`, type: 'success' },
{ label: '异常数', value: String(failedTotal), type: 'danger' }
]
const orderItems = Array.isArray(orderRecent?.items) ? orderRecent.items : []
const productItems = Array.isArray(productRecent?.items) ? productRecent.items : []
this.recentRecords = [...orderItems, ...productItems].slice(0, 5).map((item, index) => ({
id: item.id || index,
title: item.order_no || item.process_order_no || '-',
subtitle: item.process_name && item.step_name ? `${item.process_name} / ${item.step_name}` : '生产记录',
status: Number(item.scan_result) === 0 ? '失败' : '成功',
statusClass: Number(item.scan_result) === 0 ? 'failed' : 'success',
time: item.scan_date || item.created_date || '-',
count: item.quantity ? `+${item.quantity}` : '1'
}))
this.typeRate = [
{ label: '订单模式', value: orderItems.length, color: '#2f6cf6' },
{ label: '整机模式', value: productItems.length, color: '#35bfa3' },
{ label: '异常记录', value: failedTotal, color: '#f7b733' }
]
this.exceptionData = [
{ label: '已处理', value: successTotal, color: '#19be6b' },
{ label: '待复核', value: failedTotal > 0 ? Math.min(failedTotal, 18) : 0, color: '#f7b733' },
{ label: '待处理', value: failedTotal > 18 ? failedTotal - 18 : 0, color: '#e91f3f' }
]
} catch (error) {
console.error('[scanapp] 统计页数据加载失败', error)
}
}
}
}
</script>
<style lang="scss" scoped>
.statistics-page {
min-height: 100vh;
padding: 24rpx 22rpx 40rpx;
box-sizing: border-box;
background: #f5f7fb;
}
.hero-card,
.profile-card,
.metric-card,
.trend-card,
.record-card,
.split-card {
margin-top: 18rpx;
padding: 24rpx;
border-radius: 22rpx;
background: #ffffff;
box-shadow: 0 10rpx 26rpx rgba(23, 35, 61, 0.05);
}
.hero-card {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0;
padding: 30rpx 26rpx;
background: linear-gradient(135deg, #2f6cf6 0%, #35bfa3 100%);
}
.hero-title,
.hero-subtitle,
.section-title,
.section-subtitle,
.profile-name,
.profile-desc,
.metric-label,
.record-main,
.record-sub,
.record-bottom,
.progress-label,
.progress-value {
display: block;
}
.hero-title {
color: #ffffff;
font-size: 38rpx;
font-weight: 800;
line-height: 1.35;
}
.hero-subtitle {
margin-top: 8rpx;
color: rgba(255, 255, 255, 0.84);
font-size: 22rpx;
line-height: 1.35;
}
.hero-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 84rpx;
height: 84rpx;
border-radius: 26rpx;
background: rgba(255, 255, 255, 0.18);
}
.profile-row {
display: flex;
align-items: center;
gap: 0;
}
.profile-badge {
min-width: 98rpx;
margin-right: 24rpx;
padding: 18rpx 14rpx;
border-radius: 18rpx;
background: rgba(47, 108, 246, 0.1);
color: #2f6cf6;
font-size: 22rpx;
font-weight: 700;
text-align: center;
flex: none;
}
.profile-meta {
flex: 1;
min-width: 0;
}
.profile-name {
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.profile-desc {
margin-top: 6rpx;
color: #7d8798;
font-size: 20rpx;
line-height: 1.4;
}
.profile-extra {
display: flex;
gap: 0;
margin-top: 16rpx;
flex-wrap: wrap;
}
.profile-chip {
padding: 6rpx 16rpx;
border-radius: 999rpx;
background: #eef3fb;
color: #6b778c;
font-size: 18rpx;
line-height: 1.35;
flex: none;
}
.profile-chip + .profile-chip {
margin-left: 14rpx;
}
.profile-chip.success {
background: rgba(25, 190, 107, 0.12);
color: #19be6b;
}
.section-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-header.compact {
margin-bottom: 12rpx;
}
.section-title {
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.section-subtitle {
color: #9aa4b4;
font-size: 20rpx;
line-height: 1.35;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14rpx;
}
.metric-item {
padding: 18rpx;
border-radius: 18rpx;
background: #f8fbff;
}
.metric-value {
color: #17233d;
font-size: 32rpx;
font-weight: 800;
line-height: 1.2;
}
.metric-value.primary {
color: #2f6cf6;
}
.metric-value.success {
color: #19be6b;
}
.metric-value.danger {
color: #e91f3f;
}
.metric-label {
margin-top: 6rpx;
color: #7d8798;
font-size: 20rpx;
line-height: 1.35;
}
.bar-chart {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 12rpx;
align-items: end;
height: 220rpx;
}
.bar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
}
.bar-track {
display: flex;
align-items: end;
justify-content: center;
width: 100%;
height: 170rpx;
border-radius: 16rpx;
background: #f3f6fb;
overflow: hidden;
}
.bar-fill {
width: 72%;
border-radius: 16rpx 16rpx 0 0;
}
.bar-label {
margin-top: 10rpx;
color: #9aa4b4;
font-size: 18rpx;
line-height: 1.35;
}
.record-item {
padding: 18rpx 0;
border-bottom: 1rpx solid #eef1f6;
}
.record-item:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.record-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14rpx;
}
.record-main {
color: #17233d;
font-size: 28rpx;
font-weight: 700;
line-height: 1.35;
}
.record-sub {
margin-top: 5rpx;
color: #7d8798;
font-size: 20rpx;
line-height: 1.35;
}
.record-pill {
padding: 4rpx 14rpx;
border-radius: 999rpx;
font-size: 18rpx;
line-height: 1.35;
flex: none;
}
.record-pill.success {
background: rgba(25, 190, 107, 0.12);
color: #19be6b;
}
.record-pill.failed {
background: rgba(233, 31, 63, 0.12);
color: #e91f3f;
}
.record-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10rpx;
color: #9aa4b4;
font-size: 18rpx;
line-height: 1.35;
}
.split-card {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18rpx;
}
.split-section {
padding: 20rpx;
border-radius: 18rpx;
background: #f8fbff;
}
.progress-row {
margin-top: 14rpx;
}
.progress-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
.progress-label,
.progress-value {
color: #7d8798;
font-size: 20rpx;
line-height: 1.35;
}
.progress-value {
color: #17233d;
font-weight: 700;
}
.progress-track {
height: 14rpx;
border-radius: 999rpx;
background: #e9edf4;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 999rpx;
}
.theme-dark.statistics-page {
background: #151923;
}
.theme-dark .hero-card,
.theme-dark .profile-card,
.theme-dark .metric-card,
.theme-dark .trend-card,
.theme-dark .record-card,
.theme-dark .split-card {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .metric-item,
.theme-dark .split-section,
.theme-dark .profile-chip {
background: #1c2431;
}
.theme-dark .profile-name,
.theme-dark .section-title,
.theme-dark .metric-value,
.theme-dark .record-main,
.theme-dark .progress-value {
color: #f2f5fa;
}
.theme-dark .profile-desc,
.theme-dark .section-subtitle,
.theme-dark .metric-label,
.theme-dark .bar-label,
.theme-dark .record-sub,
.theme-dark .record-bottom,
.theme-dark .progress-label,
.theme-dark .profile-chip {
color: #aeb9ca;
}
.theme-dark .record-item,
.theme-dark .progress-track {
border-color: #364152;
}
.theme-dark .bar-track,
.theme-dark .progress-track {
background: #1c2431;
}
.theme-dark .profile-badge {
background: rgba(47, 108, 246, 0.2);
}
.theme-dark .record-pill.success,
.theme-dark .record-pill.failed {
background: rgba(25, 190, 107, 0.18);
}
.theme-dark .record-pill.failed {
background: rgba(233, 31, 63, 0.18);
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+14
View File
@@ -0,0 +1,14 @@
uni.addInterceptor({
returnValue(res) {
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then ===
"function")) {
return res;
}
return new Promise((resolve, reject) => {
res.then((res) => {
if (!res) return resolve(res)
return res[0] ? reject(res[0]) : resolve(res[1])
});
});
},
});
+78
View File
@@ -0,0 +1,78 @@
@import "@/uni_modules/uview-pro/theme.scss";
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;
@@ -0,0 +1,2 @@
## 1.0.0(2024-10-30)
发布程序
@@ -0,0 +1,227 @@
<template>
<view>
<camera v-if="isPermission" device-position="back" style="width: 100%; height: 100vh;position: fixed;" mode="scanCode"
@scancode="scanProcess" :flash="flash" :initdone="(isInitDone = true)">
<view @tap="back" class="backButton" :style="{top: getStatusBarHeight,height: getCapsuleHeight,width: getCapsuleHeight,}">
<image src="../../static/back.png"></image>
</view>
<view class="cameraUI" v-if="isInitDone">
<image class="scanBox" :src=" isOnlyBarCode === true ? '../../static/BarCodeScan.png' : '../../static/scan.png'" mode="widthFix">
<image class="scanEffect" src="../../static/scan-effect.png" mode="aspectFill"></image>
</image>
<view class="control">
<view :class="['flashLight', { flashOpen: flash === 'on' }]" @tap="swithFlash" hover-class="flashClick">
<image :src="flash === 'off' ? '../../static/flash_close.png' : '../../static/flash_open.png'"></image>
</view>
</view>
</view>
</camera>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
const { isOnlyBarCode, scanResult } = defineProps({
isOnlyBarCode: {
type: Boolean,
default: false,
},
scanResult: {
type: Function,
default: (data) => {
console.log(data);
uni.showToast({
icon: "none",
title: "扫描结果:" + data,
});
},
},
});
const SYSTEM_INFO = uni.getSystemInfoSync();
const getStatusBarHeight = SYSTEM_INFO.statusBarHeight + 5 + "px";
const getCapsuleHeight = uni.getMenuButtonBoundingClientRect().height + "px";
const isPermission = ref(false);
const isShowButtonAni = ref(false);
const isInitDone = ref(false);
const flash = ref("off");
onShow(() => {
handleCameraPermission()
.then(() => {
isPermission.value = true;
})
.catch((err) => {
uni.showModal({
title: "访问失败",
content: "由于您" + err.message +",无法使用本功能,如需使用请重新进入本页面进行授权。",
showCancel: false,
complete: (res) => {
back();
},
});
});
});
const back = () => {
uni.navigateBack();
};
const swithFlash = () => {
flash.value = flash.value === "off" ? "on" : "off";
uni.vibrateShort();
};
const scanProcess = (e) => {
if (isOnlyBarCode === true && e.detail.type !== "barcode") {
uni.showToast({
icon: "error",
title: "请扫描条形码",
});
uni.vibrateShort();
} else {
scanResult(e.detail.result);
uni.vibrateLong();
}
};
const handleCameraPermission = () => {
return new Promise((resolve, reject) => {
const showCameraPermissionModal = () => {
uni.showModal({
title: "提示",
content: "检测到未授权摄像头权限,将无法使用本功能,是否前往设置权限?",
success: (res) => {
if (res.confirm) {
uni.openSetting();
} else {
reject(new Error("未授权摄像头权限"));
}
},
});
};
uni.getSetting({
success: (res) => {
const hasCameraPermission =
res.authSetting.hasOwnProperty("scope.camera");
if (hasCameraPermission) {
if (!res.authSetting["scope.camera"]) {
showCameraPermissionModal();
} else {
resolve();
}
} else {
uni.authorize({
scope: "scope.camera",
success: () => {
resolve();
},
fail: () => {
showCameraPermissionModal();
},
});
}
},
fail: (err) => {
reject(err);
},
});
});
};
</script>
<style lang="less" scoped>
.cameraUI {
width: 750rpx;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.scanBox {
width: 636rpx;
display: block;
position: relative;
.scanEffect {
position: absolute;
left: 50%;
top: 5%;
width: 595rpx;
height: 86rpx;
transform: translateX(-50%);
z-index: 1;
animation: scan 1.5s ease-in-out infinite;
}
}
.BarCodeScanEffect {
top: 30%;
animation: BarCodeScan 1.5s ease-in-out infinite;
}
@keyframes scan {
0% {
top: 5%;
opacity: 0;
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
top: 70%;
opacity: 0;
}
}
.control {
margin-top: 80rpx;
height: 110rpx;
display: flex;
align-items: center;
justify-content: center;
.flashLight {
width: 90rpx;
height: 90rpx;
background: rgba(74, 74, 74, 0.8);
box-shadow: rgba(142, 142, 142, 0.19) 0px 6px 15px 0px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
image {
width: 60%;
height: 60%;
}
}
.flashOpen {
backdrop-filter: blur(6px);
background: rgba(255, 255, 255, 0.5);
}
.flashClick {
transition: all 0.1s ease-in-out 0s;
transform: scale(1.2);
}
}
}
.backButton {
position: absolute;
left: 30rpx;
width: 65rpx;
height: 65rpx;
background: rgba(255, 255, 255, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
image {
width: 75%;
height: 75%;
}
}
</style>
@@ -0,0 +1,85 @@
{
"id": "Zeekin-ScanCode",
"displayName": "小程序扫描扫码解析二维码和条形码,简洁的代码、细节的交互和界面设计,开箱即用",
"version": "1.0.0",
"description": "基于camera实现,细节满满的界面、动画和交互设计,简洁清晰的组件代码,已配置完善的权限校验交互",
"keywords": [
"扫码",
"条形码",
"二维码",
"小程序"
],
"repository": "",
"engines": {
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "摄像头"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "y"
},
"client": {
"Vue": {
"vue2": "n",
"vue3": "y"
},
"App": {
"app-vue": "n",
"app-nvue": "n",
"app-uvue": "n"
},
"H5-mobile": {
"Safari": "n",
"Android Browser": "n",
"微信浏览器(Android)": "n",
"QQ浏览器(Android)": "n"
},
"H5-pc": {
"Chrome": "n",
"IE": "n",
"Edge": "n",
"Firefox": "n",
"Safari": "n"
},
"小程序": {
"微信": "y",
"阿里": "n",
"百度": "n",
"字节跳动": "y",
"QQ": "n",
"钉钉": "n",
"快手": "y",
"飞书": "y",
"京东": "n"
},
"快应用": {
"华为": "n",
"联盟": "n"
}
}
}
}
}
@@ -0,0 +1,28 @@
### 程序简介
> 由于自带的uni.scancode不支持连续扫码和实现定制化的界面,故设计本程序。
基于camera实现,细节满满的界面、动画和交互设计,简洁清晰的组件代码,已配置完善的权限校验交互。
- 默认支持连续扫码,通过自行改写自定义扫描事件可以实现单次扫码;
- 支持仅扫描条形码,将更改扫描框样式和扫描结果过滤;
- 支持扫码震动反馈、提示轻震反馈,支持闪光灯功能;
- 清晰、简洁、兼容良好的代码和界面,可基于现有逻辑快速二次开发。
### 组件参数说明
参数名 | 类型 | 必填 | 说明
------------- | -------------
isOnlyBarCode | Boolean | 否 | 配置是否仅扫码条形码模式,默认为false
scanResult | Function | 否 | 自定义扫码成功事件,默认为弹出结果提示框
### 使用示例(uni_module)
```html
<Zeekin-ScanCode :scanResult="handleScanResult" :isOnlyBarCode="true" />
```
```javascript
const handleScanResult = (data) => {
uni.showToast({
icon: 'none',
title: '自定义处理扫描结果:' + data
});
};
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,61 @@
# 更新日志
## 1.3.0(2026-08-29)
### 新增
- `scanStyle.cornerStyle` 新增 `rounded` 完整圆角边框和 `rounded-corner` 四角圆角边框。
- 新增 `scanStyle.cornerRadius`,单位 dp,范围 `0 ~ 160`,默认 `20`。
- 两种圆角扫描框启用遮罩时,遮罩缺口和扫描线同步按同一圆角路径裁剪。
### 兼容性
- 保留 `corner` 四角边框与 `full` 完整矩形边框的原有绘制和调用方式。
- 圆角半径会自动限制在扫描框短边的一半以内,适配窄条码扫描框。
## 1.2.0(2026-08-28)
### 新增
- 新增独立的 `scanStyle` 配置,不再将视觉表现与 `scanRegion` 识别区域混合。
- 支持 `showMask`,可控制扫描框外遮罩的显示与隐藏。
- 支持 `showBeam`,可控制扫描线的显示与隐藏。
- 支持 `borderColor`、`maskColor`、`beamColor` 自定义边框、遮罩和扫描线颜色。
- 支持 `beamDuration` 配置扫描线移动周期,原生层限制为 `100 ~ 60000` 毫秒。
- 支持 `cornerStyle`:`corner` 四角边框、`full` 完整矩形边框。
- README 补充扫描区域、视觉样式、扫码页交互、生命周期回调和错误码说明。
- 新增 `9021005 ~ 9021011` 细化错误码,覆盖相机占用/初始化、相册读取与无条码、非法 `formats` 和单次扫码超时。
- 新增可选 `scanTimeout`,单次扫码可设置 `1000 ~ 600000` 毫秒超时;连续扫码不启用超时。
### 兼容性
- 保持原有 `openCodeScan` 调用方式不变。
- 未传 `scanStyle` 时使用默认样式:绿色边框、半透明黑色遮罩、绿色扫描线、四角边框。
- `scanStyle` 仅影响 Android 扫描页绘制,不改变条码识别逻辑。
- 同屏多码定格后的绿色点选标记增加呼吸波纹动画,帮助用户快速定位可点击目标。
## 1.1.0(2026-07-22)
### 新增
- 增加相册识图能力,支持打开扫码页后直接进入相册,也支持扫码页内手动选图。
- 增加 `formats` 码制过滤,支持二维码、一维码、商品码、Data Matrix、PDF417、Aztec 等格式。
- 增加 `vibrate` 成功震动开关。
- 增加 `continuous` 连续扫码和 `showPauseButton` 暂停/继续控制。
- 增加 `torch` 手电筒策略:`off`、`on`、`auto`。
- 增加 `resultMode` 结果处理策略:`immediate`、`confirm`、`pause`。
- 区分 Activity 获取失败、相机权限拒绝、用户取消和无有效结果等错误码。
### 增强
- 支持同屏多个条码定格并点击选择。
- 增加 `smartZoom` 智能变焦和双指手势缩放。
- 拆分 Kotlin 原生实现,改善扫码页、相册识图、结果处理和码制解析的维护性。
## 1.0.0(2026-07-22)
### 新增
- 初始化 Android 扫码 UTS API 插件。
- 基于 CameraX + Google ML Kit 实现实时条码识别。
- 支持通过 `success`、`fail`、`complete` 回调接收扫码结果和流程状态。
@@ -0,0 +1,101 @@
{
"id": "mas-code-scan",
"displayName": "android端扫码功能,uts插件,支持多二维码",
"version": "1.3.0",
"description": "Android 扫码 UTS 插件:全格式、多码点选、相册识图、连续扫、码制过滤、圆角扫描框视觉配置、错误码细分和扫码超时",
"keywords": [
"code-scan",
"扫码",
"scan-code",
"android"
],
"repository": "",
"engines": {
"HBuilderX": "^3.6.8",
"uni-app": "^5.0",
"uni-app-x": "^5.0"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "20871209"
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "相机、震动"
},
"npmurl": "",
"darkmode": "x",
"i18n": "x",
"widescreen": "√"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app": {
"vue": {
"vue2": "√",
"vue3": "√"
},
"web": {
"safari": "x",
"chrome": "x"
},
"app": {
"vue": "√",
"nvue": "x",
"android": "√",
"ios": "x",
"harmony": "x"
},
"mp": {
"weixin": "x",
"alipay": "x",
"toutiao": "x",
"baidu": "x",
"kuaishou": "x",
"jd": "x",
"harmony": "x",
"qq": "x",
"lark": "x",
"xhs": "x"
},
"quickapp": {
"huawei": "x",
"union": "x"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
}
}
@@ -0,0 +1,751 @@
# mas-code-scan
Android 端扫码 **UTS API 插件**(非 Vue 组件)。
基于 CameraX + Google ML Kit,支持全格式条码/二维码、多码同屏点选、识别区域过滤、扫描框视觉配置、智能变焦、手势缩放、相册识图、连续扫码、手电筒策略、结果确认和暂停控制。
插件对外只提供 `openCodeScan` 一个 UTS API。扫码页由原生 Android Activity 提供,识别结果通过 `success`、`fail`、`complete` 回调返回。
支持:**Android App(uni-app Vue2/Vue3)**。
暂不支持:iOS / 鸿蒙 / H5 / 小程序 / nvue。
> 依赖原生三方库,**必须使用自定义调试基座或正式云打包**后才能真机调用。
---
## 插件定位与工作流程
`mas-code-scan` 是一个打开原生 Android 扫码 Activity 的 UTS API 插件,不是 Vue 组件,也不是对 `uni.scanCode` 的简单包装。一次扫码流程如下:
1. 业务页面调用 `openCodeScan(options)`。
2. 插件启动全屏竖屏原生扫码页,并根据参数初始化 CameraX、ML Kit 和界面控件。
3. 相机帧交给 ML Kit 识别;如果配置了 `scanRegion`,实时结果还会经过扫描框中心点过滤。
4. 单码按照 `resultMode` 返回;多码进入定格点选;连续模式通过 `success` 多次上报。
5. Activity 结束后通过 `complete` 收尾;失败场景同时通过 `fail` 和 `complete` 通知。
### 能力边界
- 识别引擎:Google ML Kit Barcode Scanning。
- 相机管线:CameraX,使用后置摄像头,预览采用 `FILL_CENTER` 填充显示。
- 图片来源:实时相机帧、系统相册图片。
- 原生页面:全屏、竖屏、无标题栏;关闭、相册、手电筒和连续扫码暂停按钮由插件绘制。
- 生命周期:每次调用创建一个扫码会话;关闭、成功、权限失败、超时或初始化失败后会话结束。
## 目录
1. [功能一览](#功能一览)
2. [安装](#安装)
3. [制作自定义基座(必做)](#制作自定义基座必做)
4. [快速上手](#快速上手)
5. [进阶用法](#进阶用法)
6. [API](#api)
7. [错误码](#错误码)
8. [扫码页交互说明](#扫码页交互说明)
9. [码制 formats 取值](#码制-formats-取值)
10. [常见问题](#常见问题)
11. [注意事项](#注意事项)
12. [目录结构](#目录结构)
13. [版本](#版本)
---
## 功能一览
| 能力 | 说明 |
| --- | --- |
| 全格式识别 | QR / EAN / CODE_128 / PDF417 / AZTEC 等 |
| 码制过滤 | `formats` 只扫指定类型 |
| 扫描区域 | `scanRegion` 配置扫描框尺寸、位置,并过滤框外条码 |
| 扫描框样式 | `scanStyle` 配置遮罩、扫描线、边框颜色、动画周期、边框形态和圆角大小 |
| 单码自动返回 | 识别成功后关闭并回调 |
| 多码点选 | 同屏多个码定格,点选其中一个 |
| 智能变焦 | `smartZoom`:码偏小时自动放大 |
| 手势缩放 | 双指捏合 |
| 手电筒 | 扫码页右下角开关,支持关闭、启动时开启和低照度自动补光 |
| 相册识图 | 页内相册按钮,或 `fromAlbum: true` |
| 连续扫码 | `continuous: true`,多次 `success`,点关闭结束;可显示暂停/继续按钮 |
| 结果处理 | 单次扫码支持立即返回、确认后返回、暂停后选择 |
| 成功震动 | `vibrate`,可关 |
| 错误码区分 | 权限拒绝 `9021002` ≠ 用户取消 `9021003` |
| 原生回收 | 页面结束时停止分析、释放相机、关闭 ML Kit Scanner、关闭手电筒并清理临时图片 |
---
## 安装
将插件拷贝到业务项目:
```text
你的项目/uni_modules/mas-code-scan/
```
UTS 插件需 **import 后调用**,不会 easycom 自动注册组件。
要求:
- HBuilderX ≥ 3.6.8(建议较新版本)
- uni-app Vue2 / Vue3 均可
- **仅 Android**;需制作自定义基座
本仓库演示页:`frontend/scanapp/pages/scan/prototype.vue`,用于在 Android 自定义基座中验证插件能力。页面包含:
- 智能变焦、成功震动、手电筒策略、结果处理模式和连续扫码暂停按钮。
- 扫码页标题、扫描引导语、成功提示语、无结果提示语自定义。
- 连续扫码成功提示停留时长、单次扫码超时配置。
- 二维码正方形框、横向条码框和全屏识别三种识别区域预设;横向条码框支持上/中/下位置、自定义上下微调和三档高度。
- 扫描框遮罩、扫描线、边框颜色、遮罩颜色、扫描线颜色、扫描线周期、四种边框形态和圆角半径配置。
- 二维码、CODE 128、EAN-13 码制筛选,相册识图、单次扫码、连续扫码和本地结果列表。
演示页的“最近结果”仅保存在当前页面内,用于展示回调结果,不会提交业务接口。
---
## 制作自定义基座(必做)
插件依赖:
- `androidx.camera:*` 1.4.1
- `com.google.mlkit:barcode-scanning` 17.3.0
标准基座不含上述库,**不制作自定义基座会调用失败 / 编译不过**。
### 步骤
1. HBuilderX 菜单:**运行** → **运行到手机或模拟器** → **制作自定义调试基座**
2. 选择当前项目,填写 Android 包名、证书(可用公共测试证书)
3. 云打包,等待完成并安装到手机
4. 之后每次运行选择 **「自定义调试基座」**,再打开演示页或业务页调试扫码
正式发版:使用 **云打包 / 本地打包** 生成安装包即可(打包流程会带上 UTS 原生依赖)。
---
## 快速上手
### 1. uni-app(Vue3 `<script setup>`)
```vue
<template>
<view>
<button type="primary" @click="doScan">扫码</button>
<text>{{ tip }}</text>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { openCodeScan } from '@/uni_modules/mas-code-scan'
const tip = ref('')
const doScan = () => {
openCodeScan({
success: (res) => {
tip.value = res.content + ' / ' + res.format
},
fail: (err) => {
tip.value = err.errMsg + '(' + err.errCode + ')'
}
})
}
</script>
```
### 2. uni-app(Options API)
```js
import { openCodeScan } from '@/uni_modules/mas-code-scan'
export default {
methods: {
doScan() {
openCodeScan({
success: (res) => {
console.log(res.content, res.format, res.fromAlbum)
},
fail: (err) => {
console.log(err.errCode, err.errMsg)
},
complete: () => {
console.log('扫码流程结束')
}
})
}
}
}
```
### 3. 最少参数
```ts
import { openCodeScan } from '@/uni_modules/mas-code-scan'
openCodeScan({
success: (res) => console.log(res.content)
})
```
---
## 进阶用法
### 自定义扫码页文案
以下参数只影响原生扫码页显示文字,不影响识别逻辑:
```ts
openCodeScan({
title: '扫描商品码',
hint: '请将商品条码放入框内',
successHint: '识别成功',
emptyHint: '图片中没有商品条码',
success: (res) => console.log(res.content)
})
```
- `title`:顶部标题,默认 `扫一扫`。
- `hint`:扫描框下方引导语,默认 `请将条码放入扫描框内`。
- `successHint`:连续扫码成功提示层标题,也用于部分原生提示,默认 `扫码成功`。
- `emptyHint`:相册图片未识别到条码时的提示,默认 `未识别到有效条码`。
- 空字符串或全空格会回退到默认文案;原生层会去除首尾空格。
### 只扫二维码
```ts
openCodeScan({
formats: ['QR_CODE'],
success: (res) => console.log(res.content)
})
```
### 手电筒和结果处理
```ts
openCodeScan({
torch: 'auto', // 'off' | 'on' | 'auto'
resultMode: 'confirm', // 'immediate' | 'confirm' | 'pause'
success: (res) => console.log(res.content)
})
```
`confirm` 会弹出结果确认框;`pause` 会暂停识别并提供“继续扫描/确认”操作。两种模式下,点击确认才会结束并触发 `success`、`complete`;点击重新扫描或继续扫描会恢复相机识别,不会触发失败回调。
### 设置单次扫码超时
```ts
openCodeScan({
scanTimeout: 15000,
success: (res) => console.log(res.content),
fail: (err) => {
if (err.errCode === 9021011) console.log('15 秒内未识别到条码')
}
})
```
`scanTimeout` 单位为毫秒。只有单次扫码且值在 `1000 ~ 600000` 时启用;不传、传 `0` 或小于 `1000` 时不启用。连续扫码始终不启用单次超时。
### 关闭智能变焦 / 震动
```ts
openCodeScan({
smartZoom: false,
vibrate: false,
success: (res) => console.log(res.content)
})
```
### 配置扫描区域
`scanRegion` 同时决定扫描框的位置和实时识别过滤区域。宽度、高度、中心点均使用 `0 ~ 1` 的比例值;宽高以预览宽度为参考,越界值会由原生层收敛到可显示范围。`scanRegion` 只作用于实时相机,不裁剪或过滤相册图片。
```ts
openCodeScan({
scanRegion: {
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
},
success: (res) => console.log(res.content)
})
```
- 不传 `scanRegion`:保持全屏识别,同时保留默认扫描框显示。
- `scanRegion: { fullScreen: true }`:整画面识别,不显示扫描框,也不进行区域过滤。
- 配置扫描区域后,条码中心点必须落在扫描框内才会被实时相机识别结果接受。
- 相册识图不受 `scanRegion` 限制。
### 配置扫描框视觉样式
`scanStyle` 只控制扫描页视觉表现,不改变识别区域。颜色支持 `#RRGGBB`、`#AARRGGBB`;遮罩还支持 `rgba(r, g, b, a)`。
```ts
openCodeScan({
scanRegion: {
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
},
scanStyle: {
showMask: true,
showBeam: true,
borderColor: '#19be6b',
maskColor: 'rgba(0, 0, 0, 0.45)',
beamColor: '#12e880',
beamDuration: 1800,
cornerStyle: 'rounded',
cornerRadius: 20
},
success: (res) => console.log(res.content)
})
```
- `showMask: false` 仅隐藏框外遮罩,不会扩大或改变识别范围。
- `showBeam: false` 隐藏扫描线;扫描线周期 `beamDuration` 单位为毫秒,原生层限制在 `100 ~ 60000`。
- `cornerStyle: 'corner'` 显示直角四角边框,`'full'` 显示完整矩形边框,`'rounded'` 显示完整圆角边框,`'rounded-corner'` 显示四角圆角边框。
- `cornerRadius` 为圆角半径,单位为 dp,范围 `0 ~ 160`,在两种圆角边框样式下均生效;圆角遮罩缺口和扫描线会同步裁剪为相同圆角。
- `rounded` 是四边完整的圆角边框;`rounded-corner` 只绘制四个带圆弧过渡的角,中间不连接四条边。
- 圆角半径最终还会限制在扫描框短边的一半以内,适合二维码框和横向条码框。
- 当未启用 `scanRegion` 时,视觉样式仅作用于默认扫描框;`fullScreen: true` 时不会显示扫描框。
### 打开后直接进相册
```ts
openCodeScan({
fromAlbum: true,
success: (res) => {
console.log(res.content, res.fromAlbum) // fromAlbum === true
}
})
```
扫码页内也可随时点左下角 **相册** 按钮选图识别。
### 连续扫码
页内不关闭,每识别一次触发一次 `success`;用户点左上角关闭(或系统返回)后走 `complete`(若已有命中,**不会**再走 `fail`)。
```ts
const list = []
openCodeScan({
continuous: true,
showPauseButton: true,
vibrate: true,
success: (res) => {
list.push(res.content)
console.log('命中', res.content, res.format)
},
fail: (err) => {
// 一次都没扫到就关闭 → 一般是 9021003 取消
console.log('失败', err.errCode, err.errMsg)
},
complete: (res) => {
if (res && res.closed) {
console.log('结束,共', res.hitCount, '次', list)
}
}
})
```
### 组合:仅二维码 + 连续扫 + 相册入口
```ts
openCodeScan({
formats: ['QR_CODE'],
continuous: true,
smartZoom: true,
vibrate: true,
success: (res) => console.log(res),
complete: (res) => console.log('done', res)
})
```
---
## API
### openCodeScan(options?)
打开全屏扫码页。
#### CodeScanOptions
| 参数 | 类型 | 必填 | 默认 | 说明 |
| --- | --- | :---: | --- | --- |
| `title` | `string` | 否 | `扫一扫` | 原生扫码页顶部标题 |
| `hint` | `string` | 否 | `请将条码放入扫描框内` | 扫描框下方引导语 |
| `successHint` | `string` | 否 | `扫码成功` | 连续扫码成功提示层标题 |
| `emptyHint` | `string` | 否 | `未识别到有效条码` | 图片无条码时提示语 |
| `smartZoom` | `boolean` | 否 | `true` | 条码占画面较小时自动平滑放大;最多尝试 6 次 |
| `formats` | `string[]` | 否 | 全部 | 限定码制,见下方取值表;空/不传 = 全格式 |
| `vibrate` | `boolean` | 否 | `true` | 识别成功后是否短震动约 40ms |
| `scanRegion` | `ScanRegion` | 否 | 未启用 | 实时相机识别区域,控制扫描框尺寸、位置和中心点过滤 |
| `scanStyle` | `CodeScanStyle` | 否 | 见下表 | 扫描框视觉样式,不改变识别区域 |
| `torch` | `'off' \| 'on' \| 'auto'` | 否 | `'off'` | 手电筒策略;`on` 启动后开启,`auto` 低照度连续满足条件后自动开启 |
| `resultMode` | `'immediate' \| 'confirm' \| 'pause'` | 否 | `'immediate'` | 单次扫码结果处理方式;连续扫码固定按立即上报处理 |
| `continuous` | `boolean` | 否 | `false` | 连续扫:页内不关闭,每次命中触发一次 `success` |
| `continuousSuccessDuration` | `number` | 否 | `1000` | 连续扫码成功提示停留时长,单位毫秒,范围 `0 ~ 600000` |
| `showPauseButton` | `boolean` | 否 | `false` | 仅 `continuous: true` 时显示右上角暂停/继续按钮 |
| `fromAlbum` | `boolean` | 否 | `false` | `true` 时打开扫码页后立即拉起系统相册 |
| `scanTimeout` | `number` | 否 | 不启用 | 单次扫码超时,单位毫秒,范围 `1000 ~ 600000` |
| `success` | `(res: CodeScanSuccess) => void` | 否 | - | 成功;连续模式下可多次触发 |
| `fail` | `(err: CodeScanFail) => void` | 否 | - | 失败或取消;连续模式已有命中后关闭时不触发 |
| `complete` | `(res: any) => void` | 否 | - | 会话结束;成功、失败、取消和连续扫码关闭都会触发 |
#### ScanRegion
| 参数 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `fullScreen` | `boolean` | `false` | 为 `true` 时整画面识别,不显示扫描框,也不做中心点过滤;优先级高于其他区域参数 |
| `width` | `number` | `0.74` | 扫描框宽度相对预览宽度的比例,原生限制在 `0.1 ~ 1` |
| `height` | `number` | `0.74` | 扫描框高度相对预览宽度的比例,原生限制在 `0.1 ~ 1` |
| `centerX` | `number` | `0.5` | 扫描框中心横坐标比例,范围 `0 ~ 1`,越界会收敛 |
| `centerY` | `number` | `0.5` | 扫描框中心纵坐标比例,范围 `0 ~ 1`,越界会收敛 |
区域映射会补偿 `PreviewView` 的 `FILL_CENTER` 裁剪偏移和图像旋转;最终判断的是 ML Kit 包围盒的中心点是否在区域内,而不是要求整个条码完全位于框内。
建议:二维码使用接近正方形的区域,横向条码使用较宽且较矮的区域;如果业务只需要识别、不需要框选提示,可使用 `fullScreen: true`。
#### 组合约束与默认行为
| 组合 | 实际行为 |
| --- | --- |
| 不传 `scanRegion` | 实时相机按全画面识别;使用原生默认扫描框布局 |
| `scanRegion.fullScreen: true` | 全画面识别,不显示扫描框,不过滤框外结果 |
| `fromAlbum: true` | 先打开相册;选图识别失败后,在相机可用时仍可继续相机扫码 |
| `continuous: true` | 页内持续识别,`resultMode` 不参与单码确认;关闭页结束会话 |
| `continuous: true` + `showPauseButton: true` | 右上角显示暂停/继续按钮,暂停期间不处理相机帧 |
| `continuous: true` + `scanTimeout` | 忽略 `scanTimeout`,连续扫码没有会话超时 |
| `continuous: true` + `resultMode` | 连续扫码固定立即上报,每次命中通过 `success` 返回 |
| `formats` 含未知值和有效值 | 忽略未知值,使用有效码制 |
| `formats` 非空且全部未知 | 立即失败并返回 `9021010` |
| 单次识别到多个有定位框的条码 | 定格当前画面,显示绿色点选标记,点击后返回选中条码 |
| 连续扫码短时间重复同一内容 | 约 1.2 秒内去重,避免重复触发 `success` |
### CodeScanStyle
| 参数 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `showMask` | `boolean` | `true` | 是否显示扫描框外遮罩 |
| `showBeam` | `boolean` | `true` | 是否显示扫描线 |
| `borderColor` | `string` | `#19be6b` | 边框颜色,支持 `#RRGGBB`、`#AARRGGBB` |
| `maskColor` | `string` | `rgba(0, 0, 0, 0.45)` | 遮罩颜色和透明度,支持 `rgba()` |
| `beamColor` | `string` | `#12e880` | 扫描线颜色,支持 `#RRGGBB`、`#AARRGGBB` |
| `beamDuration` | `number` | `1800` | 扫描线移动周期,单位为毫秒,范围 `100 ~ 60000` |
| `cornerStyle` | `'corner' \| 'full' \| 'rounded' \| 'rounded-corner'` | `'corner'` | `corner` 为直角四角边框,`full` 为完整边框,`rounded` 为完整圆角边框,`rounded-corner` 为四角圆角边框 |
| `cornerRadius` | `number` | `20` | 圆角半径,单位 dp,范围 `0 ~ 160`,两种圆角样式均生效 |
默认值:`showMask: true`、`showBeam: true`、`borderColor: '#19be6b'`、`maskColor: 'rgba(0, 0, 0, 0.45)'`、`beamColor: '#12e880'`、`beamDuration: 1800`、`cornerStyle: 'corner'`、`cornerRadius: 20`。非法颜色会回退到默认值,`beamDuration` 会限制在 `100 ~ 60000` 毫秒,`cornerRadius` 会限制在 `0 ~ 160` dp。
示例:
```ts
openCodeScan({
scanRegion: {
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
},
scanStyle: {
showMask: true,
showBeam: true,
borderColor: '#19be6b',
maskColor: 'rgba(0, 0, 0, 0.45)',
beamColor: '#12e880',
beamDuration: 1800,
cornerStyle: 'rounded',
cornerRadius: 20
},
success: (res) => console.log(res.content)
})
```
`scanRegion` 控制实时识别区域,`scanStyle` 只控制扫描页的视觉表现;两者可以独立配置。不传 `scanRegion` 时实时相机按全画面识别,但仍显示原生默认扫描框;需要隐藏扫描框时请传 `scanRegion: { fullScreen: true }`。
#### 回调返回值与生命周期
单次扫码成功:
```ts
success({ content: '...', format: 'QR_CODE', fromAlbum: false })
complete({ content: '...', format: 'QR_CODE', fromAlbum: false })
```
单次扫码失败或用户取消:
```ts
fail({ errCode: 9021003, errMsg: '用户取消扫码', errSubject: 'mas-code-scan' })
complete({ errCode: 9021003, ... })
```
连续扫码关闭:
```ts
complete({ closed: true, hitCount: 3 })
```
连续扫码如果一次命中都没有就关闭,通常会先触发 `fail(9021003)`,随后触发 `complete({ closed: true, hitCount: 0, failed: true, errCode: 9021003 })`。已有命中后关闭只触发 `complete`,不会触发 `fail`。
| 回调 | 触发时机 | 注意 |
| --- | --- | --- |
| `success` | 单次确认成功;或连续模式每次命中 | 多码点选只有点击某个码后触发 |
| `fail` | Activity 无法启动、权限失败、相机失败、相册失败、超时、用户取消等 | 用户取消错误码为 `9021003` |
| `complete` | 每次会话结束 | 不能只依赖 `success` 清理页面状态 |
#### CodeScanSuccess
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `content` | `string` | 识别到的文本内容 |
| `format` | `string` | 码制名,如 `QR_CODE`、`CODE_128` |
| `fromAlbum` | `boolean` | 是否来自相册识图(可选) |
#### CodeScanFail
继承 `UniError`:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `errCode` | `number` | 见错误码表 |
| `errMsg` | `string` | 错误描述 |
| `errSubject` | `string` | 固定 `"mas-code-scan"` |
回调规则:
- 单次扫码成功:依次调用 `success`、`complete`,成功对象包含 `content`、`format` 和 `fromAlbum`。
- 单次扫码失败或取消:依次调用 `fail`、`complete`。
- 连续扫码:每次命中调用一次 `success`;关闭页面时调用 `complete({ closed: true, hitCount })`。
- 连续扫码在已有命中后关闭:不会再调用 `fail`,只调用 `complete`。
- 相册识图成功:`fromAlbum` 为 `true`;相机实时识别成功时为 `false`。
#### 类型定义(摘录)
```ts
type CodeScanOptions = {
title?: string
hint?: string
successHint?: string
emptyHint?: string
smartZoom?: boolean
formats?: string[]
vibrate?: boolean
torch?: 'off' | 'on' | 'auto'
resultMode?: 'immediate' | 'confirm' | 'pause'
continuous?: boolean
continuousSuccessDuration?: number
showPauseButton?: boolean
fromAlbum?: boolean
scanRegion?: {
fullScreen?: boolean
width?: number
height?: number
centerX?: number
centerY?: number
}
scanStyle?: {
showMask?: boolean
showBeam?: boolean
borderColor?: string
maskColor?: string
beamColor?: string
beamDuration?: number
cornerStyle?: 'corner' | 'full' | 'rounded' | 'rounded-corner'
cornerRadius?: number
}
// 单次扫码超时(毫秒);小于 1000 或不传则不启用,最大 600000;连续扫码不启用超时
scanTimeout?: number
success?: (res: CodeScanSuccess) => void
fail?: (res: CodeScanFail) => void
complete?: (res: any) => void
}
type CodeScanSuccess = {
content: string
format: string
fromAlbum?: boolean
}
```
---
## 错误码
| 错误码 | 说明 | 常见场景 |
| --- | --- | --- |
| `9021001` | 无法获取页面 Activity / 平台不支持 | 非 Android、页面上下文异常 |
| `9021002` | 相机权限被拒绝 | 用户拒绝相机权限(非「仅相册」场景) |
| `9021003` | 用户取消扫码 | 点关闭、系统返回、未扫就退出 |
| `9021004` | 未获取到有效扫码结果 | 成功关页但内容为空(少见) |
| `9021005` | 相机被其他应用占用或当前不可用 | 相机被其他应用、系统功能或设备状态占用 |
| `9021006` | 设备不支持手电筒 | 点击手电筒时提示;不结束当前扫码会话 |
| `9021007` | 相机初始化失败 | CameraX 初始化、绑定预览或分析管线失败 |
| `9021008` | 相册图片无法读取 | 所选 URI 无法读取、复制或解码图片 |
| `9021009` | 图片中未识别到条码 | `fromAlbum: true` 且无可回退相机时,图片中不存在有效码 |
| `9021010` | 指定码制不支持 | `formats` 非空但其中没有插件支持的码制 |
| `9021011` | 扫码超时 | 单次扫码设置了有效 `scanTimeout` 且超时未成功;连续扫码不启用 |
说明:
- `9021001` ~ `9021004` 为已有错误码,含义保持不变;新增错误码从 `9021005` 开始。
- `fail` 与 `complete` 在失败时都会触发(连续扫「已有命中后关闭」除外:只 `complete`)。
- 打开即相册且无相机权限时,仍可用相册;点关闭且无命中 → 一般为 `9021003`。
- 无闪光灯不影响扫码,因此 `9021006` 仅通过扫码页提示展示,不会触发 `fail`。
---
## 扫码页交互说明
| 控件 / 手势 | 说明 |
| --- | --- |
| 左上关闭 | 退出;连续扫时结束会话 |
| 左下相册 | 选图识别;多码时同样可点选 |
| 右下手电筒 | 有闪光灯时可用;可由 `torch` 设置初始策略,`auto` 会在低照度时自动开启 |
| 右上暂停按钮 | 仅连续扫码且 `showPauseButton: true` 时显示,暂停/继续识别 |
| 结果确认框 | `resultMode: 'confirm'` 或 `'pause'` 时,确认返回或继续扫描 |
| 双指捏合 | 手动变焦 |
| 多码绿点 | 同屏多个有效码时定格,显示绿色呼吸波纹,点击选中 |
| 系统返回键 | 等同关闭 |
---
## 码制 formats 取值
传入字符串数组;空/缺省时按全格式。若 `formats` 非空但全部不受支持,调用失败并返回 `9021010`。
| 取值 | 说明 |
| --- | --- |
| `QR_CODE` / `QR` | 二维码 |
| `EAN_13` / `EAN_8` | 商品条码 |
| `CODE_128` / `CODE_39` / `CODE_93` | 一维码 |
| `CODABAR` | Codabar |
| `DATA_MATRIX` | Data Matrix |
| `UPC_A` / `UPC_E` | UPC |
| `ITF` | ITF |
| `PDF417` | PDF417 |
| `AZTEC` | Aztec |
| `ALL` | 全格式(与具体码制混用时按全格式处理) |
补充规则:插件会忽略未知 token;只要数组中存在一个有效 token 就使用有效 token。数组非空但全部无效时返回 `9021010`。`ALL` 与其他有效码制同时传入时最终使用全格式。
示例:
```ts
formats: ['QR_CODE', 'CODE_128', 'EAN_13']
```
---
## 常见问题
### 1. 点击没反应 / 报找不到类?
未使用自定义基座。请按上文制作并选择「自定义调试基座」再运行。
### 2. 提示相机权限被拒绝(9021002)?
到系统设置打开应用相机权限后重试。
### 3. 连续扫只回调一次?
确认传了 `continuous: true`。同一内容约 1.2 秒内会去重,避免狂刷;换个码或稍等再扫。
### 4. 相册选了图但提示未识别?
图中可能没有清晰条码,或被 `formats` 过滤掉。可先不传 `formats` 试全格式;打开即相册且没有可用相机时,会以 `9021009` 返回无条码错误。
### 5. 为什么会提示扫码超时(9021011)?
仅在单次扫码传入 `scanTimeout`(1000 ~ 600000 毫秒)后启用。超时不会影响连续扫码;不需要限制时不传该参数或传 `0`。
### 6. iOS / 鸿蒙能用吗?
当前版本仅 Android。目录未包含 iOS/鸿蒙实现。
### 7. 能否当组件标签用?
不能。这是 **UTS API**,请 `import { openCodeScan } from '@/uni_modules/mas-code-scan'` 后调用。
### 8. 演示页可以直接当业务页面使用吗?
`prototype.vue` 是能力验证页面,结果列表是本地状态,不包含业务接口提交、权限引导和业务字段转换。正式业务建议复制 `openScan` 调用方式,在自己的页面中处理 `success`、`fail` 和 `complete`,并在 `complete` 中恢复 loading、页面状态等资源。
### 9. `scanStyle` 不生效怎么办?
确认调用的是包含当前插件的自定义调试基座或正式打包应用。修改 UTS 原生代码后需要重新制作自定义基座或重新云打包,普通标准基座不会加载插件原生依赖和最新实现。
### 10. 扫描框显示了但框外条码没有识别?
这是 `scanRegion` 的预期行为。扫描框不仅是视觉提示,也会作为实时识别过滤区域;如需整屏识别,请不传 `scanRegion` 或传入 `fullScreen: true`。
### 11. `resultMode` 和 `continuous` 一起传会怎样?
连续扫码以连续模式为准,不显示单次扫码确认框,每次识别成功都会通过 `success` 上报;如需单个结果确认,请不要传 `continuous: true`。
### 12. `fromAlbum` 和相机权限有什么关系?
Android 13 及以上优先使用系统 Photo Picker;Android 12 及以下使用系统图片选择器,可能需要读取相册权限。打开即相册时,即使没有相机权限,也可以先完成相册识图;相册无结果且无法继续使用相机时返回 `9021009`。
### 13. 为什么识别框位置和相机画面看起来不完全一致?
预览采用 `FILL_CENTER`,相机画面会按比例填充并裁剪边缘;插件会在识别过滤时补偿裁剪和旋转,但不同设备的相机传感器比例、状态栏和厂商裁剪策略可能存在少量视觉差异。
---
## 注意事项
1. 仅 Android App-Plus;发布市场包请走正式打包,勿依赖标准基座调试结果。
2. 首次使用实时相机需要相机权限;插件不会主动跳转系统设置,权限被拒绝后由业务决定是否引导用户开启。
3. 震动权限由插件 Manifest 声明;设置 `vibrate: false` 可关闭成功震动。
4. 相册选图走系统选择器;Android 12 及以下可能需要读取相册权限,Android 13 及以上优先使用 Photo Picker。
5. `scanRegion` 只过滤实时相机,且按条码包围盒中心点判断;相册识图始终分析整张图片。
6. 连续扫依赖原生命中队列和 UTS 桥接轮询,业务应在 `complete` 中收尾,不要只在 `success` 中恢复状态。
7. 多码点选依赖 ML Kit 返回定位框;多码中只有一个可解码码时会直接返回,无法定位的结果不能作为可点击目标。
8. 智能变焦和双指缩放共享相机变焦能力;不支持变焦的设备会保持原始比例。
9. 大尺寸相册图片会先缩放到最长边约 1920 像素再识别,以降低内存压力。
10. 文档与演示不保证覆盖所有机型相机差异,建议使用真实目标机型回归。
---
## 目录结构
```text
mas-code-scan/
├── package.json
├── readme.md
├── changelog.md
└── utssdk/
├── interface.uts # 对外类型与 API 声明
├── unierror.uts # 错误实现
└── app-android/
├── index.uts # JS/UTS 桥接
├── config.json # 原生依赖
├── AndroidManifest.xml
├── CodeCaptureActivity.kt
├── MultiCodePickView.kt
├── ScanFrameDecorView.kt
├── YuvFrames.kt
├── SymbologyUtil.kt
├── CodeScanHitBus.kt
└── res/drawable/ # 关闭/相册等资源
```
---
## 版本
| 版本 | 说明 |
| --- | --- |
| 1.3.0 | 新增 `rounded` 完整圆角边框、`rounded-corner` 四角圆角边框和自定义 `cornerRadius`,遮罩与扫描线同步适配圆角 |
| 1.2.0 | 新增 `scanStyle` 扫描框视觉配置,支持遮罩、扫描线、颜色、动画周期、四角/完整边框;完善 `scanRegion` 与回调说明 |
| 1.1.0 | 权限码区分、关闭/相册、码制过滤、震动、连续扫、Kotlin 拆分 |
| 1.0.0 | Android 扫码初版 |
更多变更见 `changelog.md`。
## 相关链接
- [UTS 语法](https://uniapp.dcloud.net.cn/tutorial/syntax-uts.html)
- [UTS API 插件](https://uniapp.dcloud.net.cn/plugin/uts-plugin.html)
- [制作自定义调试基座](https://uniapp.dcloud.net.cn/tutorial/run/run-app.html#customplayground)
@@ -0,0 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="uts.sdk.modules.masCodeScan">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application>
<activity
android:name="uts.sdk.modules.masCodeScan.CodeCaptureActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
</application>
</manifest>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
package uts.sdk.modules.masCodeScan
import java.util.concurrent.ConcurrentLinkedQueue
/**
* 连续扫码命中总线:Activity 入队,index.uts 轮询出队并触发 success。
*/
object CodeScanHitBus {
private val queue = ConcurrentLinkedQueue<Array<String>>()
@JvmStatic
fun detach() {
queue.clear()
}
@JvmStatic
fun emitHit(content: String, format: String, fromAlbum: Boolean) {
queue.offer(
arrayOf(
content,
format,
if (fromAlbum) "1" else "0"
)
)
}
/** 取出一条命中:[content, format, fromAlbumFlag];无数据返回 null */
@JvmStatic
fun pollHit(): Array<String>? {
return queue.poll()
}
}
@@ -0,0 +1,96 @@
package uts.sdk.modules.masCodeScan
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.view.View
import kotlin.math.min
/**
* 连续扫码成功提示层:覆盖扫描框中部,显示成功状态和本次扫码值。
*/
class ContinuousSuccessOverlayView(
context: Context,
private val successHint: String
) : View(context) {
private val density = resources.displayMetrics.density
private val scaledDensity = resources.displayMetrics.scaledDensity
private val panelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(218, 48, 48, 48)
style = Paint.Style.FILL
}
private val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 16f * scaledDensity
textAlign = Paint.Align.CENTER
typeface = Typeface.DEFAULT_BOLD
}
private val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 15f * scaledDensity
textAlign = Paint.Align.CENTER
}
private val panelRect = RectF()
private var frameRect = RectF()
private var stillFrame: Bitmap? = null
private var value = ""
fun showSuccess(scanFrame: RectF, content: String, bitmap: Bitmap?) {
stillFrame?.recycle()
stillFrame = bitmap
frameRect = RectF(scanFrame)
value = content
visibility = VISIBLE
invalidate()
}
fun hideSuccess() {
stillFrame?.recycle()
stillFrame = null
value = ""
visibility = GONE
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (value.isBlank() || width <= 0 || height <= 0) return
stillFrame?.takeIf { !it.isRecycled && it.width > 0 && it.height > 0 }?.let { bitmap ->
val bitmapWidth = bitmap.width.toFloat()
val bitmapHeight = bitmap.height.toFloat()
val scale = maxOf(width / bitmapWidth, height / bitmapHeight)
val drawWidth = bitmapWidth * scale
val drawHeight = bitmapHeight * scale
val left = (width - drawWidth) / 2f
val top = (height - drawHeight) / 2f
canvas.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), null)
}
val availableWidth = if (frameRect.isEmpty) width.toFloat() else frameRect.width()
val panelWidth = min(300f * density, maxOf(0f, availableWidth - 24f * density))
val panelHeight = 68f * density
val centerX = if (frameRect.isEmpty) width / 2f else frameRect.centerX()
val centerY = if (frameRect.isEmpty) height / 2f else frameRect.centerY()
panelRect.set(
centerX - panelWidth / 2f,
centerY - panelHeight / 2f,
centerX + panelWidth / 2f,
centerY + panelHeight / 2f
)
panelRect.left = panelRect.left.coerceAtLeast(12f * density)
panelRect.right = panelRect.right.coerceAtMost(width - 12f * density)
panelRect.top = panelRect.top.coerceAtLeast(24f * density)
panelRect.bottom = panelRect.bottom.coerceAtMost(height - 24f * density)
canvas.drawRoundRect(panelRect, 12f * density, 12f * density, panelPaint)
val titleBaseline = panelRect.centerY() - 3f * density
val valueBaseline = panelRect.centerY() + 19f * density
canvas.drawText(successHint, panelRect.centerX(), titleBaseline, titlePaint)
canvas.drawText(value, panelRect.centerX(), valueBaseline, valuePaint)
}
}
@@ -0,0 +1,267 @@
package uts.sdk.modules.masCodeScan
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.os.SystemClock
import android.view.View
import com.google.mlkit.vision.barcode.common.Barcode
import kotlin.math.max
import kotlin.math.sin
data class HitMark(
val rawValue: String,
val format: Int,
val formatName: String,
val screenRect: Rect
) {
val centerX: Float get() = screenRect.exactCenterX()
val centerY: Float get() = screenRect.exactCenterY()
}
/**
* 多码点选覆盖层:定格帧 + 圆点标记。
*/
class MultiCodePickView(context: Context) : View(context) {
private var stillFrame: Bitmap? = null
private var rawHits: List<Barcode> = emptyList()
private var hitMarks: List<HitMark> = emptyList()
private var visualCrop: Rect = Rect()
private var stillW: Int = 0
private var stillH: Int = 0
private var layerW: Int = 0
private var layerH: Int = 0
private var recognizing: Boolean = false
private var recognizingStartedAt: Long = 0L
private val density: Float = resources.displayMetrics.density
private val dotOuterR: Float = 18f * density
private val dotInnerR: Float = 6f * density
private val touchR: Float = 28f * density
private val breatheDurationMs = 1500L
private val haloPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.argb(60, 7, 193, 96)
}
private val shellPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.WHITE
}
private val corePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.rgb(7, 193, 96)
}
private val shadePaint = Paint().apply {
color = Color.argb(40, 0, 0, 0)
}
private val promptBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(190, 0, 0, 0)
}
private val promptTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 16f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
private val loadingTrackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 3f * density
strokeCap = Paint.Cap.ROUND
color = Color.argb(100, 255, 255, 255)
}
private val loadingArcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 3f * density
strokeCap = Paint.Cap.ROUND
color = Color.rgb(7, 193, 96)
}
private val loadingTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 15f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
layerW = right - left
layerH = bottom - top
if (changed && rawHits.isNotEmpty()) {
rebuildHitMarks()
}
}
fun showRecognizing(bitmap: Bitmap) {
if (stillFrame !== bitmap) {
stillFrame?.recycle()
}
stillFrame = bitmap
rawHits = emptyList()
hitMarks = emptyList()
visualCrop.setEmpty()
stillW = bitmap.width
stillH = bitmap.height
recognizing = true
recognizingStartedAt = SystemClock.elapsedRealtime()
visibility = VISIBLE
invalidate()
}
fun presentStillFrame(bitmap: Bitmap, barcodes: List<Barcode>, visualCropRect: Rect) {
if (stillFrame !== bitmap) {
stillFrame?.recycle()
}
stillFrame = bitmap
rawHits = barcodes
visualCrop = Rect(visualCropRect)
stillW = bitmap.width
stillH = bitmap.height
recognizing = false
rebuildHitMarks()
visibility = VISIBLE
invalidate()
}
private fun rebuildHitMarks() {
if (layerW <= 0 || layerH <= 0 || stillW <= 0 || stillH <= 0) {
hitMarks = emptyList()
return
}
hitMarks = rawHits.mapNotNull { barcode ->
val box = barcode.boundingBox ?: return@mapNotNull null
val adjustedBox = Rect(
box.left - visualCrop.left,
box.top - visualCrop.top,
box.right - visualCrop.left,
box.bottom - visualCrop.top
)
val clippedBox = Rect(adjustedBox)
if (!clippedBox.intersect(0, 0, stillW, stillH)) {
return@mapNotNull null
}
val screenRect = mapBoxToScreen(clippedBox, stillW, stillH, layerW, layerH)
HitMark(
rawValue = barcode.rawValue ?: "",
format = barcode.format,
formatName = SymbologyUtil.nameOf(barcode.format),
screenRect = screenRect
)
}
}
fun clear() {
stillFrame?.recycle()
stillFrame = null
rawHits = emptyList()
hitMarks = emptyList()
visualCrop.setEmpty()
stillW = 0
stillH = 0
recognizing = false
recognizingStartedAt = 0L
visibility = GONE
invalidate()
}
fun pickHitAt(x: Float, y: Float): HitMark? {
return hitMarks.minByOrNull {
val dx = x - it.centerX
val dy = y - it.centerY
dx * dx + dy * dy
}?.takeIf {
val dx = x - it.centerX
val dy = y - it.centerY
dx * dx + dy * dy <= touchR * touchR
}
}
private fun mapBoxToScreen(
boundingBox: Rect,
sourceW: Int, sourceH: Int,
viewW: Int, viewH: Int
): Rect {
val scale = max(viewW.toFloat() / sourceW, viewH.toFloat() / sourceH)
val offsetX = (viewW - sourceW * scale) / 2f
val offsetY = (viewH - sourceH * scale) / 2f
return Rect(
(boundingBox.left * scale + offsetX).toInt(),
(boundingBox.top * scale + offsetY).toInt(),
(boundingBox.right * scale + offsetX).toInt(),
(boundingBox.bottom * scale + offsetY).toInt()
)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
stillFrame?.let { bmp ->
if (layerW <= 0 || layerH <= 0) return@let
val bmpW = bmp.width.toFloat()
val bmpH = bmp.height.toFloat()
val scale = max(layerW.toFloat() / bmpW, layerH.toFloat() / bmpH)
val drawW = (bmpW * scale).toInt()
val drawH = (bmpH * scale).toInt()
val left = (layerW - drawW) / 2
val top = (layerH - drawH) / 2
val destRect = Rect(left, top, left + drawW, top + drawH)
canvas.drawBitmap(bmp, null, destRect, null)
if (!recognizing) {
canvas.drawRect(destRect, shadePaint)
}
}
if (recognizing) {
drawRecognizing(canvas)
postInvalidateDelayed(16)
return
}
if (hitMarks.size >= 2) {
val prompt = "已识别到多个码,请选择一个"
val horizontalPadding = 18f * density
val verticalPadding = 10f * density
val textBounds = Rect()
promptTextPaint.getTextBounds(prompt, 0, prompt.length, textBounds)
val promptWidth = textBounds.width() + horizontalPadding * 2
val promptHeight = textBounds.height() + verticalPadding * 2
val promptLeft = (layerW - promptWidth) / 2f
// 放在底部操作按钮上方,避免遮挡条码和顶部关闭按钮。
val promptTop = layerH - promptHeight - 100f * density
canvas.drawRoundRect(
promptLeft,
promptTop,
promptLeft + promptWidth,
promptTop + promptHeight,
promptHeight / 2,
promptHeight / 2,
promptBackgroundPaint
)
val baseline = promptTop + verticalPadding - textBounds.top
canvas.drawText(prompt, layerW / 2f, baseline, promptTextPaint)
}
val breatheProgress = (SystemClock.elapsedRealtime() % breatheDurationMs).toFloat() / breatheDurationMs
val breatheWave = ((sin(breatheProgress * Math.PI * 2.0) + 1.0) / 2.0).toFloat()
for (mark in hitMarks) {
haloPaint.alpha = (34 + breatheWave * 70).toInt()
canvas.drawCircle(mark.centerX, mark.centerY, touchR + breatheWave * 8f * density, haloPaint)
canvas.drawCircle(mark.centerX, mark.centerY, dotOuterR, shellPaint)
canvas.drawCircle(mark.centerX, mark.centerY, dotInnerR, corePaint)
}
if (hitMarks.isNotEmpty()) {
postInvalidateDelayed(16)
}
}
private fun drawRecognizing(canvas: Canvas) {
val cx = layerW / 2f
val cy = layerH / 2f
val radius = 24f * density
val bounds = android.graphics.RectF(cx - radius, cy - radius, cx + radius, cy + radius)
val elapsed = SystemClock.elapsedRealtime() - recognizingStartedAt
val startAngle = (elapsed % 1000L) * 360f / 1000f - 90f
canvas.drawCircle(cx, cy, radius, loadingTrackPaint)
canvas.drawArc(bounds, startAngle, 110f, false, loadingArcPaint)
canvas.drawText("识别中", cx, cy + radius + 30f * density, loadingTextPaint)
}
}
@@ -0,0 +1,328 @@
package uts.sdk.modules.masCodeScan
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.BlurMaskFilter
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Path
import android.graphics.Shader
import android.view.View
import kotlin.math.max
import kotlin.math.min
/**
* 扫描框视觉样式。颜色支持 #RRGGBB、#AARRGGBB 和 rgba()。
*/
data class ScanStyleConfig(
val showMask: Boolean = true,
val showBeam: Boolean = true,
val borderColor: Int = Color.rgb(25, 190, 107),
val maskColor: Int = Color.argb(115, 0, 0, 0),
val beamColor: Int = Color.rgb(18, 232, 128),
val beamDuration: Long = 1800L,
val cornerStyle: String = "corner",
val cornerRadius: Float = 20f
) {
companion object {
private const val DEFAULT_BORDER_COLOR = "#19be6b"
private const val DEFAULT_MASK_COLOR = "rgba(0, 0, 0, 0.45)"
private const val DEFAULT_BEAM_COLOR = "#12e880"
private const val DEFAULT_DURATION = 1800L
fun safe(
showMask: Boolean,
showBeam: Boolean,
borderColor: String,
maskColor: String,
beamColor: String,
beamDuration: Int,
cornerStyle: String,
cornerRadius: Int
): ScanStyleConfig {
return ScanStyleConfig(
showMask = showMask,
showBeam = showBeam,
borderColor = parseColor(borderColor, DEFAULT_BORDER_COLOR),
maskColor = parseColor(maskColor, DEFAULT_MASK_COLOR),
beamColor = parseColor(beamColor, DEFAULT_BEAM_COLOR),
beamDuration = beamDuration.toLong().coerceIn(100L, 60000L),
cornerStyle = when (cornerStyle) {
"full" -> "full"
"rounded" -> "rounded"
"rounded-corner" -> "rounded-corner"
else -> "corner"
},
cornerRadius = cornerRadius.toFloat().coerceIn(0f, 160f)
)
}
private fun parseColor(value: String, fallback: String): Int {
val input = value.trim()
if (input.startsWith("rgba(") && input.endsWith(")")) {
val parts = input.substring(5, input.length - 1).split(',').map { it.trim() }
if (parts.size == 4) {
val red = parts[0].toIntOrNull()
val green = parts[1].toIntOrNull()
val blue = parts[2].toIntOrNull()
val alpha = parts[3].toFloatOrNull()
if (red != null && green != null && blue != null && alpha != null) {
return Color.argb(
(alpha.coerceIn(0f, 1f) * 255f).toInt(),
red.coerceIn(0, 255),
green.coerceIn(0, 255),
blue.coerceIn(0, 255)
)
}
}
}
return try {
Color.parseColor(input)
} catch (_: IllegalArgumentException) {
Color.parseColor(fallback)
}
}
}
}
/**
* 取景框光条装饰层。
*/
class ScanFrameDecorView(
context: Context,
private val scanRegion: ScanRegionConfig,
private val scanStyle: ScanStyleConfig,
private val hint: String
) : View(context) {
private val density = resources.displayMetrics.density
private val beamH = 4f * density
private val beamInset = 12f * density
private val cornerLength = 24f * density
private val finderRect = RectF()
private val roundedFinderPath = Path()
private var beamProgress = 0f
private var beamAnimator: ValueAnimator? = null
private val beamPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}
private val regionMaskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = scanStyle.maskColor
style = Paint.Style.FILL
}
private val regionBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = scanStyle.borderColor
style = Paint.Style.STROKE
strokeWidth = 2f * density
strokeCap = Paint.Cap.SQUARE
}
private val beamGlowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(
(Color.alpha(scanStyle.beamColor) * 0.2f).toInt(),
Color.red(scanStyle.beamColor),
Color.green(scanStyle.beamColor),
Color.blue(scanStyle.beamColor)
)
style = Paint.Style.FILL
maskFilter = BlurMaskFilter(18f * density, BlurMaskFilter.Blur.NORMAL)
}
private val hintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 14f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
}
init {
setLayerType(LAYER_TYPE_SOFTWARE, null)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
runBeamLoop()
}
override fun onDetachedFromWindow() {
haltBeamLoop()
super.onDetachedFromWindow()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
layoutFinder(w, h)
updateFinderPath()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (width <= 0 || height <= 0) return
if (scanRegion.enabled) {
if (scanStyle.showMask) {
paintRegionMask(canvas)
}
paintRegionBorder(canvas)
}
if (scanStyle.showBeam) {
paintBeam(canvas)
}
if (hint.isNotBlank()) {
val hintBaseline = min(height - 24f * density, finderRect.bottom + 30f * density)
canvas.drawText(hint, finderRect.centerX(), hintBaseline, hintPaint)
}
}
fun haltBeamLoop() {
beamAnimator?.cancel()
beamAnimator = null
}
private fun runBeamLoop() {
if (beamAnimator != null) return
beamAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = scanStyle.beamDuration
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.RESTART
addUpdateListener {
beamProgress = it.animatedValue as Float
invalidate()
}
start()
}
}
private fun layoutFinder(w: Int, h: Int) {
if (scanRegion.enabled) {
finderRect.set(scanRegion.resolveViewRect(w, h))
return
}
// 未配置 scanRegion 时保持旧版默认扫描框,避免影响现有调用方。
val horizontalMargin = 36f * density
val maxFrameWidth = min(w * 0.74f, 320f * density)
val frameWidth = min(maxFrameWidth, w - horizontalMargin * 2f)
val frameHeight = frameWidth
val left = (w - frameWidth) / 2f
val top = (h - frameHeight) / 2f - 44f * density
finderRect.set(left, max(24f * density, top), left + frameWidth, max(24f * density, top) + frameHeight)
}
private fun updateFinderPath() {
val maxRadius = min(finderRect.width(), finderRect.height()) / 2f
val radius = min(scanStyle.cornerRadius * density, maxRadius)
roundedFinderPath.reset()
roundedFinderPath.addRoundRect(finderRect, radius, radius, Path.Direction.CW)
roundedFinderPath.close()
}
private fun paintRegionMask(canvas: Canvas) {
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
canvas.drawColor(scanStyle.maskColor)
canvas.drawPath(roundedFinderPath, Paint(Paint.ANTI_ALIAS_FLAG).apply {
xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.CLEAR)
})
return
}
canvas.drawRect(0f, 0f, width.toFloat(), finderRect.top, regionMaskPaint)
canvas.drawRect(0f, finderRect.bottom, width.toFloat(), height.toFloat(), regionMaskPaint)
canvas.drawRect(0f, finderRect.top, finderRect.left, finderRect.bottom, regionMaskPaint)
canvas.drawRect(finderRect.right, finderRect.top, width.toFloat(), finderRect.bottom, regionMaskPaint)
}
private fun paintRegionBorder(canvas: Canvas) {
if (scanStyle.cornerStyle == "full") {
canvas.drawRect(finderRect, regionBorderPaint)
return
}
if (scanStyle.cornerStyle == "rounded") {
canvas.drawPath(roundedFinderPath, regionBorderPaint)
return
}
if (scanStyle.cornerStyle == "rounded-corner") {
paintRoundedCorners(canvas)
return
}
val left = finderRect.left
val top = finderRect.top
val right = finderRect.right
val bottom = finderRect.bottom
canvas.drawLine(left, top, left + cornerLength, top, regionBorderPaint)
canvas.drawLine(left, top, left, top + cornerLength, regionBorderPaint)
canvas.drawLine(right - cornerLength, top, right, top, regionBorderPaint)
canvas.drawLine(right, top, right, top + cornerLength, regionBorderPaint)
canvas.drawLine(left, bottom - cornerLength, left, bottom, regionBorderPaint)
canvas.drawLine(left, bottom, left + cornerLength, bottom, regionBorderPaint)
canvas.drawLine(right - cornerLength, bottom, right, bottom, regionBorderPaint)
canvas.drawLine(right, bottom - cornerLength, right, bottom, regionBorderPaint)
}
private fun paintRoundedCorners(canvas: Canvas) {
val radius = min(scanStyle.cornerRadius * density, min(finderRect.width(), finderRect.height()) / 2f)
val left = finderRect.left
val top = finderRect.top
val right = finderRect.right
val bottom = finderRect.bottom
val segmentLength = min(this.cornerLength, radius + this.cornerLength)
val cornerOnlyPath = Path()
val topLeft = RectF(left, top, left + radius * 2f, top + radius * 2f)
val topRight = RectF(right - radius * 2f, top, right, top + radius * 2f)
val bottomLeft = RectF(left, bottom - radius * 2f, left + radius * 2f, bottom)
val bottomRight = RectF(right - radius * 2f, bottom - radius * 2f, right, bottom)
cornerOnlyPath.moveTo(left + segmentLength, top)
cornerOnlyPath.lineTo(left + radius, top)
cornerOnlyPath.arcTo(topLeft, 270f, -90f)
cornerOnlyPath.lineTo(left, top + segmentLength)
cornerOnlyPath.moveTo(right - segmentLength, top)
cornerOnlyPath.lineTo(right - radius, top)
cornerOnlyPath.arcTo(topRight, 270f, 90f)
cornerOnlyPath.lineTo(right, top + segmentLength)
cornerOnlyPath.moveTo(left, bottom - segmentLength)
cornerOnlyPath.lineTo(left, bottom - radius)
cornerOnlyPath.arcTo(bottomLeft, 180f, -90f)
cornerOnlyPath.lineTo(left + segmentLength, bottom)
cornerOnlyPath.moveTo(right, bottom - segmentLength)
cornerOnlyPath.lineTo(right, bottom - radius)
cornerOnlyPath.arcTo(bottomRight, 0f, 90f)
cornerOnlyPath.lineTo(right - segmentLength, bottom)
canvas.drawPath(cornerOnlyPath, regionBorderPaint)
}
private fun paintBeam(canvas: Canvas) {
val availableHeight = finderRect.height() - beamInset * 2f
if (availableHeight <= 0f) return
val y = finderRect.top + beamInset + availableHeight * beamProgress
val lineTop = y - beamH / 2f
val lineBottom = y + beamH / 2f
val lineLeft = finderRect.left + beamInset
val lineRight = finderRect.right - beamInset
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
canvas.save()
canvas.clipPath(roundedFinderPath)
}
beamPaint.shader = LinearGradient(
lineLeft, y, lineRight, y,
intArrayOf(
scanStyle.beamColor and 0x00ffffff,
scanStyle.beamColor,
scanStyle.beamColor and 0x00ffffff
),
floatArrayOf(0f, 0.5f, 1f),
Shader.TileMode.CLAMP
)
canvas.drawRect(
lineLeft, lineTop - 10f * density, lineRight, lineBottom + 10f * density, beamGlowPaint
)
canvas.drawRect(lineLeft, lineTop, lineRight, lineBottom, beamPaint)
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
canvas.restore()
}
}
}
@@ -0,0 +1,108 @@
package uts.sdk.modules.masCodeScan
import android.graphics.Rect
import android.graphics.RectF
import kotlin.math.max
import kotlin.math.min
/**
* 相对于实时预览层的有效扫码区域配置。
* 由 Activity 统一校验后同时用于扫描框绘制和条码结果过滤。
*/
data class ScanRegionConfig(
val enabled: Boolean,
val widthRatio: Float = DEFAULT_SIZE_RATIO,
val heightRatio: Float = DEFAULT_SIZE_RATIO,
val centerXRatio: Float = DEFAULT_CENTER_RATIO,
val centerYRatio: Float = DEFAULT_CENTER_RATIO
) {
companion object {
const val DEFAULT_SIZE_RATIO = 0.74f
const val DEFAULT_CENTER_RATIO = 0.5f
private const val MIN_SIZE_RATIO = 0.1f
private const val MAX_SIZE_RATIO = 1f
fun safe(
enabled: Boolean,
widthRatio: Float,
heightRatio: Float,
centerXRatio: Float,
centerYRatio: Float
): ScanRegionConfig {
val safeWidth = widthRatio.sanitize(DEFAULT_SIZE_RATIO).coerceIn(MIN_SIZE_RATIO, MAX_SIZE_RATIO)
val safeHeight = heightRatio.sanitize(DEFAULT_SIZE_RATIO).coerceIn(MIN_SIZE_RATIO, MAX_SIZE_RATIO)
return ScanRegionConfig(
enabled = enabled,
widthRatio = safeWidth,
heightRatio = safeHeight,
centerXRatio = centerXRatio.sanitize(DEFAULT_CENTER_RATIO).coerceIn(0f, 1f),
centerYRatio = centerYRatio.sanitize(DEFAULT_CENTER_RATIO).coerceIn(0f, 1f)
)
}
private fun Float.sanitize(defaultValue: Float): Float {
return if (isFinite()) this else defaultValue
}
}
/** 返回扫描框在预览 View 坐标系中的实际位置,越界部分会收敛到 View 内。 */
fun resolveViewRect(viewWidth: Int, viewHeight: Int): RectF {
if (viewWidth <= 0 || viewHeight <= 0) return RectF()
// width / height 都以预览宽度为基准,并使用同一缩放因子收敛,确保始终保持配置比例。
val desiredWidth = viewWidth * widthRatio
val desiredHeight = viewWidth * heightRatio
val sizeScale = min(1f, min(viewWidth / desiredWidth, viewHeight / desiredHeight))
val frameWidth = desiredWidth * sizeScale
val frameHeight = desiredHeight * sizeScale
val wantedLeft = viewWidth * centerXRatio - frameWidth / 2f
val wantedTop = viewHeight * centerYRatio - frameHeight / 2f
val left = wantedLeft.coerceIn(0f, max(0f, viewWidth - frameWidth))
val top = wantedTop.coerceIn(0f, max(0f, viewHeight - frameHeight))
return RectF(left, top, left + frameWidth, top + frameHeight)
}
}
/**
* 将 PreviewView 的扫描框映射到 ML Kit 返回 boundingBox 所在的旋转后图像坐标。
* PreviewView 使用 FILL_CENTER,因此必须补偿等比填充产生的裁剪偏移。
*/
object ScanRegionMapper {
fun mapViewRectToImage(
viewRect: RectF,
viewWidth: Int,
viewHeight: Int,
imageWidth: Int,
imageHeight: Int,
cropRect: Rect,
rotationDegrees: Int
): Rect? {
if (viewRect.isEmpty || viewWidth <= 0 || viewHeight <= 0 || imageWidth <= 0 || imageHeight <= 0) {
return null
}
val visualCrop = YuvFrames.rectToVisualSpace(cropRect, imageWidth, imageHeight, rotationDegrees)
val cropWidth = visualCrop.width()
val cropHeight = visualCrop.height()
if (cropWidth <= 0 || cropHeight <= 0) return null
val scale = max(viewWidth.toFloat() / cropWidth, viewHeight.toFloat() / cropHeight)
if (!scale.isFinite() || scale <= 0f) return null
val offsetX = (viewWidth - cropWidth * scale) / 2f
val offsetY = (viewHeight - cropHeight * scale) / 2f
val mapped = Rect(
((viewRect.left - offsetX) / scale).toInt() + visualCrop.left,
((viewRect.top - offsetY) / scale).toInt() + visualCrop.top,
((viewRect.right - offsetX) / scale).toInt() + visualCrop.left,
((viewRect.bottom - offsetY) / scale).toInt() + visualCrop.top
)
return mapped.apply {
left = left.coerceIn(visualCrop.left, visualCrop.right)
top = top.coerceIn(visualCrop.top, visualCrop.bottom)
right = right.coerceIn(left, visualCrop.right)
bottom = bottom.coerceIn(top, visualCrop.bottom)
}.takeIf { it.width() > 0 && it.height() > 0 }
}
fun containsBarcodeCenter(region: Rect, barcodeBox: Rect): Boolean {
return region.contains(barcodeBox.centerX(), barcodeBox.centerY())
}
}
@@ -0,0 +1,67 @@
package uts.sdk.modules.masCodeScan
import com.google.mlkit.vision.barcode.common.Barcode
object SymbologyUtil {
fun nameOf(format: Int): String = when (format) {
Barcode.FORMAT_QR_CODE -> "QR_CODE"
Barcode.FORMAT_EAN_13 -> "EAN_13"
Barcode.FORMAT_EAN_8 -> "EAN_8"
Barcode.FORMAT_CODE_128 -> "CODE_128"
Barcode.FORMAT_CODE_39 -> "CODE_39"
Barcode.FORMAT_CODE_93 -> "CODE_93"
Barcode.FORMAT_CODABAR -> "CODABAR"
Barcode.FORMAT_DATA_MATRIX -> "DATA_MATRIX"
Barcode.FORMAT_UPC_A -> "UPC_A"
Barcode.FORMAT_UPC_E -> "UPC_E"
Barcode.FORMAT_ITF -> "ITF"
Barcode.FORMAT_PDF417 -> "PDF417"
Barcode.FORMAT_AZTEC -> "AZTEC"
else -> "UNKNOWN"
}
fun hasSupportedFormat(csv: String?): Boolean {
if (csv.isNullOrBlank()) return true
return csv.split(',').any { tokenToFormat(it.trim().uppercase()) != null }
}
fun parseFormatList(csv: String?): IntArray {
if (csv.isNullOrBlank()) {
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
}
val mapped = csv.split(',')
.map { it.trim().uppercase() }
.mapNotNull { tokenToFormat(it) }
.distinct()
if (mapped.isEmpty()) {
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
}
// ALL 与具体码制混用时以全格式为准,避免 ML Kit 参数异常
if (mapped.contains(Barcode.FORMAT_ALL_FORMATS)) {
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
}
return mapped.toIntArray()
}
private fun tokenToFormat(token: String): Int? = when (token) {
"QR_CODE", "QR" -> Barcode.FORMAT_QR_CODE
"EAN_13" -> Barcode.FORMAT_EAN_13
"EAN_8" -> Barcode.FORMAT_EAN_8
"CODE_128" -> Barcode.FORMAT_CODE_128
"CODE_39" -> Barcode.FORMAT_CODE_39
"CODE_93" -> Barcode.FORMAT_CODE_93
"CODABAR" -> Barcode.FORMAT_CODABAR
"DATA_MATRIX" -> Barcode.FORMAT_DATA_MATRIX
"UPC_A" -> Barcode.FORMAT_UPC_A
"UPC_E" -> Barcode.FORMAT_UPC_E
"ITF" -> Barcode.FORMAT_ITF
"PDF417" -> Barcode.FORMAT_PDF417
"AZTEC" -> Barcode.FORMAT_AZTEC
"ALL", "FORMAT_ALL_FORMATS" -> Barcode.FORMAT_ALL_FORMATS
else -> null
}
fun isDecodedText(barcode: Barcode): Boolean {
return !barcode.rawValue.isNullOrBlank()
}
}
@@ -0,0 +1,136 @@
package uts.sdk.modules.masCodeScan
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageFormat
import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.YuvImage
import android.media.Image
import java.io.ByteArrayOutputStream
object YuvFrames {
fun imageToBitmap(
image: Image,
width: Int,
height: Int,
cropRect: Rect,
rotationDegrees: Int
): Bitmap? {
return try {
val planes = image.planes
val yPlane = planes[0]
val uPlane = planes[1]
val vPlane = planes[2]
val yRowStride = yPlane.rowStride
val yPixelStride = yPlane.pixelStride
val uRowStride = uPlane.rowStride
val uPixelStride = uPlane.pixelStride
val vRowStride = vPlane.rowStride
val vPixelStride = vPlane.pixelStride
val nv21Size = width * height * 3 / 2
val nv21 = ByteArray(nv21Size)
var destOffset = 0
if (yPixelStride == 1) {
for (row in 0 until height) {
yPlane.buffer.position(row * yRowStride)
yPlane.buffer.get(nv21, destOffset, width)
destOffset += width
}
} else {
val yBuffer = yPlane.buffer
for (row in 0 until height) {
for (col in 0 until width) {
nv21[destOffset++] = yBuffer.get(row * yRowStride + col * yPixelStride)
}
}
}
var uvOffset = width * height
if (uPixelStride == 1 && vPixelStride == 1) {
for (row in 0 until height / 2) {
vPlane.buffer.position(row * vRowStride)
vPlane.buffer.get(nv21, uvOffset + row * width, width / 2)
}
for (row in 0 until height / 2) {
uPlane.buffer.position(row * uRowStride)
uPlane.buffer.get(nv21, uvOffset + width / 2 + row * width, width / 2)
}
} else {
val uBuffer = uPlane.buffer
val vBuffer = vPlane.buffer
for (row in 0 until height / 2) {
for (col in 0 until width / 2) {
val uvIndex = row * uRowStride + col * uPixelStride
nv21[uvOffset++] = vBuffer.get(uvIndex)
nv21[uvOffset++] = uBuffer.get(uvIndex)
}
}
}
val safeCropRect = Rect(cropRect).apply {
left = left.coerceIn(0, width)
top = top.coerceIn(0, height)
right = right.coerceIn(left, width)
bottom = bottom.coerceIn(top, height)
}
val jpegRect = if (safeCropRect.width() > 0 && safeCropRect.height() > 0) {
safeCropRect
} else {
Rect(0, 0, width, height)
}
val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null)
val baos = ByteArrayOutputStream()
yuvImage.compressToJpeg(jpegRect, 85, baos)
val jpegData = baos.toByteArray()
baos.close()
var bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size)
if (bitmap != null && rotationDegrees != 0) {
val matrix = Matrix()
matrix.postRotate(rotationDegrees.toFloat())
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
if (rotated != bitmap) {
bitmap.recycle()
}
bitmap = rotated
}
bitmap
} catch (_: Exception) {
null
}
}
fun rectToVisualSpace(
rect: Rect,
sourceWidth: Int,
sourceHeight: Int,
rotationDegrees: Int
): Rect {
return when (rotationDegrees) {
90 -> Rect(
sourceHeight - rect.bottom,
rect.left,
sourceHeight - rect.top,
rect.right
)
180 -> Rect(
sourceWidth - rect.right,
sourceHeight - rect.bottom,
sourceWidth - rect.left,
sourceHeight - rect.top
)
270 -> Rect(
rect.top,
sourceWidth - rect.right,
rect.bottom,
sourceWidth - rect.left
)
else -> Rect(rect)
}
}
}
@@ -0,0 +1,12 @@
{
"dependencies": [
"androidx.activity:activity:1.8.0",
"androidx.camera:camera-core:1.4.1",
"androidx.camera:camera-camera2:1.4.1",
"androidx.camera:camera-lifecycle:1.4.1",
"androidx.camera:camera-view:1.4.1",
"androidx.appcompat:appcompat:1.7.0",
"com.google.mlkit:barcode-scanning:17.3.0"
],
"minSdkVersion": 21
}
@@ -0,0 +1,356 @@
import Activity from 'android.app.Activity'
import Intent from 'android.content.Intent'
import { OpenCodeScan, CodeScanErrorCode, CodeScanOptions, CodeScanSuccess } from '../interface.uts'
import { CodeScanFailImpl } from '../unierror.uts'
const LAUNCH_REQ: Int = 53011
const EXTRA_PAYLOAD = 'mas_content'
const EXTRA_FORMAT = 'mas_format'
const EXTRA_TITLE = 'mas_title'
const EXTRA_HINT = 'mas_hint'
const EXTRA_SUCCESS_HINT = 'mas_success_hint'
const EXTRA_EMPTY_HINT = 'mas_empty_hint'
const EXTRA_SMART_ZOOM = 'mas_smart_zoom'
const EXTRA_TORCH_MODE = 'mas_torch_mode'
const EXTRA_RESULT_MODE = 'mas_result_mode'
const EXTRA_SHOW_PAUSE_BUTTON = 'mas_show_pause_button'
const EXTRA_VIBRATE = 'mas_vibrate'
const EXTRA_CONTINUOUS = 'mas_continuous'
const EXTRA_FROM_ALBUM = 'mas_from_album'
const EXTRA_FORMATS = 'mas_formats'
const EXTRA_SCAN_REGION_ENABLED = 'mas_scan_region_enabled'
const EXTRA_SCAN_REGION = 'mas_scan_region'
const EXTRA_SCAN_STYLE_SHOW_MASK = 'mas_scan_style_show_mask'
const EXTRA_SCAN_STYLE_SHOW_BEAM = 'mas_scan_style_show_beam'
const EXTRA_SCAN_STYLE_BORDER_COLOR = 'mas_scan_style_border_color'
const EXTRA_SCAN_STYLE_MASK_COLOR = 'mas_scan_style_mask_color'
const EXTRA_SCAN_STYLE_BEAM_COLOR = 'mas_scan_style_beam_color'
const EXTRA_SCAN_STYLE_BEAM_DURATION = 'mas_scan_style_beam_duration'
const EXTRA_SCAN_STYLE_CORNER = 'mas_scan_style_corner'
const EXTRA_SCAN_STYLE_CORNER_RADIUS = 'mas_scan_style_corner_radius'
const EXTRA_SCAN_TIMEOUT = 'mas_scan_timeout'
const EXTRA_CONTINUOUS_SUCCESS_DURATION = 'mas_continuous_success_duration'
const EXTRA_ERR = 'mas_err'
const EXTRA_FROM_ALBUM_RESULT = 'mas_result_from_album'
const EXTRA_HAD_HITS = 'mas_had_hits'
const CAPTURE_ACTIVITY = 'uts.sdk.modules.masCodeScan.CodeCaptureActivity'
let activityResultHook: ((requestCode: Int, resultCode: Int, data?: Intent) => void) | null = null
let activeOptions: CodeScanOptions | null = null
let continuousHitCount: Int = 0
let pollTimerId: number = -1
let sessionContinuous: boolean = false
let continuousSuccess: ((res: CodeScanSuccess) => void) | null = null
@UTSJS.keepAlive
export function openCodeScan(options?: CodeScanOptions | null): void {
const opts = options != null ? options : null
const host = UTSAndroid.getUniActivity()
if (host == null) {
emitFailure(opts, 9021001)
return
}
launchCapture(host, opts)
}
function clearPollTimer() {
if (pollTimerId >= 0) {
clearInterval(pollTimerId)
pollTimerId = -1
}
}
function drainHits() {
const current = activeOptions
const success = continuousSuccess
if (current == null || success == null) return
while (true) {
const hit = CodeScanHitBus.pollHit()
if (hit == null) break
const content = hit[0]
const format = hit[1]
const fromAlbumFlag = hit[2]
if (content == null || format == null || fromAlbumFlag == null) continue
continuousHitCount++
const ok: CodeScanSuccess = {
content: content,
format: format,
fromAlbum: fromAlbumFlag == '1'
}
success(ok)
}
}
function launchCapture(host: Activity, opts: CodeScanOptions | null): void {
if (activityResultHook != null) {
UTSAndroid.offAppActivityResult(activityResultHook)
activityResultHook = null
}
clearPollTimer()
CodeScanHitBus.detach()
activeOptions = opts
continuousHitCount = 0
sessionContinuous = opts != null && opts.continuous == true
continuousSuccess = sessionContinuous && opts != null ? opts.success : null
if (sessionContinuous) {
pollTimerId = setInterval(() => {
drainHits()
}, 120)
}
const onResult = (requestCode: Int, resultCode: Int, data?: Intent) => {
if (requestCode != LAUNCH_REQ) {
return
}
UTSAndroid.offAppActivityResult()
activityResultHook = null
drainHits()
clearPollTimer()
CodeScanHitBus.detach()
const current = activeOptions
const wasContinuous = sessionContinuous
sessionContinuous = false
if (resultCode != Activity.RESULT_OK) {
let errCode: CodeScanErrorCode = 9021003
if (data != null) {
const nativeErr = data.getIntExtra(EXTRA_ERR, 0)
if (nativeErr >= 9021002 && nativeErr <= 9021011) {
errCode = nativeErr as CodeScanErrorCode
}
}
// 连续扫已有命中:关闭只 complete,不 fail
if (wasContinuous && continuousHitCount > 0) {
if (current != null) {
current.complete?.({ closed: true, hitCount: continuousHitCount })
}
activeOptions = null
continuousSuccess = null
return
}
if (wasContinuous && data != null && data.getBooleanExtra(EXTRA_HAD_HITS, false)) {
if (current != null) {
current.complete?.({ closed: true, hitCount: continuousHitCount })
}
activeOptions = null
continuousSuccess = null
return
}
if (wasContinuous && current != null) {
emitFailure(current, errCode)
const complete = current.complete
if (complete != null) {
complete({ closed: true, hitCount: continuousHitCount, failed: true, errCode: errCode })
}
activeOptions = null
continuousSuccess = null
return
}
emitFailure(current, errCode)
activeOptions = null
continuousSuccess = null
return
}
if (wasContinuous) {
if (current != null) {
current.complete?.({ closed: true, hitCount: continuousHitCount })
}
activeOptions = null
continuousSuccess = null
return
}
let payload = ''
let formatName = ''
let fromAlbum = false
if (data != null) {
const rawPayload = data.getStringExtra(EXTRA_PAYLOAD)
const rawFormat = data.getStringExtra(EXTRA_FORMAT)
payload = rawPayload != null ? rawPayload : ''
formatName = rawFormat != null ? rawFormat : ''
fromAlbum = data.getBooleanExtra(EXTRA_FROM_ALBUM_RESULT, false)
}
if (payload == '') {
emitFailure(current, 9021004)
activeOptions = null
continuousSuccess = null
return
}
const ok: CodeScanSuccess = {
content: payload,
format: formatName,
fromAlbum: fromAlbum
}
if (current != null) {
current.success?.(ok)
current.complete?.(ok)
}
activeOptions = null
continuousSuccess = null
}
activityResultHook = onResult
UTSAndroid.onAppActivityResult(onResult)
let pageTitle = '扫一扫'
let scanHint = '请将条码放入扫描框内'
let successHint = '扫码成功'
let emptyHint = '未识别到有效条码'
let useSmartZoom = true
let useTorchMode = 'off'
let useResultMode = 'immediate'
let useShowPauseButton = false
let useVibrate = true
let useContinuous = false
let useFromAlbum = false
let useScanRegion = false
let regionWidth = 0.74
let regionHeight = 0.74
let regionCenterX = 0.5
let regionCenterY = 0.5
let formatsCsv = ''
let styleShowMask = true
let styleShowBeam = true
let styleBorderColor = '#19be6b'
let styleMaskColor = 'rgba(0, 0, 0, 0.45)'
let styleBeamColor = '#12e880'
let styleBeamDuration = 1800
let styleCorner = 'corner'
let styleCornerRadius = 20
let scanTimeout = 0
let continuousSuccessDuration = 1000
if (opts != null) {
if (opts.title != null && opts.title!.trim().length > 0) {
pageTitle = opts.title!.trim()
}
if (opts.hint != null && opts.hint!.trim().length > 0) {
scanHint = opts.hint!.trim()
}
if (opts.successHint != null && opts.successHint!.trim().length > 0) {
successHint = opts.successHint!.trim()
}
if (opts.emptyHint != null && opts.emptyHint!.trim().length > 0) {
emptyHint = opts.emptyHint!.trim()
}
if (opts.smartZoom != null) {
useSmartZoom = opts.smartZoom!
}
if (opts.torch == 'on' || opts.torch == 'auto' || opts.torch == 'off') {
useTorchMode = opts.torch!
}
if (opts.resultMode == 'confirm' || opts.resultMode == 'pause' || opts.resultMode == 'immediate') {
useResultMode = opts.resultMode!
}
if (opts.showPauseButton != null) {
useShowPauseButton = opts.showPauseButton!
}
if (opts.vibrate != null) {
useVibrate = opts.vibrate!
}
if (opts.continuous != null) {
useContinuous = opts.continuous!
}
if (opts.fromAlbum != null) {
useFromAlbum = opts.fromAlbum!
}
if (opts.scanRegion != null) {
useScanRegion = opts.scanRegion!.fullScreen != true
if (opts.scanRegion!.width != null) {
regionWidth = opts.scanRegion!.width!
}
if (opts.scanRegion!.height != null) {
regionHeight = opts.scanRegion!.height!
}
if (opts.scanRegion!.centerX != null) {
regionCenterX = opts.scanRegion!.centerX!
}
if (opts.scanRegion!.centerY != null) {
regionCenterY = opts.scanRegion!.centerY!
}
}
if (opts.scanTimeout != null) {
scanTimeout = Math.floor(opts.scanTimeout!)
}
if (opts.continuousSuccessDuration != null) {
continuousSuccessDuration = Math.max(0, Math.min(600000, Math.floor(opts.continuousSuccessDuration!)))
}
if (opts.scanStyle != null) {
if (opts.scanStyle!.showMask != null) {
styleShowMask = opts.scanStyle!.showMask!
}
if (opts.scanStyle!.showBeam != null) {
styleShowBeam = opts.scanStyle!.showBeam!
}
if (opts.scanStyle!.borderColor != null) {
styleBorderColor = opts.scanStyle!.borderColor!
}
if (opts.scanStyle!.maskColor != null) {
styleMaskColor = opts.scanStyle!.maskColor!
}
if (opts.scanStyle!.beamColor != null) {
styleBeamColor = opts.scanStyle!.beamColor!
}
if (opts.scanStyle!.beamDuration != null) {
styleBeamDuration = opts.scanStyle!.beamDuration!
}
if (opts.scanStyle!.cornerStyle == 'full' || opts.scanStyle!.cornerStyle == 'corner' || opts.scanStyle!.cornerStyle == 'rounded' || opts.scanStyle!.cornerStyle == 'rounded-corner') {
styleCorner = opts.scanStyle!.cornerStyle!
}
if (opts.scanStyle!.cornerRadius != null) {
styleCornerRadius = Math.max(0, Math.min(160, Math.floor(opts.scanStyle!.cornerRadius!)))
}
}
if (opts.formats != null && opts.formats!.length > 0) {
const parts: string[] = []
for (let i = 0; i < opts.formats!.length; i++) {
const item = opts.formats![i]
if (item != null && item.length > 0) {
parts.push(item)
}
}
formatsCsv = parts.join(',')
}
}
const intent = new Intent()
intent.setClassName(host, CAPTURE_ACTIVITY)
intent.putExtra(EXTRA_TITLE, pageTitle)
intent.putExtra(EXTRA_HINT, scanHint)
intent.putExtra(EXTRA_SUCCESS_HINT, successHint)
intent.putExtra(EXTRA_EMPTY_HINT, emptyHint)
intent.putExtra(EXTRA_SMART_ZOOM, useSmartZoom)
intent.putExtra(EXTRA_TORCH_MODE, useTorchMode)
intent.putExtra(EXTRA_RESULT_MODE, useResultMode)
intent.putExtra(EXTRA_SHOW_PAUSE_BUTTON, useShowPauseButton)
intent.putExtra(EXTRA_VIBRATE, useVibrate)
intent.putExtra(EXTRA_CONTINUOUS, useContinuous)
intent.putExtra(EXTRA_FROM_ALBUM, useFromAlbum)
intent.putExtra(EXTRA_FORMATS, formatsCsv)
intent.putExtra(EXTRA_SCAN_REGION_ENABLED, useScanRegion)
intent.putExtra(EXTRA_SCAN_REGION, `${regionWidth},${regionHeight},${regionCenterX},${regionCenterY}`)
intent.putExtra(EXTRA_SCAN_STYLE_SHOW_MASK, styleShowMask)
intent.putExtra(EXTRA_SCAN_STYLE_SHOW_BEAM, styleShowBeam)
intent.putExtra(EXTRA_SCAN_STYLE_BORDER_COLOR, styleBorderColor)
intent.putExtra(EXTRA_SCAN_STYLE_MASK_COLOR, styleMaskColor)
intent.putExtra(EXTRA_SCAN_STYLE_BEAM_COLOR, styleBeamColor)
intent.putExtra(EXTRA_SCAN_STYLE_BEAM_DURATION, styleBeamDuration)
intent.putExtra(EXTRA_SCAN_STYLE_CORNER, styleCorner)
intent.putExtra(EXTRA_SCAN_STYLE_CORNER_RADIUS, styleCornerRadius)
intent.putExtra(EXTRA_SCAN_TIMEOUT, scanTimeout)
intent.putExtra(EXTRA_CONTINUOUS_SUCCESS_DURATION, continuousSuccessDuration)
host.startActivityForResult(intent, LAUNCH_REQ)
}
function emitFailure(opts: CodeScanOptions | null, code: CodeScanErrorCode): void {
const err = new CodeScanFailImpl(code)
if (opts != null) {
opts.fail?.(err)
opts.complete?.(err)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,90 @@
export type CodeScanSuccess = {
content : string
format : string
fromAlbum ?: boolean
}
export type CodeScanErrorCode = 9021001 | 9021002 | 9021003 | 9021004 | 9021005 | 9021006 | 9021007 | 9021008 | 9021009 | 9021010 | 9021011
export type CodeScanTorchMode = 'off' | 'on' | 'auto'
export type CodeScanResultMode = 'immediate' | 'confirm' | 'pause'
export type CodeScanCornerStyle = 'corner' | 'full' | 'rounded' | 'rounded-corner'
export type CodeScanStyle = {
/** 是否显示扫描框外遮罩,默认 true */
showMask ?: boolean
/** 是否显示扫描线,默认 true */
showBeam ?: boolean
/** 扫描框边框颜色,支持 #RRGGBB / #AARRGGBB */
borderColor ?: string
/** 遮罩颜色,支持 #RRGGBB / #AARRGGBB 或 rgba() */
maskColor ?: string
/** 扫描线颜色,支持 #RRGGBB / #AARRGGBB */
beamColor ?: string
/** 扫描线移动周期,单位毫秒,默认 1800 */
beamDuration ?: number
/** 扫描框样式:corner 四角边框,full 完整边框,rounded 完整圆角边框,rounded-corner 四角圆角边框,默认 corner */
cornerStyle ?: CodeScanCornerStyle
/** 圆角半径,单位 dp,rounded 和 rounded-corner 样式生效,默认 20,范围 0 ~ 160 */
cornerRadius ?: number
}
export interface CodeScanFail extends IUniError {
errCode : CodeScanErrorCode
}
/**
* 实时相机扫描区域。宽度和高度均以预览宽度为参考比例,因此可稳定锁定宽高比。
* 不传时保持原有整画面识别;相册识图不受该配置限制。
*/
export type ScanRegion = {
/** 全屏识别:不绘制扫描框、不进行区域过滤 */
fullScreen ?: boolean
/** 扫描框宽度比例,建议 0.1 ~ 1 */
width ?: number
/** 扫描框高度比例,建议 0.1 ~ 1 */
height ?: number
/** 扫描框中心横坐标比例,0 为最左、1 为最右 */
centerX ?: number
/** 扫描框中心纵坐标比例,0 为顶部、1 为底部 */
centerY ?: number
}
export type CodeScanOptions = {
/** 扫码页标题,默认“扫一扫” */
title ?: string
/** 扫描框下方引导语,默认“请将条码放入扫描框内” */
hint ?: string
/** 识别成功提示语,默认“扫码成功” */
successHint ?: string
/** 未识别到条码时的提示语,默认“未识别到有效条码” */
emptyHint ?: string
/** 条码偏小时自动放大,默认 true */
smartZoom ?: boolean
/** 手电筒策略:off 关闭、on 默认开启、auto 根据环境亮度自动补光,默认 off */
torch ?: CodeScanTorchMode
/** 单次扫码结果策略:immediate 立即返回、confirm 确认后返回、pause 暂停后可继续扫描,默认 immediate */
resultMode ?: CodeScanResultMode
/** 连续扫码页是否显示暂停/继续按钮,默认 false */
showPauseButton ?: boolean
/** 限定码制,如 ['QR_CODE','CODE_128'];空/缺省为全格式 */
formats ?: string[]
/** 识别成功是否震动,默认 true */
vibrate ?: boolean
/** 连续扫码:页内不关闭,多次 success;点关闭结束;可通过 showPauseButton 显示暂停按钮 */
continuous ?: boolean
/** 连续扫码成功提示停留时长,单位毫秒,默认 1000,范围 0 ~ 600000 */
continuousSuccessDuration ?: number
/** 打开后直接进入相册选图 */
fromAlbum ?: boolean
/** 实时相机有效识别区域;fullScreen 为 true 时整画面均可识别 */
scanRegion ?: ScanRegion
/** 扫描框视觉样式 */
scanStyle ?: CodeScanStyle
/** 扫描会话超时毫秒数;不传或小于 1000 时不启用,最大 600000 */
scanTimeout ?: number
success ?: (res : CodeScanSuccess) => void
fail ?: (res : CodeScanFail) => void
complete ?: (res : any) => void
}
export type OpenCodeScan = (options ?: CodeScanOptions | null) => void
@@ -0,0 +1,28 @@
import { CodeScanErrorCode, CodeScanFail } from './interface.uts'
export const CodeScanErrorSubject = 'mas-code-scan'
export const CodeScanErrorText: Map<CodeScanErrorCode, string> = new Map([
[9021001, '当前无法获取页面 Activity'],
[9021002, '相机权限被拒绝'],
[9021003, '用户取消扫码'],
[9021004, '未获取到有效的扫码结果'],
[9021005, '相机被其他应用占用或当前不可用'],
[9021006, '设备不支持手电筒'],
[9021007, '相机初始化失败'],
[9021008, '相册图片无法读取'],
[9021009, '图片中未识别到条码'],
[9021010, '指定码制不支持'],
[9021011, '扫码超时']
])
export class CodeScanFailImpl extends UniError implements CodeScanFail {
override errCode: CodeScanErrorCode
constructor(errCode: CodeScanErrorCode) {
super()
this.errSubject = CodeScanErrorSubject
this.errCode = errCode
this.errMsg = CodeScanErrorText.get(errCode) ?? ''
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
import type { ExtractPropTypes, PropType } from 'vue';
import { getColor } from '../../';
/**
* actionSheet 操作菜单
* @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
*/
export const ActionSheetItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 标题 */
text: { type: String, default: '' },
/** 描述 */
subText: { type: String, default: '' },
/** 边距 */
padding: { type: [Number, String] as PropType<number | string>, default: '34rpx 0' },
/** 字体颜色 */
color: { type: String, default: () => getColor('mainColor') },
/** 字体大小 */
fontSize: { type: [String, Number], default: '32rpx' },
/** 是否禁用 */
disabled: { type: Boolean, default: false },
/** 是否异步关闭 */
asyncClose: { type: Boolean, default: false }
};
export type ActionSheetItemProps = ExtractPropTypes<typeof ActionSheetItemProps>;
@@ -0,0 +1,85 @@
<template>
<view
class="u-action-sheet-item u-line-1"
:class="[!isLast ? 'u-border-bottom' : '', disabled ? 'u-action-sheet-item--disabled' : '', customClass]"
:style="$u.toStyle(itemStyle, customStyle)"
@click="handleClick"
@touchmove.stop.prevent
hover-class="u-hover-class"
:hover-stay-time="150"
>
<slot>
<text>{{ text }}</text>
<text class="u-action-sheet-item__subtext u-line-1" v-if="subText">{{ subText }}</text>
</slot>
</view>
</template>
<script lang="ts">
export default {
name: 'u-action-sheet-item',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { ActionSheetItemProps } from './types';
import { $u, useChildren } from '../../libs';
const props = defineProps(ActionSheetItemProps);
const emit = defineEmits(['click']);
const { parent, parentExposed, childIndex, emitToParent } = useChildren('u-action-sheet-item', 'u-action-sheet');
const isLast = computed(() => {
return (childIndex?.value ?? 0) + 1 >= (parent?.value?.getChildren()?.length ?? 0);
});
const itemStyle = computed(() => {
const style: Record<string, string> = {};
style.color = props.color || parentExposed?.value?.props?.color;
style.fontSize = $u.addUnit(props.fontSize || parentExposed?.value?.props?.fontSize);
style.padding = $u.addUnit(props.padding);
if (props.disabled) style.color = 'var(--u-light-color)';
return style;
});
function handleClick() {
if (props.disabled) return;
emit('click', childIndex?.value || 0);
if (!props.asyncClose && !parentExposed?.value?.props?.asyncClose) {
emitToParent('itemClick', childIndex?.value || 0);
}
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-action-sheet-item {
@include vue-flex;
line-height: 1;
justify-content: center;
align-items: center;
font-size: 32rpx;
padding: 34rpx 0;
flex-direction: column;
}
.u-action-sheet-item--disabled {
color: $u-light-color;
}
.u-action-sheet-item__subtext {
font-size: 24rpx;
color: $u-tips-color;
margin-top: 20rpx;
}
</style>
@@ -0,0 +1,52 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ActionSheetItem, ActionSheetTips } from '../../types/global';
import { getColor } from '../../';
/**
* actionSheet 操作菜单
* @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
*/
export const ActionSheetProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 点击遮罩是否可以关闭actionSheet */
maskCloseAble: { type: Boolean, default: true },
/** 按钮的文字数组,可以自定义颜色和字体大小,字体单位为rpx */
list: {
type: Array as unknown as PropType<ActionSheetItem[]>,
default: () => []
},
/** 顶部的提示文字 */
tips: {
type: Object as unknown as PropType<ActionSheetTips>,
default: () => ({ text: '', color: getColor('tipsColor'), fontSize: '26rpx' })
},
/** 底部的取消按钮 */
cancelBtn: { type: Boolean, default: true },
/** 是否开启底部安全区适配,开启的话,会在iPhoneX机型底部添加一定的内边距 */
safeAreaInsetBottom: { type: Boolean, default: false },
/** 通过双向绑定控制组件的弹出与收起 */
modelValue: { type: Boolean, default: false },
/** 弹出的顶部圆角值 */
borderRadius: { type: [String, Number], default: 0 },
/** 弹出的z-index值 */
zIndex: { type: [String, Number], default: 0 },
/** 取消按钮的文字提示 */
cancelText: { type: String, default: '' },
/** 字体颜色 */
color: { type: String, default: () => getColor('mainColor') },
/** 字体大小 */
fontSize: { type: [String, Number], default: '32rpx' },
/** 是否异步关闭 */
asyncClose: { type: Boolean, default: false }
};
export type ActionSheetProps = ExtractPropTypes<typeof ActionSheetProps>;
@@ -0,0 +1,196 @@
<template>
<u-popup
mode="bottom"
:border-radius="borderRadius"
:popup="false"
v-model="popupValue"
:maskCloseAble="maskCloseAble"
length="auto"
:safeAreaInsetBottom="safeAreaInsetBottom"
@close="popupClose"
:z-index="uZIndex"
:custom-class="customClass"
:custom-style="customStyle"
>
<view class="u-tips u-border-bottom" v-if="tips.text" :style="[tipsStyle]">
{{ tips.text }}
</view>
<block v-if="list && list.length > 0" v-for="(item, index) in list" :key="index">
<view
@touchmove.stop.prevent
@tap="itemClick(index)"
:style="[itemStyle(index)]"
class="u-action-sheet-item u-line-1"
:class="[index < list.length - 1 ? 'u-border-bottom' : '']"
:hover-stay-time="150"
>
<text>{{ item.text }}</text>
<text class="u-action-sheet-item__subtext u-line-1" v-if="item.subText">{{ item.subText }}</text>
</view>
</block>
<template v-else>
<slot></slot>
</template>
<view class="u-gab" v-if="cancelBtn"> </view>
<view
@touchmove.stop.prevent
class="u-action-sheet-cancel u-action-sheet-item"
hover-class="u-hover-class"
:hover-stay-time="150"
v-if="cancelBtn"
@tap="close"
>
{{ getCancelText }}
</view>
</u-popup>
</template>
<script lang="ts">
export default {
name: 'u-action-sheet',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { $u, useParent, useLocale } from '../..';
import { ActionSheetProps } from './types';
import uPopup from '../u-popup/u-popup.vue';
/**
* actionSheet 操作菜单
* @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
* @tutorial https://uviewpro.cn/zh/components/actionSheet.html
* @property {Array<{ text: string; subText?: string; color?: string; fontSize?: string; disabled?: boolean }>} list 按钮的文字数组,见官方文档示例
* @property {{text: string; color?: string; fontSize?: string}} tips 顶部的提示文字,见官方文档示例
* @property {String} cancel-text 取消按钮的提示文字
* @property {Boolean} cancel-btn 是否显示底部的取消按钮(默认true)
* @property {Number String} border-radius 弹出部分顶部左右的圆角值,单位rpx(默认0)
* @property {Boolean} mask-close-able 点击遮罩是否可以关闭(默认true)
* @property {Boolean} safe-area-inset-bottom 是否开启底部安全区适配(默认false)
* @property {Number String} z-index z-index值(默认1075)
* @property {String} cancel-text 取消按钮的提示文字
* @event {Function} click 点击ActionSheet列表项时触发
* @event {Function} close 点击取消按钮时触发
* @example <u-action-sheet :list="list" @click="click" v-model="show"></u-action-sheet>
*/
const props = defineProps(ActionSheetProps);
const emit = defineEmits(['update:modelValue', 'click', 'close']);
const { t } = useLocale();
useParent('u-action-sheet');
const popupValue = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val)
});
// 国际化计算属性
const getCancelText = computed(() => props.cancelText || t('uActionSheet.cancelText'));
// 顶部提示的样式
const tipsStyle = computed(() => {
let style: Record<string, string> = {};
style.color = String(props.tips?.color || $u.color.tipsColor);
style.fontSize = $u.addUnit(String(props.tips?.fontSize || '26rpx'));
return style;
});
// 操作项目的样式
const itemStyle = (index: number) => {
let style: Record<string, string> = {};
style.color = String(props.list[index]?.color || props.color);
style.fontSize = $u.addUnit(String(props.list[index]?.fontSize || props.fontSize));
// 选项被禁用的样式
if (props.list[index]?.disabled) style.color = String($u.color.lightColor || '');
return style;
};
// 计算z-index
const uZIndex = computed(() => {
// 如果用户有传递z-index值,优先使用
return props.zIndex ? props.zIndex : $u.zIndex.popup;
});
/**
* 点击取消按钮
*/
function close() {
// 发送input事件,并不会作用于父组件,而是要设置组件内部通过props传递的value参数
// 这是一个vue发送事件的特殊用法
popupClose();
emit('close');
}
/**
* 弹窗关闭
*/
function popupClose() {
emit('update:modelValue', false);
}
/**
* 点击某一个item
* @param index 选项索引
*/
function itemClick(index: number) {
// disabled的项禁止点击
if (props.list[index]?.disabled) return;
emit('click', index);
if (props.asyncClose) return;
emit('update:modelValue', false);
}
defineExpose({
props,
close,
itemClick
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-tips {
font-size: 26rpx;
text-align: center;
padding: 34rpx 0;
line-height: 1;
color: $u-tips-color;
}
.u-action-sheet-item {
@include vue-flex;
line-height: 1;
justify-content: center;
align-items: center;
font-size: 32rpx;
padding: 34rpx 0;
flex-direction: column;
}
.u-action-sheet-item__subtext {
font-size: 24rpx;
color: $u-tips-color;
margin-top: 20rpx;
}
.u-gab {
height: 12rpx;
background-color: rgb(234, 234, 236);
}
.u-action-sheet-cancel {
color: $u-main-color;
}
</style>
@@ -0,0 +1,49 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ThemeType } from '../../types/global';
/**
* alertTips 警告提示
* @description 警告提示,展现需要关注的信息
*/
export const AlertTipsProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 显示文字 */
title: { type: String, default: '' },
/** 主题,success/warning/info/error */
type: { type: String as PropType<ThemeType>, default: 'warning' },
/** 辅助性文字 */
description: { type: String, default: '' },
/** 是否可关闭 */
closeAble: { type: Boolean, default: false },
/** 关闭按钮自定义文本 */
closeText: { type: String, default: '' },
/** 是否显示图标 */
showIcon: { type: Boolean, default: false },
/** 文字颜色,如果定义了color值,icon会失效 */
color: { type: String, default: '' },
/** 背景颜色 */
bgColor: { type: String, default: '' },
/** 边框颜色 */
borderColor: { type: String, default: '' },
/** 是否显示 */
show: { type: Boolean, default: true },
/** 左边显示的icon */
icon: { type: String, default: '' },
/** icon的样式 */
iconStyle: { type: Object, default: () => ({}) },
/** 标题的样式 */
titleStyle: { type: Object, default: () => ({}) },
/** 描述文字的样式 */
descStyle: { type: Object, default: () => ({}) }
};
export type AlertTipsProps = ExtractPropTypes<typeof AlertTipsProps>;
@@ -0,0 +1,239 @@
<template>
<view
class="u-alert-tips"
v-if="show"
:class="[
!show ? 'u-close-alert-tips' : '',
type ? 'u-alert-tips--bg--' + type + '-light' : '',
type ? 'u-alert-tips--border--' + type + '-disabled' : '',
customClass
]"
:style="
$u.toStyle(
{
backgroundColor: bgColor,
borderColor: borderColor
},
customStyle
)
"
>
<view class="u-icon-wrap">
<u-icon
v-if="showIcon"
:name="uIconName"
:size="description ? 40 : 32"
:color="uIconType"
:custom-style="iconStyle"
custom-class="u-tips-icon"
></u-icon>
</view>
<view class="u-alert-content" @tap.stop="onClick">
<view class="u-alert-title" :style="uTitleStyle">
{{ title }}
</view>
<view v-if="description" class="u-alert-desc" :style="descStyle">
{{ description }}
</view>
</view>
<view class="u-icon-wrap">
<u-icon
@click="onClose"
v-if="closeAble && !closeText"
hoverClass="u-type-error-hover-color"
name="close"
color="var(--u-tips-color)"
:size="22"
:custom-style="{
top: description ? '18rpx' : '24rpx'
}"
custom-class="u-close-icon"
></u-icon>
</view>
<text
v-if="closeAble && closeText"
class="u-close-text"
:style="{
top: description ? '18rpx' : '24rpx'
}"
>
{{ closeText }}
</text>
</view>
</template>
<script lang="ts">
export default {
name: 'u-alert-tips',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { $u } from '../..';
import { AlertTipsProps } from './types';
import uIcon from '../u-icon/u-icon.vue';
/**
* alertTips 警告提示
* @description 警告提示,展现需要关注的信息
* @tutorial https://uviewpro.cn/zh/components/alertTips.html
* @property {String} title 显示的标题文字
* @property {String} description 辅助性文字,颜色比title浅一点,字号也小一点,可选
* @property {String} type 关闭按钮(默认为叉号icon图标)
* @property {String} icon 图标名称
* @property {Object} icon-style 图标的样式,对象形式
* @property {Object} title-style 标题的样式,对象形式
* @property {Object} desc-style 描述的样式,对象形式
* @property {String} close-able 用文字替代关闭图标,close-able为true时有效
* @property {Boolean} show-icon 是否显示左边的辅助图标
* @property {Boolean} show 显示或隐藏组件
* @event {Function} click 点击组件时触发
* @event {Function} close 点击关闭按钮时触发
*/
const props = defineProps(AlertTipsProps);
const emit = defineEmits(['click', 'close']);
/**
* 标题样式,合并加粗和用户自定义样式
*/
const uTitleStyle = computed(() => {
let style: Record<string, any> = {};
// 如果有描述文字的话,标题进行加粗
style.fontWeight = props.description ? 500 : 'normal';
// 将用户传入样式对象和style合并,传入的优先级比style高,同属性会被覆盖
return $u.deepMerge(style, props.titleStyle);
});
/**
* 图标名称,优先使用用户传入,否则根据type主题推定默认图标
*/
const uIconName = computed(() => {
// 如果有设置icon名称就使用,否则根据type主题,推定一个默认的图标
return props.icon ? props.icon : $u.type2icon(props.type as any);
});
/**
* 图标类型,优先使用iconStyle,否则用type
*/
const uIconType = computed(() => {
// 如果有设置图标的样式,优先使用,没有的话,则用type的样式
return Object.keys(props.iconStyle).length ? '' : props.type;
});
/**
* 点击内容
*/
function onClick() {
emit('click');
}
/**
* 点击关闭按钮
*/
function onClose() {
emit('close');
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-alert-tips {
@include vue-flex;
align-items: center;
padding: 16rpx 30rpx;
border-radius: 8rpx;
position: relative;
transition: all 0.3s linear;
border: 1px solid var(--u-white-color);
&--bg--primary-light {
background-color: $u-type-primary-light;
}
&--bg--info-light {
background-color: $u-type-info-light;
}
&--bg--success-light {
background-color: $u-type-success-light;
}
&--bg--warning-light {
background-color: $u-type-warning-light;
}
&--bg--error-light {
background-color: $u-type-error-light;
}
&--border--primary-disabled {
border-color: $u-type-primary-disabled;
}
&--border--success-disabled {
border-color: $u-type-success-disabled;
}
&--border--error-disabled {
border-color: $u-type-error-disabled;
}
&--border--warning-disabled {
border-color: $u-type-warning-disabled;
}
&--border--info-disabled {
border-color: $u-type-info-disabled;
}
:deep(.u-tips-icon) {
margin-right: 16rpx;
}
:deep(.u-close-icon) {
position: absolute;
right: 20rpx;
cursor: pointer;
}
}
.u-close-alert-tips {
opacity: 0;
visibility: hidden;
}
.u-alert-title {
font-size: 28rpx;
color: $u-main-color;
}
.u-alert-desc {
font-size: 26rpx;
text-align: left;
color: $u-content-color;
}
.u-close-hover {
color: red;
}
.u-close-text {
font-size: 24rpx;
color: $u-tips-color;
position: absolute;
top: 20rpx;
right: 20rpx;
line-height: 1;
}
</style>
@@ -0,0 +1,23 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { AvatarCropperBoundStyle } from '../../types/global';
export const AvatarCropperProps = {
/** 裁剪矩形框的样式 */
boundStyle: {
type: Object as PropType<AvatarCropperBoundStyle>,
default: (): AvatarCropperBoundStyle => ({
lineWidth: 4,
borderColor: 'rgb(245, 245, 245)',
mask: 'rgba(0, 0, 0, 0.35)'
})
}
// 其他 props 可参考注释,后续如需开放直接补充
// rectWidth: { type: [String, Number], default: 400 },
// rectHeight: { type: [String, Number], default: 400 },
// destWidth: { type: [String, Number], default: 400 },
// destHeight: { type: [String, Number], default: 400 },
// fileType: { type: String, default: 'jpg' },
// quality: { type: [Number, String], default: 1 }
};
export type AvatarCropperProps = ExtractPropTypes<typeof AvatarCropperProps>;
@@ -0,0 +1,299 @@
<template>
<view class="content">
<view class="cropper-wrapper" :style="{ height: cropperOpt.height + 'px' }">
<canvas
class="cropper"
:disable-scroll="true"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
:style="{ width: cropperOpt.width, height: cropperOpt.height, backgroundColor: 'rgba(0, 0, 0, 0.8)' }"
canvas-id="cropper"
id="cropper"
></canvas>
<canvas
class="cropper"
:disable-scroll="true"
:style="{
position: 'fixed',
top: `-${cropperOpt.width * cropperOpt.pixelRatio}px`,
left: `-${cropperOpt.height * cropperOpt.pixelRatio}px`,
width: `${cropperOpt.width * cropperOpt.pixelRatio}px`,
height: `${cropperOpt.height * cropperOpt.pixelRatio}`
}"
canvas-id="targetId"
id="targetId"
></canvas>
</view>
<view class="cropper-buttons safe-area-padding" :style="{ height: bottomNavHeight + 'px' }">
<!-- #ifdef H5 -->
<view class="upload" @tap="uploadTap">{{ t('avatarCropper.select') }}</view>
<!-- #endif -->
<!-- #ifndef H5 -->
<view class="upload" @tap="uploadTap">{{ t('avatarCropper.reselect') }}</view>
<!-- #endif -->
<view class="getCropperImage" @tap="() => getCropperImage(false)">{{ t('avatarCropper.confirm') }}</view>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-avatar-cropper',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
// @ts-nocheck
import { ref, reactive, onMounted } from 'vue';
import { $u, useLocale } from '../..';
// 兼容 UMD/ESM 导入 weCropper.js
import WeCropper from './weCropper';
import { AvatarCropperProps } from './types';
/**
* 裁剪矩形框的样式,其中可包含的属性为lineWidth-边框宽度(单位rpx),color: 边框颜色,
* mask-遮罩颜色,一般设置为一个rgba的透明度,如"rgba(0, 0, 0, 0.35)"
*/
const props = defineProps(AvatarCropperProps);
const { t } = useLocale();
/**
* 组合式API变量声明
* 保留所有说明注释
*/
const bottomNavHeight = ref(50); // 底部导航的高度
const originWidth = ref(200);
const width = ref(0);
const height = ref(0);
const src = ref(''); // 选择的图片路径,用于在点击确定时,判断是否选择了图片
const destWidth = ref(200); // 输出图片宽度,单位px
const rectWidth = ref(200); // 裁剪框宽度,单位px
const fileType = ref('jpg'); // 输出的图片类型,如果'png'类型发现裁剪的图片太大,改成"jpg"即可
// cropperOpt 需响应式,且类型安全
const cropperOpt = reactive({
id: 'cropper',
targetId: 'targetCropper',
pixelRatio: 1,
width: 0,
height: 0,
scale: 2.5,
zoom: 8,
cut: {
x: 0,
y: 0,
width: originWidth.value,
height: originWidth.value
},
boundStyle: {
lineWidth: 0,
mask: '',
color: ''
}
});
let cropper: any = null; // WeCropper 实例,类型 any,建议后续补充类型声明
/**
* 组件挂载时初始化 cropper
* 保留所有说明注释
*/
onMounted(() => {
// 获取系统信息
const rectInfo = uni.getSystemInfoSync();
width.value = rectInfo.windowWidth;
height.value = rectInfo.windowHeight - bottomNavHeight.value;
cropperOpt.width = width.value;
cropperOpt.height = height.value;
cropperOpt.pixelRatio = rectInfo.pixelRatio;
// 初始化裁剪框
cropperOpt.cut = {
x: (width.value - originWidth.value) / 2,
y: (height.value - originWidth.value) / 2,
width: originWidth.value,
height: originWidth.value
};
cropperOpt.boundStyle = {
lineWidth: uni.upx2px(props.boundStyle.lineWidth),
mask: props.boundStyle.mask,
color: props.boundStyle.borderColor
};
// 设置导航栏样式,以免用户在page.json中没有设置为黑色背景
uni.setNavigationBarColor({
frontColor: 'var(--u-white-color)',
backgroundColor: 'var(--u-bg-black)'
});
// 初始化 cropper 实例
cropper = new WeCropper(cropperOpt)
.on('ready', (ctx: any) => {
// wecropper is ready for work!
})
.on('beforeImageLoad', (ctx: any) => {
// before picture loaded, i can do something
})
.on('imageLoad', (ctx: any) => {
// picture loaded
})
.on('beforeDraw', (ctx: any, instance: any) => {
// before canvas draw,i can do something
});
// 选择图片
uni.chooseImage({
count: 1, // 默认9
sizeType: ['compressed'], // 可以指定是原图还是压缩图,默认二者都有
sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有
success: (res: UniApp.ChooseImageSuccessCallbackResult) => {
src.value = res.tempFilePaths[0];
// 获取裁剪图片资源后,给data添加src属性及其值
cropper.pushOrign(src.value);
}
});
});
/**
* 触摸开始事件
* @param e 事件对象
*/
function touchStart(e: any) {
cropper && cropper.touchStart(e);
}
/**
* 触摸移动事件
* @param e 事件对象
*/
function touchMove(e: any) {
cropper && cropper.touchMove(e);
}
/**
* 触摸结束事件
* @param e 事件对象
*/
function touchEnd(e: any) {
cropper && cropper.touchEnd(e);
}
/**
* 获取裁剪后的图片
* @param isPre 是否预览
*/
function getCropperImage(isPre = false) {
if (!src.value) return $u.toast(t('avatarCropper.noSelect'));
const cropper_opt = {
destHeight: Number(destWidth.value), // uni.canvasToTempFilePath要求这些参数为数值
destWidth: Number(destWidth.value),
fileType: fileType.value
};
cropper.getCropperImage(cropper_opt, (path: string, err: any) => {
if (err) {
uni.showModal({
title: t('avatarCropper.modalTitle'),
content: err.message
});
} else {
if (isPre) {
uni.previewImage({
current: '', // 当前显示图片的 http 链接
urls: [path] // 需要预览的图片 http 链接列表
});
} else {
uni.$emit('uAvatarCropper', path);
$u.route({
type: 'back'
});
}
}
});
}
/**
* 重新选择图片
*/
function uploadTap() {
uni.chooseImage({
count: 1, // 默认9
sizeType: ['original', 'compressed'], // 可以指定是原图还是压缩图,默认二者都有
sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有
success: (res: UniApp.ChooseImageSuccessCallbackResult) => {
src.value = res.tempFilePaths[0];
// 获取裁剪图片资源后,给data添加src属性及其值
cropper.pushOrign(src.value);
}
});
}
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.content {
background: rgba(255, 255, 255, 1);
}
.cropper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 11;
}
.cropper-buttons {
background-color: var(--u-bg-black);
color: var(--u-divider-color);
}
.cropper-wrapper {
position: relative;
@include vue-flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
background-color: var(--u-bg-black);
}
.cropper-buttons {
width: 100vw;
@include vue-flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
position: fixed;
bottom: 0;
left: 0;
font-size: 28rpx;
}
.cropper-buttons .upload,
.cropper-buttons .getCropperImage {
width: 50%;
text-align: center;
}
.cropper-buttons .upload {
text-align: left;
padding-left: 50rpx;
}
.cropper-buttons .getCropperImage {
text-align: right;
padding-right: 50rpx;
}
</style>
@@ -0,0 +1,62 @@
// @ts-nocheck
/**
* we-cropper 类型声明文件
* 适用于 Vue3 + TypeScript 项目中 weCropper.js 的类型提示
*/
export interface WeCropperOptions {
id?: string;
targetId?: string;
pixelRatio?: number;
width?: number;
height?: number;
scale?: number;
zoom?: number;
cut?: {
x?: number;
y?: number;
width?: number;
height?: number;
};
boundStyle?: {
lineWidth?: number;
borderColor?: string;
mask?: string;
color?: string;
};
[key: string]: any;
}
export type WeCropperEvent = (ctx: any, instance?: any) => void;
export interface WeCropperInstance {
pushOrign(src: string): Promise<void>;
pushOrigin(src: string): Promise<void>;
removeImage(): Promise<void>;
getCropperBase64(done?: (base64: string) => void): void;
getCropperImage(opt: any, fn: (path: string, err: any) => void): void;
update(): void;
touchStart(e: any): void;
touchMove(e: any): void;
touchEnd(e: any): void;
on(event: 'ready' | 'beforeImageLoad' | 'imageLoad' | 'beforeDraw', fn: WeCropperEvent): WeCropperInstance;
// 其它动态方法
[key: string]: any;
}
// WeCropper 类型声明文件
declare class WeCropper {
constructor(params: any);
// 事件方法
on(event: string, callback: (ctx?: any) => void): WeCropper;
// 其他方法
pushOrign(src: string): void;
getCropperImage(callback?: (tempFilePath: string) => void): void;
// 属性
[key: string]: any;
}
export default WeCropper;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More