初始化本地仓库

This commit is contained in:
2026-08-31 11:33:59 +08:00
commit 663f74b633
2390 changed files with 402918 additions and 0 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
})
}
@@ -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)
}
+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: 'scanapp'
}
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>
@@ -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>
@@ -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>
@@ -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
@@ -0,0 +1,44 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ImgMode } from '../../types/global';
// u-avatar 组件 props
export const AvatarProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 背景颜色 */
bgColor: { type: String, default: 'transparent' },
/** 头像路径 */
src: { type: String, default: '' },
/** 尺寸,large-大,default-中等,mini-小,如果为数值,则单位为rpx,宽度等于高度 */
size: { type: [String, Number], default: 'default' },
/** 头像模型,square-带圆角方形,circle-圆形 */
mode: { type: String, default: 'circle' },
/** 文字内容 */
text: { type: String, default: '' },
/** 图片的裁剪模型 */
imgMode: { type: String as PropType<ImgMode>, default: 'aspectFill' },
/** 标识符 */
index: { type: [String, Number], default: '' },
/** 右上角性别角标,man-男,woman-女 */
sexIcon: { type: String as PropType<'man' | 'woman'>, default: 'man' },
/** 右下角的等级图标 */
levelIcon: { type: String, default: 'level' },
/** 右下角等级图标背景颜色 */
levelBgColor: { type: String, default: '' },
/** 右上角性别图标的背景颜色 */
sexBgColor: { type: String, default: '' },
/** 是否显示性别图标 */
showSex: { type: Boolean, default: false },
/** 是否显示等级图标 */
showLevel: { type: Boolean, default: false }
};
export type AvatarProps = ExtractPropTypes<typeof AvatarProps>;

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