完善生产查询与登录权限配置
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
unpackage/
|
||||
.DS_Store
|
||||
.hbuilderx/
|
||||
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
@@ -0,0 +1 @@
|
||||
mrleeAPP
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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('无法连接更新服务,请检查网络后重试'))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from '@/utils/request.js'
|
||||
|
||||
/** 查询生产记录(扫码、整机、异常、维修)。 */
|
||||
export function queryProduceRecords(params = {}) {
|
||||
return request.get('/produce/query/records', params, { showError: false })
|
||||
}
|
||||
|
||||
/** 查询指定订单的生产时间线。 */
|
||||
export function queryProduceTimeline(orderNo) {
|
||||
return request.get('/produce/query/timeline', {
|
||||
order_no: String(orderNo || '').trim()
|
||||
}, { showError: false })
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
const backendURL = 'http://192.168.11.30:8001'
|
||||
|
||||
const config = {
|
||||
baseURL: `${backendURL}/appapi`,
|
||||
apiBaseURL: `${backendURL}/api`,
|
||||
tokenKey: 'scanapp_token',
|
||||
tokenHeaderKey: 'Authorization',
|
||||
channelHeaderKey: 'channel',
|
||||
channel: 'app'
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,719 @@
|
||||
# mas-code-scan iOS 端支持方案
|
||||
|
||||
> 文档状态:方案设计,尚未开始 iOS 原生实现。
|
||||
>
|
||||
> 目标是在不改变现有业务调用方式的前提下,为 `mas-code-scan` 增加 iOS App 支持,并与 Android 端尽量保持相同的功能、参数语义、回调顺序和错误码。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前 `mas-code-scan` 是 Android UTS API 插件,Android 原生层基于 CameraX + Google ML Kit 实现。对外 API 已沉淀在 `utssdk/interface.uts`,业务页面通过 `openCodeScan` 调用,因此具备扩展 iOS 实现的良好基础。
|
||||
|
||||
### 1.1 当前状态
|
||||
|
||||
| 项目 | 当前情况 |
|
||||
| --- | --- |
|
||||
| 对外 API | `openCodeScan(options)` |
|
||||
| 公共类型 | `CodeScanOptions`、`CodeScanSuccess`、`CodeScanFail`、`ScanRegion`、`CodeScanStyle` |
|
||||
| Android 实现 | `utssdk/app-android/`,CameraX + Google ML Kit |
|
||||
| iOS 实现 | 暂无 `utssdk/app-ios/` 目录 |
|
||||
| 业务调用 | `pages/scan/prototype.vue` 只需调用统一 API |
|
||||
| 当前支持平台 | Android App(uni-app Vue2/Vue3) |
|
||||
|
||||
### 1.2 建设目标
|
||||
|
||||
1. **API 不变**:iOS 与 Android 均使用 `openCodeScan`,业务页面不增加平台分支。
|
||||
2. **回调一致**:统一 `success`、`fail`、`complete` 的数据结构和触发时机。
|
||||
3. **优先原生能力**:使用 iOS 系统框架,尽量不新增第三方识别库。
|
||||
4. **功能分阶段交付**:先完成可真机使用的最小链路,再逐步补齐 Android 已有能力。
|
||||
5. **平台差异显式处理**:对系统不支持或实现成本较高的能力,明确降级或延后,不以静默不生效代替。
|
||||
|
||||
### 1.3 非目标
|
||||
|
||||
本期方案不包含:
|
||||
|
||||
- 鸿蒙、H5、小程序、nvue 支持;
|
||||
- 修改 Android 识别链路;
|
||||
- 将扫码页改造为 Vue 组件;
|
||||
- 为尚未经过真机验证的 iOS 能力提前修改 README 平台声明。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
### 2.1 目录规划
|
||||
|
||||
在保持现有 Android 目录不变的基础上新增 iOS 实现:
|
||||
|
||||
```text
|
||||
frontend/scanapp/uni_modules/mas-code-scan/
|
||||
├── package.json
|
||||
├── readme.md
|
||||
├── changelog.md
|
||||
└── utssdk/
|
||||
├── interface.uts # Android / iOS 共用公开类型
|
||||
├── unierror.uts # Android / iOS 共用错误对象
|
||||
├── app-android/ # 现有 Android 实现
|
||||
│ ├── index.uts
|
||||
│ ├── CodeCaptureActivity.kt
|
||||
│ ├── MultiCodePickView.kt
|
||||
│ └── ScanFrameDecorView.kt
|
||||
└── app-ios/ # 新增 iOS 实现
|
||||
├── index.uts # UTS 桥接与回调编排
|
||||
├── CodeCaptureViewController.swift
|
||||
├── CodeScanner.swift
|
||||
├── ScanOverlayView.swift
|
||||
├── MultiCodePickView.swift
|
||||
├── ScanStyleParser.swift
|
||||
└── Info.plist # 或按 UTS iOS 配置规范声明权限
|
||||
```
|
||||
|
||||
> `app-ios` 的最终配置文件和 Swift 文件参与编译方式,必须以当前 HBuilderX 版本的 UTS iOS 官方规范为准;开始编码前需要用官方最小示例验证目录结构。
|
||||
|
||||
### 2.2 分层职责
|
||||
|
||||
```text
|
||||
业务页面
|
||||
↓ openCodeScan(options)
|
||||
UTS 平台入口(app-android/index.uts 或 app-ios/index.uts)
|
||||
↓ 参数转换 / 生命周期 / 回调派发
|
||||
原生扫码控制器
|
||||
↓ 相机、识别、相册、交互
|
||||
原生绘制层
|
||||
↓ 扫描框、扫描线、多码点选、呼吸动画
|
||||
```
|
||||
|
||||
- `interface.uts`:作为跨端 API 契约,不应写入 Android 或 iOS 私有字段。
|
||||
- `app-ios/index.uts`:负责将 `CodeScanOptions` 转为原生配置,处理单次与连续扫码回调。
|
||||
- `CodeCaptureViewController`:负责页面展示、权限、相机生命周期、工具栏和结果模式。
|
||||
- `CodeScanner`:负责相机帧分析、Vision 识别、格式过滤、区域过滤和缩放策略。
|
||||
- `ScanOverlayView`:负责 `scanStyle` 与 `scanRegion` 的视觉绘制。
|
||||
- `MultiCodePickView`:负责多码定格、点选和绿色呼吸波纹。
|
||||
|
||||
---
|
||||
|
||||
## 3. iOS 技术选型
|
||||
|
||||
| 能力 | 推荐技术 | 选择理由 |
|
||||
| --- | --- | --- |
|
||||
| 相机预览与帧采集 | `AVFoundation` | 系统原生、稳定、无需新增依赖 |
|
||||
| 条码识别 | `Vision` 的 `VNDetectBarcodesRequest` | 覆盖常见二维码与条形码,识别结果可获取码制和边界框 |
|
||||
| 相册选图 | `PHPickerViewController` | iOS 14+ 可按用户选择读取图片,通常无需完整相册权限 |
|
||||
| 低版本相册兼容 | `UIImagePickerController` | 仅在确认最低系统版本低于 iOS 14 时补充 |
|
||||
| 扫描线动画 | `CAShapeLayer` + `CABasicAnimation` | 使用 Core Animation,避免定时器逐帧重绘 |
|
||||
| 多码呼吸动画 | `CAShapeLayer` + `CABasicAnimation` | 与 Android 绿点呼吸波纹实现保持视觉一致 |
|
||||
| 手势缩放 | `UIPinchGestureRecognizer` | 对应 Android 双指捏合 |
|
||||
| 震动反馈 | `UIImpactFeedbackGenerator` | 适配 iOS 系统触感反馈 |
|
||||
|
||||
### 3.1 不引入第三方扫码库
|
||||
|
||||
首版不建议接入 ZXing、ML Kit iOS 等第三方库,原因:
|
||||
|
||||
- 系统 `Vision` 已能覆盖主要码制;
|
||||
- 避免 CocoaPods/SPM 与 UTS iOS 构建的额外集成风险;
|
||||
- 减少安装包和维护成本;
|
||||
- 便于优先验证 UTS + Swift 桥接与相机链路。
|
||||
|
||||
若后续测试确认某些业务关键码制在 `Vision` 上识别率不足,再单独评估第三方引擎,不在首版预置依赖。
|
||||
|
||||
---
|
||||
|
||||
## 4. 公共 API 与行为约束
|
||||
|
||||
### 4.1 保持现有 API
|
||||
|
||||
现有 `CodeScanOptions` 不新增必填字段,iOS 端识别并实现以下参数:
|
||||
|
||||
| 参数 | iOS 首版目标 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `formats` | 支持 | 映射到 Vision 支持的 symbology;不支持值忽略 |
|
||||
| `vibrate` | 支持 | 识别成功后触发轻触感反馈 |
|
||||
| `fromAlbum` | 支持 | 打开扫码页后直接进入相册选择 |
|
||||
| `scanRegion` | 支持 | 控制实时识别区域与扫描框;相册识图不受限制 |
|
||||
| `scanStyle` | 支持 | 控制遮罩、边框、扫描线及动画 |
|
||||
| `resultMode` | 支持 | `immediate`、`confirm`、`pause` |
|
||||
| `continuous` | 支持 | 多次 `success`,关闭时 `complete` |
|
||||
| `showPauseButton` | 支持 | 仅连续扫码时展示暂停/继续控制 |
|
||||
| `torch: 'off'` | 支持 | 默认关闭 |
|
||||
| `torch: 'on'` | 支持 | 设备支持时尝试开启 |
|
||||
| `smartZoom` | 第二阶段 | 首版可读取但不自动变焦,后续补齐 |
|
||||
| `torch: 'auto'` | 第二阶段 | 需要基于视频帧亮度策略验证 |
|
||||
|
||||
### 4.2 成功数据
|
||||
|
||||
iOS 识别结果必须统一转换为:
|
||||
|
||||
```ts
|
||||
{
|
||||
content: string,
|
||||
format: string,
|
||||
fromAlbum: boolean
|
||||
}
|
||||
```
|
||||
|
||||
格式名称与 Android 对齐,至少包括:
|
||||
|
||||
```text
|
||||
QR_CODE
|
||||
EAN_13
|
||||
EAN_8
|
||||
UPC_A
|
||||
UPC_E
|
||||
CODE_128
|
||||
CODE_39
|
||||
CODE_93
|
||||
ITF
|
||||
CODABAR
|
||||
PDF417
|
||||
AZTEC
|
||||
DATA_MATRIX
|
||||
```
|
||||
|
||||
当 Vision 不支持某个 Android 已支持的格式时:
|
||||
|
||||
1. `formats` 中的该值不参与 Vision 请求;
|
||||
2. 不因单个非法或不支持值使整个调用失败;
|
||||
3. 若过滤后没有任何 iOS 支持的格式,可按全格式或返回 `9021004`,最终策略需在编码前固定,并同步文档;
|
||||
4. 推荐与 Android 保持一致:过滤为空时退回全格式识别。
|
||||
|
||||
### 4.3 回调一致性
|
||||
|
||||
| 场景 | `success` | `fail` | `complete` |
|
||||
| --- | --- | --- | --- |
|
||||
| 单次扫码成功 | 调用一次 | 不调用 | 紧随成功调用一次,参数为成功结果 |
|
||||
| 单次取消/关闭 | 不调用 | `9021003` | 紧随失败调用一次 |
|
||||
| 相机权限拒绝 | 不调用 | `9021002` | 紧随失败调用一次 |
|
||||
| 无有效扫码结果的异常结束 | 不调用 | `9021004` | 紧随失败调用一次 |
|
||||
| 连续扫码单次命中 | 每次命中调用 | 不调用 | 页面未关闭前不调用 |
|
||||
| 连续扫码关闭且已有命中 | 不再调用 | 不调用 | `complete({ closed: true, hitCount })` |
|
||||
| 连续扫码关闭且无命中 | 不调用 | `9021003` | 紧随失败调用一次 |
|
||||
|
||||
所有业务回调必须在主线程派发,控制器关闭、权限回调和异步识别完成之间要有会话状态保护,确保不会重复回调。
|
||||
|
||||
### 4.4 错误码复用
|
||||
|
||||
继续使用 `unierror.uts` 中的错误码:
|
||||
|
||||
| 错误码 | 语义 | iOS 对应场景 |
|
||||
| --- | --- | --- |
|
||||
| `9021001` | 当前无法获取页面 Activity | 无法获取当前 `UIViewController` 或不能展示扫码页 |
|
||||
| `9021002` | 相机权限被拒绝 | `.denied` / `.restricted` 或用户拒绝授权 |
|
||||
| `9021003` | 用户取消扫码 | 点击关闭、系统返回或取消图片选择 |
|
||||
| `9021004` | 未获取到有效的扫码结果 | 扫码处理异常结束、相册图片无有效条码后由调用方选择结束 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 核心功能设计
|
||||
|
||||
### 5.1 相机权限与页面生命周期
|
||||
|
||||
流程:
|
||||
|
||||
```text
|
||||
openCodeScan
|
||||
↓
|
||||
获取当前 UIViewController
|
||||
↓
|
||||
检查 AVCaptureDevice 相机权限
|
||||
├─ 已授权 → 初始化扫码控制器与会话
|
||||
├─ 未决定 → 请求授权 → 成功后初始化
|
||||
└─ 拒绝/受限 → fail(9021002) → complete
|
||||
```
|
||||
|
||||
控制器生命周期要求:
|
||||
|
||||
- 页面展示时创建并启动 `AVCaptureSession`;
|
||||
- 页面进入后台时暂停会话;
|
||||
- 页面恢复前台时,未完成且未暂停时恢复会话;
|
||||
- 页面关闭、成功返回或发生不可恢复错误时停止会话;
|
||||
- 释放 `AVCaptureVideoDataOutput` 委托、图片引用、动画图层和 UTS 回调引用;
|
||||
- 防止异步 Vision 回调在页面关闭后再提交结果。
|
||||
|
||||
### 5.2 实时扫码链路
|
||||
|
||||
```text
|
||||
AVCaptureVideoDataOutput
|
||||
↓ CMSampleBuffer
|
||||
Vision VNDetectBarcodesRequest
|
||||
↓ VNBarcodeObservation 列表
|
||||
formats 过滤
|
||||
↓
|
||||
scanRegion 区域过滤
|
||||
↓
|
||||
单码 / 多码 / 连续扫码 / 结果模式处理
|
||||
```
|
||||
|
||||
性能策略:
|
||||
|
||||
- 使用串行 `DispatchQueue` 处理 Vision 请求,避免同一时间处理多帧;
|
||||
- 在上一帧识别尚未结束时丢弃新帧;
|
||||
- 多码定格、确认弹窗、暂停状态、页面关闭后停止提交识别请求;
|
||||
- 连续扫码需按 `content + format` 做短时间去重,建议冷却窗口 `800ms ~ 1200ms`;
|
||||
- 不在 Vision 回调线程直接更新 UIKit,统一回主线程更新界面。
|
||||
|
||||
### 5.3 formats 映射
|
||||
|
||||
新增 iOS 私有映射表:
|
||||
|
||||
```text
|
||||
QR_CODE → VNBarcodeSymbologyQR
|
||||
EAN_13 → VNBarcodeSymbologyEAN13
|
||||
EAN_8 → VNBarcodeSymbologyEAN8
|
||||
UPC_A → VNBarcodeSymbologyUPCE(需以实际 Vision 结果确认映射)
|
||||
UPC_E → VNBarcodeSymbologyUPCE
|
||||
CODE_128 → VNBarcodeSymbologyCode128
|
||||
CODE_39 → VNBarcodeSymbologyCode39
|
||||
CODE_93 → VNBarcodeSymbologyCode93
|
||||
ITF → VNBarcodeSymbologyI2of5
|
||||
CODABAR → VNBarcodeSymbologyCodabar
|
||||
PDF417 → VNBarcodeSymbologyPDF417
|
||||
AZTEC → VNBarcodeSymbologyAztec
|
||||
DATA_MATRIX → VNBarcodeSymbologyDataMatrix
|
||||
```
|
||||
|
||||
> `VNBarcodeSymbology` 的实际可用范围与命名随 iOS SDK 变化。开发时必须以目标 Xcode / iOS SDK 编译结果为准,不能假定每个 Android 格式都一一对应。
|
||||
|
||||
### 5.4 scanRegion 坐标转换与过滤
|
||||
|
||||
`scanRegion` 是业务 API 中最容易出现跨平台差异的部分,iOS 必须确保“画面上显示的扫描框”与“实际允许识别的区域”一致。
|
||||
|
||||
处理步骤:
|
||||
|
||||
1. 根据 `width`、`height`、`centerX`、`centerY` 计算预览层的扫描框 `CGRect`;
|
||||
2. 使用 `AVCaptureVideoPreviewLayer` 将 Vision 的归一化 `boundingBox` 转换为 UIKit 坐标;
|
||||
3. 判断条码边界框中心点是否在扫描框中;
|
||||
4. 仅保留区域内条码;
|
||||
5. `fullScreen: true` 时不绘制扫描框且不进行区域过滤;
|
||||
6. 相册识图跳过 `scanRegion` 过滤,保持 Android 行为。
|
||||
|
||||
验证重点:
|
||||
|
||||
- Vision 坐标原点与 UIKit 坐标原点不同;
|
||||
- `.resizeAspectFill` 会产生裁切偏移;
|
||||
- 前置镜像、横竖屏和刘海安全区可能影响坐标;
|
||||
- 多码点选标记必须与真实条码中心重合。
|
||||
|
||||
### 5.5 scanStyle 绘制
|
||||
|
||||
`ScanOverlayView` 使用 `CAShapeLayer` 与 `CAGradientLayer` 绘制,配置语义与 Android 一致:
|
||||
|
||||
| 参数 | iOS 实现 |
|
||||
| --- | --- |
|
||||
| `showMask` | 显示/隐藏扫描框外遮罩层 |
|
||||
| `showBeam` | 显示/隐藏扫描线图层 |
|
||||
| `borderColor` | `CGColor` 边框颜色 |
|
||||
| `maskColor` | 解析 `#RRGGBB`、`#AARRGGBB`、`rgba()` 后填充遮罩 |
|
||||
| `beamColor` | 扫描线颜色 |
|
||||
| `beamDuration` | 扫描线 `CABasicAnimation.duration`,限制 `100 ~ 60000ms` |
|
||||
| `cornerStyle: 'corner'` | 绘制四角边框 |
|
||||
| `cornerStyle: 'full'` | 绘制完整矩形边框 |
|
||||
|
||||
默认值必须沿用当前接口默认行为:
|
||||
|
||||
```ts
|
||||
{
|
||||
showMask: true,
|
||||
showBeam: true,
|
||||
borderColor: '#19be6b',
|
||||
maskColor: 'rgba(0, 0, 0, 0.45)',
|
||||
beamColor: '#12e880',
|
||||
beamDuration: 1800,
|
||||
cornerStyle: 'corner'
|
||||
}
|
||||
```
|
||||
|
||||
颜色解析建议单独实现 `ScanStyleParser`:
|
||||
|
||||
- 支持 `#RRGGBB`;
|
||||
- 支持 `#AARRGGBB`;
|
||||
- 支持 `rgba(r,g,b,a)`;
|
||||
- 解析失败时回退默认色;
|
||||
- 所有回退规则与 Android `readScanStyle()` 保持一致。
|
||||
|
||||
### 5.6 单码、多码和点选
|
||||
|
||||
#### 单码
|
||||
|
||||
单个有效条码按 `resultMode` 处理:
|
||||
|
||||
- `immediate`:停止识别并立即返回;
|
||||
- `confirm`:暂停识别,弹出确认框;
|
||||
- `pause`:暂停识别,显示继续扫描和确认返回操作。
|
||||
|
||||
#### 多码
|
||||
|
||||
同一帧出现两个及以上有效、有内容且有边界框的条码时:
|
||||
|
||||
1. 停止实时识别;
|
||||
2. 抓取当前预览帧;
|
||||
3. 显示定格画面和半透明遮罩;
|
||||
4. 把各码的边界框映射到屏幕;
|
||||
5. 在中心位置绘制点选标记;
|
||||
6. 显示“已识别到多个码,请选择一个”提示;
|
||||
7. 用户点击最近标记后提交对应条码结果。
|
||||
|
||||
`MultiCodePickView` 的标记视觉与 Android 对齐:
|
||||
|
||||
```text
|
||||
绿色中心圆点
|
||||
+ 白色圆形底
|
||||
+ 绿色半透明外圈
|
||||
+ 周期性呼吸扩散动画
|
||||
```
|
||||
|
||||
建议使用 `CAKeyframeAnimation` 或 `CABasicAnimation` 同步变化外圈的 `transform.scale` 与 `opacity`,周期暂定 `1500ms`。
|
||||
|
||||
点击策略:
|
||||
|
||||
- 以点位中心为基准选择最近候选;
|
||||
- 设置最小可点击半径,建议约 `28pt`;
|
||||
- 无命中点时继续等待选择,不直接取消;
|
||||
- 用户主动关闭时按取消流程处理。
|
||||
|
||||
### 5.7 相册识图
|
||||
|
||||
优先采用 `PHPickerViewController`:
|
||||
|
||||
```text
|
||||
fromAlbum: true 或点击相册按钮
|
||||
↓
|
||||
系统图片选择器
|
||||
↓
|
||||
获取 UIImage / CGImage
|
||||
↓
|
||||
Vision VNDetectBarcodesRequest
|
||||
↓
|
||||
单码直接返回;多码进入点选
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 相册识图结果的 `fromAlbum` 固定为 `true`;
|
||||
- 相册图片不受 `scanRegion` 限制;
|
||||
- 用户取消图片选择:单次扫码按 `9021003` 处理;
|
||||
- 图片中未识别到条码时,提示“未识别到有效条码”,并返回扫码页继续操作;
|
||||
- 多码相册图片使用图片本身作为定格背景并进入点选。
|
||||
|
||||
### 5.8 连续扫码与暂停
|
||||
|
||||
`continuous: true` 时:
|
||||
|
||||
- 命中一次条码调用一次 `success`;
|
||||
- 扫码页不关闭;
|
||||
- 同一内容需通过短时冷却避免高频重复提交;
|
||||
- `showPauseButton: true` 时展示暂停/继续按钮;
|
||||
- 用户关闭时,若已有命中,则仅调用:
|
||||
|
||||
```ts
|
||||
complete({ closed: true, hitCount })
|
||||
```
|
||||
|
||||
- 用户关闭时,若没有命中,则按 `fail(9021003)` 后 `complete`;
|
||||
- 多码点选确认后,在连续模式下应投递一次成功结果、清除定格层、恢复相机识别,而不是关闭控制器。
|
||||
|
||||
### 5.9 手电筒、缩放与震动
|
||||
|
||||
#### 手电筒
|
||||
|
||||
- 使用 `AVCaptureDevice.hasTorch` 判断设备能力;
|
||||
- 使用 `lockForConfiguration()` + `torchMode` 变更状态;
|
||||
- `off`:默认关闭;
|
||||
- `on`:启动后尝试开启;
|
||||
- 无手电筒设备隐藏或禁用页面按钮;
|
||||
- 页面结束时关闭手电筒。
|
||||
|
||||
#### 手势缩放
|
||||
|
||||
- 使用 `UIPinchGestureRecognizer`;
|
||||
- 映射至 `videoZoomFactor`;
|
||||
- 限制在 `1.0 ... activeFormat.videoMaxZoomFactor` 的安全范围;
|
||||
- 变焦期间禁止智能变焦抢占。
|
||||
|
||||
#### 智能变焦
|
||||
|
||||
作为第二阶段能力:
|
||||
|
||||
- 根据条码边界框占画面比例计算建议倍率;
|
||||
- 设定最大尝试次数、最大倍率和平滑过渡;
|
||||
- 检测到多个码时先恢复 `1.0x`,避免多码点选区域被放大裁切;
|
||||
- 与手势缩放、连续扫码、定格状态互斥。
|
||||
|
||||
#### 震动
|
||||
|
||||
- `vibrate: true` 时使用 `UIImpactFeedbackGenerator(style: .light)`;
|
||||
- 连续扫码每次实际成功投递时触发;
|
||||
- 系统可能因“减少动态效果”、静音模式或设备策略弱化触感反馈,业务层不应依赖其作为唯一成功提示。
|
||||
|
||||
---
|
||||
|
||||
## 6. UTS 桥接设计
|
||||
|
||||
### 6.1 app-ios/index.uts 职责
|
||||
|
||||
`app-ios/index.uts` 负责:
|
||||
|
||||
1. 获取可展示扫码页的当前 `UIViewController`;
|
||||
2. 校验当前是否已有扫码会话,避免重复打开;
|
||||
3. 归一化 `CodeScanOptions` 默认值;
|
||||
4. 创建并展示 `CodeCaptureViewController`;
|
||||
5. 接收原生事件并转换为 `CodeScanSuccess` / `CodeScanFailImpl`;
|
||||
6. 保证回调顺序和只执行一次的终止语义;
|
||||
7. 连续扫码时维护 `hitCount`;
|
||||
8. 控制器退出后解除事件与对象引用。
|
||||
|
||||
### 6.2 建议的原生事件模型
|
||||
|
||||
控制器向桥接层输出有限事件,不直接感知 UTS 回调:
|
||||
|
||||
```text
|
||||
onResult(content, format, fromAlbum)
|
||||
onCancel()
|
||||
onPermissionDenied()
|
||||
onFatalError()
|
||||
onClosed(hitCount, hadHits)
|
||||
```
|
||||
|
||||
桥接层统一决定:
|
||||
|
||||
- 单次成功:`success` → `complete`;
|
||||
- 单次失败:`fail` → `complete`;
|
||||
- 连续扫码:每次 `onResult` → `success`,关闭时再 `complete`;
|
||||
- 已结束会话的后续事件全部丢弃。
|
||||
|
||||
### 6.3 会话状态
|
||||
|
||||
建议 iOS 桥接层维护:
|
||||
|
||||
```text
|
||||
idle → presenting → scanning → paused / picking → finishing → finished
|
||||
```
|
||||
|
||||
关键约束:
|
||||
|
||||
- `finished` 后不允许再次派发回调;
|
||||
- 权限请求、Vision 回调、相册回调都需检查当前状态;
|
||||
- 控制器 dismissal 完成后再释放强引用;
|
||||
- 业务层再次调用时必须创建新会话,不复用旧回调。
|
||||
|
||||
---
|
||||
|
||||
## 7. 权限与配置
|
||||
|
||||
### 7.1 必需权限
|
||||
|
||||
iOS 必须声明:
|
||||
|
||||
```xml
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机扫描二维码和条形码</string>
|
||||
```
|
||||
|
||||
若采用 `PHPickerViewController` 且最低系统版本为 iOS 14,一般不需要完整相册访问权限。
|
||||
|
||||
若最终需要 `UIImagePickerController` 或直接读取照片库,则补充:
|
||||
|
||||
```xml
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>需要访问相册以识别图片中的二维码和条形码</string>
|
||||
```
|
||||
|
||||
### 7.2 最低系统版本建议
|
||||
|
||||
建议首版目标:
|
||||
|
||||
```text
|
||||
iOS 14+
|
||||
```
|
||||
|
||||
理由:
|
||||
|
||||
- 可以使用 `PHPickerViewController`;
|
||||
- Vision 条码能力更成熟;
|
||||
- 降低图片权限和兼容逻辑复杂度。
|
||||
|
||||
若产品必须支持 iOS 13,需要增加相册选择兼容分支,并重新评估 Vision 可识别格式集合。
|
||||
|
||||
### 7.3 打包与调试
|
||||
|
||||
iOS 原生 UTS 代码不能通过 Web 预览验证,必须使用:
|
||||
|
||||
- 包含插件的 iOS 自定义调试基座;或
|
||||
- iOS 云打包 / 本地原生打包产物;
|
||||
- 真机设备与 Xcode 控制台。
|
||||
|
||||
模拟器不应作为相机扫码主验收环境;可用于页面布局、权限分支和相册图片识别的部分验证。
|
||||
|
||||
---
|
||||
|
||||
## 8. 分阶段实施计划
|
||||
|
||||
### 阶段 0:UTS iOS 可编译性验证
|
||||
|
||||
目标:确认当前 HBuilderX 与项目配置可编译 `app-ios` 原生实现。
|
||||
|
||||
任务:
|
||||
|
||||
- 创建最小 `app-ios/index.uts`;
|
||||
- 验证获取当前 iOS 控制器与展示原生页面的官方方式;
|
||||
- 验证 Swift 文件参与 UTS iOS 编译的目录和配置;
|
||||
- 在 iPhone 真机执行一个最小“打开原生页并返回”示例;
|
||||
- 固化项目实际约定后再开始完整扫码实现。
|
||||
|
||||
验收:iOS 自定义基座可编译并成功打开、关闭原生测试页面。
|
||||
|
||||
### 阶段 1:最小可用扫码
|
||||
|
||||
范围:
|
||||
|
||||
- 相机权限;
|
||||
- 后置相机预览;
|
||||
- Vision 单码实时识别;
|
||||
- `success`、`fail`、`complete`;
|
||||
- 用户关闭;
|
||||
- 常见二维码与条形码格式。
|
||||
|
||||
验收:iPhone 真机可扫描 QR Code 和 Code 128,并按既定顺序回调。
|
||||
|
||||
### 阶段 2:跨端核心功能对齐
|
||||
|
||||
范围:
|
||||
|
||||
- `formats`;
|
||||
- `scanRegion`;
|
||||
- `scanStyle`;
|
||||
- 相册识图;
|
||||
- 多码定格点选;
|
||||
- 绿色呼吸波纹;
|
||||
- `resultMode`;
|
||||
- `vibrate`。
|
||||
|
||||
验收:Android 与 iOS 对同一配置的显示、区域过滤和数据结构一致。
|
||||
|
||||
### 阶段 3:会话与相机增强
|
||||
|
||||
范围:
|
||||
|
||||
- `continuous`;
|
||||
- `showPauseButton`;
|
||||
- `torch: 'off' | 'on'`;
|
||||
- 手势缩放;
|
||||
- 连续扫码去重与关闭结算。
|
||||
|
||||
验收:连续扫码有命中和无命中关闭时的回调行为与 Android 一致。
|
||||
|
||||
### 阶段 4:高级策略
|
||||
|
||||
范围:
|
||||
|
||||
- `smartZoom`;
|
||||
- `torch: 'auto'`;
|
||||
- 复杂机型上的性能优化;
|
||||
- 必要时评估第三方识别引擎。
|
||||
|
||||
验收:在目标机型上无明显误触发、频繁跳变、发热或回调重复问题。
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收矩阵
|
||||
|
||||
### 9.1 功能验收
|
||||
|
||||
| 场景 | 预期 |
|
||||
| --- | --- |
|
||||
| 单个二维码 | 返回 `QR_CODE`、`fromAlbum: false` |
|
||||
| 单个 Code 128 | 返回 `CODE_128` |
|
||||
| formats 过滤 | 仅接受指定码制 |
|
||||
| 全屏识别 | 不显示扫描框,整画面可识别 |
|
||||
| 自定义区域 | 框外条码不返回,框内条码正常返回 |
|
||||
| 相册单码 | 返回 `fromAlbum: true` |
|
||||
| 相册多码 | 显示定格图片与多个可点击标记 |
|
||||
| 实时多码 | 显示定格画面、提示语和绿色呼吸点 |
|
||||
| confirm 模式 | 确认后返回,取消后继续识别 |
|
||||
| pause 模式 | 可继续扫描或确认结果 |
|
||||
| 连续扫码 | 多次 success,关闭后返回正确 hitCount |
|
||||
| 连续扫码无命中关闭 | fail `9021003` 后 complete |
|
||||
| 相机权限拒绝 | fail `9021002` 后 complete |
|
||||
| 无手电筒设备 | 不崩溃,按钮隐藏或禁用 |
|
||||
|
||||
### 9.2 机型与系统验收
|
||||
|
||||
至少覆盖:
|
||||
|
||||
- iPhone 小屏机型;
|
||||
- 刘海屏机型;
|
||||
- 灵动岛机型;
|
||||
- 无手电筒或手电筒不可用场景;
|
||||
- iOS 14、16、17 及项目实际要求的最新系统;
|
||||
- 深色模式;
|
||||
- 权限首次授权、拒绝、设置页重新授权;
|
||||
- 前后台切换、来电或控制中心打断后恢复;
|
||||
- 快速打开、关闭、再次打开扫码页。
|
||||
|
||||
### 9.3 性能与稳定性验收
|
||||
|
||||
- 连续识别 5 分钟不出现明显内存持续增长;
|
||||
- 多次进入、退出扫码页后相机可再次正常打开;
|
||||
- 多码定格帧不会造成明显卡顿或黑屏;
|
||||
- 页面关闭后不再收到 `success`;
|
||||
- 不发生重复 `complete` 或 success/fail 同时触发;
|
||||
- 扫描框与实际识别区域、点选标记位置对齐。
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与应对
|
||||
|
||||
| 风险 | 影响 | 应对措施 |
|
||||
| --- | --- | --- |
|
||||
| UTS iOS 原生目录/Swift 桥接规则与预期不同 | 无法编译或调用 | 阶段 0 使用官方最小示例先验证,不直接大规模开发 |
|
||||
| Vision 支持的码制与 Android 不完全一致 | formats 行为差异 | 建立格式能力矩阵,过滤不支持项并在 README 明示 |
|
||||
| Vision 到预览层坐标转换错误 | scanRegion、点选位置偏移 | 单独编写坐标转换测试清单,覆盖 Aspect Fill、旋转和安全区 |
|
||||
| 定格帧转换开销高 | 多码时卡顿、内存上涨 | 限制图像分辨率,及时释放 `CGImage` / `UIImage`,仅保留一帧 |
|
||||
| 异步回调竞争 | 重复回调、页面关闭后回调 | 使用会话状态机和主线程串行化终止事件 |
|
||||
| Torch / zoom 在机型上差异大 | 功能不稳定 | 将 `auto` 与 smartZoom 放到后续阶段,先实现基础能力 |
|
||||
| iOS 权限文案或 Info.plist 缺失 | 审核或运行时崩溃 | 将权限配置纳入构建前检查和真机回归 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 代码与文档更新清单
|
||||
|
||||
### 11.1 开发完成后需要修改
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
| --- | --- |
|
||||
| `utssdk/app-ios/*` | 新增 iOS UTS 桥接和 Swift 原生实现 |
|
||||
| `package.json` | `displayName`、描述、关键字、`platforms.client.uni-app.app.ios` 改为支持 |
|
||||
| `readme.md` | 支持平台改为 Android / iOS;补充 iOS 权限、最低版本和格式差异说明 |
|
||||
| `changelog.md` | 记录 iOS 首次支持及功能范围 |
|
||||
| `pages/scan/prototype.vue` | 将 Android 专属提示改为 Android / iOS App 提示 |
|
||||
|
||||
### 11.2 开发完成前不得修改
|
||||
|
||||
以下内容必须等 iOS 自定义基座成功编译且完成真机验收后再调整:
|
||||
|
||||
- README 的“支持 iOS”声明;
|
||||
- `package.json` 中的 `ios: '√'`;
|
||||
- 业务页面中“Android 专属”的运行提示;
|
||||
- changelog 中的正式 iOS 发布记录。
|
||||
|
||||
---
|
||||
|
||||
## 12. 推荐实施顺序
|
||||
|
||||
1. 确认项目的 HBuilderX 版本、Xcode 版本和最低 iOS 版本;
|
||||
2. 用官方 UTS iOS 最小原生页面验证 `app-ios` 目录与 Swift 编译方式;
|
||||
3. 实现相机权限 + 单码 QR 实时识别闭环;
|
||||
4. 固化 `success` / `fail` / `complete` 的 iOS 回调行为;
|
||||
5. 加入 formats、scanRegion 和 scanStyle;
|
||||
6. 加入相册识图和多码定格点选;
|
||||
7. 加入连续扫码、暂停、手电筒和手势缩放;
|
||||
8. 最后实现 smartZoom、auto torch 等设备敏感能力;
|
||||
9. 真机回归通过后更新插件元信息、README、changelog 和演示页文案。
|
||||
|
||||
---
|
||||
|
||||
## 13. 结论
|
||||
|
||||
推荐采用 **AVFoundation + Vision + PHPickerViewController** 的原生 iOS 实现方案,并在 `utssdk/app-ios/` 中增加独立平台入口。现有 `interface.uts`、`unierror.uts` 和 `openCodeScan` 调用方式继续作为跨端契约,Android 代码无需重构。
|
||||
|
||||
实施上应先完成 iOS UTS 编译与原生页面呈现验证,再逐步建立单码扫码、多码点选、相册识图和连续扫码能力。这样能将最大的构建与坐标转换风险前置,同时避免在未验证前修改对外平台支持声明。
|
||||
@@ -0,0 +1,617 @@
|
||||
# mas-code-scan 功能与设置规划
|
||||
|
||||
> 本文用于整理 `mas-code-scan` 当前已有能力、可继续增加的功能,以及建议的开发优先级。
|
||||
>
|
||||
> 状态说明:
|
||||
>
|
||||
> - **已实现**:当前插件代码已经支持;
|
||||
> - **建议增加**:功能方向已经明确,但尚未实现;
|
||||
> - **后续评估**:需要结合实际业务、设备兼容性和实现成本再决定。
|
||||
|
||||
---
|
||||
|
||||
## 1. 插件当前能力
|
||||
|
||||
| 功能 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| Android 相机扫码 | 已实现 | 基于 CameraX + Google ML Kit |
|
||||
| 全格式识别 | 已实现 | 未传 `formats` 时按全格式识别 |
|
||||
| 指定码制识别 | 已实现 | 通过 `formats` 限定二维码、条形码等格式 |
|
||||
| 单次扫码 | 已实现 | 成功后自动返回结果 |
|
||||
| 连续扫码 | 已实现 | `continuous: true`,识别后继续停留在扫码页 |
|
||||
| 相册识图 | 已实现 | 支持打开后直接进入相册,也支持扫码页内打开相册 |
|
||||
| 多码识别 | 已实现 | 同屏多个码时进入点选逻辑 |
|
||||
| 智能变焦 | 已实现 | `smartZoom: true` 时对较小条码自动放大 |
|
||||
| 双指手势缩放 | 已实现 | 扫码页内手动调整相机倍率 |
|
||||
| 手电筒 | 已实现 | 扫码页右下角手动开关 |
|
||||
| 成功震动 | 已实现 | 通过 `vibrate` 控制 |
|
||||
| 自定义有效区域 | 已实现 | 通过 `scanRegion` 控制识别区域 |
|
||||
| 二维码正方形区域 | 已实现 | `width` 与 `height` 使用相同值,锁定 1:1 |
|
||||
| 横向条码区域 | 已实现 | 默认宽度较大、高度可调 |
|
||||
| 条码框上下位置 | 已实现 | 支持上、中、下、自定义位置 |
|
||||
| 条码框高度选择 | 已实现 | 原型页支持 24%、32%、40% 三档 |
|
||||
| 全屏识别模式 | 已实现 | `scanRegion: { fullScreen: true }`,整画面识别且不显示扫描框 |
|
||||
| 扫描框直角样式 | 已实现 | 当前不绘制圆角 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前 API 概览
|
||||
|
||||
### 2.1 基础调用
|
||||
|
||||
```ts
|
||||
import { openCodeScan } from '@/uni_modules/mas-code-scan'
|
||||
|
||||
openCodeScan({
|
||||
smartZoom: true,
|
||||
vibrate: true,
|
||||
success: (res) => {
|
||||
console.log(res.content, res.format)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log(err.errCode, err.errMsg)
|
||||
},
|
||||
complete: () => {
|
||||
console.log('扫码结束')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 2.2 全屏识别
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
scanRegion: {
|
||||
fullScreen: true
|
||||
},
|
||||
success: (res) => {
|
||||
console.log(res.content)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
全屏模式的行为:
|
||||
|
||||
- 整个相机预览区域均可识别;
|
||||
- 不显示扫描框;
|
||||
- 不显示扫描框遮罩;
|
||||
- 不过滤扫描框外的条码;
|
||||
- 不影响 `formats`、`smartZoom`、`vibrate`、`continuous` 等其他设置。
|
||||
|
||||
### 2.3 二维码正方形区域
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
formats: ['QR_CODE'],
|
||||
scanRegion: {
|
||||
width: 0.74,
|
||||
height: 0.74,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
二维码区域要求:
|
||||
|
||||
- `width` 和 `height` 使用相同值;
|
||||
- 严格保持 1:1 正方形比例;
|
||||
- 扫描框使用直角,不使用圆角。
|
||||
|
||||
### 2.4 横向条码区域
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
formats: ['CODE_128', 'EAN_13'],
|
||||
scanRegion: {
|
||||
width: 0.96,
|
||||
height: 0.24,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `width` 控制横向宽度;
|
||||
- `height` 控制扫描框内高度;
|
||||
- `centerY` 控制扫描框上下位置;
|
||||
- 目前原型页提供 24%、32%、40% 三档高度;
|
||||
- 框内条码才会进入有效识别结果。
|
||||
|
||||
---
|
||||
|
||||
## 3. 建议优先增加的功能
|
||||
|
||||
以下功能对仓库、设备管理、物料盘点等场景最有价值。
|
||||
|
||||
### 3.1 手电筒 API 控制
|
||||
|
||||
当前已经有扫码页手电筒按钮,建议继续增加调用参数,用于控制打开扫码页时的默认状态。
|
||||
|
||||
建议接口:
|
||||
|
||||
```ts
|
||||
torch?: 'auto' | 'on' | 'off'
|
||||
```
|
||||
|
||||
建议行为:
|
||||
|
||||
| 值 | 行为 |
|
||||
|---|---|
|
||||
| `auto` | 使用系统或插件默认状态,不强制打开 |
|
||||
| `on` | 打开扫码页后自动开启手电筒 |
|
||||
| `off` | 打开扫码页后保持关闭 |
|
||||
|
||||
适用场景:弱光仓库、货架底部、设备背面标签、夜间扫码。
|
||||
|
||||
---
|
||||
|
||||
### 3.2 连续扫码去重策略
|
||||
|
||||
连续扫码时建议开放更明确的去重配置。
|
||||
|
||||
建议接口:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
continuous: true,
|
||||
duplicatePolicy: 'cooldown',
|
||||
duplicateCooldown: 1500
|
||||
})
|
||||
```
|
||||
|
||||
建议支持:
|
||||
|
||||
| 策略 | 行为 |
|
||||
|---|---|
|
||||
| `allow` | 同一个码可以重复回调 |
|
||||
| `ignore` | 同一次扫码会话中,相同内容只回调一次 |
|
||||
| `cooldown` | 相同内容在指定时间内不重复回调 |
|
||||
|
||||
建议参数:
|
||||
|
||||
- `duplicatePolicy`:去重策略;
|
||||
- `duplicateCooldown`:去重时间,单位毫秒;
|
||||
- `maxResults`:连续扫码最多保存的结果数量。
|
||||
|
||||
这是批量盘点场景中最值得增加的功能之一。
|
||||
|
||||
---
|
||||
|
||||
### 3.3 多码识别策略
|
||||
|
||||
当前同屏多个条码时可以进入点选。建议增加自动选择策略,减少用户额外操作。
|
||||
|
||||
建议接口:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
multiCodeMode: 'nearestCenter'
|
||||
})
|
||||
```
|
||||
|
||||
建议取值:
|
||||
|
||||
| 值 | 行为 |
|
||||
|---|---|
|
||||
| `select` | 保持当前交互,暂停画面并由用户点选 |
|
||||
| `first` | 取 ML Kit 返回的第一个有效条码 |
|
||||
| `nearestCenter` | 选择距离扫描框中心最近的条码 |
|
||||
| `largest` | 选择画面中面积最大的条码 |
|
||||
| `all` | 一次返回全部有效条码 |
|
||||
|
||||
建议默认使用 `nearestCenter`,更符合扫描框交互习惯。
|
||||
|
||||
---
|
||||
|
||||
### 3.4 扫码结果确认模式
|
||||
|
||||
目前单次扫码识别成功后会自动返回。建议增加确认模式,避免误扫。
|
||||
|
||||
建议接口:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
resultMode: 'confirm'
|
||||
})
|
||||
```
|
||||
|
||||
建议取值:
|
||||
|
||||
| 值 | 行为 |
|
||||
|---|---|
|
||||
| `immediate` | 识别成功后立即返回,保持当前行为 |
|
||||
| `confirm` | 显示结果,用户确认后返回 |
|
||||
| `pause` | 识别成功后暂停相机,用户可确认或继续扫描 |
|
||||
|
||||
适用场景:设备绑定、物料入库、资产登记等不能容忍误扫的业务。
|
||||
|
||||
---
|
||||
|
||||
### 3.5 前后摄像头切换
|
||||
|
||||
建议支持默认摄像头和扫码页内切换。
|
||||
|
||||
建议接口:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
camera: 'back'
|
||||
})
|
||||
```
|
||||
|
||||
建议取值:
|
||||
|
||||
- `back`:后置摄像头,默认;
|
||||
- `front`:前置摄像头;
|
||||
- `auto`:由插件选择可用摄像头。
|
||||
|
||||
扫码页可以增加一个左下角或右上角切换按钮。
|
||||
|
||||
---
|
||||
|
||||
## 4. 识别准确率和性能设置
|
||||
|
||||
### 4.1 稳定识别帧数
|
||||
|
||||
要求同一个条码连续出现在若干帧后才判定成功。
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
stableFrames: 2
|
||||
})
|
||||
```
|
||||
|
||||
优点:
|
||||
|
||||
- 减少模糊画面误触发;
|
||||
- 减少条码刚进入画面时的误扫;
|
||||
- 对连续扫码更稳定。
|
||||
|
||||
注意:数值过高会增加识别延迟,建议默认 1 或 2。
|
||||
|
||||
---
|
||||
|
||||
### 4.2 智能变焦参数
|
||||
|
||||
当前只有开关,后续可以细化为:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
smartZoom: true,
|
||||
smartZoomMaxRatio: 2.5,
|
||||
smartZoomTriggerSize: 0.12
|
||||
})
|
||||
```
|
||||
|
||||
建议参数:
|
||||
|
||||
- `smartZoomMaxRatio`:最大自动变焦倍率;
|
||||
- `smartZoomTriggerSize`:条码小于指定比例时触发;
|
||||
- `smartZoomResetAfterSuccess`:识别成功后是否恢复倍率。
|
||||
|
||||
---
|
||||
|
||||
### 4.3 最小条码尺寸过滤
|
||||
|
||||
用于忽略过远或过小的无关条码。
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
minBarcodeWidth: 0.08,
|
||||
minBarcodeHeight: 0.03
|
||||
})
|
||||
```
|
||||
|
||||
注意:该功能和 `scanRegion` 不同:
|
||||
|
||||
- `scanRegion` 解决“条码是否在有效区域内”;
|
||||
- 最小尺寸过滤解决“条码是否足够大、是否值得识别”。
|
||||
|
||||
---
|
||||
|
||||
## 5. 扫描框和交互设置
|
||||
|
||||
### 5.1 扫描框视觉样式
|
||||
|
||||
目前扫描框已经支持尺寸、位置和直角样式。增加独立的视觉配置:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
scanRegion: {
|
||||
width: 0.96,
|
||||
height: 0.24,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5
|
||||
},
|
||||
scanStyle: {
|
||||
showMask: true,
|
||||
showBeam: true,
|
||||
borderColor: '#19be6b',
|
||||
maskColor: 'rgba(0, 0, 0, 0.45)',
|
||||
beamColor: '#12e880'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
建议支持:
|
||||
|
||||
- `showMask`:是否显示框外遮罩;
|
||||
- `showBeam`:是否显示扫描线;
|
||||
- `borderColor`:边框颜色;
|
||||
- `maskColor`:遮罩颜色和透明度;
|
||||
- `beamColor`:扫描线颜色;
|
||||
- `beamDuration`:扫描线移动周期;
|
||||
- `cornerStyle`:完整边框或四角边框。
|
||||
|
||||
建议将这些属性放在 `scanStyle` 中,不要和识别区域参数混在一起。
|
||||
|
||||
---
|
||||
|
||||
### 5.2 扫码页提示文案
|
||||
|
||||
不同业务可以使用不同的引导语。
|
||||
|
||||
建议接口:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
title: '设备绑定',
|
||||
hint: '请将设备条码放入扫描框内'
|
||||
})
|
||||
```
|
||||
|
||||
建议支持:
|
||||
|
||||
- `title`:页面标题;
|
||||
- `hint`:扫描框下方提示;
|
||||
- `successHint`:识别成功提示;
|
||||
- `emptyHint`:暂未识别到条码时的提示。
|
||||
|
||||
---
|
||||
|
||||
### 5.3 暂停与继续扫描
|
||||
|
||||
连续扫码可以增加暂停和继续按钮。
|
||||
|
||||
建议行为:
|
||||
|
||||
- 点击暂停后停止分析帧;
|
||||
- 相机预览可以继续显示,也可以冻结画面;
|
||||
- 点击继续后恢复分析;
|
||||
- 连续扫码结果列表不清空。
|
||||
- 实时帧中只有一条已解码条码时必须直接上报;ML Kit 额外返回的无内容候选框不能触发多码点选或跳过该单码。
|
||||
|
||||
建议接口:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
continuous: true,
|
||||
showPauseButton: true
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 扫码结果增强
|
||||
|
||||
### 6.1 返回条码位置
|
||||
|
||||
当前结果主要返回内容和码制。后续可以返回条码在图像中的位置。
|
||||
|
||||
建议结果:
|
||||
|
||||
```ts
|
||||
{
|
||||
content: 'EQ-12345678',
|
||||
format: 'CODE_128',
|
||||
fromAlbum: false,
|
||||
bounds: {
|
||||
left: 120,
|
||||
top: 360,
|
||||
width: 480,
|
||||
height: 96
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
用途:
|
||||
|
||||
- 调试扫描框映射;
|
||||
- 保存条码位置;
|
||||
- 多码场景二次判断;
|
||||
- 识别结果可视化。
|
||||
|
||||
---
|
||||
|
||||
### 6.2 结果校验
|
||||
|
||||
建议在插件外层或调用参数中增加结果校验能力。
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
formats: ['CODE_128'],
|
||||
validator: {
|
||||
pattern: '^EQ-[A-Z0-9]{8}$',
|
||||
message: '请扫描有效设备编码'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
建议支持:
|
||||
|
||||
- 正则表达式;
|
||||
- 固定前缀;
|
||||
- 固定长度;
|
||||
- 自定义校验回调;
|
||||
- 校验失败后继续扫描,不关闭扫码页。
|
||||
|
||||
建议优先在业务层实现,避免把业务规则过度耦合到通用插件。
|
||||
|
||||
---
|
||||
|
||||
### 6.3 扫码状态回调
|
||||
|
||||
用于业务页面展示状态或进行调试。
|
||||
|
||||
建议接口:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
onStatus: (status) => {
|
||||
console.log(status)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
建议状态:
|
||||
|
||||
- `opening`:正在打开扫码页;
|
||||
- `cameraReady`:相机已准备完成;
|
||||
- `scanning`:正在识别;
|
||||
- `paused`:已暂停;
|
||||
- `result`:识别到结果;
|
||||
- `closed`:扫码页关闭。
|
||||
|
||||
---
|
||||
|
||||
## 7. 相册识图增强
|
||||
|
||||
### 7.1 多图批量识别
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
fromAlbum: true,
|
||||
albumMultiple: true,
|
||||
maxAlbumImages: 9
|
||||
})
|
||||
```
|
||||
|
||||
适用场景:
|
||||
|
||||
- 批量导入物料标签照片;
|
||||
- 历史图片盘点;
|
||||
- 一次处理多个设备照片。
|
||||
|
||||
### 7.2 图片裁剪后识别
|
||||
|
||||
相册选择图片后增加裁剪步骤:
|
||||
|
||||
- 用户手动裁剪识别区域;
|
||||
- 使用当前扫描框比例作为裁剪建议;
|
||||
- 裁剪后再交给 ML Kit 识别。
|
||||
|
||||
复杂照片中存在多个标签时,该功能可以明显提高识别准确率。
|
||||
|
||||
---
|
||||
|
||||
## 8. 调试和错误处理
|
||||
|
||||
### 8.1 调试模式
|
||||
|
||||
建议增加:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
debug: true
|
||||
})
|
||||
```
|
||||
|
||||
调试信息可包括:
|
||||
|
||||
- 预览 View 尺寸;
|
||||
- 实际扫描框像素坐标;
|
||||
- 输入图像尺寸;
|
||||
- 图像旋转角度;
|
||||
- cropRect;
|
||||
- 区域过滤前后的条码数量;
|
||||
- 智能变焦触发记录;
|
||||
- 每帧识别耗时。
|
||||
|
||||
正式环境默认关闭,避免性能损耗和敏感信息泄露。
|
||||
|
||||
### 8.2 更细的错误码
|
||||
|
||||
**状态:已实现。** 现有 `9021001 ~ 9021004` 的含义保持不变,新增错误码从 `9021005` 开始:
|
||||
|
||||
| 错误码 | 场景 | 处理方式 |
|
||||
| --- | --- | --- |
|
||||
| `9021005` | 相机被其他应用占用或当前不可用 | 关闭其他相机应用后重试 |
|
||||
| `9021006` | 设备没有闪光灯 | 仅在扫码页提示,不中断扫码会话 |
|
||||
| `9021007` | 相机初始化失败 | 检查设备相机状态,必要时重启应用 |
|
||||
| `9021008` | 相册图片无法读取 | 重新选择本地图片 |
|
||||
| `9021009` | 图片中没有有效条码 | 仅打开即相册且无可用相机回退时结束会话 |
|
||||
| `9021010` | 指定码制不支持 | `formats` 非空且全部不受支持时返回 |
|
||||
| `9021011` | 扫描超时 | 单次扫码传入 `scanTimeout: 1000 ~ 600000` 后启用;连续扫码不启用 |
|
||||
|
||||
错误码增加遵循兼容原则:不修改已有错误码的含义;致命错误走 `fail` 后 `complete`;无闪光灯不会影响相机扫码,因此只提示,不触发 `fail`。
|
||||
|
||||
---
|
||||
|
||||
## 9. 横屏和平板支持
|
||||
|
||||
当前 Android 扫码页固定竖屏。后续可以评估:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
orientation: 'portrait'
|
||||
})
|
||||
```
|
||||
|
||||
建议取值:
|
||||
|
||||
- `portrait`:竖屏,当前默认行为;
|
||||
- `landscape`:横屏;
|
||||
- `auto`:跟随设备方向。
|
||||
|
||||
适用设备:
|
||||
|
||||
- 平板;
|
||||
- 工业手持终端;
|
||||
- 横向固定安装设备;
|
||||
- 长条码集中扫描场景。
|
||||
|
||||
---
|
||||
|
||||
## 10. 推荐开发优先级
|
||||
|
||||
### 第一阶段:直接提升日常可用性
|
||||
|
||||
1. 手电筒默认状态 API;
|
||||
2. 连续扫码去重策略;
|
||||
3. 多码自动选择策略;
|
||||
4. 扫码结果确认模式;
|
||||
5. 前后摄像头切换。
|
||||
|
||||
### 第二阶段:提升业务适配能力
|
||||
|
||||
1. 扫码页提示文案;
|
||||
2. 扫描状态回调;
|
||||
3. 稳定识别帧数;
|
||||
4. 扫描框视觉样式配置;
|
||||
5. 结果位置 `bounds` 返回。
|
||||
|
||||
### 第三阶段:扩展复杂业务场景
|
||||
|
||||
1. 相册多图批量识别;
|
||||
2. 图片裁剪后识别;
|
||||
3. 横屏和平板支持;
|
||||
4. 扫码结果校验器;
|
||||
5. 调试模式和性能指标。
|
||||
|
||||
---
|
||||
|
||||
## 11. 开发注意事项
|
||||
|
||||
1. 新增功能应优先保持 `openCodeScan(options)` 的兼容性,不要破坏现有调用。
|
||||
2. 识别区域参数和视觉样式参数应分开设计。
|
||||
3. 全屏模式必须保持“整画面识别、不绘制扫描框、不进行区域过滤”的语义。
|
||||
4. 二维码区域必须继续保持 1:1,不能因为屏幕比例或边界收敛发生变形。
|
||||
5. 连续扫码新增去重策略时,不能削弱当前已有的基本去重行为。
|
||||
6. 相册识图和实时相机识别应保持清晰区分,相册识图默认不受实时扫描框限制。
|
||||
7. 新增 Android 原生能力后,应同步更新 UTS 类型定义、插件 README 和演示页。
|
||||
8. 每次修改原生相机、图像坐标映射或区域过滤逻辑后,都需要使用 Android 真机或自定义调试基座验证。
|
||||
9. 新增错误码时只能追加,不能改变已有错误码的含义。
|
||||
10. 涉及权限、相机、闪光灯和设备方向的功能,需要至少验证主流 Android 设备和不同屏幕尺寸。
|
||||
@@ -0,0 +1,498 @@
|
||||
# mas-code-scan 自定义识别区域方案
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在 Android 扫码页面中提供可配置的实时识别区域(ROI,Region of Interest):
|
||||
|
||||
- 摄像头打开后显示一个矩形扫描框;
|
||||
- 只有条码位于矩形区域内时才允许识别成功;
|
||||
- 扫描框外的条码不触发单次扫码成功;
|
||||
- 支持自定义扫描框宽度、高度、水平位置和垂直位置;
|
||||
- 支持不同屏幕尺寸和横竖屏适配;
|
||||
- 不破坏现有的单次扫码、连续扫码、相册识图、多码点选、智能变焦和码制过滤能力。
|
||||
|
||||
## 2. 当前实现核对
|
||||
|
||||
### 2.1 现有接口
|
||||
|
||||
文件:`uni_modules/mas-code-scan/utssdk/interface.uts`
|
||||
|
||||
当前 `CodeScanOptions` 支持:
|
||||
|
||||
```ts
|
||||
{
|
||||
smartZoom?: boolean
|
||||
formats?: string[]
|
||||
vibrate?: boolean
|
||||
continuous?: boolean
|
||||
fromAlbum?: boolean
|
||||
success?: (res) => void
|
||||
fail?: (res) => void
|
||||
complete?: (res) => void
|
||||
}
|
||||
```
|
||||
|
||||
当前没有识别区域配置字段。
|
||||
|
||||
### 2.2 预览层
|
||||
|
||||
文件:`uni_modules/mas-code-scan/utssdk/app-android/CodeCaptureActivity.kt`
|
||||
|
||||
- 使用 `PreviewView.ScaleType.FILL_CENTER` 显示相机预览;
|
||||
- `ScanFrameDecorView` 覆盖在预览层上方绘制扫描动画;
|
||||
- 预览层和装饰层都是全屏 `FrameLayout`;
|
||||
- 触摸缩放绑定在 `PreviewView` 上。
|
||||
|
||||
文件:`uni_modules/mas-code-scan/utssdk/app-android/ScanFrameDecorView.kt`
|
||||
|
||||
当前扫描框是固定的正方形:
|
||||
|
||||
```kotlin
|
||||
val frameWidth = ...
|
||||
val frameHeight = frameWidth
|
||||
```
|
||||
|
||||
它只负责画扫描线,不参与实际识别。
|
||||
|
||||
### 2.3 识别层
|
||||
|
||||
文件:`uni_modules/mas-code-scan/utssdk/app-android/CodeCaptureActivity.kt`
|
||||
|
||||
当前把完整的 `ImageProxy` 图像交给 ML Kit:
|
||||
|
||||
```kotlin
|
||||
val inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees)
|
||||
scanner.process(inputImage)
|
||||
```
|
||||
|
||||
因此当前扫描框只是视觉提示,框外条码仍可能被识别。
|
||||
|
||||
## 3. 推荐接口设计
|
||||
|
||||
建议新增 `scanRegion`,使用相对比例配置,不使用固定像素作为公共接口。
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
scanRegion: {
|
||||
width: 0.92,
|
||||
height: 0.18,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
字段定义:
|
||||
|
||||
| 字段 | 类型 | 默认值 | 范围 | 含义 |
|
||||
|---|---|---:|---|---|
|
||||
| `fullScreen` | `boolean` | `false` | - | 为 `true` 时整画面识别,不绘制扫描框且不进行区域过滤 |
|
||||
| `width` | `number` | `0.74` | `0.1 ~ 1` | 扫描框宽度,占预览宽度的比例 |
|
||||
| `height` | `number` | `0.74` | `0.1 ~ 1` | 扫描框高度,占预览宽度的比例 |
|
||||
| `centerX` | `number` | `0.5` | `0 ~ 1` | 扫描框中心的水平位置比例 |
|
||||
| `centerY` | `number` | `0.5` | `0 ~ 1` | 扫描框中心的垂直位置比例 |
|
||||
|
||||
全屏识别示例:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
scanRegion: { fullScreen: true }
|
||||
})
|
||||
```
|
||||
|
||||
二维码区域约束:
|
||||
|
||||
- `width` 与 `height` 使用相同值,严格锁定 `1:1` 正方形比例;
|
||||
- 默认值为 `width: 0.74`、`height: 0.74`;
|
||||
- 原生扫描框使用直角,不绘制圆角。
|
||||
|
||||
推荐默认值:
|
||||
|
||||
```ts
|
||||
{
|
||||
width: 0.74,
|
||||
height: 0.74,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5
|
||||
}
|
||||
```
|
||||
|
||||
业务页面可针对一维码使用横向区域,并通过 `centerY` 调整上下位置、通过 `height` 调整框高:
|
||||
|
||||
```ts
|
||||
{
|
||||
width: 0.96,
|
||||
height: 0.24,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5
|
||||
}
|
||||
```
|
||||
|
||||
原型页提供三档横向条码框高度:
|
||||
|
||||
- 中:`0.24`(24%);
|
||||
- 大:`0.32`(32%);
|
||||
- 特大:`0.40`(40%)。
|
||||
|
||||
### 3.1 为什么使用比例
|
||||
|
||||
- 不依赖具体手机分辨率和 density;
|
||||
- 适配不同 Android 设备;
|
||||
- 与当前全屏预览布局一致;
|
||||
- 后续可在页面中通过滑块或预设配置调整;
|
||||
- 避免将前端 `rpx` 直接与原生像素混用。
|
||||
|
||||
## 4. 实现分层
|
||||
|
||||
### 4.1 UTS 接口层
|
||||
|
||||
修改:`utssdk/interface.uts`
|
||||
|
||||
新增:
|
||||
|
||||
```ts
|
||||
export type ScanRegion = {
|
||||
width?: number
|
||||
height?: number
|
||||
centerX?: number
|
||||
centerY?: number
|
||||
}
|
||||
```
|
||||
|
||||
并在 `CodeScanOptions` 中增加:
|
||||
|
||||
```ts
|
||||
scanRegion?: ScanRegion
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- 字段全部可选;
|
||||
- 不传时保持现有默认扫描框;
|
||||
- 对非法值进行原生端裁剪和兜底;
|
||||
- 不把 UI 颜色、文案等表现层参数与识别区域参数混在一起。
|
||||
|
||||
### 4.2 UTS 到 Android Intent
|
||||
|
||||
修改:`utssdk/app-android/index.uts`
|
||||
|
||||
新增一个 JSON 字符串 Extra,例如:
|
||||
|
||||
```text
|
||||
mas_scan_region
|
||||
```
|
||||
|
||||
将 `scanRegion` 序列化后传递给 `CodeCaptureActivity`。
|
||||
|
||||
推荐传递 JSON,而不是增加多个 Intent Extra,原因是:
|
||||
|
||||
- 字段集中;
|
||||
- 后续增加圆角、边框颜色等非识别参数时更容易扩展;
|
||||
- 保持接口和 Activity 参数数量可控。
|
||||
|
||||
### 4.3 Activity 参数解析
|
||||
|
||||
修改:`CodeCaptureActivity.kt`
|
||||
|
||||
新增:
|
||||
|
||||
```kotlin
|
||||
private data class ScanRegionConfig(
|
||||
val width: Float,
|
||||
val height: Float,
|
||||
val centerX: Float,
|
||||
val centerY: Float
|
||||
)
|
||||
```
|
||||
|
||||
初始化时:
|
||||
|
||||
1. 读取 Intent Extra;
|
||||
2. 解析 JSON;
|
||||
3. 将数值限制在安全范围;
|
||||
4. 对过小区域、越界中心点进行兜底;
|
||||
5. 将同一份配置传给 `ScanFrameDecorView` 和识别过滤逻辑。
|
||||
|
||||
最终必须保证:
|
||||
|
||||
```text
|
||||
视觉扫描框区域 == 实际有效识别区域
|
||||
```
|
||||
|
||||
## 5. 坐标体系设计
|
||||
|
||||
这是本需求的核心难点。项目中至少存在三套坐标:
|
||||
|
||||
1. **屏幕/装饰层坐标**:`ScanFrameDecorView` 的 view 坐标;
|
||||
2. **预览可视区域坐标**:`PreviewView` 中用户看到的相机画面;
|
||||
3. **ImageProxy 图像坐标**:ML Kit `Barcode.boundingBox` 使用的图像坐标。
|
||||
|
||||
由于预览使用 `FILL_CENTER`,图像可能在屏幕两侧或上下被裁剪,不能简单按照屏幕宽高直接换算。
|
||||
|
||||
### 5.1 统一区域定义
|
||||
|
||||
推荐先在预览层坐标中计算扫描框:
|
||||
|
||||
```text
|
||||
frameLeft = viewWidth * (centerX - width / 2)
|
||||
frameTop = viewHeight * (centerY - height / 2)
|
||||
frameRight = viewWidth * (centerX + width / 2)
|
||||
frameBottom = viewHeight * (centerY + height / 2)
|
||||
```
|
||||
|
||||
并限制在预览层范围内。
|
||||
|
||||
### 5.2 预览坐标映射到图像坐标
|
||||
|
||||
当前 `PreviewView` 使用 `FILL_CENTER`,映射应考虑:
|
||||
|
||||
```text
|
||||
scale = max(viewWidth / rotatedImageWidth, viewHeight / rotatedImageHeight)
|
||||
```
|
||||
|
||||
相机图像在屏幕上的实际显示尺寸为:
|
||||
|
||||
```text
|
||||
displayWidth = rotatedImageWidth * scale
|
||||
displayHeight = rotatedImageHeight * scale
|
||||
offsetX = (viewWidth - displayWidth) / 2
|
||||
offsetY = (viewHeight - displayHeight) / 2
|
||||
```
|
||||
|
||||
扫描框转回旋转后的图像坐标:
|
||||
|
||||
```text
|
||||
imageLeft = (frameLeft - offsetX) / scale
|
||||
imageTop = (frameTop - offsetY) / scale
|
||||
imageRight = (frameRight - offsetX) / scale
|
||||
imageBottom = (frameBottom - offsetY) / scale
|
||||
```
|
||||
|
||||
然后再根据 `rotationDegrees` 转换到 `ImageProxy` 原始坐标。
|
||||
|
||||
注意:实际代码需要以 `PreviewView` 的 `viewPort`、绑定的 UseCase 分辨率和设备方向为准验证,不能只凭静态公式上线。
|
||||
|
||||
## 6. 识别限制策略
|
||||
|
||||
### 6.1 第一阶段:结果过滤,推荐先实现
|
||||
|
||||
ML Kit 仍分析整帧,但只接受扫描区域内的条码:
|
||||
|
||||
```kotlin
|
||||
val validBarcodes = barcodes.filter { barcode ->
|
||||
val box = barcode.boundingBox ?: return@filter false
|
||||
isBarcodeInsideScanRegion(box, imageProxy, rotationDegrees)
|
||||
}
|
||||
```
|
||||
|
||||
区域判断建议使用条码边界框中心点,默认规则:
|
||||
|
||||
```text
|
||||
条码中心点位于扫描框内 => 允许识别
|
||||
条码中心点位于扫描框外 => 忽略
|
||||
```
|
||||
|
||||
对于严格业务场景,可以改为:
|
||||
|
||||
```text
|
||||
条码边界框完全位于扫描框内 => 允许识别
|
||||
```
|
||||
|
||||
本项目建议默认使用“中心点在区域内”,原因是:
|
||||
|
||||
- 用户将条码移入框内时体验更自然;
|
||||
- 条码边缘轻微超出时不会频繁丢失;
|
||||
- 对一维横向条码更友好。
|
||||
|
||||
### 6.2 多码识别
|
||||
|
||||
过滤应发生在多码判断之前:
|
||||
|
||||
```text
|
||||
原始 barcodes
|
||||
-> 过滤无 boundingBox 的结果
|
||||
-> 过滤框外结果
|
||||
-> 再执行单码 / 多码判断
|
||||
```
|
||||
|
||||
这样框外多码不会触发点选页面,也不会影响智能变焦。
|
||||
|
||||
### 6.3 相册识图
|
||||
|
||||
实时摄像头的 `scanRegion` 不应默认限制相册识图:
|
||||
|
||||
- 相册图片没有实时预览框坐标;
|
||||
- 用户选择的是整张图片;
|
||||
- 继续保持相册识别整图的现有行为。
|
||||
|
||||
如果未来确实需要对相册图片裁剪,应单独设计 `albumRegion`,不要复用实时预览坐标。
|
||||
|
||||
### 6.4 识别前裁剪
|
||||
|
||||
第二阶段可选做真正的 ROI 图像裁剪,只将区域图像提交给 ML Kit。
|
||||
|
||||
暂不作为第一阶段实现,原因:
|
||||
|
||||
- 需要处理 ImageProxy YUV 裁剪;
|
||||
- 需要处理旋转和 cropRect;
|
||||
- 需要重新映射多码点选坐标;
|
||||
- 可能影响智能变焦依据和现有连续扫码稳定性;
|
||||
- 设备兼容性验证成本更高。
|
||||
|
||||
## 7. 扫描框视觉设计
|
||||
|
||||
修改:`ScanFrameDecorView.kt`
|
||||
|
||||
扫描框应从固定正方形改为配置矩形:
|
||||
|
||||
- 宽度由 `width` 决定;
|
||||
- 高度由 `height` 决定;
|
||||
- 位置由 `centerX`、`centerY` 决定;
|
||||
- 扫描线只在矩形内部移动;
|
||||
- 扫描线左右留出固定 inset;
|
||||
- 其他区域可增加半透明遮罩,增强用户对有效范围的理解。
|
||||
|
||||
建议提供清晰的 UI 语义:
|
||||
|
||||
```text
|
||||
框内:有效识别区域
|
||||
框外:不识别
|
||||
```
|
||||
|
||||
当前截图的横向条码场景推荐:
|
||||
|
||||
```ts
|
||||
scanRegion: {
|
||||
width: 0.96,
|
||||
height: 0.16,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 原型页面示例规划
|
||||
|
||||
文件:`pages/scan/prototype.vue`
|
||||
|
||||
原型页增加“识别区域”配置区,但不直接实现摄像头 UI。页面负责展示和传递配置:
|
||||
|
||||
### 8.1 预设
|
||||
|
||||
提供三个预设:
|
||||
|
||||
- 默认正方形:适合二维码;
|
||||
- 横向条码:适合设备码、物料码;
|
||||
- 自定义:通过宽度、高度、水平位置、垂直位置调整。
|
||||
|
||||
### 8.2 示例数据
|
||||
|
||||
```js
|
||||
scanRegion: {
|
||||
width: 0.96,
|
||||
height: 0.16,
|
||||
centerX: 0.5,
|
||||
centerY: 0.5
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 原型页交互
|
||||
|
||||
- 使用滑块或步进按钮调整四个比例参数;
|
||||
- 页面实时显示当前区域参数;
|
||||
- 单次扫码、连续扫码都使用当前 `scanRegion`;
|
||||
- 相册识图按钮不传实时扫描区域,继续整图识别;
|
||||
- 在 Web 预览中展示“仅 Android App-Plus 支持原生扫码”的提示。
|
||||
|
||||
## 9. 兼容性策略
|
||||
|
||||
### 9.1 不传配置
|
||||
|
||||
现有调用全部保持兼容:
|
||||
|
||||
```ts
|
||||
openCodeScan({
|
||||
smartZoom: true,
|
||||
vibrate: true
|
||||
})
|
||||
```
|
||||
|
||||
行为继续使用默认正方形区域。
|
||||
|
||||
### 9.2 旧构建包
|
||||
|
||||
如果前端代码传入 `scanRegion`,但运行的是旧插件包,旧原生代码会忽略未知 Extra,仍可正常扫码,但不会限制区域。因此必须重新制作自定义基座或正式 APK。
|
||||
|
||||
### 9.3 非 Android 平台
|
||||
|
||||
继续由 `#ifdef APP-PLUS` 控制插件导入和调用,Web、iOS、小程序不加载 Android UTS 实现。
|
||||
|
||||
### 9.4 参数安全
|
||||
|
||||
原生端必须再次校验参数,不能只依赖前端:
|
||||
|
||||
- `width`、`height` 限制在 `0.1 ~ 1`;
|
||||
- `centerX`、`centerY` 限制在 `0 ~ 1`;
|
||||
- 区域超出屏幕时进行边界收敛;
|
||||
- 宽高过小导致不可用时回退默认值。
|
||||
|
||||
## 10. 实施顺序
|
||||
|
||||
### 阶段一:方案验证
|
||||
|
||||
1. 增加 `scanRegion` 接口定义;
|
||||
2. 将参数传入 Android Activity;
|
||||
3. 扫描框支持矩形和位置配置;
|
||||
4. 根据条码中心点过滤框外结果;
|
||||
5. 原型页提供横向条码预设;
|
||||
6. 保持相册整图识别。
|
||||
|
||||
### 阶段二:真机验证
|
||||
|
||||
至少验证:
|
||||
|
||||
- 二维码默认正方形识别;
|
||||
- 横向一维码识别;
|
||||
- 框内识别成功;
|
||||
- 框外完全不识别;
|
||||
- 条码横跨边界时的行为;
|
||||
- 多个条码一内一外时只处理框内条码;
|
||||
- 连续扫码只回传框内结果;
|
||||
- 智能变焦不会被框外条码触发;
|
||||
- 横竖屏和不同分辨率设备;
|
||||
- 相册识图不受实时区域影响;
|
||||
- 关闭、取消、权限拒绝和页面销毁流程。
|
||||
|
||||
### 阶段三:可选优化
|
||||
|
||||
根据真机效果再决定是否:
|
||||
|
||||
- 改为识别前裁剪 ROI;
|
||||
- 增加边框颜色、圆角、遮罩透明度配置;
|
||||
- 增加“中心点在框内 / 完全包含”两种命中规则;
|
||||
- 增加一维码专用默认模板。
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
功能验收:
|
||||
|
||||
- 扫描框宽高和位置可以通过 `openCodeScan` 参数调整;
|
||||
- 用户看到的扫描框与实际识别区域一致;
|
||||
- 框外条码不会触发成功回调;
|
||||
- 框内条码可以正常触发成功回调;
|
||||
- 多码点选只展示有效区域内的条码;
|
||||
- 旧调用不传 `scanRegion` 时行为不变;
|
||||
- 单次、连续、相册三种模式互不回归。
|
||||
|
||||
工程验收:
|
||||
|
||||
- UTS 接口类型、Intent Extra、Activity 解析三层字段一致;
|
||||
- 不在页面中直接修改原生插件内部逻辑;
|
||||
- 所有新增配置有默认值和越界保护;
|
||||
- 真机重新构建自定义基座后验证;
|
||||
- 不以 Web 预览结果代替 Android 真机验证。
|
||||
|
||||
## 12. 结论
|
||||
|
||||
本项目适合采用“**比例配置 + 原生扫描框绘制 + 条码结果区域过滤**”作为第一版实现。
|
||||
|
||||
该方案能满足截图所示的横向矩形识别场景,改动范围可控,且能保留当前插件已有能力。真正的图像裁剪 ROI 作为后续优化,不在第一版中引入,以降低旋转、坐标映射和多码点选的回归风险。
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -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])
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
```
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
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)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 254 B |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 736 B |
|
After Width: | Height: | Size: 634 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 788 B |
|
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) ?? ''
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||