wui-components-v2 1.1.69 → 1.1.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/core/index.ts +74 -74
- package/api/feishu.ts +20 -20
- package/api/index.ts +4 -4
- package/api/login.ts +51 -51
- package/api/menu.ts +45 -45
- package/api/page.ts +114 -114
- package/api/sys.ts +12 -12
- package/api/tree.ts +28 -28
- package/components/action-popup/action-popup.vue +116 -116
- package/components/add-address-list/add-address-list.vue +187 -187
- package/components/add-address-page/add-address-page.vue +77 -77
- package/components/add-address-page/config.ts +297 -297
- package/components/audio-play/audio-play.vue +220 -220
- package/components/batch-upload-file/batch-upload-file.vue +75 -75
- package/components/card-botom-buttons/card-botom-buttons.vue +279 -279
- package/components/custom-date-picker/custom-date-picker.vue +106 -106
- package/components/custom-select-picker/custom-select-picker.vue +95 -95
- package/components/demo-block/demo-block.vue +63 -63
- package/components/demo-card/demo-card.vue +44 -44
- package/components/detail-popup/detail-popup.vue +99 -99
- package/components/evaluation-page/evaluation-page.vue +196 -196
- package/components/fold-card/fold-card.vue +171 -171
- package/components/form-control/form-control.vue +661 -661
- package/components/global-loading/GlobalLoading.md +366 -366
- package/components/global-loading/global-loading.vue +55 -55
- package/components/global-message/GlobalMessage.md +570 -570
- package/components/global-message/global-message.vue +68 -68
- package/components/global-toast/GlobalToast.md +261 -261
- package/components/global-toast/global-toast.vue +56 -56
- package/components/label-value/label-value.vue +144 -144
- package/components/label-value/open-value-more.vue +56 -56
- package/components/list-top-buttons/list-top-buttons.vue +19 -19
- package/components/loading/loading.vue +5 -5
- package/components/login-form/login-form.vue +126 -126
- package/components/login-form-box/login-form-box.vue +65 -65
- package/components/mulselect-picker/mulselect-picker.vue +86 -86
- package/components/phone-login-form/phone-login-form.vue +204 -204
- package/components/privacy-popup/privacy-popup.vue +191 -191
- package/components/product-card/product-card.vue +201 -201
- package/components/scan-input/scan-input.vue +406 -406
- package/components/search/search.vue +128 -128
- package/components/select-col-picker/select-col-picker.vue +168 -168
- package/components/tab-search/tab-search.vue +73 -73
- package/components/tree-select/components/TreeNode.vue +200 -200
- package/components/tree-select/components/index.vue +471 -471
- package/components/tree-select/components/tree-select.vue +56 -56
- package/components/tree-select/index.vue +93 -93
- package/components/user-choose/user-choose.vue +169 -151
- package/components/video-play/video-play.vue +56 -56
- package/components/wui-auto-update-component/wui-auto-update-component.vue +132 -132
- package/components/wui-default/wui-default.vue +49 -49
- package/components/wui-details-page/wui-details-page.vue +147 -147
- package/components/wui-edit-page/wui-edit-page-copy.vue +757 -757
- package/components/wui-edit-page/wui-edit-page.vue +793 -793
- package/components/wui-enume-select-control/wui-enume-select-control.vue +92 -92
- package/components/wui-list/wui-list.vue +235 -235
- package/components/wui-login/wui-login.vue +43 -43
- package/components/wui-login1/wui-login.vue +41 -41
- package/components/wui-menus/wui-menus-top.vue +137 -137
- package/components/wui-menus/wui-menus-top1.vue +8 -8
- package/components/wui-menus/wui-menus.vue +247 -247
- package/components/wui-menus1/components/banner-carousel.vue +320 -320
- package/components/wui-menus1/components/navbar.vue +43 -43
- package/components/wui-menus1/components/quick-panel.vue +55 -55
- package/components/wui-menus1/components/search-bar.vue +49 -49
- package/components/wui-menus1/components/section-menus.vue +208 -208
- package/components/wui-menus1/wui-menus-top.vue +718 -718
- package/components/wui-menus1/wui-menus-top1.vue +398 -398
- package/components/wui-menus1/wui-menus.vue +564 -564
- package/components/wui-notify-info/notify-handle.vue +53 -53
- package/components/wui-notify-info/wui-notify-info.vue +280 -280
- package/components/wui-scan-binding-sensor/wui-scan-binding-sensor.vue +75 -75
- package/components/wui-search-history-babbar/components/SearchBar.vue +70 -70
- package/components/wui-search-history-babbar/wui-search-history-babbar.vue +204 -204
- package/components/wui-select-list/wui-select-list.vue +310 -310
- package/components/wui-select-popup/wui-select-popup.vue +613 -612
- package/components/wui-system-settings/wui-system-settings.vue +144 -144
- package/components/wui-tabbar/wui-tabbar.vue +106 -106
- package/components/wui-tree-page/components/tree-item.vue +238 -238
- package/components/wui-tree-page/wui-tree-page.vue +297 -297
- package/components/wui-update-component/wui-update-component.vue +221 -221
- package/components/wui-user/wui-user.vue +202 -202
- package/composables/types/theme.ts +65 -65
- package/composables/types/user.ts +5 -5
- package/composables/useColorGenerator.ts +221 -221
- package/composables/useCompanyFieldFilter.ts +91 -91
- package/composables/useEnumes.ts +43 -43
- package/composables/useEventBus.ts +37 -37
- package/composables/useGlobalLoading.ts +47 -47
- package/composables/useGlobalMessage.ts +53 -53
- package/composables/useGlobalToast.ts +63 -63
- package/composables/useLocale.ts +54 -54
- package/composables/useManualTheme.ts +179 -179
- package/composables/useMenus.ts +193 -193
- package/composables/useSectionMenus.ts +120 -120
- package/composables/useTabbar.ts +50 -50
- package/composables/useTheme.ts +74 -74
- package/composables/useUser.ts +24 -24
- package/index.d.ts +90 -90
- package/index.ts +83 -83
- package/package.json +24 -24
- package/static/iconfont/iconfont.css +63 -63
- package/store/language.ts +151 -151
- package/store/manualThemeStore.ts +143 -143
- package/store/persist.ts +41 -41
- package/store/themeStore.ts +74 -74
- package/store/userStore.ts +22 -22
- package/styles/dark-mode.css +523 -523
- package/styles/dark-mode.min.css +1 -1
- package/styles/dark-mode.scss +498 -498
- package/type.ts +154 -154
- package/utils/control-tree.ts +353 -353
- package/utils/control-type-supportor.ts +148 -148
- package/utils/eventBus.ts +36 -36
- package/utils/idata-scan.ts +99 -99
- package/utils/index.ts +979 -979
- package/utils/rsaEncrypt.ts +10 -10
|
@@ -1,406 +1,406 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
|
3
|
-
|
|
4
|
-
defineOptions({
|
|
5
|
-
name: 'ScanInput',
|
|
6
|
-
})
|
|
7
|
-
|
|
8
|
-
// ---- 类型 ----
|
|
9
|
-
type Html5QrcodeInstance = {
|
|
10
|
-
start: (config: object, opts: object, onSuccess: (text: string) => void, onFailure: () => void) => Promise<void>
|
|
11
|
-
stop: () => Promise<void>
|
|
12
|
-
clear: () => Promise<void>
|
|
13
|
-
isScanning?: boolean
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// ---- Props ----
|
|
17
|
-
const props = withDefaults(defineProps<{
|
|
18
|
-
/** v-model 绑定值 */
|
|
19
|
-
modelValue?: string
|
|
20
|
-
/** 自动启动扫码 */
|
|
21
|
-
autoStart?: boolean
|
|
22
|
-
/** 占位文本 */
|
|
23
|
-
placeholder?: string
|
|
24
|
-
/** 是否禁用 */
|
|
25
|
-
disabled?: boolean
|
|
26
|
-
/** 扫码成功后自动关闭弹窗 (H5) */
|
|
27
|
-
autoCloseOnSuccess?: boolean
|
|
28
|
-
/** 扫码成功后是否展示 toast 提示 */
|
|
29
|
-
showSuccessToast?: boolean
|
|
30
|
-
}>(), {
|
|
31
|
-
modelValue: '',
|
|
32
|
-
autoStart: false,
|
|
33
|
-
placeholder: '请输入或扫描二维码/条形码',
|
|
34
|
-
disabled: false,
|
|
35
|
-
autoCloseOnSuccess: true,
|
|
36
|
-
showSuccessToast: true,
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
const emit = defineEmits<{
|
|
40
|
-
(e: 'update:modelValue', val: string): void
|
|
41
|
-
(e: 'scan', data: string): void
|
|
42
|
-
(e: 'input', data: string): void
|
|
43
|
-
(e: 'error', error: string): void
|
|
44
|
-
}>()
|
|
45
|
-
|
|
46
|
-
// ---- 状态 ----
|
|
47
|
-
const inputValue = ref(props.modelValue)
|
|
48
|
-
const isScanning = ref(false)
|
|
49
|
-
const showScanModal = ref(false)
|
|
50
|
-
const isLoading = ref(false)
|
|
51
|
-
let html5QrcodeScanner: Html5QrcodeInstance | null = null
|
|
52
|
-
let scanLocked = false
|
|
53
|
-
|
|
54
|
-
// v-model 同步
|
|
55
|
-
watch(() => props.modelValue, (val) => { inputValue.value = val })
|
|
56
|
-
watch(inputValue, (val) => { emit('update:modelValue', val); emit('input', val) })
|
|
57
|
-
|
|
58
|
-
/** 外部可调用的方法 */
|
|
59
|
-
function clear() { inputValue.value = '' }
|
|
60
|
-
function focus() { /* wd-input 聚焦可通过 ref 实现 */ }
|
|
61
|
-
|
|
62
|
-
defineExpose({ clear, focus, startScan: handleStartScan, stopScan })
|
|
63
|
-
|
|
64
|
-
// ======================== H5 平台扫码 ========================
|
|
65
|
-
// #ifdef H5
|
|
66
|
-
import { Html5Qrcode } from 'html5-qrcode'
|
|
67
|
-
|
|
68
|
-
/** 环境检测 */
|
|
69
|
-
function checkCameraEnvironment(): { ok: boolean; reason?: string } {
|
|
70
|
-
const hostname = window.location.hostname
|
|
71
|
-
if (hostname === 'localhost' || hostname === '127.0.0.1') return { ok: true }
|
|
72
|
-
if (window.location.protocol !== 'https:') {
|
|
73
|
-
return { ok: false, reason: '当前非HTTPS环境,请在 https 下使用扫码功能' }
|
|
74
|
-
}
|
|
75
|
-
return { ok: true }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function startScan() {
|
|
79
|
-
if (isScanning.value || scanLocked) return
|
|
80
|
-
const envCheck = checkCameraEnvironment()
|
|
81
|
-
if (!envCheck.ok) {
|
|
82
|
-
uni.showToast({ title: envCheck.reason || '环境不支持', icon: 'none' })
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
isLoading.value = true
|
|
87
|
-
isScanning.value = true
|
|
88
|
-
showScanModal.value = true
|
|
89
|
-
scanLocked = false
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
await nextTick()
|
|
93
|
-
await new Promise(r => setTimeout(r, 200))
|
|
94
|
-
|
|
95
|
-
const container = document.getElementById('h5-scan-reader')
|
|
96
|
-
if (!container) throw new Error('扫码容器未找到')
|
|
97
|
-
|
|
98
|
-
html5QrcodeScanner = new Html5Qrcode('h5-scan-reader') as unknown as Html5QrcodeInstance
|
|
99
|
-
|
|
100
|
-
await html5QrcodeScanner.start(
|
|
101
|
-
{ facingMode: 'environment' },
|
|
102
|
-
{ fps: 5 },
|
|
103
|
-
(decodedText: string) => {
|
|
104
|
-
if (scanLocked || !isScanning.value) return
|
|
105
|
-
scanLocked = true // 加锁防止重复触发
|
|
106
|
-
|
|
107
|
-
console.log('[ScanInput] 扫码成功:', decodedText)
|
|
108
|
-
inputValue.value = decodedText
|
|
109
|
-
emit('scan', decodedText)
|
|
110
|
-
if (props.showSuccessToast) uni.showToast({ title: '扫描成功', icon: 'success' })
|
|
111
|
-
if (props.autoCloseOnSuccess) stopScan()
|
|
112
|
-
},
|
|
113
|
-
() => {}, // 忽略中间帧未识别错误
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
// JS 强制全屏 video(html5-qrcode 库会覆盖 CSS,需运行时修正)
|
|
117
|
-
const videoEl = document.querySelector('#h5-scan-reader video') as HTMLVideoElement | null
|
|
118
|
-
if (videoEl) {
|
|
119
|
-
Object.assign(videoEl.style, {
|
|
120
|
-
width: '100%',
|
|
121
|
-
height: '100vh',
|
|
122
|
-
objectFit: 'cover',
|
|
123
|
-
objectPosition: 'center',
|
|
124
|
-
})
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
catch (error: unknown) {
|
|
128
|
-
console.error('[ScanInput] 启动失败:', error)
|
|
129
|
-
const errMsg = error instanceof Error ? error.message : String(error)
|
|
130
|
-
let msg = '摄像头访问失败'
|
|
131
|
-
if (/Permission|NotAllowed/i.test(errMsg)) msg = '请允许浏览器使用摄像头'
|
|
132
|
-
else if (/NotFound|not found/i.test(errMsg)) msg = '未检测到摄像头设备'
|
|
133
|
-
else if (/secure origin|insecure/i.test(errMsg)) msg = '需要HTTPS环境才能使用摄像头'
|
|
134
|
-
|
|
135
|
-
emit('error', msg)
|
|
136
|
-
isScanning.value = false
|
|
137
|
-
showScanModal.value = false
|
|
138
|
-
uni.showModal({ title: '提示', content: msg, showCancel: false })
|
|
139
|
-
}
|
|
140
|
-
finally {
|
|
141
|
-
isLoading.value = false
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async function stopScan() {
|
|
146
|
-
isScanning.value = false
|
|
147
|
-
showScanModal.value = false
|
|
148
|
-
scanLocked = false
|
|
149
|
-
if (html5QrcodeScanner) {
|
|
150
|
-
try {
|
|
151
|
-
if (html5QrcodeScanner.isScanning) await html5QrcodeScanner.stop()
|
|
152
|
-
await html5QrcodeScanner.clear()
|
|
153
|
-
}
|
|
154
|
-
catch { /* ignore cleanup error */ }
|
|
155
|
-
html5QrcodeScanner = null
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
// #endif
|
|
159
|
-
|
|
160
|
-
// ======================== 非 H5 平台扫码 ========================
|
|
161
|
-
function startUniScanCode() {
|
|
162
|
-
// #ifndef H5
|
|
163
|
-
uni.scanCode({
|
|
164
|
-
success(res: UniApp.ScanCodeSuccessRes) {
|
|
165
|
-
inputValue.value = res.result
|
|
166
|
-
emit('scan', res.result)
|
|
167
|
-
uni.showToast({ title: '扫描成功', icon: 'success' })
|
|
168
|
-
},
|
|
169
|
-
fail() { emit('error', '扫描已取消') },
|
|
170
|
-
})
|
|
171
|
-
// #endif
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ======================== 公共方法 ========================
|
|
175
|
-
function handleStartScan() {
|
|
176
|
-
if (props.disabled) return
|
|
177
|
-
// #ifdef H5
|
|
178
|
-
isScanning.value ? stopScan() : startScan()
|
|
179
|
-
// #endif
|
|
180
|
-
// #ifndef H5
|
|
181
|
-
startUniScanCode()
|
|
182
|
-
// #endif
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** 点击遮罩层关闭 (仅 H5) */
|
|
186
|
-
function handleModalMaskClick() {
|
|
187
|
-
// #ifdef H5
|
|
188
|
-
stopScan()
|
|
189
|
-
// #endif
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function handleConfirm() {
|
|
193
|
-
const val = inputValue.value.trim()
|
|
194
|
-
if (val) emit('scan', val)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// ---- 生命周期 ----
|
|
198
|
-
onMounted(() => { if (props.autoStart && !props.disabled) handleStartScan() })
|
|
199
|
-
onUnmounted(() => {
|
|
200
|
-
// #ifdef H5
|
|
201
|
-
stopScan()
|
|
202
|
-
// #endif
|
|
203
|
-
})
|
|
204
|
-
</script>
|
|
205
|
-
|
|
206
|
-
<template>
|
|
207
|
-
<view class="si-wrap">
|
|
208
|
-
<!-- 输入框 -->
|
|
209
|
-
<wd-input
|
|
210
|
-
v-model="inputValue"
|
|
211
|
-
class="!pr-3"
|
|
212
|
-
:placeholder="placeholder"
|
|
213
|
-
type="text"
|
|
214
|
-
confirm-type="search"
|
|
215
|
-
clearable
|
|
216
|
-
no-border
|
|
217
|
-
:disabled="disabled"
|
|
218
|
-
custom-class="si-wd-input"
|
|
219
|
-
@confirm="handleConfirm"
|
|
220
|
-
suffix-icon="scan"
|
|
221
|
-
@clicksuffixicon="handleStartScan"
|
|
222
|
-
/>
|
|
223
|
-
|
|
224
|
-
<!-- #ifdef H5 -->
|
|
225
|
-
<!-- 全屏扫码弹窗 -->
|
|
226
|
-
<view
|
|
227
|
-
v-if="showScanModal"
|
|
228
|
-
class="si-modal"
|
|
229
|
-
catchtouchmove
|
|
230
|
-
@click="handleModalMaskClick"
|
|
231
|
-
>
|
|
232
|
-
<!-- 摄像头区域 -->
|
|
233
|
-
<view class="si-camera-area" @click.stop>
|
|
234
|
-
<!-- 加载中 -->
|
|
235
|
-
<view v-if="isLoading" class="si-loading-mask">
|
|
236
|
-
<view class="si-loading-box">
|
|
237
|
-
<text class="si-loading-text">正在启动摄像头...</text>
|
|
238
|
-
</view>
|
|
239
|
-
</view>
|
|
240
|
-
|
|
241
|
-
<!-- 渲染容器 + 扫描线 -->
|
|
242
|
-
<view class="si-reader-wrap overflow-hidden">
|
|
243
|
-
<div id="h5-scan-reader" class="si-reader" />
|
|
244
|
-
<view v-show="isScanning && !isLoading" class="si-scan-frame">
|
|
245
|
-
<view class="si-scan-line" />
|
|
246
|
-
</view>
|
|
247
|
-
</view>
|
|
248
|
-
</view>
|
|
249
|
-
<!-- 底部提示栏 -->
|
|
250
|
-
<view class="si-modal-footer" @click.stop>
|
|
251
|
-
<text class="si-tip">将二维码 / 条形码放入框内即可自动识别</text>
|
|
252
|
-
<text class="si-close-text" @click="stopScan">关闭</text>
|
|
253
|
-
</view>s
|
|
254
|
-
</view>
|
|
255
|
-
<!-- #endif -->
|
|
256
|
-
</view>
|
|
257
|
-
</template>
|
|
258
|
-
|
|
259
|
-
<!-- 全局样式:html5-qrcode 内部元素 -->
|
|
260
|
-
<style lang="scss">
|
|
261
|
-
#h5-scan-reader {
|
|
262
|
-
width: 100% !important;
|
|
263
|
-
height: 100vh !important;
|
|
264
|
-
|
|
265
|
-
video {
|
|
266
|
-
width: 100% !important;
|
|
267
|
-
height: 100vh !important;
|
|
268
|
-
object-fit: cover !important;
|
|
269
|
-
object-position: center !important;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
#qr-shaded-region,
|
|
273
|
-
#html5-qrcode-anchor-scan-region {
|
|
274
|
-
border-radius: inherit;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
</style>
|
|
278
|
-
|
|
279
|
-
<style scoped lang="scss">
|
|
280
|
-
.si-wrap {
|
|
281
|
-
width: 100%;
|
|
282
|
-
box-sizing: border-box;
|
|
283
|
-
|
|
284
|
-
// ---- 输入框容器 ----
|
|
285
|
-
:deep(.si-wd-input) {
|
|
286
|
-
background: transparent;
|
|
287
|
-
border-radius: 10rpx;
|
|
288
|
-
border: none;
|
|
289
|
-
padding-right: 4rpx;
|
|
290
|
-
|
|
291
|
-
&.is-disabled {
|
|
292
|
-
opacity: 0.6;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// ---- 全屏扫码弹窗 ----
|
|
297
|
-
.si-modal {
|
|
298
|
-
position: fixed;
|
|
299
|
-
inset: 0;
|
|
300
|
-
z-index: 999;
|
|
301
|
-
background: #000;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
.si-camera-area {
|
|
305
|
-
width: 100%;
|
|
306
|
-
height: 100%;
|
|
307
|
-
position: relative;
|
|
308
|
-
overflow: hidden;
|
|
309
|
-
|
|
310
|
-
// 摄像头包装容器(全屏)
|
|
311
|
-
.si-reader-wrap {
|
|
312
|
-
position: absolute;
|
|
313
|
-
inset: 0;
|
|
314
|
-
overflow: hidden;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// 摄像头渲染容器
|
|
318
|
-
.si-reader {
|
|
319
|
-
width: 100%;
|
|
320
|
-
height: 100%;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// 扫描动画线(全屏时收缩在视频可视区域中间)
|
|
324
|
-
.si-scan-frame {
|
|
325
|
-
position: absolute;
|
|
326
|
-
top: 0;
|
|
327
|
-
bottom: 0;
|
|
328
|
-
left: 0;
|
|
329
|
-
right:0;
|
|
330
|
-
pointer-events: none;
|
|
331
|
-
z-index: 5;
|
|
332
|
-
overflow: hidden;
|
|
333
|
-
width:100vw;
|
|
334
|
-
height: 100vh;
|
|
335
|
-
|
|
336
|
-
.si-scan-line {
|
|
337
|
-
position: absolute;
|
|
338
|
-
top: 2%;
|
|
339
|
-
left: 10rpx;
|
|
340
|
-
right: 10rpx;
|
|
341
|
-
height: 6rpx;
|
|
342
|
-
background: #07c160;
|
|
343
|
-
border-radius: 3rpx;
|
|
344
|
-
box-shadow: 0 0 20rpx 4rpx rgba(7, 193, 96, 0.7);
|
|
345
|
-
animation: si-sweep 2.5s ease-in-out infinite;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// 加载中遮罩
|
|
350
|
-
.si-loading-mask {
|
|
351
|
-
position: absolute;
|
|
352
|
-
inset: 0;
|
|
353
|
-
display: flex;
|
|
354
|
-
align-items: center;
|
|
355
|
-
justify-content: center;
|
|
356
|
-
z-index: 10;
|
|
357
|
-
background: rgba(0, 0, 0, 0.5);
|
|
358
|
-
|
|
359
|
-
.si-loading-box {
|
|
360
|
-
background: rgba(0, 0, 0, 0.75);
|
|
361
|
-
padding: 30rpx 48rpx;
|
|
362
|
-
border-radius: 16rpx;
|
|
363
|
-
|
|
364
|
-
.si-loading-text {
|
|
365
|
-
color: #fff;
|
|
366
|
-
font-size: 28rpx;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// 底部提示栏(绝对定位在弹窗底部)
|
|
373
|
-
.si-modal-footer {
|
|
374
|
-
position: absolute;
|
|
375
|
-
left: 0;
|
|
376
|
-
right: 0;
|
|
377
|
-
bottom: 0;
|
|
378
|
-
z-index: 10;
|
|
379
|
-
padding: 40rpx 32rpx calc(80rpx + env(safe-area-inset-bottom, 0));
|
|
380
|
-
display: flex;
|
|
381
|
-
flex-direction: column;
|
|
382
|
-
align-items: center;
|
|
383
|
-
gap: 24rpx;
|
|
384
|
-
|
|
385
|
-
.si-tip {
|
|
386
|
-
color: rgba(255, 255, 255, 0.65);
|
|
387
|
-
font-size: 26rpx;
|
|
388
|
-
letter-spacing: 2rpx;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
.si-close-text {
|
|
392
|
-
color: rgba(255, 255, 255, 0.85);
|
|
393
|
-
font-size: 30rpx;
|
|
394
|
-
padding: 12rpx 48rpx;
|
|
395
|
-
border: 2rpx solid rgba(255, 255, 255, 0.3);
|
|
396
|
-
border-radius: 40rpx;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
@keyframes si-sweep {
|
|
402
|
-
0% { top:30%; opacity: 1; }
|
|
403
|
-
50% { opacity: 1; }
|
|
404
|
-
100% { top: 70%; opacity: 0.3; }
|
|
405
|
-
}
|
|
406
|
-
</style>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
defineOptions({
|
|
5
|
+
name: 'ScanInput',
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
// ---- 类型 ----
|
|
9
|
+
type Html5QrcodeInstance = {
|
|
10
|
+
start: (config: object, opts: object, onSuccess: (text: string) => void, onFailure: () => void) => Promise<void>
|
|
11
|
+
stop: () => Promise<void>
|
|
12
|
+
clear: () => Promise<void>
|
|
13
|
+
isScanning?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ---- Props ----
|
|
17
|
+
const props = withDefaults(defineProps<{
|
|
18
|
+
/** v-model 绑定值 */
|
|
19
|
+
modelValue?: string
|
|
20
|
+
/** 自动启动扫码 */
|
|
21
|
+
autoStart?: boolean
|
|
22
|
+
/** 占位文本 */
|
|
23
|
+
placeholder?: string
|
|
24
|
+
/** 是否禁用 */
|
|
25
|
+
disabled?: boolean
|
|
26
|
+
/** 扫码成功后自动关闭弹窗 (H5) */
|
|
27
|
+
autoCloseOnSuccess?: boolean
|
|
28
|
+
/** 扫码成功后是否展示 toast 提示 */
|
|
29
|
+
showSuccessToast?: boolean
|
|
30
|
+
}>(), {
|
|
31
|
+
modelValue: '',
|
|
32
|
+
autoStart: false,
|
|
33
|
+
placeholder: '请输入或扫描二维码/条形码',
|
|
34
|
+
disabled: false,
|
|
35
|
+
autoCloseOnSuccess: true,
|
|
36
|
+
showSuccessToast: true,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits<{
|
|
40
|
+
(e: 'update:modelValue', val: string): void
|
|
41
|
+
(e: 'scan', data: string): void
|
|
42
|
+
(e: 'input', data: string): void
|
|
43
|
+
(e: 'error', error: string): void
|
|
44
|
+
}>()
|
|
45
|
+
|
|
46
|
+
// ---- 状态 ----
|
|
47
|
+
const inputValue = ref(props.modelValue)
|
|
48
|
+
const isScanning = ref(false)
|
|
49
|
+
const showScanModal = ref(false)
|
|
50
|
+
const isLoading = ref(false)
|
|
51
|
+
let html5QrcodeScanner: Html5QrcodeInstance | null = null
|
|
52
|
+
let scanLocked = false
|
|
53
|
+
|
|
54
|
+
// v-model 同步
|
|
55
|
+
watch(() => props.modelValue, (val) => { inputValue.value = val })
|
|
56
|
+
watch(inputValue, (val) => { emit('update:modelValue', val); emit('input', val) })
|
|
57
|
+
|
|
58
|
+
/** 外部可调用的方法 */
|
|
59
|
+
function clear() { inputValue.value = '' }
|
|
60
|
+
function focus() { /* wd-input 聚焦可通过 ref 实现 */ }
|
|
61
|
+
|
|
62
|
+
defineExpose({ clear, focus, startScan: handleStartScan, stopScan })
|
|
63
|
+
|
|
64
|
+
// ======================== H5 平台扫码 ========================
|
|
65
|
+
// #ifdef H5
|
|
66
|
+
import { Html5Qrcode } from 'html5-qrcode'
|
|
67
|
+
|
|
68
|
+
/** 环境检测 */
|
|
69
|
+
function checkCameraEnvironment(): { ok: boolean; reason?: string } {
|
|
70
|
+
const hostname = window.location.hostname
|
|
71
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1') return { ok: true }
|
|
72
|
+
if (window.location.protocol !== 'https:') {
|
|
73
|
+
return { ok: false, reason: '当前非HTTPS环境,请在 https 下使用扫码功能' }
|
|
74
|
+
}
|
|
75
|
+
return { ok: true }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function startScan() {
|
|
79
|
+
if (isScanning.value || scanLocked) return
|
|
80
|
+
const envCheck = checkCameraEnvironment()
|
|
81
|
+
if (!envCheck.ok) {
|
|
82
|
+
uni.showToast({ title: envCheck.reason || '环境不支持', icon: 'none' })
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
isLoading.value = true
|
|
87
|
+
isScanning.value = true
|
|
88
|
+
showScanModal.value = true
|
|
89
|
+
scanLocked = false
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await nextTick()
|
|
93
|
+
await new Promise(r => setTimeout(r, 200))
|
|
94
|
+
|
|
95
|
+
const container = document.getElementById('h5-scan-reader')
|
|
96
|
+
if (!container) throw new Error('扫码容器未找到')
|
|
97
|
+
|
|
98
|
+
html5QrcodeScanner = new Html5Qrcode('h5-scan-reader') as unknown as Html5QrcodeInstance
|
|
99
|
+
|
|
100
|
+
await html5QrcodeScanner.start(
|
|
101
|
+
{ facingMode: 'environment' },
|
|
102
|
+
{ fps: 5 },
|
|
103
|
+
(decodedText: string) => {
|
|
104
|
+
if (scanLocked || !isScanning.value) return
|
|
105
|
+
scanLocked = true // 加锁防止重复触发
|
|
106
|
+
|
|
107
|
+
console.log('[ScanInput] 扫码成功:', decodedText)
|
|
108
|
+
inputValue.value = decodedText
|
|
109
|
+
emit('scan', decodedText)
|
|
110
|
+
if (props.showSuccessToast) uni.showToast({ title: '扫描成功', icon: 'success' })
|
|
111
|
+
if (props.autoCloseOnSuccess) stopScan()
|
|
112
|
+
},
|
|
113
|
+
() => {}, // 忽略中间帧未识别错误
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
// JS 强制全屏 video(html5-qrcode 库会覆盖 CSS,需运行时修正)
|
|
117
|
+
const videoEl = document.querySelector('#h5-scan-reader video') as HTMLVideoElement | null
|
|
118
|
+
if (videoEl) {
|
|
119
|
+
Object.assign(videoEl.style, {
|
|
120
|
+
width: '100%',
|
|
121
|
+
height: '100vh',
|
|
122
|
+
objectFit: 'cover',
|
|
123
|
+
objectPosition: 'center',
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error: unknown) {
|
|
128
|
+
console.error('[ScanInput] 启动失败:', error)
|
|
129
|
+
const errMsg = error instanceof Error ? error.message : String(error)
|
|
130
|
+
let msg = '摄像头访问失败'
|
|
131
|
+
if (/Permission|NotAllowed/i.test(errMsg)) msg = '请允许浏览器使用摄像头'
|
|
132
|
+
else if (/NotFound|not found/i.test(errMsg)) msg = '未检测到摄像头设备'
|
|
133
|
+
else if (/secure origin|insecure/i.test(errMsg)) msg = '需要HTTPS环境才能使用摄像头'
|
|
134
|
+
|
|
135
|
+
emit('error', msg)
|
|
136
|
+
isScanning.value = false
|
|
137
|
+
showScanModal.value = false
|
|
138
|
+
uni.showModal({ title: '提示', content: msg, showCancel: false })
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
isLoading.value = false
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function stopScan() {
|
|
146
|
+
isScanning.value = false
|
|
147
|
+
showScanModal.value = false
|
|
148
|
+
scanLocked = false
|
|
149
|
+
if (html5QrcodeScanner) {
|
|
150
|
+
try {
|
|
151
|
+
if (html5QrcodeScanner.isScanning) await html5QrcodeScanner.stop()
|
|
152
|
+
await html5QrcodeScanner.clear()
|
|
153
|
+
}
|
|
154
|
+
catch { /* ignore cleanup error */ }
|
|
155
|
+
html5QrcodeScanner = null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// #endif
|
|
159
|
+
|
|
160
|
+
// ======================== 非 H5 平台扫码 ========================
|
|
161
|
+
function startUniScanCode() {
|
|
162
|
+
// #ifndef H5
|
|
163
|
+
uni.scanCode({
|
|
164
|
+
success(res: UniApp.ScanCodeSuccessRes) {
|
|
165
|
+
inputValue.value = res.result
|
|
166
|
+
emit('scan', res.result)
|
|
167
|
+
uni.showToast({ title: '扫描成功', icon: 'success' })
|
|
168
|
+
},
|
|
169
|
+
fail() { emit('error', '扫描已取消') },
|
|
170
|
+
})
|
|
171
|
+
// #endif
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ======================== 公共方法 ========================
|
|
175
|
+
function handleStartScan() {
|
|
176
|
+
if (props.disabled) return
|
|
177
|
+
// #ifdef H5
|
|
178
|
+
isScanning.value ? stopScan() : startScan()
|
|
179
|
+
// #endif
|
|
180
|
+
// #ifndef H5
|
|
181
|
+
startUniScanCode()
|
|
182
|
+
// #endif
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 点击遮罩层关闭 (仅 H5) */
|
|
186
|
+
function handleModalMaskClick() {
|
|
187
|
+
// #ifdef H5
|
|
188
|
+
stopScan()
|
|
189
|
+
// #endif
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function handleConfirm() {
|
|
193
|
+
const val = inputValue.value.trim()
|
|
194
|
+
if (val) emit('scan', val)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---- 生命周期 ----
|
|
198
|
+
onMounted(() => { if (props.autoStart && !props.disabled) handleStartScan() })
|
|
199
|
+
onUnmounted(() => {
|
|
200
|
+
// #ifdef H5
|
|
201
|
+
stopScan()
|
|
202
|
+
// #endif
|
|
203
|
+
})
|
|
204
|
+
</script>
|
|
205
|
+
|
|
206
|
+
<template>
|
|
207
|
+
<view class="si-wrap">
|
|
208
|
+
<!-- 输入框 -->
|
|
209
|
+
<wd-input
|
|
210
|
+
v-model="inputValue"
|
|
211
|
+
class="!pr-3"
|
|
212
|
+
:placeholder="placeholder"
|
|
213
|
+
type="text"
|
|
214
|
+
confirm-type="search"
|
|
215
|
+
clearable
|
|
216
|
+
no-border
|
|
217
|
+
:disabled="disabled"
|
|
218
|
+
custom-class="si-wd-input"
|
|
219
|
+
@confirm="handleConfirm"
|
|
220
|
+
suffix-icon="scan"
|
|
221
|
+
@clicksuffixicon="handleStartScan"
|
|
222
|
+
/>
|
|
223
|
+
|
|
224
|
+
<!-- #ifdef H5 -->
|
|
225
|
+
<!-- 全屏扫码弹窗 -->
|
|
226
|
+
<view
|
|
227
|
+
v-if="showScanModal"
|
|
228
|
+
class="si-modal"
|
|
229
|
+
catchtouchmove
|
|
230
|
+
@click="handleModalMaskClick"
|
|
231
|
+
>
|
|
232
|
+
<!-- 摄像头区域 -->
|
|
233
|
+
<view class="si-camera-area" @click.stop>
|
|
234
|
+
<!-- 加载中 -->
|
|
235
|
+
<view v-if="isLoading" class="si-loading-mask">
|
|
236
|
+
<view class="si-loading-box">
|
|
237
|
+
<text class="si-loading-text">正在启动摄像头...</text>
|
|
238
|
+
</view>
|
|
239
|
+
</view>
|
|
240
|
+
|
|
241
|
+
<!-- 渲染容器 + 扫描线 -->
|
|
242
|
+
<view class="si-reader-wrap overflow-hidden">
|
|
243
|
+
<div id="h5-scan-reader" class="si-reader" />
|
|
244
|
+
<view v-show="isScanning && !isLoading" class="si-scan-frame">
|
|
245
|
+
<view class="si-scan-line" />
|
|
246
|
+
</view>
|
|
247
|
+
</view>
|
|
248
|
+
</view>
|
|
249
|
+
<!-- 底部提示栏 -->
|
|
250
|
+
<view class="si-modal-footer" @click.stop>
|
|
251
|
+
<text class="si-tip">将二维码 / 条形码放入框内即可自动识别</text>
|
|
252
|
+
<text class="si-close-text" @click="stopScan">关闭</text>
|
|
253
|
+
</view>s
|
|
254
|
+
</view>
|
|
255
|
+
<!-- #endif -->
|
|
256
|
+
</view>
|
|
257
|
+
</template>
|
|
258
|
+
|
|
259
|
+
<!-- 全局样式:html5-qrcode 内部元素 -->
|
|
260
|
+
<style lang="scss">
|
|
261
|
+
#h5-scan-reader {
|
|
262
|
+
width: 100% !important;
|
|
263
|
+
height: 100vh !important;
|
|
264
|
+
|
|
265
|
+
video {
|
|
266
|
+
width: 100% !important;
|
|
267
|
+
height: 100vh !important;
|
|
268
|
+
object-fit: cover !important;
|
|
269
|
+
object-position: center !important;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#qr-shaded-region,
|
|
273
|
+
#html5-qrcode-anchor-scan-region {
|
|
274
|
+
border-radius: inherit;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
</style>
|
|
278
|
+
|
|
279
|
+
<style scoped lang="scss">
|
|
280
|
+
.si-wrap {
|
|
281
|
+
width: 100%;
|
|
282
|
+
box-sizing: border-box;
|
|
283
|
+
|
|
284
|
+
// ---- 输入框容器 ----
|
|
285
|
+
:deep(.si-wd-input) {
|
|
286
|
+
background: transparent;
|
|
287
|
+
border-radius: 10rpx;
|
|
288
|
+
border: none;
|
|
289
|
+
padding-right: 4rpx;
|
|
290
|
+
|
|
291
|
+
&.is-disabled {
|
|
292
|
+
opacity: 0.6;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---- 全屏扫码弹窗 ----
|
|
297
|
+
.si-modal {
|
|
298
|
+
position: fixed;
|
|
299
|
+
inset: 0;
|
|
300
|
+
z-index: 999;
|
|
301
|
+
background: #000;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.si-camera-area {
|
|
305
|
+
width: 100%;
|
|
306
|
+
height: 100%;
|
|
307
|
+
position: relative;
|
|
308
|
+
overflow: hidden;
|
|
309
|
+
|
|
310
|
+
// 摄像头包装容器(全屏)
|
|
311
|
+
.si-reader-wrap {
|
|
312
|
+
position: absolute;
|
|
313
|
+
inset: 0;
|
|
314
|
+
overflow: hidden;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 摄像头渲染容器
|
|
318
|
+
.si-reader {
|
|
319
|
+
width: 100%;
|
|
320
|
+
height: 100%;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 扫描动画线(全屏时收缩在视频可视区域中间)
|
|
324
|
+
.si-scan-frame {
|
|
325
|
+
position: absolute;
|
|
326
|
+
top: 0;
|
|
327
|
+
bottom: 0;
|
|
328
|
+
left: 0;
|
|
329
|
+
right:0;
|
|
330
|
+
pointer-events: none;
|
|
331
|
+
z-index: 5;
|
|
332
|
+
overflow: hidden;
|
|
333
|
+
width:100vw;
|
|
334
|
+
height: 100vh;
|
|
335
|
+
|
|
336
|
+
.si-scan-line {
|
|
337
|
+
position: absolute;
|
|
338
|
+
top: 2%;
|
|
339
|
+
left: 10rpx;
|
|
340
|
+
right: 10rpx;
|
|
341
|
+
height: 6rpx;
|
|
342
|
+
background: #07c160;
|
|
343
|
+
border-radius: 3rpx;
|
|
344
|
+
box-shadow: 0 0 20rpx 4rpx rgba(7, 193, 96, 0.7);
|
|
345
|
+
animation: si-sweep 2.5s ease-in-out infinite;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 加载中遮罩
|
|
350
|
+
.si-loading-mask {
|
|
351
|
+
position: absolute;
|
|
352
|
+
inset: 0;
|
|
353
|
+
display: flex;
|
|
354
|
+
align-items: center;
|
|
355
|
+
justify-content: center;
|
|
356
|
+
z-index: 10;
|
|
357
|
+
background: rgba(0, 0, 0, 0.5);
|
|
358
|
+
|
|
359
|
+
.si-loading-box {
|
|
360
|
+
background: rgba(0, 0, 0, 0.75);
|
|
361
|
+
padding: 30rpx 48rpx;
|
|
362
|
+
border-radius: 16rpx;
|
|
363
|
+
|
|
364
|
+
.si-loading-text {
|
|
365
|
+
color: #fff;
|
|
366
|
+
font-size: 28rpx;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 底部提示栏(绝对定位在弹窗底部)
|
|
373
|
+
.si-modal-footer {
|
|
374
|
+
position: absolute;
|
|
375
|
+
left: 0;
|
|
376
|
+
right: 0;
|
|
377
|
+
bottom: 0;
|
|
378
|
+
z-index: 10;
|
|
379
|
+
padding: 40rpx 32rpx calc(80rpx + env(safe-area-inset-bottom, 0));
|
|
380
|
+
display: flex;
|
|
381
|
+
flex-direction: column;
|
|
382
|
+
align-items: center;
|
|
383
|
+
gap: 24rpx;
|
|
384
|
+
|
|
385
|
+
.si-tip {
|
|
386
|
+
color: rgba(255, 255, 255, 0.65);
|
|
387
|
+
font-size: 26rpx;
|
|
388
|
+
letter-spacing: 2rpx;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.si-close-text {
|
|
392
|
+
color: rgba(255, 255, 255, 0.85);
|
|
393
|
+
font-size: 30rpx;
|
|
394
|
+
padding: 12rpx 48rpx;
|
|
395
|
+
border: 2rpx solid rgba(255, 255, 255, 0.3);
|
|
396
|
+
border-radius: 40rpx;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
@keyframes si-sweep {
|
|
402
|
+
0% { top:30%; opacity: 1; }
|
|
403
|
+
50% { opacity: 1; }
|
|
404
|
+
100% { top: 70%; opacity: 0.3; }
|
|
405
|
+
}
|
|
406
|
+
</style>
|