diff --git a/CHANGELOG.md b/CHANGELOG.md index 2172c2b15a7bed3d8178de8e7141e676c4b63060..bdd6eb389414e339170cb0b7cd7f35c4a0dcbd50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ ## [Unreleased] +### Added + +- 新增二维码阅读器编辑器 +- 新增二维码工具类 + ## [0.0.38] - 2024-12-06 ### Added diff --git a/package.json b/package.json index 25d9075790f5a62cd3a671e979733075a7822d0a..b9c1e983499807abf1aedad6689bbdfc2158a490 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,9 @@ "vant": "^4.7.2", "vue": "^3.3.8", "vue-i18n": "^9.6.5", - "vue-router": "^4.2.5" + "vue-router": "^4.2.5", + "qr-code-styling": "^1.8.3", + "vue-qrcode-reader": "5.5.11" }, "devDependencies": { "@commitlint/cli": "^18.4.1", diff --git a/src/editor/index.ts b/src/editor/index.ts index 54f72fb31116b5720622bb61fb02f7e30fb47e7d..6fe9e64a8e6b1cbaf9e8836b9221220be7fb033b 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -39,6 +39,7 @@ import { ColorPickerEditorProvider, IBizColorPicker } from './color-picker'; import { MarkDownEditorProvider } from './markdown'; import { HtmlEditorProvider } from './html'; import { IBizDropdownList } from './dropdown-list/ibiz-dropdown-list/ibiz-dropdown-list'; +import { IBizQrcode, QrcodeEditorProvider } from './qrcode'; export const IBizEditor = { install: (v: App): void => { @@ -69,6 +70,7 @@ export const IBizEditor = { v.component(IBizColorPicker.name, IBizColorPicker); v.component(IBizPickerSelectView.name, IBizPickerSelectView); v.component(IBizEditorCarousel.name, IBizEditorCarousel); + v.component(IBizQrcode.name, IBizQrcode); v.component( 'IBizMarkDown', @@ -280,6 +282,11 @@ export const IBizEditor = { 'AUTH_PASSWORD_PASSWORD', () => textBoxEditorProvider, ); + // 二维码阅读器 + registerEditorProvider( + 'MOB2DBARCODEREADER', + () => new QrcodeEditorProvider(), + ); }, }; diff --git a/src/editor/qrcode/ibiz-qrcode/ibiz-qrcode.scss b/src/editor/qrcode/ibiz-qrcode/ibiz-qrcode.scss new file mode 100644 index 0000000000000000000000000000000000000000..cfc1a22cac4e35579d33495f034e241d9b8d79bb --- /dev/null +++ b/src/editor/qrcode/ibiz-qrcode/ibiz-qrcode.scss @@ -0,0 +1,11 @@ +@include b(qrcode) { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + + @include e('no-img') { + color: getCssVar(color, disabled, text); + } +} \ No newline at end of file diff --git a/src/editor/qrcode/ibiz-qrcode/ibiz-qrcode.tsx b/src/editor/qrcode/ibiz-qrcode/ibiz-qrcode.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1fe2d036ccd17c30b0aec3f8cf8a5549c19fb066 --- /dev/null +++ b/src/editor/qrcode/ibiz-qrcode/ibiz-qrcode.tsx @@ -0,0 +1,63 @@ +import { defineComponent, ref, watch } from 'vue'; +import { + getEditorEmits, + getRawProps, + useNamespace, +} from '@ibiz-template/vue3-util'; +import { isNil } from 'ramda'; +import { QrcodeEditorController } from '../qrcode-editor.controller'; +import './ibiz-qrcode.scss'; + +export const IBizQrcode = defineComponent({ + name: 'IBizQrcode', + props: getRawProps(), + emits: getEditorEmits(), + setup(props, { attrs }) { + const ns = useNamespace('qrcode'); + + // 转换的二维码图片路径 + const dataUrl = ref(''); + + watch( + () => props.value, + async (newVal, oldVal) => { + let text = ''; + if (newVal !== oldVal) { + if (isNil(newVal)) { + text = ''; + } else { + text = `${newVal}`; + } + } + if (text) { + const qrCode = ibiz.qrcodeUtil.createQrcode(text, { + margin: 8, + ...attrs, + }); + const element = await qrCode._getElement(); + dataUrl.value = element.toDataURL(); + } + }, + { + immediate: true, + }, + ); + return { + ns, + dataUrl, + }; + }, + render() { + let content = ( + + ); + if (this.dataUrl) { + content = ; + } + return
{content}
; + }, +}); diff --git a/src/editor/qrcode/index.ts b/src/editor/qrcode/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..81d8a03054d1a07cbf96f8882d0293f41c202c95 --- /dev/null +++ b/src/editor/qrcode/index.ts @@ -0,0 +1,3 @@ +export { IBizQrcode } from './ibiz-qrcode/ibiz-qrcode'; +export * from './qrcode-editor.controller'; +export * from './qrcode-editor.provider'; diff --git a/src/editor/qrcode/qrcode-editor.controller.ts b/src/editor/qrcode/qrcode-editor.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d471deec5e264aeb0d4d7e786f143ec11a39289 --- /dev/null +++ b/src/editor/qrcode/qrcode-editor.controller.ts @@ -0,0 +1,13 @@ +import { EditorController } from '@ibiz-template/runtime'; +import { IBarCode2DReader } from '@ibiz/model-core'; + +/** + * 二维码阅读器编辑器控制器 + * + * @author ljx + * @date 2024-12-10 10:09:03 + * @export + * @class QrcodeEditorController + * @extends {EditorController} + */ +export class QrcodeEditorController extends EditorController {} diff --git a/src/editor/qrcode/qrcode-editor.provider.ts b/src/editor/qrcode/qrcode-editor.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..372511d85d5713d931e3b2e0092d24edec4eb816 --- /dev/null +++ b/src/editor/qrcode/qrcode-editor.provider.ts @@ -0,0 +1,30 @@ +import { + IEditorContainerController, + IEditorProvider, +} from '@ibiz-template/runtime'; +import { IBarCode2DReader } from '@ibiz/model-core'; +import { QrcodeEditorController } from './qrcode-editor.controller'; + +/** + * 二维码阅读器编辑器适配器 + * + * @author ljx + * @date 2024-12-10 10:09:03 + * @export + * @class QrcodeEditorProvider + * @implements {EditorProvider} + */ +export class QrcodeEditorProvider implements IEditorProvider { + formEditor: string = 'IBizQrcode'; + + gridEditor: string = 'IBizQrcode'; + + async createController( + editorModel: IBarCode2DReader, + parentController: IEditorContainerController, + ): Promise { + const c = new QrcodeEditorController(editorModel, parentController); + await c.init(); + return c; + } +} diff --git a/src/locale/en/index.ts b/src/locale/en/index.ts index 1bef4d54b36306e8e641f09a2a1ccdcb4d092094..56b67ceab5210344ffd9f2c01adafb11814db091 100644 --- a/src/locale/en/index.ts +++ b/src/locale/en/index.ts @@ -278,6 +278,16 @@ export default { unrealized: 'Unrealized', cacheWarningPrompt: 'There is only one item in the cache stack and it cannot be navigated back', + scanQrcode: { + notAllowedError: 'You need to grant camera access permission', + notFoundError: 'No camera on this device', + notSupportedError: 'Secure context required (HTTPS, localhost)', + notReadableError: 'Is the camera already in use?', + overconstrainedError: 'Installed cameras are not suitable', + streamApiNotSupportedError: 'Stream API is not supported in this browser', + insecureContextError: + 'Camera access is only permitted in secure context. use HTTPS or localhost rather than HTTP.', + }, }, // 视图 view: { diff --git a/src/locale/zh-CN/index.ts b/src/locale/zh-CN/index.ts index 4ed140d622849ca78f7d75315e984e17fc87a3f5..33f28642e9f8bb4cde48acc111795c06268734c0 100644 --- a/src/locale/zh-CN/index.ts +++ b/src/locale/zh-CN/index.ts @@ -266,6 +266,16 @@ export default { notAchieved: '没有实现', unrealized: '未实现', cacheWarningPrompt: '堆栈只有一个缓存不能后退了', + scanQrcode: { + notAllowedError: '您需要授予相机访问权限', + notFoundError: '此设备上没有摄像头', + notSupportedError: '需要安全上下文(HTTPS,localhost)', + notReadableError: '相机是否已在使用中?', + overconstrainedError: '安装的摄像头不合适', + streamApiNotSupportedError: '此浏览器不支持流API', + insecureContextError: + '只有在安全的情况下才允许访问摄像头。使用HTTPS或localhost而不是HTTP。', + }, }, // 视图 view: { diff --git a/src/mob-app/main.ts b/src/mob-app/main.ts index 252bc01fff8205345de2db3c6657d81557547216..ce6cf3fe7bedb1bdfe1c081b41250b3d8f58897f 100644 --- a/src/mob-app/main.ts +++ b/src/mob-app/main.ts @@ -28,6 +28,7 @@ import { OpenViewUtil, OverlayController, FullscreenUtil, + QrcodeUtil, } from '../util'; export async function runApp(plugins?: Plugin[]): Promise { @@ -91,6 +92,7 @@ export async function runApp(plugins?: Plugin[]): Promise { ibiz.util.text.format = (value, code): string => { return app.config.globalProperties.$textFormat(value, code); }; + ibiz.qrcodeUtil = new QrcodeUtil(); await ibiz.i18n.init(); diff --git a/src/util/index.ts b/src/util/index.ts index 6e8090c21e005183ef0e89362d3136c4ba843392..02bc696edfeeb345485695db6a9a641807e59a80 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -11,3 +11,4 @@ export { usePagination } from './pagination/use-pagination'; export { FullscreenUtil } from './fullscreen/fullscreen-util'; export * from './store'; export { usePopstateListener } from './use-popstate-util/use-popstate-util'; +export { QrcodeUtil } from './qrcode-util/qrcode-util'; diff --git a/src/util/qrcode-util/qrcode-util.ts b/src/util/qrcode-util/qrcode-util.ts new file mode 100644 index 0000000000000000000000000000000000000000..11afc55d890c9f0f0d0bdd4c2d21cc07dfeda375 --- /dev/null +++ b/src/util/qrcode-util/qrcode-util.ts @@ -0,0 +1,27 @@ +import { IQrcodeUtil } from '@ibiz-template/runtime'; +import QRCodeStyling from 'qr-code-styling'; +import { createScanQrcode } from '../scan-qrcode/scan-qrcode'; + +/** + * 二维码工具类 + * + * @description 此实现类挂载在 ibiz.qrcodeUtil + * @author ljx + * @date 2024-12-10 10:12:50 + * @export + * @class QrcodeUtil + * @implements {IQrcodeUtil} + */ +export class QrcodeUtil implements IQrcodeUtil { + async scanQrcode(options?: IParams | undefined): Promise { + const overlay = createScanQrcode(options); + return overlay.onWillDismiss(); + } + + createQrcode(value: string, options?: IParams | undefined): IParams { + return new QRCodeStyling({ + data: unescape(encodeURIComponent(value)), + ...options, + }); + } +} diff --git a/src/util/scan-qrcode/scan-qrcode.scss b/src/util/scan-qrcode/scan-qrcode.scss new file mode 100644 index 0000000000000000000000000000000000000000..5fabd7d16c1c0aa046ec6e04d2c368649b1bc091 --- /dev/null +++ b/src/util/scan-qrcode/scan-qrcode.scss @@ -0,0 +1,99 @@ +$scan-qrcode: ( + 'background-image-bg': rgb(136 176 255 / 10%), + 'bg-mask': rgb(0 0 0 / 50%), +); + +@include b('scan-qrcode') { + + @include set-component-css-var('scan-qrcode', $scan-qrcode); + position: fixed; + inset: 0; + z-index: 99; + width: 100vw; + height: 100vh; + background: getCssVar('scan-qrcode', 'bg-mask'); + + @include e('scanner') { + position: absolute; + inset: 0; + z-index: 9; + width: 100%; + height: 100%; + background-color: getCssVar('scan-qrcode', 'bg-mask'); + } + + @include e('line-box') { + position: relative; + top: 50%; + left: 50%; + width: 75vw; + max-width: 75vh; + height: 75vw; + max-height: 75vh; + overflow: hidden; + border: 1px solid getCssVar(color, primary); + transform: translate(-50%, -50%); + + @include m('row') { + display: flex; + align-items: flex-end; + justify-content: center; + width: 100%; + overflow: hidden; + background-image: linear-gradient(0deg, + transparent 24%, + getCssVar('scan-qrcode', 'background-image-bg') 25%, + getCssVar('scan-qrcode', 'background-image-bg') 26%, + transparent 27%, + transparent 74%, + getCssVar('scan-qrcode', 'background-image-bg') 75%, + getCssVar('scan-qrcode', 'background-image-bg') 76%, + transparent 77%, + transparent), + linear-gradient(90deg, + transparent 24%, + getCssVar('scan-qrcode', 'background-image-bg') 25%, + getCssVar('scan-qrcode', 'background-image-bg') 26%, + transparent 27%, + transparent 74%, + getCssVar('scan-qrcode', 'background-image-bg') 75%, + getCssVar('scan-qrcode', 'background-image-bg') 76%, + transparent 77%, + transparent); + background-position: -1rem -1rem; + background-size: 3rem 3rem; + border-bottom: 1px solid getCssVar('scan-qrcode', 'background-image-bg'); + animation: height-change 2s infinite; + animation-timing-function: cubic-bezier(0.53, 0, 0.43, 0.99); + animation-delay: 1.4s; + } + + @include m('line') { + width: 100%; + height: rem(3px); + background: getCssVar(color, primary); + filter: blur(rem(4px)); + } + } + + @include e('close') { + position: absolute; + top: getCssVar('spacing', 'base'); + left: getCssVar('spacing', 'base'); + color: getCssVar(color, white); + font-size: getCssVar('font-size', 'header-3'); + cursor: pointer; + } + + + + @keyframes height-change { + 0% { + height: 0; + } + + 100% { + height: 100%; + } + } +} \ No newline at end of file diff --git a/src/util/scan-qrcode/scan-qrcode.tsx b/src/util/scan-qrcode/scan-qrcode.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f2502794692a114caacae6d0af630d0b38e2d64c --- /dev/null +++ b/src/util/scan-qrcode/scan-qrcode.tsx @@ -0,0 +1,160 @@ +import { defineComponent, PropType, ref } from 'vue'; +import { + OverlayContainer, + useNamespace, + useUIStore, +} from '@ibiz-template/vue3-util'; +import { QrcodeStream } from 'vue-qrcode-reader'; +import { IModalOptions, IOverlayContainer } from '@ibiz-template/runtime'; +import './scan-qrcode.scss'; + +export const ScanQrcodeComponent = defineComponent({ + components: { + QrcodeStream, + }, + props: { + opts: { + type: Object as PropType, + default: () => ({}), + }, + }, + setup(props, { emit }) { + const ns = useNamespace('scan-qrcode'); + + const { zIndex } = useUIStore(); + const overlayZIndex = zIndex.increment(); + + // 相机配置选项: 'user'|'environment' (默认:environment) + const constraintsConfig = ref({ facingMode: 'environment' }); + + /** + * 错误提示 + * + * @author ljx + * @date 2024-12-10 10:12:50 + * @param {IParams} error + * @return {*} + */ + const onError = (error: IParams) => { + let errorText = `[${error.name}]: `; + switch (error.name) { + case 'NotAllowedError': + errorText += ibiz.i18n.t('util.scanQrcode.notAllowedError'); + break; + case 'NotFoundError': + errorText += ibiz.i18n.t('util.scanQrcode.notFoundError'); + break; + case 'NotSupportedError': + errorText += ibiz.i18n.t('util.scanQrcode.notSupportedError'); + break; + case 'NotReadableError': + errorText += ibiz.i18n.t('util.scanQrcode.notReadableError'); + break; + case 'OverconstrainedError': + errorText += ibiz.i18n.t('util.scanQrcode.overconstrainedError'); + break; + case 'StreamApiNotSupportedError': + errorText += ibiz.i18n.t( + 'util.scanQrcode.streamApiNotSupportedError', + ); + break; + case 'InsecureContextError': + errorText += ibiz.i18n.t('util.scanQrcode.insecureContextError'); + break; + default: + errorText += error.message; + } + ibiz.notification.error({ desc: errorText }); + }; + + /** + * 解码完成 交给父组件处理 + * + * @author ljx + * @date 2024-12-10 10:12:50 + * @param {string} value + * @return {*} + */ + const onDecode = (value: string) => { + emit('dismiss', { ok: true, value }); + }; + + /** + * 一旦用户相机的流被加载,它就会显示出来并不断扫描二维码 + * + * @author ljx + * @date 2024-12-10 10:12:50 + * @param {IData[]} detectedCodes + * @return {*} + */ + const onDetect = (detectedCodes: IData[]) => { + if (detectedCodes.length > 0) { + onDecode(detectedCodes[0]?.rawValue); + } + }; + + /** + * 关闭弹框 + * + * @author ljx + * @date 2024-12-10 10:12:50 + * @return {*} + */ + const onClose = () => { + emit('dismiss', { ok: false, value: '' }); + }; + + return { + ns, + overlayZIndex, + constraintsConfig, + onDetect, + onError, + onClose, + }; + }, + render() { + return ( +
+ +
+
+
+
+
+
+
+ +
+
+
+
+ ); + }, +}); + +/** + * 创建模态框 + * + * @author ljx + * @date 2024-12-10 10:12:50 + * @export + * @param {(IModalOptions | undefined)} [opts] + * @return {*} {IOverlayContainer} + */ +export function createScanQrcode( + opts?: IModalOptions | undefined, +): IOverlayContainer { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new OverlayContainer(ScanQrcodeComponent, {} as any, opts); +}