Ethscriptions commited on
Commit
9de7b70
·
verified ·
1 Parent(s): 2e456c3

上传 BeadQR 微信小程序项目源码

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +4 -0
  2. BeadQR/README.md +50 -0
  3. BeadQR/cloudfunctions/quickstartFunctions/config.json +9 -0
  4. BeadQR/cloudfunctions/quickstartFunctions/index.js +92 -0
  5. BeadQR/cloudfunctions/quickstartFunctions/package-lock.json +1157 -0
  6. BeadQR/cloudfunctions/quickstartFunctions/package.json +14 -0
  7. BeadQR/docs/promo/douwei-1080-cover.png +3 -0
  8. BeadQR/docs/promo/douwei-1080.mp4 +3 -0
  9. BeadQR/docs/promo/douwei-720-cover.png +3 -0
  10. BeadQR/docs/promo/douwei-720.mp4 +3 -0
  11. BeadQR/docs/video-script.md +116 -0
  12. BeadQR/docs/审核驳回处理-文案与步骤.md +58 -0
  13. BeadQR/miniprogram/app.js +14 -0
  14. BeadQR/miniprogram/app.json +19 -0
  15. BeadQR/miniprogram/app.wxss +37 -0
  16. BeadQR/miniprogram/assets/icon.png +0 -0
  17. BeadQR/miniprogram/assets/miniprogram-code.png +0 -0
  18. BeadQR/miniprogram/assets/shield.svg +1 -0
  19. BeadQR/miniprogram/components/privacy-popup/index.js +40 -0
  20. BeadQR/miniprogram/components/privacy-popup/index.json +4 -0
  21. BeadQR/miniprogram/components/privacy-popup/index.wxml +13 -0
  22. BeadQR/miniprogram/components/privacy-popup/index.wxss +10 -0
  23. BeadQR/miniprogram/pages/index/index.js +1073 -0
  24. BeadQR/miniprogram/pages/index/index.json +6 -0
  25. BeadQR/miniprogram/pages/index/index.wxml +403 -0
  26. BeadQR/miniprogram/pages/index/index.wxss +828 -0
  27. BeadQR/miniprogram/sitemap.json +7 -0
  28. BeadQR/miniprogram/utils/color.js +46 -0
  29. BeadQR/miniprogram/utils/engine.js +225 -0
  30. BeadQR/miniprogram/utils/imaging.js +95 -0
  31. BeadQR/miniprogram/utils/intro.js +703 -0
  32. BeadQR/miniprogram/utils/palette-data.js +48 -0
  33. BeadQR/miniprogram/utils/palette.js +74 -0
  34. BeadQR/miniprogram/utils/pattern.js +381 -0
  35. BeadQR/miniprogram/utils/pdf.js +373 -0
  36. BeadQR/miniprogram/utils/qr.js +58 -0
  37. BeadQR/miniprogram/utils/qrcore/alignment-pattern.js +83 -0
  38. BeadQR/miniprogram/utils/qrcore/alphanumeric-data.js +59 -0
  39. BeadQR/miniprogram/utils/qrcore/bit-buffer.js +37 -0
  40. BeadQR/miniprogram/utils/qrcore/bit-matrix.js +65 -0
  41. BeadQR/miniprogram/utils/qrcore/byte-data.js +61 -0
  42. BeadQR/miniprogram/utils/qrcore/dijkstrajs.js +165 -0
  43. BeadQR/miniprogram/utils/qrcore/error-correction-code.js +135 -0
  44. BeadQR/miniprogram/utils/qrcore/error-correction-level.js +50 -0
  45. BeadQR/miniprogram/utils/qrcore/finder-pattern.js +22 -0
  46. BeadQR/miniprogram/utils/qrcore/format-info.js +29 -0
  47. BeadQR/miniprogram/utils/qrcore/galois-field.js +69 -0
  48. BeadQR/miniprogram/utils/qrcore/kanji-data.js +54 -0
  49. BeadQR/miniprogram/utils/qrcore/mask-pattern.js +234 -0
  50. BeadQR/miniprogram/utils/qrcore/mode.js +167 -0
.gitattributes CHANGED
@@ -52,3 +52,7 @@ img/memes.png filter=lfs diff=lfs merge=lfs -text
52
  普通拼豆作品展示.MOV filter=lfs diff=lfs merge=lfs -text
53
  烫好了,还没有揭开烫纸,用微信二维码扫一扫,成功扫码.MP4 filter=lfs diff=lfs merge=lfs -text
54
  用烫豆机对拼好的作品烫.MOV filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
52
  普通拼豆作品展示.MOV filter=lfs diff=lfs merge=lfs -text
53
  烫好了,还没有揭开烫纸,用微信二维码扫一扫,成功扫码.MP4 filter=lfs diff=lfs merge=lfs -text
54
  用烫豆机对拼好的作品烫.MOV filter=lfs diff=lfs merge=lfs -text
55
+ BeadQR/docs/promo/douwei-1080-cover.png filter=lfs diff=lfs merge=lfs -text
56
+ BeadQR/docs/promo/douwei-1080.mp4 filter=lfs diff=lfs merge=lfs -text
57
+ BeadQR/docs/promo/douwei-720-cover.png filter=lfs diff=lfs merge=lfs -text
58
+ BeadQR/docs/promo/douwei-720.mp4 filter=lfs diff=lfs merge=lfs -text
BeadQR/README.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 豆维 · 可扫码的拼豆
2
+
3
+ 输入一句话(可以再选一张照片),生成**用微信扫一扫就能读出内容**的 MARD 拼豆图纸,并支持导出 PDF 或图片。
4
+
5
+ ## 本版重点
6
+
7
+ - **一眼看懂**:首屏直接说明用途,页面按“写一句话 → 选张照片 → 导出图纸”三步走,打开就能看到示例效果。
8
+ - **更快**:配色查表加速,色格数据不再走页面数据通道;PDF 改为矢量直出(毫秒级,文件更小);导出时不再重复绘制。
9
+ - **更好看、更好扫**:
10
+ - 感知更准的配色(OKLab)
11
+ - 照片按面积取平均色,不再有锯齿
12
+ - 相邻色块自动平滑,零散颜色自动合并,所需豆子种类更少
13
+ - 二维码定位图形强制高对比
14
+ - 本机扫码自检:扫不出时一键优化
15
+ - **打印友好**:PDF 为 A4 矢量图纸,大作品会自动分块并附总览页;每一格都印有色号,另附带用量的清单。
16
+ - **精选色板**:除标准 221 色和完整 291 色外,还提供 144/96/72/48/24 色精选套装,方便少买豆。
17
+
18
+ ## 内容安全链路(按微信平台要求)
19
+
20
+ | 环节 | 措施 |
21
+ | --- | --- |
22
+ | 二维码文字、扫码导入的文字、水印 | 云函数调用 `security.msgSecCheck` v2(带 openid、按场景值检测),结果为 risky 或 review 均拦截 |
23
+ | 照片 | 选图后立即压缩到官方推荐的 750×1334 以内,由云函数调用 `security.imgSecCheck`;**检测通过前不能应用照片** |
24
+ | 检测异常 | 一律视为未通过,不允许生成与导出(fail-closed) |
25
+ | 导出前 | 再次校验全部文本(已通过的走缓存,不增加等待) |
26
+ | 云函数加固 | 必须有 OPENID;只接受 `sec-check/` 目录下的 jpg;通过文件头判断格式;限制 1MB;检测后立即删除云端临时文件 |
27
+ | 隐私 | 已开启 `__usePrivacyCheck__`,内置隐私授权弹窗;页脚设有“隐私保护指引”入口 |
28
+ | 投诉与反馈 | 页脚提供“内容规范”说明及 `open-type="feedback"` 的投诉反馈入口 |
29
+ | 分享 | 分享出去的只是小程序卡片,不带用户内容 |
30
+
31
+ ## 部署步骤
32
+
33
+ 1. 用微信开发者工具打开本目录(appid 已配置)。
34
+ 2. 右键 `cloudfunctions/quickstartFunctions` →「上传并部署:云端安装依赖」。**本版云函数有改动,必须重新部署。**
35
+ 3. 在云开发控制台 → 存储中确认可以写入 `sec-check/` 目录(默认权限即可)。
36
+ 4. 在小程序管理后台 →「设置 → 服务内容声明 → 用户隐私保护指引」中声明:选择图片/拍照(`chooseMedia`)、保存到相册(`saveImageToPhotosAlbum`)、扫码(`scanCode`)。
37
+ 5. 真机测试:正常/违规文字、正常/违规图片、断网时的检测异常,确认都会被正确拦截和提示。
38
+
39
+ ## 说明
40
+
41
+ - 为保证扫码成功率,**二维码默认保留 1 格白边**。如需原来的“无白边”效果,可在「高级设置 → 白边」中选择“无”。
42
+ - 圆豆、中空豆样式只影响图纸的显示效果,实际拼出来的位置与方豆完全一致。
43
+ - 第三方代码:`utils/qrcore`(node-qrcode,MIT)、`utils/vendor/jsqr.js`(jsQR,Apache-2.0)。
44
+
45
+ ## 首屏演示动画 & 宣传视频
46
+
47
+ - 首次打开(还没写内容)时,预览区播放 12 秒 1:1 演示动画:写一句话 → 编成二维码 → 融进照片 → 微信一扫 → 结尾卡片。
48
+ - 动画由 `miniprogram/utils/intro.js` 纯代码实时绘制,**不含任何视频/图片素材,几乎不占包体积**(约 20KB)。演示作品由真实配色引擎生成,画面里的二维码真的能扫出 “Happy Birthday!”。
49
+ - 滑出屏幕、切到后台、打开裁剪时自动暂停;点一下预览区重新播放;写下内容(或选了照片)后自动切回真实二维码。
50
+ - `docs/promo/` 里是用同一套代码渲染的宣传视频(1080 / 720,含原创合成配乐)和封面图,可直接发公众号。该目录不在 `miniprogram/` 内,不计入小程序包体积。
BeadQR/cloudfunctions/quickstartFunctions/config.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "timeout": 20,
3
+ "permissions": {
4
+ "openapi": [
5
+ "security.msgSecCheck",
6
+ "security.imgSecCheck"
7
+ ]
8
+ }
9
+ }
BeadQR/cloudfunctions/quickstartFunctions/index.js ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 豆维 · 内容安全云函数
2
+ // type: checkMessage | checkMessages | checkImage
3
+ // 返回约定:{ risk: boolean, skipped?: boolean };skipped 表示检测未能完成,前端必须拦截
4
+ const cloud = require("wx-server-sdk");
5
+ cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV });
6
+
7
+ const MAX_TEXT = 2500;
8
+ const MAX_IMAGE_BYTES = 1024 * 1024;
9
+ const MAX_BATCH = 5;
10
+
11
+ const extractErrCode = (error) => {
12
+ if (!error) return undefined;
13
+ if (typeof error.errCode === "number") return error.errCode;
14
+ if (typeof error.code === "number") return error.code;
15
+ const match = typeof error.errMsg === "string" ? error.errMsg.match(/-?\d{3,}/) : null;
16
+ return match ? Number(match[0]) : undefined;
17
+ };
18
+
19
+ // msgSecCheck v2:scene 1 资料 / 2 评论 / 3 论坛 / 4 社交日志
20
+ async function checkOneMessage(content, scene, openid) {
21
+ const value = String(content || "").slice(0, MAX_TEXT);
22
+ if (!value.trim()) return { risk: false };
23
+ const safeScene = [1, 2, 3, 4].includes(Number(scene)) ? Number(scene) : 2;
24
+ try {
25
+ const response = await cloud.openapi.security.msgSecCheck({
26
+ version: 2,
27
+ scene: safeScene,
28
+ openid,
29
+ content: value,
30
+ });
31
+ const suggest = response && response.result && response.result.suggest;
32
+ if (!suggest) return { risk: false, skipped: true };
33
+ // review(疑似)按违规处理,宁严勿松
34
+ return { risk: suggest === "risky" || suggest === "review", verdict: suggest };
35
+ } catch (e) {
36
+ if (extractErrCode(e) === 87014) return { risk: true, verdict: "risky" };
37
+ return { risk: false, skipped: true, errCode: extractErrCode(e) };
38
+ }
39
+ }
40
+
41
+ // 通过文件头判断真实格式,不信任客户端传入的类型
42
+ function sniffType(buffer) {
43
+ if (buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
44
+ if (buffer.length > 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return "image/png";
45
+ return "";
46
+ }
47
+
48
+ async function checkImage(fileID) {
49
+ // 只允许检测本小程序上传到 sec-check/ 目录的临时文件,防止借接口删除/读取其他文件
50
+ if (typeof fileID !== "string" || !/^cloud:\/\/[^/]+\/sec-check\/[\w.-]+\.jpg$/.test(fileID)) {
51
+ return { risk: false, skipped: true, reason: "INVALID_FILE" };
52
+ }
53
+ try {
54
+ const download = await cloud.downloadFile({ fileID });
55
+ const buffer = download && download.fileContent;
56
+ const contentType = buffer ? sniffType(buffer) : "";
57
+ if (!buffer || !buffer.length || buffer.length > MAX_IMAGE_BYTES || !contentType) {
58
+ return { risk: false, skipped: true, reason: "BAD_IMAGE" };
59
+ }
60
+ await cloud.openapi.security.imgSecCheck({
61
+ media: { contentType, value: buffer },
62
+ });
63
+ return { risk: false };
64
+ } catch (e) {
65
+ if (extractErrCode(e) === 87014) return { risk: true, verdict: "risky" };
66
+ return { risk: false, skipped: true, errCode: extractErrCode(e) };
67
+ } finally {
68
+ try {
69
+ await cloud.deleteFile({ fileList: [fileID] });
70
+ } catch (e) {
71
+ // 清理失败不影响检测结果
72
+ }
73
+ }
74
+ }
75
+
76
+ exports.main = async (event) => {
77
+ const { OPENID } = cloud.getWXContext();
78
+ if (!OPENID) return { risk: false, skipped: true, reason: "NO_OPENID" };
79
+ const type = event && event.type;
80
+ if (type === "checkMessage") {
81
+ return checkOneMessage(event.content, event.scene, OPENID);
82
+ }
83
+ if (type === "checkMessages") {
84
+ const items = Array.isArray(event.items) ? event.items.slice(0, MAX_BATCH) : [];
85
+ const results = await Promise.all(items.map((it) => checkOneMessage(it && it.value, it && it.scene, OPENID)));
86
+ return { results };
87
+ }
88
+ if (type === "checkImage") {
89
+ return checkImage(event.fileID);
90
+ }
91
+ return { risk: false, skipped: true, reason: "UNKNOWN_TYPE" };
92
+ };
BeadQR/cloudfunctions/quickstartFunctions/package-lock.json ADDED
@@ -0,0 +1,1157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "quickstartFunctions",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "quickstartFunctions",
9
+ "version": "1.0.0",
10
+ "license": "ISC",
11
+ "dependencies": {
12
+ "wx-server-sdk": "~4.0.2"
13
+ }
14
+ },
15
+ "node_modules/@cloudbase/adapter-interface": {
16
+ "version": "0.7.1",
17
+ "resolved": "https://registry.npmjs.org/@cloudbase/adapter-interface/-/adapter-interface-0.7.1.tgz",
18
+ "integrity": "sha512-BjHTAvIMSLxbp26SeT6qLiSSBygz3kAVno/PMwwBwjgzVkVUvuZGVcvf1mhHxY/BjdRGLrd6X60OgvymV/aAUw==",
19
+ "license": "ISC"
20
+ },
21
+ "node_modules/@cloudbase/adapter-wx_mp": {
22
+ "version": "1.3.1",
23
+ "resolved": "https://registry.npmjs.org/@cloudbase/adapter-wx_mp/-/adapter-wx_mp-1.3.1.tgz",
24
+ "integrity": "sha512-rS0N1aD1a0zc421ds17/HzM4RChQ6T2ixMz87bhPJ1yveqJt/KUqYxiXJDWhZ803wwTmJs8aqNkgugsNusNhbg==",
25
+ "license": "ISC",
26
+ "dependencies": {
27
+ "@cloudbase/adapter-interface": "^0.7.0",
28
+ "web-streams-polyfill": "^4.0.0"
29
+ }
30
+ },
31
+ "node_modules/@cloudbase/ai": {
32
+ "version": "2.30.0",
33
+ "resolved": "https://registry.npmjs.org/@cloudbase/ai/-/ai-2.30.0.tgz",
34
+ "integrity": "sha512-RtBux47omXWcMhiiDDVJ96d2JlqnQ/bzKC6GG3ROgupYG3ReQyv1IssCpg5cnzmZ+l651IqdOpt27VTuO3kEGQ==",
35
+ "license": "Apache-2.0",
36
+ "dependencies": {
37
+ "@cloudbase/types": "2.30.0",
38
+ "@cloudbase/utilities": "2.30.0",
39
+ "@mattiasbuelens/web-streams-adapter": "^0.1.0",
40
+ "text-encoding-shim": "^1.0.5",
41
+ "web-streams-polyfill": "^4.0.0"
42
+ }
43
+ },
44
+ "node_modules/@cloudbase/ai/node_modules/@cloudbase/types": {
45
+ "version": "2.30.0",
46
+ "resolved": "https://registry.npmjs.org/@cloudbase/types/-/types-2.30.0.tgz",
47
+ "integrity": "sha512-tVapfLLDA1MlmG2OwC5eq5eSra9l3EgpeJnx1kNR4CGh8zQRrVvFRyaRLo4BrzY0p/5xqX3iJliRmcASF7Ip7g==",
48
+ "license": "Apache-2.0",
49
+ "dependencies": {
50
+ "@cloudbase/adapter-interface": "^0.7.1"
51
+ }
52
+ },
53
+ "node_modules/@cloudbase/app": {
54
+ "version": "2.32.0",
55
+ "resolved": "https://registry.npmjs.org/@cloudbase/app/-/app-2.32.0.tgz",
56
+ "integrity": "sha512-78+eSo8OLO4iUiifdynZo4dK4wNlyaz5x9423muSibyl4FLwt5/Xt0t9s7StFmGaBdvB75UYM9dGWxOdIFRzaw==",
57
+ "license": "Apache-2.0",
58
+ "dependencies": {
59
+ "@cloudbase/adapter-interface": "^0.7.1",
60
+ "@cloudbase/adapter-wx_mp": "^1.3.1",
61
+ "@cloudbase/types": "2.32.0",
62
+ "@cloudbase/utilities": "2.32.0"
63
+ },
64
+ "peerDependencies": {
65
+ "@cloudbase/signature-nodejs": ">=1.0.0",
66
+ "jsonwebtoken": ">=8.0.0",
67
+ "ws": "^8.18.0"
68
+ },
69
+ "peerDependenciesMeta": {
70
+ "@cloudbase/signature-nodejs": {
71
+ "optional": true
72
+ },
73
+ "jsonwebtoken": {
74
+ "optional": true
75
+ },
76
+ "ws": {
77
+ "optional": true
78
+ }
79
+ }
80
+ },
81
+ "node_modules/@cloudbase/app/node_modules/@cloudbase/utilities": {
82
+ "version": "2.32.0",
83
+ "resolved": "https://registry.npmjs.org/@cloudbase/utilities/-/utilities-2.32.0.tgz",
84
+ "integrity": "sha512-vYrr1aXOpU+/IrkHMSTxi/k2d4N8raNu+E3IrKtWt4w7Y0Ke3DLvCHeUJcuWECvQBVhzmg1ert4DcXVUymSKlg==",
85
+ "license": "Apache-2.0",
86
+ "dependencies": {
87
+ "@cloudbase/adapter-interface": "^0.7.1",
88
+ "@cloudbase/types": "2.32.0",
89
+ "jwt-decode": "^3.1.2"
90
+ }
91
+ },
92
+ "node_modules/@cloudbase/database": {
93
+ "version": "1.4.3",
94
+ "resolved": "https://registry.npmjs.org/@cloudbase/database/-/database-1.4.3.tgz",
95
+ "integrity": "sha512-JzmdsGjy9LwzSQQ0Fv4OlNHNK61BRXdJSembTpy4408AQA3q2Ip3N5eB3v9OpAlFMvP/6MGmOQ/ZuUPKfJddJA==",
96
+ "license": "ISC",
97
+ "dependencies": {
98
+ "bson": "^4.0.3",
99
+ "lodash.clonedeep": "4.5.0",
100
+ "lodash.set": "4.3.2",
101
+ "lodash.unset": "4.5.2"
102
+ }
103
+ },
104
+ "node_modules/@cloudbase/node-sdk": {
105
+ "version": "3.17.2",
106
+ "resolved": "https://registry.npmjs.org/@cloudbase/node-sdk/-/node-sdk-3.17.2.tgz",
107
+ "integrity": "sha512-74ekvRsdmeGch9hFHkQnjHPTEfk8k09iQFvI1aAo48ZsbLqK3czqj/3GoVpSkw5+eYe05tZV13Ct/kWiV5tuGg==",
108
+ "license": "MIT",
109
+ "dependencies": {
110
+ "@cloudbase/ai": "^2.23.0",
111
+ "@cloudbase/app": "^2.23.0",
112
+ "@cloudbase/database": "1.4.3",
113
+ "@cloudbase/signature-nodejs": "2.2.0",
114
+ "@cloudbase/types": "^2.23.0",
115
+ "@cloudbase/wx-cloud-client-sdk": "1.7.1",
116
+ "agentkeepalive": "^4.3.0",
117
+ "axios": "0.27.2",
118
+ "form-data": "^4.0.0",
119
+ "http-proxy-agent": "^5.0.0",
120
+ "https-proxy-agent": "^5.0.1",
121
+ "jsonwebtoken": "^9.0.2",
122
+ "retry": "^0.13.1",
123
+ "web-streams-polyfill": "^4.2.0",
124
+ "xml2js": "^0.6.2"
125
+ },
126
+ "engines": {
127
+ "node": ">=12"
128
+ }
129
+ },
130
+ "node_modules/@cloudbase/signature-nodejs": {
131
+ "version": "2.2.0",
132
+ "resolved": "https://registry.npmjs.org/@cloudbase/signature-nodejs/-/signature-nodejs-2.2.0.tgz",
133
+ "integrity": "sha512-2y2rieOpJNnBA2MW7sLJKek3iFOjgr8TNicw5qjBkcdVczLR0cFzxMhip75NLCwarFTIAONE3oeNxoQ19eVHcA==",
134
+ "dependencies": {
135
+ "@types/clone": "^2.1.0",
136
+ "clone": "^2.1.2",
137
+ "is-stream": "^2.0.0",
138
+ "url": "^0.11.0"
139
+ }
140
+ },
141
+ "node_modules/@cloudbase/types": {
142
+ "version": "2.32.0",
143
+ "resolved": "https://registry.npmjs.org/@cloudbase/types/-/types-2.32.0.tgz",
144
+ "integrity": "sha512-HPsMEHHt5SAyD3tpv/C/v2nYudSawE48ixjK5Y9w3R/82auJag0AUsZGfCExgdPa4Zud9OvGx5127ssEXLgadw==",
145
+ "license": "Apache-2.0",
146
+ "dependencies": {
147
+ "@cloudbase/adapter-interface": "^0.7.1"
148
+ }
149
+ },
150
+ "node_modules/@cloudbase/utilities": {
151
+ "version": "2.30.0",
152
+ "resolved": "https://registry.npmjs.org/@cloudbase/utilities/-/utilities-2.30.0.tgz",
153
+ "integrity": "sha512-52DfQH5qgfOtrwj26KCGw6cz0pUjuJludJ7fccUKCzBtn/QwPudwtXo7gOUTtKKDubW3+VUCjjBm7UiDwFu+ag==",
154
+ "license": "Apache-2.0",
155
+ "dependencies": {
156
+ "@cloudbase/adapter-interface": "^0.7.1",
157
+ "@cloudbase/types": "2.30.0",
158
+ "jwt-decode": "^3.1.2"
159
+ }
160
+ },
161
+ "node_modules/@cloudbase/utilities/node_modules/@cloudbase/types": {
162
+ "version": "2.30.0",
163
+ "resolved": "https://registry.npmjs.org/@cloudbase/types/-/types-2.30.0.tgz",
164
+ "integrity": "sha512-tVapfLLDA1MlmG2OwC5eq5eSra9l3EgpeJnx1kNR4CGh8zQRrVvFRyaRLo4BrzY0p/5xqX3iJliRmcASF7Ip7g==",
165
+ "license": "Apache-2.0",
166
+ "dependencies": {
167
+ "@cloudbase/adapter-interface": "^0.7.1"
168
+ }
169
+ },
170
+ "node_modules/@cloudbase/wx-cloud-client-sdk": {
171
+ "version": "1.7.1",
172
+ "resolved": "https://registry.npmjs.org/@cloudbase/wx-cloud-client-sdk/-/wx-cloud-client-sdk-1.7.1.tgz",
173
+ "integrity": "sha512-NzLOoYDAeoRh2lRjCtvr6yXu4KPVHqZtPxyv70e+3b2+dZIDE5S5gtU8daE7xS5u4TwPqKB/E2vmgRTYOsCS9w==",
174
+ "dependencies": {
175
+ "core-js-pure": "^3.45.0"
176
+ }
177
+ },
178
+ "node_modules/@mattiasbuelens/web-streams-adapter": {
179
+ "version": "0.1.0",
180
+ "resolved": "https://registry.npmjs.org/@mattiasbuelens/web-streams-adapter/-/web-streams-adapter-0.1.0.tgz",
181
+ "integrity": "sha512-oV4PyZfwJNtmFWhvlJLqYIX1Nn22ML8FZpS16ZUKv0hg7414xV1fjsGqxQzLT2dyK92TKxsJSwMOd7VNHAtPmA==",
182
+ "license": "MIT",
183
+ "engines": {
184
+ "node": ">= 12"
185
+ }
186
+ },
187
+ "node_modules/@protobufjs/aspromise": {
188
+ "version": "1.1.2",
189
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
190
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
191
+ "license": "BSD-3-Clause"
192
+ },
193
+ "node_modules/@protobufjs/base64": {
194
+ "version": "1.1.2",
195
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
196
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
197
+ "license": "BSD-3-Clause"
198
+ },
199
+ "node_modules/@protobufjs/codegen": {
200
+ "version": "2.0.5",
201
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
202
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
203
+ "license": "BSD-3-Clause"
204
+ },
205
+ "node_modules/@protobufjs/eventemitter": {
206
+ "version": "1.1.1",
207
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
208
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
209
+ "license": "BSD-3-Clause"
210
+ },
211
+ "node_modules/@protobufjs/fetch": {
212
+ "version": "1.1.1",
213
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
214
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
215
+ "license": "BSD-3-Clause",
216
+ "dependencies": {
217
+ "@protobufjs/aspromise": "^1.1.1"
218
+ }
219
+ },
220
+ "node_modules/@protobufjs/float": {
221
+ "version": "1.0.2",
222
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
223
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
224
+ "license": "BSD-3-Clause"
225
+ },
226
+ "node_modules/@protobufjs/path": {
227
+ "version": "1.1.2",
228
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
229
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
230
+ "license": "BSD-3-Clause"
231
+ },
232
+ "node_modules/@protobufjs/pool": {
233
+ "version": "1.1.0",
234
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
235
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
236
+ "license": "BSD-3-Clause"
237
+ },
238
+ "node_modules/@protobufjs/utf8": {
239
+ "version": "1.1.2",
240
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz",
241
+ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==",
242
+ "license": "BSD-3-Clause"
243
+ },
244
+ "node_modules/@tootallnate/once": {
245
+ "version": "2.0.1",
246
+ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
247
+ "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==",
248
+ "license": "MIT",
249
+ "engines": {
250
+ "node": ">= 10"
251
+ }
252
+ },
253
+ "node_modules/@types/clone": {
254
+ "version": "2.1.4",
255
+ "resolved": "https://registry.npmjs.org/@types/clone/-/clone-2.1.4.tgz",
256
+ "integrity": "sha512-NKRWaEGaVGVLnGLB2GazvDaZnyweW9FJLLFL5LhywGJB3aqGMT9R/EUoJoSRP4nzofYnZysuDmrEJtJdAqUOtQ==",
257
+ "license": "MIT"
258
+ },
259
+ "node_modules/@types/node": {
260
+ "version": "26.3.0",
261
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz",
262
+ "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==",
263
+ "license": "MIT",
264
+ "dependencies": {
265
+ "undici-types": "~8.3.0"
266
+ }
267
+ },
268
+ "node_modules/agent-base": {
269
+ "version": "6.0.2",
270
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
271
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
272
+ "license": "MIT",
273
+ "dependencies": {
274
+ "debug": "4"
275
+ },
276
+ "engines": {
277
+ "node": ">= 6.0.0"
278
+ }
279
+ },
280
+ "node_modules/agentkeepalive": {
281
+ "version": "4.6.0",
282
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
283
+ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
284
+ "license": "MIT",
285
+ "dependencies": {
286
+ "humanize-ms": "^1.2.1"
287
+ },
288
+ "engines": {
289
+ "node": ">= 8.0.0"
290
+ }
291
+ },
292
+ "node_modules/asynckit": {
293
+ "version": "0.4.0",
294
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
295
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
296
+ "license": "MIT"
297
+ },
298
+ "node_modules/axios": {
299
+ "version": "0.27.2",
300
+ "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
301
+ "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
302
+ "license": "MIT",
303
+ "dependencies": {
304
+ "follow-redirects": "^1.14.9",
305
+ "form-data": "^4.0.0"
306
+ }
307
+ },
308
+ "node_modules/base64-js": {
309
+ "version": "1.5.1",
310
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
311
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
312
+ "funding": [
313
+ {
314
+ "type": "github",
315
+ "url": "https://github.com/sponsors/feross"
316
+ },
317
+ {
318
+ "type": "patreon",
319
+ "url": "https://www.patreon.com/feross"
320
+ },
321
+ {
322
+ "type": "consulting",
323
+ "url": "https://feross.org/support"
324
+ }
325
+ ],
326
+ "license": "MIT"
327
+ },
328
+ "node_modules/bignumber.js": {
329
+ "version": "9.3.1",
330
+ "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
331
+ "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
332
+ "license": "MIT",
333
+ "engines": {
334
+ "node": "*"
335
+ }
336
+ },
337
+ "node_modules/bson": {
338
+ "version": "4.7.2",
339
+ "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz",
340
+ "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==",
341
+ "license": "Apache-2.0",
342
+ "dependencies": {
343
+ "buffer": "^5.6.0"
344
+ },
345
+ "engines": {
346
+ "node": ">=6.9.0"
347
+ }
348
+ },
349
+ "node_modules/buffer": {
350
+ "version": "5.7.1",
351
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
352
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
353
+ "funding": [
354
+ {
355
+ "type": "github",
356
+ "url": "https://github.com/sponsors/feross"
357
+ },
358
+ {
359
+ "type": "patreon",
360
+ "url": "https://www.patreon.com/feross"
361
+ },
362
+ {
363
+ "type": "consulting",
364
+ "url": "https://feross.org/support"
365
+ }
366
+ ],
367
+ "license": "MIT",
368
+ "dependencies": {
369
+ "base64-js": "^1.3.1",
370
+ "ieee754": "^1.1.13"
371
+ }
372
+ },
373
+ "node_modules/buffer-equal-constant-time": {
374
+ "version": "1.0.1",
375
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
376
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
377
+ "license": "BSD-3-Clause"
378
+ },
379
+ "node_modules/call-bind-apply-helpers": {
380
+ "version": "1.0.2",
381
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
382
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
383
+ "license": "MIT",
384
+ "dependencies": {
385
+ "es-errors": "^1.3.0",
386
+ "function-bind": "^1.1.2"
387
+ },
388
+ "engines": {
389
+ "node": ">= 0.4"
390
+ }
391
+ },
392
+ "node_modules/call-bound": {
393
+ "version": "1.0.4",
394
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
395
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
396
+ "license": "MIT",
397
+ "dependencies": {
398
+ "call-bind-apply-helpers": "^1.0.2",
399
+ "get-intrinsic": "^1.3.0"
400
+ },
401
+ "engines": {
402
+ "node": ">= 0.4"
403
+ },
404
+ "funding": {
405
+ "url": "https://github.com/sponsors/ljharb"
406
+ }
407
+ },
408
+ "node_modules/clone": {
409
+ "version": "2.1.2",
410
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
411
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
412
+ "license": "MIT",
413
+ "engines": {
414
+ "node": ">=0.8"
415
+ }
416
+ },
417
+ "node_modules/combined-stream": {
418
+ "version": "1.0.8",
419
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
420
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
421
+ "license": "MIT",
422
+ "dependencies": {
423
+ "delayed-stream": "~1.0.0"
424
+ },
425
+ "engines": {
426
+ "node": ">= 0.8"
427
+ }
428
+ },
429
+ "node_modules/core-js-pure": {
430
+ "version": "3.50.0",
431
+ "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.50.0.tgz",
432
+ "integrity": "sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==",
433
+ "hasInstallScript": true,
434
+ "license": "MIT",
435
+ "engines": {
436
+ "node": "*"
437
+ },
438
+ "funding": {
439
+ "type": "opencollective",
440
+ "url": "https://opencollective.com/core-js"
441
+ }
442
+ },
443
+ "node_modules/debug": {
444
+ "version": "4.4.3",
445
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
446
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
447
+ "license": "MIT",
448
+ "dependencies": {
449
+ "ms": "^2.1.3"
450
+ },
451
+ "engines": {
452
+ "node": ">=6.0"
453
+ },
454
+ "peerDependenciesMeta": {
455
+ "supports-color": {
456
+ "optional": true
457
+ }
458
+ }
459
+ },
460
+ "node_modules/delayed-stream": {
461
+ "version": "1.0.0",
462
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
463
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
464
+ "license": "MIT",
465
+ "engines": {
466
+ "node": ">=0.4.0"
467
+ }
468
+ },
469
+ "node_modules/dunder-proto": {
470
+ "version": "1.0.1",
471
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
472
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
473
+ "license": "MIT",
474
+ "dependencies": {
475
+ "call-bind-apply-helpers": "^1.0.1",
476
+ "es-errors": "^1.3.0",
477
+ "gopd": "^1.2.0"
478
+ },
479
+ "engines": {
480
+ "node": ">= 0.4"
481
+ }
482
+ },
483
+ "node_modules/ecdsa-sig-formatter": {
484
+ "version": "1.0.11",
485
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
486
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
487
+ "license": "Apache-2.0",
488
+ "dependencies": {
489
+ "safe-buffer": "^5.0.1"
490
+ }
491
+ },
492
+ "node_modules/es-define-property": {
493
+ "version": "1.0.1",
494
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
495
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
496
+ "license": "MIT",
497
+ "engines": {
498
+ "node": ">= 0.4"
499
+ }
500
+ },
501
+ "node_modules/es-errors": {
502
+ "version": "1.3.0",
503
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
504
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
505
+ "license": "MIT",
506
+ "engines": {
507
+ "node": ">= 0.4"
508
+ }
509
+ },
510
+ "node_modules/es-object-atoms": {
511
+ "version": "1.1.2",
512
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
513
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
514
+ "license": "MIT",
515
+ "dependencies": {
516
+ "es-errors": "^1.3.0"
517
+ },
518
+ "engines": {
519
+ "node": ">= 0.4"
520
+ }
521
+ },
522
+ "node_modules/es-set-tostringtag": {
523
+ "version": "2.1.0",
524
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
525
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
526
+ "license": "MIT",
527
+ "dependencies": {
528
+ "es-errors": "^1.3.0",
529
+ "get-intrinsic": "^1.2.6",
530
+ "has-tostringtag": "^1.0.2",
531
+ "hasown": "^2.0.2"
532
+ },
533
+ "engines": {
534
+ "node": ">= 0.4"
535
+ }
536
+ },
537
+ "node_modules/follow-redirects": {
538
+ "version": "1.16.0",
539
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
540
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
541
+ "funding": [
542
+ {
543
+ "type": "individual",
544
+ "url": "https://github.com/sponsors/RubenVerborgh"
545
+ }
546
+ ],
547
+ "license": "MIT",
548
+ "engines": {
549
+ "node": ">=4.0"
550
+ },
551
+ "peerDependenciesMeta": {
552
+ "debug": {
553
+ "optional": true
554
+ }
555
+ }
556
+ },
557
+ "node_modules/form-data": {
558
+ "version": "4.0.6",
559
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
560
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
561
+ "license": "MIT",
562
+ "dependencies": {
563
+ "asynckit": "^0.4.0",
564
+ "combined-stream": "^1.0.8",
565
+ "es-set-tostringtag": "^2.1.0",
566
+ "hasown": "^2.0.4",
567
+ "mime-types": "^2.1.35"
568
+ },
569
+ "engines": {
570
+ "node": ">= 6"
571
+ }
572
+ },
573
+ "node_modules/function-bind": {
574
+ "version": "1.1.2",
575
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
576
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
577
+ "license": "MIT",
578
+ "funding": {
579
+ "url": "https://github.com/sponsors/ljharb"
580
+ }
581
+ },
582
+ "node_modules/get-intrinsic": {
583
+ "version": "1.3.0",
584
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
585
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
586
+ "license": "MIT",
587
+ "dependencies": {
588
+ "call-bind-apply-helpers": "^1.0.2",
589
+ "es-define-property": "^1.0.1",
590
+ "es-errors": "^1.3.0",
591
+ "es-object-atoms": "^1.1.1",
592
+ "function-bind": "^1.1.2",
593
+ "get-proto": "^1.0.1",
594
+ "gopd": "^1.2.0",
595
+ "has-symbols": "^1.1.0",
596
+ "hasown": "^2.0.2",
597
+ "math-intrinsics": "^1.1.0"
598
+ },
599
+ "engines": {
600
+ "node": ">= 0.4"
601
+ },
602
+ "funding": {
603
+ "url": "https://github.com/sponsors/ljharb"
604
+ }
605
+ },
606
+ "node_modules/get-proto": {
607
+ "version": "1.0.1",
608
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
609
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
610
+ "license": "MIT",
611
+ "dependencies": {
612
+ "dunder-proto": "^1.0.1",
613
+ "es-object-atoms": "^1.0.0"
614
+ },
615
+ "engines": {
616
+ "node": ">= 0.4"
617
+ }
618
+ },
619
+ "node_modules/gopd": {
620
+ "version": "1.2.0",
621
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
622
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
623
+ "license": "MIT",
624
+ "engines": {
625
+ "node": ">= 0.4"
626
+ },
627
+ "funding": {
628
+ "url": "https://github.com/sponsors/ljharb"
629
+ }
630
+ },
631
+ "node_modules/has-symbols": {
632
+ "version": "1.1.0",
633
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
634
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
635
+ "license": "MIT",
636
+ "engines": {
637
+ "node": ">= 0.4"
638
+ },
639
+ "funding": {
640
+ "url": "https://github.com/sponsors/ljharb"
641
+ }
642
+ },
643
+ "node_modules/has-tostringtag": {
644
+ "version": "1.0.2",
645
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
646
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
647
+ "license": "MIT",
648
+ "dependencies": {
649
+ "has-symbols": "^1.0.3"
650
+ },
651
+ "engines": {
652
+ "node": ">= 0.4"
653
+ },
654
+ "funding": {
655
+ "url": "https://github.com/sponsors/ljharb"
656
+ }
657
+ },
658
+ "node_modules/hasown": {
659
+ "version": "2.0.4",
660
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
661
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
662
+ "license": "MIT",
663
+ "dependencies": {
664
+ "function-bind": "^1.1.2"
665
+ },
666
+ "engines": {
667
+ "node": ">= 0.4"
668
+ }
669
+ },
670
+ "node_modules/http-proxy-agent": {
671
+ "version": "5.0.0",
672
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
673
+ "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
674
+ "license": "MIT",
675
+ "dependencies": {
676
+ "@tootallnate/once": "2",
677
+ "agent-base": "6",
678
+ "debug": "4"
679
+ },
680
+ "engines": {
681
+ "node": ">= 6"
682
+ }
683
+ },
684
+ "node_modules/https-proxy-agent": {
685
+ "version": "5.0.1",
686
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
687
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
688
+ "license": "MIT",
689
+ "dependencies": {
690
+ "agent-base": "6",
691
+ "debug": "4"
692
+ },
693
+ "engines": {
694
+ "node": ">= 6"
695
+ }
696
+ },
697
+ "node_modules/humanize-ms": {
698
+ "version": "1.2.1",
699
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
700
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
701
+ "license": "MIT",
702
+ "dependencies": {
703
+ "ms": "^2.0.0"
704
+ }
705
+ },
706
+ "node_modules/ieee754": {
707
+ "version": "1.2.1",
708
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
709
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
710
+ "funding": [
711
+ {
712
+ "type": "github",
713
+ "url": "https://github.com/sponsors/feross"
714
+ },
715
+ {
716
+ "type": "patreon",
717
+ "url": "https://www.patreon.com/feross"
718
+ },
719
+ {
720
+ "type": "consulting",
721
+ "url": "https://feross.org/support"
722
+ }
723
+ ],
724
+ "license": "BSD-3-Clause"
725
+ },
726
+ "node_modules/is-stream": {
727
+ "version": "2.0.1",
728
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
729
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
730
+ "license": "MIT",
731
+ "engines": {
732
+ "node": ">=8"
733
+ },
734
+ "funding": {
735
+ "url": "https://github.com/sponsors/sindresorhus"
736
+ }
737
+ },
738
+ "node_modules/json-bigint": {
739
+ "version": "1.0.0",
740
+ "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
741
+ "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
742
+ "license": "MIT",
743
+ "dependencies": {
744
+ "bignumber.js": "^9.0.0"
745
+ }
746
+ },
747
+ "node_modules/jsonwebtoken": {
748
+ "version": "9.0.3",
749
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
750
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
751
+ "license": "MIT",
752
+ "dependencies": {
753
+ "jws": "^4.0.1",
754
+ "lodash.includes": "^4.3.0",
755
+ "lodash.isboolean": "^3.0.3",
756
+ "lodash.isinteger": "^4.0.4",
757
+ "lodash.isnumber": "^3.0.3",
758
+ "lodash.isplainobject": "^4.0.6",
759
+ "lodash.isstring": "^4.0.1",
760
+ "lodash.once": "^4.0.0",
761
+ "ms": "^2.1.1",
762
+ "semver": "^7.5.4"
763
+ },
764
+ "engines": {
765
+ "node": ">=12",
766
+ "npm": ">=6"
767
+ }
768
+ },
769
+ "node_modules/jwa": {
770
+ "version": "2.0.1",
771
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
772
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
773
+ "license": "MIT",
774
+ "dependencies": {
775
+ "buffer-equal-constant-time": "^1.0.1",
776
+ "ecdsa-sig-formatter": "1.0.11",
777
+ "safe-buffer": "^5.0.1"
778
+ }
779
+ },
780
+ "node_modules/jws": {
781
+ "version": "4.0.1",
782
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
783
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
784
+ "license": "MIT",
785
+ "dependencies": {
786
+ "jwa": "^2.0.1",
787
+ "safe-buffer": "^5.0.1"
788
+ }
789
+ },
790
+ "node_modules/jwt-decode": {
791
+ "version": "3.1.2",
792
+ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
793
+ "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==",
794
+ "license": "MIT"
795
+ },
796
+ "node_modules/lodash.clonedeep": {
797
+ "version": "4.5.0",
798
+ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
799
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
800
+ "license": "MIT"
801
+ },
802
+ "node_modules/lodash.includes": {
803
+ "version": "4.3.0",
804
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
805
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
806
+ "license": "MIT"
807
+ },
808
+ "node_modules/lodash.isboolean": {
809
+ "version": "3.0.3",
810
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
811
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
812
+ "license": "MIT"
813
+ },
814
+ "node_modules/lodash.isinteger": {
815
+ "version": "4.0.4",
816
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
817
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
818
+ "license": "MIT"
819
+ },
820
+ "node_modules/lodash.isnumber": {
821
+ "version": "3.0.3",
822
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
823
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
824
+ "license": "MIT"
825
+ },
826
+ "node_modules/lodash.isplainobject": {
827
+ "version": "4.0.6",
828
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
829
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
830
+ "license": "MIT"
831
+ },
832
+ "node_modules/lodash.isstring": {
833
+ "version": "4.0.1",
834
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
835
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
836
+ "license": "MIT"
837
+ },
838
+ "node_modules/lodash.once": {
839
+ "version": "4.1.1",
840
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
841
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
842
+ "license": "MIT"
843
+ },
844
+ "node_modules/lodash.set": {
845
+ "version": "4.3.2",
846
+ "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
847
+ "integrity": "sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg==",
848
+ "license": "MIT"
849
+ },
850
+ "node_modules/lodash.unset": {
851
+ "version": "4.5.2",
852
+ "resolved": "https://registry.npmjs.org/lodash.unset/-/lodash.unset-4.5.2.tgz",
853
+ "integrity": "sha512-bwKX88k2JhCV9D1vtE8+naDKlLiGrSmf8zi/Y9ivFHwbmRfA8RxS/aVJ+sIht2XOwqoNr4xUPUkGZpc1sHFEKg==",
854
+ "license": "MIT"
855
+ },
856
+ "node_modules/long": {
857
+ "version": "5.3.2",
858
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
859
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
860
+ "license": "Apache-2.0"
861
+ },
862
+ "node_modules/math-intrinsics": {
863
+ "version": "1.1.0",
864
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
865
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
866
+ "license": "MIT",
867
+ "engines": {
868
+ "node": ">= 0.4"
869
+ }
870
+ },
871
+ "node_modules/mime-db": {
872
+ "version": "1.52.0",
873
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
874
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
875
+ "license": "MIT",
876
+ "engines": {
877
+ "node": ">= 0.6"
878
+ }
879
+ },
880
+ "node_modules/mime-types": {
881
+ "version": "2.1.35",
882
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
883
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
884
+ "license": "MIT",
885
+ "dependencies": {
886
+ "mime-db": "1.52.0"
887
+ },
888
+ "engines": {
889
+ "node": ">= 0.6"
890
+ }
891
+ },
892
+ "node_modules/ms": {
893
+ "version": "2.1.3",
894
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
895
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
896
+ "license": "MIT"
897
+ },
898
+ "node_modules/object-inspect": {
899
+ "version": "1.13.4",
900
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
901
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
902
+ "license": "MIT",
903
+ "engines": {
904
+ "node": ">= 0.4"
905
+ },
906
+ "funding": {
907
+ "url": "https://github.com/sponsors/ljharb"
908
+ }
909
+ },
910
+ "node_modules/protobufjs": {
911
+ "version": "7.6.5",
912
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
913
+ "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
914
+ "hasInstallScript": true,
915
+ "license": "BSD-3-Clause",
916
+ "dependencies": {
917
+ "@protobufjs/aspromise": "^1.1.2",
918
+ "@protobufjs/base64": "^1.1.2",
919
+ "@protobufjs/codegen": "^2.0.5",
920
+ "@protobufjs/eventemitter": "^1.1.1",
921
+ "@protobufjs/fetch": "^1.1.1",
922
+ "@protobufjs/float": "^1.0.2",
923
+ "@protobufjs/path": "^1.1.2",
924
+ "@protobufjs/pool": "^1.1.0",
925
+ "@protobufjs/utf8": "^1.1.1",
926
+ "@types/node": ">=13.7.0",
927
+ "long": "^5.3.2"
928
+ },
929
+ "engines": {
930
+ "node": ">=12.0.0"
931
+ }
932
+ },
933
+ "node_modules/punycode": {
934
+ "version": "1.4.1",
935
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
936
+ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
937
+ "license": "MIT"
938
+ },
939
+ "node_modules/qs": {
940
+ "version": "6.15.3",
941
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
942
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
943
+ "license": "BSD-3-Clause",
944
+ "dependencies": {
945
+ "es-define-property": "^1.0.1",
946
+ "side-channel": "^1.1.1"
947
+ },
948
+ "engines": {
949
+ "node": ">=0.6"
950
+ },
951
+ "funding": {
952
+ "url": "https://github.com/sponsors/ljharb"
953
+ }
954
+ },
955
+ "node_modules/retry": {
956
+ "version": "0.13.1",
957
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
958
+ "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
959
+ "license": "MIT",
960
+ "engines": {
961
+ "node": ">= 4"
962
+ }
963
+ },
964
+ "node_modules/safe-buffer": {
965
+ "version": "5.2.1",
966
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
967
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
968
+ "funding": [
969
+ {
970
+ "type": "github",
971
+ "url": "https://github.com/sponsors/feross"
972
+ },
973
+ {
974
+ "type": "patreon",
975
+ "url": "https://www.patreon.com/feross"
976
+ },
977
+ {
978
+ "type": "consulting",
979
+ "url": "https://feross.org/support"
980
+ }
981
+ ],
982
+ "license": "MIT"
983
+ },
984
+ "node_modules/sax": {
985
+ "version": "1.6.1",
986
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
987
+ "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
988
+ "license": "BlueOak-1.0.0",
989
+ "engines": {
990
+ "node": ">=11.0.0"
991
+ }
992
+ },
993
+ "node_modules/semver": {
994
+ "version": "7.8.5",
995
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
996
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
997
+ "license": "ISC",
998
+ "bin": {
999
+ "semver": "bin/semver.js"
1000
+ },
1001
+ "engines": {
1002
+ "node": ">=10"
1003
+ }
1004
+ },
1005
+ "node_modules/side-channel": {
1006
+ "version": "1.1.1",
1007
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
1008
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
1009
+ "license": "MIT",
1010
+ "dependencies": {
1011
+ "es-errors": "^1.3.0",
1012
+ "object-inspect": "^1.13.4",
1013
+ "side-channel-list": "^1.0.1",
1014
+ "side-channel-map": "^1.0.1",
1015
+ "side-channel-weakmap": "^1.0.2"
1016
+ },
1017
+ "engines": {
1018
+ "node": ">= 0.4"
1019
+ },
1020
+ "funding": {
1021
+ "url": "https://github.com/sponsors/ljharb"
1022
+ }
1023
+ },
1024
+ "node_modules/side-channel-list": {
1025
+ "version": "1.0.1",
1026
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
1027
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
1028
+ "license": "MIT",
1029
+ "dependencies": {
1030
+ "es-errors": "^1.3.0",
1031
+ "object-inspect": "^1.13.4"
1032
+ },
1033
+ "engines": {
1034
+ "node": ">= 0.4"
1035
+ },
1036
+ "funding": {
1037
+ "url": "https://github.com/sponsors/ljharb"
1038
+ }
1039
+ },
1040
+ "node_modules/side-channel-map": {
1041
+ "version": "1.0.1",
1042
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
1043
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
1044
+ "license": "MIT",
1045
+ "dependencies": {
1046
+ "call-bound": "^1.0.2",
1047
+ "es-errors": "^1.3.0",
1048
+ "get-intrinsic": "^1.2.5",
1049
+ "object-inspect": "^1.13.3"
1050
+ },
1051
+ "engines": {
1052
+ "node": ">= 0.4"
1053
+ },
1054
+ "funding": {
1055
+ "url": "https://github.com/sponsors/ljharb"
1056
+ }
1057
+ },
1058
+ "node_modules/side-channel-weakmap": {
1059
+ "version": "1.0.2",
1060
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
1061
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
1062
+ "license": "MIT",
1063
+ "dependencies": {
1064
+ "call-bound": "^1.0.2",
1065
+ "es-errors": "^1.3.0",
1066
+ "get-intrinsic": "^1.2.5",
1067
+ "object-inspect": "^1.13.3",
1068
+ "side-channel-map": "^1.0.1"
1069
+ },
1070
+ "engines": {
1071
+ "node": ">= 0.4"
1072
+ },
1073
+ "funding": {
1074
+ "url": "https://github.com/sponsors/ljharb"
1075
+ }
1076
+ },
1077
+ "node_modules/text-encoding-shim": {
1078
+ "version": "1.0.5",
1079
+ "resolved": "https://registry.npmjs.org/text-encoding-shim/-/text-encoding-shim-1.0.5.tgz",
1080
+ "integrity": "sha512-H7yYW+jRn4yhu60ygZ2f/eMhXPITRt4QSUTKzLm+eCaDsdX8avmgWpmtmHAzesjBVUTAypz9odu5RKUjX5HNYA==",
1081
+ "license": "MIT"
1082
+ },
1083
+ "node_modules/tslib": {
1084
+ "version": "1.14.1",
1085
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
1086
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
1087
+ "license": "0BSD"
1088
+ },
1089
+ "node_modules/undici-types": {
1090
+ "version": "8.3.0",
1091
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
1092
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
1093
+ "license": "MIT"
1094
+ },
1095
+ "node_modules/url": {
1096
+ "version": "0.11.4",
1097
+ "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz",
1098
+ "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==",
1099
+ "license": "MIT",
1100
+ "dependencies": {
1101
+ "punycode": "^1.4.1",
1102
+ "qs": "^6.12.3"
1103
+ },
1104
+ "engines": {
1105
+ "node": ">= 0.4"
1106
+ }
1107
+ },
1108
+ "node_modules/web-streams-polyfill": {
1109
+ "version": "4.3.0",
1110
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.3.0.tgz",
1111
+ "integrity": "sha512-/Gnggvj9oSrEvJbDyyPtAnxBt5fGQM2iWOKQNu7ie1OxDgK40iZpyV3TKaRiEzVj1oA1UxKnEy9XPXh6PW3eVw==",
1112
+ "license": "MIT",
1113
+ "workspaces": [
1114
+ "test/benchmark-test",
1115
+ "test/rollup-test",
1116
+ "test/webpack-test"
1117
+ ],
1118
+ "engines": {
1119
+ "node": ">= 8"
1120
+ }
1121
+ },
1122
+ "node_modules/wx-server-sdk": {
1123
+ "version": "4.0.2",
1124
+ "resolved": "https://registry.npmjs.org/wx-server-sdk/-/wx-server-sdk-4.0.2.tgz",
1125
+ "integrity": "sha512-dgpgWxYvOFWnQdGOb78sU+uQGELoEQSKzmJ7m8Y6eTCJKsfkdIHLMhRrdN5S5oDfAqr8Pc83jZKQeNRMZASIcg==",
1126
+ "license": "MIT",
1127
+ "dependencies": {
1128
+ "@cloudbase/node-sdk": "3.17.2",
1129
+ "json-bigint": "^1.0.0",
1130
+ "protobufjs": "^7.2.4",
1131
+ "tslib": "^1.9.3"
1132
+ }
1133
+ },
1134
+ "node_modules/xml2js": {
1135
+ "version": "0.6.2",
1136
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
1137
+ "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
1138
+ "license": "MIT",
1139
+ "dependencies": {
1140
+ "sax": ">=0.6.0",
1141
+ "xmlbuilder": "~11.0.0"
1142
+ },
1143
+ "engines": {
1144
+ "node": ">=4.0.0"
1145
+ }
1146
+ },
1147
+ "node_modules/xmlbuilder": {
1148
+ "version": "11.0.1",
1149
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
1150
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
1151
+ "license": "MIT",
1152
+ "engines": {
1153
+ "node": ">=4.0"
1154
+ }
1155
+ }
1156
+ }
1157
+ }
BeadQR/cloudfunctions/quickstartFunctions/package.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "quickstartFunctions",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "author": "",
10
+ "license": "ISC",
11
+ "dependencies": {
12
+ "wx-server-sdk": "~4.0.2"
13
+ }
14
+ }
BeadQR/docs/promo/douwei-1080-cover.png ADDED

Git LFS Details

  • SHA256: aeac46fce4028cbfc2d7bae0dad427ed5f67262582ad411a00ccb2db65f8f03b
  • Pointer size: 131 Bytes
  • Size of remote file: 320 kB
BeadQR/docs/promo/douwei-1080.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:89ba1505c4ce4aa36316841c497dc96b8997bac534bb534426c23ca96ce87dcc
3
+ size 3693122
BeadQR/docs/promo/douwei-720-cover.png ADDED

Git LFS Details

  • SHA256: bdb68c450700c1e65492e708440a29d07c9288606ca0ad7b464133052855fb2a
  • Pointer size: 131 Bytes
  • Size of remote file: 195 kB
BeadQR/docs/promo/douwei-720.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:626ea04f449f33fe29c4ec6c4aa94a2db88600cababab4f5a11b74155b4eee4e
3
+ size 1650037
BeadQR/docs/video-script.md ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 豆维 · 视频文案与分镜脚本
2
+
3
+ > 目标:用一个短视频介绍「豆维 · 可扫码的拼豆」小程序,让观众理解它是什么、为什么有趣、怎么做出来。
4
+ > 结构:提出想法 → 实现想法 → 制作例子 → 完成例子。
5
+
6
+ ---
7
+
8
+ ## 一、视频定位与调性
9
+
10
+ | 维度 | 建议 |
11
+ |------|------|
12
+ | 时长 | 主版本 60–90 秒(B站 / 朋友圈 / 公众号),另附 15–30 秒短视频版 |
13
+ | 调性 | 温暖手作 × 科技惊喜,不是冷冰冰的产品演示 |
14
+ | 核心情绪 | 「拼豆不止是一幅画,它是一封能被读到的信」 |
15
+ | 目标观众 | 拼豆爱好者、手工创作者、喜欢送独特礼物的人 |
16
+ | 画面风格 | 暖色调,木桌面,自然光;手机屏幕录制与实物特写交替出现 |
17
+
18
+ ---
19
+
20
+ ## 二、核心文案(Slogan / 一句话介绍)
21
+
22
+ 以下任选一条作为标题或结尾定版字:
23
+
24
+ 1. **豆维 · 可扫码的拼豆**
25
+ 2. **每一颗珠子,都藏着一句话。**
26
+ 3. **拼出来的二维码,扫出来的心意。**
27
+ 4. **把想说的话,拼进一幅画里。**
28
+ 5. **看得见,也读得到。**
29
+
30
+ 推荐主标题:**看得见,也读得到。——豆维,可扫码的拼豆**
31
+
32
+ ---
33
+
34
+ ## 三、主版本分镜脚本(60–90 秒)
35
+
36
+ > 旁白以第一人称「我」叙述,语气像朋友分享,不念广告稿。
37
+ > 「画面」列标注了你需要准备的素材类型;如果你已有类似镜头,直接对应即可。
38
+
39
+ | 幕 | 时间 | 画面 | 旁白 / 字幕 | 剪辑提示 |
40
+ |----|------|------|-------------|----------|
41
+ | **第一幕:提出想法** | 0:00–0:08 | 桌面上摆着几幅拼豆作品,阳光照进来。手拿起其中一幅,翻到背面,又翻回来。 | **旁白:** 我做了很多拼豆作品,挂在墙上很好看。但我总在想——它能不能,不止是一幅画? | 慢推镜头,配温暖的钢琴或指弹吉他。最后一帧画面停在作品特写。 |
42
+ | | 0:08–0:14 | 手拿起手机对准拼豆作品「扫一扫」——屏幕弹出一条消息或链接。 | **旁白:** 能不能扫一下,就能看到我想说的话? | 这是全片第一处「惊喜时刻」,音效清脆,屏幕内容要清晰可见。 |
43
+ | **第二幕:实现想法** | 0:14–0:24 | 切到手机屏幕录屏:打开豆维小程序,输入一段文字。 | **旁白:** 所以我做了「豆维」——一个可扫码的拼豆图纸生成器。输入一段话,或者一个链接。 | 屏幕录制加速 2–3 倍,打字过程快放。 |
44
+ | | 0:24–0:34 | 录屏:导入一张照片,裁切、去背景。 | **旁白:** 再选一张你喜欢的照片。它会把照片和二维码,合成一幅完整的拼豆图纸。 | 展示裁切弹窗操作,去背景效果前后对比。 |
45
+ | | 0:34–0:44 | 录屏:拖动滑块调整二维码大小和位置,切换「丰富 / 平衡 / 稳定」模式。 | **旁白:** 二维码可以放大缩小、移到任何位置。颜色会自动匹配 221 色的拼豆色库,还能选择更注重美观、还是更保证能扫出来。 | 滑块操作用慢动作展示,让观众看到图案实时变化。 |
46
+ | **第三幕:制作例子** | 0:44–0:52 | 导出的图纸打印出来,摆在桌上。开始一颗一颗摆珠子。 | **旁白:** 图纸保存下来,打印出来,就可以开始拼了。 | 延时摄影或快剪,展示摆珠过程,保留几秒真实速度的特写。 |
47
+ | | 0:52–0:58 | 熨斗烫过,拼豆板冷却,掀开,成品完成。 | **旁白:** 熨好,冷却,掀开—— | 掀开那一刻音效「叮」,画面干净利落。 |
48
+ | **第四幕:完成例子** | 0:58–1:10 | 手持成品对镜头展示,然后用手机扫描。屏幕上弹出一开始输入的那段话。 | **旁白:** 它不只是一幅画。你把手机对准它——就能读到里面藏着的话。 | 这是全片情绪最高点。扫描过程用真实速度,屏幕内容要清楚。 |
49
+ | | 1:10–1:20 | 多个成品排列展示(不同图案、不同颜色),最后画面淡出到品牌 Logo 和小程序码。 | **旁白:** 豆维,可扫码的拼豆。看得见,也读得到。 | 结尾定格 3 秒,放小程序码,方便观众扫码体验。 |
50
+
51
+ ---
52
+
53
+ ## 四、15 秒短视频版(抖音 / 视频号快闪)
54
+
55
+ | 时间 | 画面 | 字幕(无旁白,纯字幕流) |
56
+ |------|------|--------------------------|
57
+ | 0–3s | 手拿起拼豆作品 → 手机扫描 → 屏幕弹出消息 | 拼豆里,藏着一条消息 |
58
+ | 3–8s | 快剪:输入文字 → 导入照片 → 图纸生成 → 摆珠 | 豆维:输入一句话,选一张图,生成拼豆图纸 |
59
+ | 8–13s | 熨烫 → 掀开 → 成品再扫一次 | 拼好,烫平,扫一下—— |
60
+ | 13–15s | 品牌 Logo + 小程序码 | 消息就出来了。豆维 · 可扫码的拼豆 |
61
+
62
+ > 快闪版不需要旁白,靠音乐节奏 + 字幕推进。音乐选节奏感强的电子或 Lo-Fi。
63
+
64
+ ---
65
+
66
+ ## 五、30 秒版本(朋友圈 / 公众号内嵌)
67
+
68
+ 节奏比 15 秒版稍慢,保留 4 个关键节点:
69
+
70
+ 1. **悬念**(5s):拿起作品 → 扫码 → 弹出内容
71
+ 2. **怎么做**(10s):小程序操作快剪(输入 → 导图 → 调整 → 导出)
72
+ 3. **手作过程**(8s):摆珠延时 + 熨烫 + 掀开
73
+ 4. **结尾**(7s):成品特写 + 再扫一次 + 定版
74
+
75
+ 配乐:轻快指弹吉他或 Lo-Fi,音量压低,让旁白清晰。
76
+
77
+ ---
78
+
79
+ ## 六、素材清单
80
+
81
+ 拍摄前对照这份清单,确认已拍到以下镜头(√ 为已有,× 为待补):
82
+
83
+ | 素材 | 用途 | 状态 |
84
+ |------|------|------|
85
+ | 多幅完成的拼豆作品摆拍(正面 / 斜角 / 背面) | 开头悬念、结尾展示 | ☐ |
86
+ | 手机扫描拼豆作品并弹出内容的实拍 | 全片核心「惊喜时刻」,需拍 2–3 次 | ☐ |
87
+ | 豆维小程序屏幕录制:输入文字、导入图片、裁切、调 QR 位置、切模式 | 第二幕 | ☐ |
88
+ | 图纸导出 / 打印的镜头 | 第三幕开头 | ☐ |
89
+ | 摆珠延时或快剪素材 | 第三幕 | ☐ |
90
+ | 熨斗烫拼豆 + 掀开的特写 | 第三幕高潮 | ☐ |
91
+ | 成品多角度特写(自然光 / 桌面) | 结尾 | ☐ |
92
+
93
+ ---
94
+
95
+ ## 七、剪辑建议
96
+
97
+ - **转场**:幕与幕之间用干净的硬切或快速模糊,不要花哨转场。产品视频要显得克制。
98
+ - **音乐**:主版本选温暖的原声吉他或钢琴;快闪版选节奏感强的 Lo-Fi / 电子。全片一首歌,不要中途换。
99
+ - **节奏**:第三幕(摆珠)可以稍慢,让观众感受手作质感;第二幕(录屏)加速推进。
100
+ - **字幕**:全程加简体中文字幕,关键词用品牌色 `#416d62`(深绿)高亮。
101
+ - **结尾**:小程序码至少停留 3 秒,让观众来得及扫码。
102
+
103
+ ---
104
+
105
+ ## 八、旁白全文(主版本,可直接朗读)
106
+
107
+ > 我做了很多拼豆作品,挂在墙上很好看。但我总在想——它能不能,不止是一幅画?能不能扫一下,就能看到我想说的话?
108
+ >
109
+ > 所以我做了「豆维」。输入一段话,或者一个链接,再选一张你喜欢的照片。它会把照片和二维码,合成一幅完整的拼豆图纸。二维码可以放大缩小、移到任何位置,颜色会自动匹配 221 色的拼豆色库,还能选择更注重美观、还是更保证能扫出来。
110
+ >
111
+ > 图纸保存下来,打印出来,就可以开始拼了。熨好,冷却,掀开——
112
+ >
113
+ > 它不只是一幅画。你把手机对准它,就能读到里面藏着的话。
114
+ >
115
+ > 豆维,可扫码的拼豆。看得见,也读得到。
116
+
BeadQR/docs/审核驳回处理-文案与步骤.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 审核驳回处理:深度合成误判 — 文案与步骤
2
+
3
+ > 驳回原因(2026-09-30):小程序服务内容涉及深度合成技术(如 AI 问答、AI 换脸视频、AI 绘画等),属个人主体尚未开放服务类目,建议申请企业主体类型小程序。
4
+
5
+ ## 结论
6
+
7
+ 代码里没有任何 AI 功能:文字是标准二维码编码;"照片转图纸"是确定性本地图像处理(按网格取平均色 → 映射到 221 种固定色号),不用任何模型、不生成新图像。被判"深度合成"大概率是**后台简介 / 服务类目 / 服务内容声明的表述**或审核员看到"照片→生成图纸"产生的误判。
8
+
9
+ ## 路径 A(推荐,最快):修正表述后重新提审
10
+
11
+ ### 1. 检查后台三处(mp.weixin.qq.com)
12
+
13
+ | 位置 | 检查项 |
14
+ | --- | --- |
15
+ | 服务类目 | 改为 **工具 - 图片处理**(个人主体可选);删除任何 AI / 深度合成相关类目 |
16
+ | 服务内容声明 / 提审问卷 | 凡问"是否提供 AI 生成内容、AI 问答、AI 绘画、深度合成"一律选 **否** |
17
+ | 小程序简介 | 换成下方"新简介",去掉"生成器 / 一键生成 / 智能"等字眼 |
18
+
19
+ ### 2. 新简介(约 90 字,上限 120)
20
+
21
+ ```
22
+ 手工拼豆图纸工具:输入一句话或选一张照片,按 MARD 标准色号排成拼豆图纸,拼好微信一扫就能读出内容。支持 A4 矢量 PDF 与图片导出、用量清单。照片仅在本地做取色处理,并经微信内容安全检测。
23
+ ```
24
+
25
+ ### 3. 版本描述 / 审核备注(重新提审时填写)
26
+
27
+ ```
28
+ 本小程序为拼豆手工图纸工具,不使用任何人工智能或深度合成技术,无 AIGC 功能:
29
+ 1. "照片转图纸"为确定性本地图像处理:将用户上传照片按网格取平均色,再映射到固定的 221 种拼豆标准色号(色彩量化 + 最近邻查表),不生成、不合成任何新图像内容,不涉及人脸或生物特征编辑;
30
+ 2. 文字功能是将文本编码为标准二维码点阵,非内容生成;
31
+ 3. 所有处理均在用户手机本地完成,且已接入微信内容安全接口(security.msgSecCheck / imgSecCheck),检测未通过时禁止生成与导出(fail-closed)。
32
+ 请按"工具-图片处理"类目审核。
33
+ ```
34
+
35
+ ## 路径 B:申诉
36
+
37
+ 若不想改简介,可直接对本次审核结果发起申诉(审核结果页 → 申诉),粘贴下文:
38
+
39
+ ```
40
+ 本小程序为拼豆手工图纸工具,不涉及深度合成技术:
41
+ (1) 不使用 AI 模型 / 神经网络,无 AI 问答、AI 绘画、AI 换脸等 AIGC 功能;
42
+ (2) 照片功能仅做确定性色彩量化:按网格取平均色并映射到 221 种固定标准色号,属于常规图像处理,不生成新图像、不做人脸或生物特征编辑;
43
+ (3) 文字功能为标准二维码编码;
44
+ (4) 已接入微信内容安全检测(msgSecCheck / imgSecCheck),检测未通过不能生成或导出。
45
+ 恳请复核,建议按"工具-图片处理"类目审核。
46
+ ```
47
+
48
+ 申诉未通过时:修改简介与类目后按路径 A 重新提审(改了表述基本都能过)。
49
+
50
+ ## 路径 C:仅当二次被拒或想保留"生成"叙事
51
+
52
+ 1. **隐藏照片功能**(只保留文字 → 二维码图纸,纯编码无争议)——我可以直接改代码;
53
+ 2. **升级主体**:注册个体工商户 / 企业 → 主体变更 → 申请深度合成相关类目 + 算法备案。仅在以后真要做 AI 功能时才值得,当前工具用不到。
54
+
55
+ ## 顺手可做的降风险小改动(可选)
56
+
57
+ - 首页徽标 `MARD 拼豆图纸生成器` → `MARD 拼豆图纸工具`
58
+ - 分享标题 `豆维 · 可扫码的拼豆图纸生成器` → `豆维 · 可扫码的拼豆图纸`
BeadQR/miniprogram/app.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 豆维 · 可扫码的拼豆
2
+ App({
3
+ onLaunch() {
4
+ if (!wx.cloud) {
5
+ console.error('当前基础库不支持云开发,内容安全检测不可用')
6
+ return
7
+ }
8
+ wx.cloud.init({
9
+ env: 'cloud1-d6gthxado7f6c9d14',
10
+ traceUser: true,
11
+ })
12
+ },
13
+ globalData: {},
14
+ })
BeadQR/miniprogram/app.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "pages": [
3
+ "pages/index/index"
4
+ ],
5
+ "window": {
6
+ "backgroundColor": "#f6f5f1",
7
+ "backgroundTextStyle": "dark",
8
+ "navigationBarBackgroundColor": "#f6f5f1",
9
+ "navigationBarTitleText": "豆维 · 可扫码的拼豆",
10
+ "navigationBarTextStyle": "black"
11
+ },
12
+ "usingComponents": {
13
+ "privacy-popup": "/components/privacy-popup/index"
14
+ },
15
+ "__usePrivacyCheck__": true,
16
+ "sitemapLocation": "sitemap.json",
17
+ "style": "v2",
18
+ "lazyCodeLoading": "requiredComponents"
19
+ }
BeadQR/miniprogram/app.wxss ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* 豆维 · 设计变量 */
2
+ page {
3
+ --ink: #16211f;
4
+ --ink-2: #3a4643;
5
+ --muted: #6f7a76;
6
+ --faint: #a4aca8;
7
+ --bg: #f6f5f1;
8
+ --card: #ffffff;
9
+ --line: #ebe8e1;
10
+ --brand: #0f766e;
11
+ --brand-2: #14b8a6;
12
+ --brand-soft: #e6f4f1;
13
+ --brand-deep: #0f3d3a;
14
+ --accent: #ff7a45;
15
+ --accent-soft: #fff1ea;
16
+ --danger: #e5484d;
17
+ --danger-soft: #fdecec;
18
+ --ok: #12a150;
19
+ --ok-soft: #e7f6ed;
20
+ --warn: #c77700;
21
+ --warn-soft: #fff5e0;
22
+ --r-s: 16rpx;
23
+ --r-m: 24rpx;
24
+ --r-l: 36rpx;
25
+ --shadow: 0 8rpx 32rpx rgba(22, 33, 31, 0.06);
26
+
27
+ background: var(--bg);
28
+ color: var(--ink);
29
+ font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif;
30
+ font-size: 28rpx;
31
+ line-height: 1.55;
32
+ }
33
+
34
+ view, text, textarea, input, button, scroll-view, canvas, image { box-sizing: border-box; }
35
+
36
+ button { padding: 0; margin: 0; background: none; border: 0; border-radius: 0; line-height: inherit; font-size: inherit; color: inherit; text-align: center; }
37
+ button::after { border: 0; }
BeadQR/miniprogram/assets/icon.png ADDED
BeadQR/miniprogram/assets/miniprogram-code.png ADDED
BeadQR/miniprogram/assets/shield.svg ADDED
BeadQR/miniprogram/components/privacy-popup/index.js ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 隐私保护指引弹窗:用户首次调用相册/相机/保存到相册等隐私接口时由微信触发
2
+ Component({
3
+ data: { visible: false, contractName: '《豆维隐私保护指引》' },
4
+
5
+ lifetimes: {
6
+ attached() {
7
+ if (!wx.onNeedPrivacyAuthorization) return
8
+ this._listener = (resolve) => {
9
+ this._resolve = resolve
10
+ this.setData({ visible: true })
11
+ }
12
+ wx.onNeedPrivacyAuthorization(this._listener)
13
+ if (wx.getPrivacySetting) {
14
+ wx.getPrivacySetting({
15
+ success: (res) => {
16
+ if (res.privacyContractName) this.setData({ contractName: res.privacyContractName })
17
+ },
18
+ })
19
+ }
20
+ },
21
+ },
22
+
23
+ methods: {
24
+ openContract() {
25
+ if (wx.openPrivacyContract) wx.openPrivacyContract({})
26
+ },
27
+ handleAgree() {
28
+ if (this._resolve) this._resolve({ buttonId: 'privacy-agree-btn', event: 'agree' })
29
+ this._resolve = null
30
+ this.setData({ visible: false })
31
+ },
32
+ handleDisagree() {
33
+ if (this._resolve) this._resolve({ event: 'disagree' })
34
+ this._resolve = null
35
+ this.setData({ visible: false })
36
+ wx.showToast({ title: '未同意将无法选择照片或保存图纸', icon: 'none' })
37
+ },
38
+ noop() {},
39
+ },
40
+ })
BeadQR/miniprogram/components/privacy-popup/index.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "component": true,
3
+ "usingComponents": {}
4
+ }
BeadQR/miniprogram/components/privacy-popup/index.wxml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <view wx:if="{{visible}}" class="mask" catchtouchmove="noop">
2
+ <view class="sheet">
3
+ <view class="title">隐私保护提示</view>
4
+ <view class="desc">
5
+ 为了帮你把照片做成拼豆图纸,豆维需要读取你选择的照片、保存图纸到相册。照片仅在本机处理,送检的压缩副本会在内容安全检测后立即删除。使用前请阅读
6
+ <text class="link" bindtap="openContract">{{contractName}}</text>。
7
+ </view>
8
+ <view class="actions">
9
+ <button class="btn ghost" bindtap="handleDisagree">不同意</button>
10
+ <button id="privacy-agree-btn" class="btn primary" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="handleAgree">同意并继续</button>
11
+ </view>
12
+ </view>
13
+ </view>
BeadQR/miniprogram/components/privacy-popup/index.wxss ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .mask { position: fixed; inset: 0; z-index: 999; background: rgba(15, 23, 22, 0.55); display: flex; align-items: flex-end; }
2
+ .sheet { width: 100%; background: #fff; border-radius: 40rpx 40rpx 0 0; padding: 48rpx 40rpx calc(40rpx + env(safe-area-inset-bottom)); box-sizing: border-box; }
3
+ .title { font-size: 36rpx; font-weight: 700; color: #1d2b2a; margin-bottom: 20rpx; }
4
+ .desc { font-size: 28rpx; line-height: 1.7; color: #55615c; }
5
+ .link { color: #0f766e; font-weight: 600; }
6
+ .actions { display: flex; gap: 20rpx; margin-top: 40rpx; }
7
+ .btn { flex: 1; height: 92rpx; line-height: 92rpx; border-radius: 999rpx; font-size: 30rpx; font-weight: 600; margin: 0 !important; padding: 0 !important; width: auto !important; }
8
+ .btn::after { border: 0; }
9
+ .ghost { background: #f1f3f2; color: #55615c; }
10
+ .primary { background: #0f766e; color: #fff; }
BeadQR/miniprogram/pages/index/index.js ADDED
@@ -0,0 +1,1073 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 豆维主页面
2
+ // 大数据(色格、图片、画布节点)一律挂在 this._ 上,不进 setData,保证滑动/输入流畅
3
+ const { MATCH_MODES, PRESETS, preparePalette } = require('../../utils/palette')
4
+ const { makeQr, createPlacement, SAMPLE_TEXT } = require('../../utils/qr')
5
+ const { getImageDimensions, drawCropped, sampleImage, removeSimpleBackground } = require('../../utils/imaging')
6
+ const { computeArtwork } = require('../../utils/engine')
7
+ const { selfCheck } = require('../../utils/scancheck')
8
+ const { renderPreview, patternPageLayout, drawPatternPage, buildInfoPages, drawWatermark } = require('../../utils/pattern')
9
+ const { PdfDoc, planPatternPages, addPatternPages, addImagePage } = require('../../utils/pdf')
10
+ const security = require('../../utils/security')
11
+ const { createIntro } = require('../../utils/intro')
12
+
13
+ const LEVELS = [
14
+ { id: 'M', label: 'M', hint: '格子少' },
15
+ { id: 'Q', label: 'Q', hint: '均衡' },
16
+ { id: 'H', label: 'H', hint: '最耐磨' },
17
+ ]
18
+ const QUIET_OPTIONS = [
19
+ { id: 0, label: '无' },
20
+ { id: 1, label: '1 格' },
21
+ { id: 2, label: '2 格' },
22
+ ]
23
+ const BEAD_STYLES = [
24
+ { id: 'square', label: '方豆' },
25
+ { id: 'round', label: '圆豆' },
26
+ { id: 'hollow', label: '中空豆' },
27
+ ]
28
+ const GRID_COLORS = ['#0f766e', '#16211f', '#e5484d', '#3b82f6', '#a855f7']
29
+ const POSITIONS = [0, 50, 100]
30
+ const MAX_CANVAS_SIDE = 8000
31
+ const MAX_CANVAS_PIXELS = 16000000
32
+ const CROP_SIZE = 640
33
+ const FILE_PREFIX = 'douwei-'
34
+
35
+ function fitScale(w, h) {
36
+ return Math.min(1, MAX_CANVAS_SIDE / w, MAX_CANVAS_SIDE / h, Math.sqrt(MAX_CANVAS_PIXELS / (w * h)))
37
+ }
38
+
39
+ function formatNumber(n) {
40
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
41
+ }
42
+
43
+ Page({
44
+ data: {
45
+ // 导航
46
+ statusBarHeight: 20,
47
+ navHeight: 44,
48
+ navSolid: false,
49
+ // 1. 内容
50
+ text: '',
51
+ textStatus: 'idle', // idle | checking | pass | risky | error
52
+ scanImportLoading: false,
53
+ // 2. 照片
54
+ hasImage: false,
55
+ imageThumb: '',
56
+ imageMeta: '',
57
+ removeBackground: false,
58
+ bgTolerance: 22,
59
+ // 3. 效果
60
+ qrScale: 100,
61
+ posX: 50,
62
+ posY: 50,
63
+ positions: POSITIONS,
64
+ modeId: 'balanced',
65
+ matchModes: MATCH_MODES,
66
+ advancedOpen: false,
67
+ fineTuneOpen: false,
68
+ presets: PRESETS.map((p) => ({ id: p.id, short: p.short, detail: p.detail })),
69
+ paletteId: '221',
70
+ coherence: 40,
71
+ cleanup: true,
72
+ levels: LEVELS,
73
+ level: 'H',
74
+ quietOptions: QUIET_OPTIONS,
75
+ quiet: 1,
76
+ // 预览与统计
77
+ generating: false,
78
+ isSample: true,
79
+ showIntro: true, // 无内容时播放动态演示,写下内容后切回真实预览
80
+ textFocus: false,
81
+ gridText: '—',
82
+ beadsText: '—',
83
+ colorsText: '—',
84
+ qrGridText: '',
85
+ scanState: 'idle', // idle | checking | ok | fail
86
+ usage: [],
87
+ usageMore: 0,
88
+ usageExpanded: false,
89
+ usageAll: [],
90
+ // 裁切
91
+ cropVisible: false,
92
+ cropCheck: 'idle', // checking | pass | risky | error
93
+ draftFocusX: 50,
94
+ draftFocusY: 50,
95
+ draftZoom: 100,
96
+ draftFlipH: false,
97
+ draftFlipV: false,
98
+ draftRemoveBg: false,
99
+ draftBgTolerance: 22,
100
+ // 导出
101
+ exportVisible: false,
102
+ exportFormat: 'pdf',
103
+ exportLayout: 'combined',
104
+ exportQuality: 'hd',
105
+ beadStyles: BEAD_STYLES,
106
+ beadStyle: 'square',
107
+ gridEnabled: true,
108
+ gridInterval: 10,
109
+ gridColors: GRID_COLORS,
110
+ gridColor: '#0f766e',
111
+ watermark: '',
112
+ watermarkStatus: 'idle',
113
+ exporting: false,
114
+ exportStep: '',
115
+ exportResult: null, // { kind, files: [{ name, path }] }
116
+ },
117
+
118
+ // —— 生命周期 ——
119
+
120
+ onLoad() {
121
+ const win = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()
122
+ let navHeight = 44
123
+ try {
124
+ const capsule = wx.getMenuButtonBoundingClientRect()
125
+ if (capsule && capsule.top) navHeight = (capsule.top - win.statusBarHeight) * 2 + capsule.height
126
+ } catch (e) { /* 使用默认高度 */ }
127
+ this._dpr = Math.min(3, win.pixelRatio || 2)
128
+ this._timers = {}
129
+ this._nodes = {}
130
+ this._token = 0
131
+ this.setData({ statusBarHeight: win.statusBarHeight || 20, navHeight })
132
+ },
133
+
134
+ onReady() {
135
+ wx.createSelectorQuery().in(this)
136
+ .select('#preview').fields({ node: true, size: true })
137
+ .select('#work').fields({ node: true })
138
+ .select('#security').fields({ node: true })
139
+ .exec((res) => {
140
+ this._nodes.preview = res[0] && res[0].node
141
+ this._previewCss = (res[0] && res[0].width) || 300
142
+ this._nodes.work = res[1] && res[1].node
143
+ this._nodes.security = res[2] && res[2].node
144
+ this.loadMpCode()
145
+ this.schedule(0)
146
+ })
147
+ },
148
+
149
+ onUnload() {
150
+ Object.keys(this._timers).forEach((k) => clearTimeout(this._timers[k]))
151
+ this.stopIntro()
152
+ },
153
+
154
+ onShow() {
155
+ this._hidden = false
156
+ if (this._nodes && this._nodes.preview) this.syncIntro()
157
+ },
158
+
159
+ onHide() {
160
+ this._hidden = true
161
+ this.pauseIntro()
162
+ },
163
+
164
+ onPageScroll(e) {
165
+ const solid = e.scrollTop > 40
166
+ if (solid !== this.data.navSolid) this.setData({ navSolid: solid })
167
+ // 演示区滚出屏幕后暂停动画,省电
168
+ const away = e.scrollTop > 650
169
+ if (away !== this._scrolledAway) {
170
+ this._scrolledAway = away
171
+ this.syncIntro()
172
+ }
173
+ },
174
+
175
+ // 分享的是小程序页面卡片,不携带用户内容
176
+ onShareAppMessage() {
177
+ return { title: '把一句话藏进拼豆里,微信一扫就能读出来', path: '/pages/index/index' }
178
+ },
179
+ onShareTimeline() {
180
+ return { title: '豆维 · 可扫码的拼豆图纸生成器' }
181
+ },
182
+
183
+ loadMpCode() {
184
+ if (!this._nodes.work) return
185
+ const image = this._nodes.work.createImage()
186
+ image.onload = () => { this._mpCode = image }
187
+ image.onerror = () => { this._mpCode = null }
188
+ image.src = '/assets/miniprogram-code.png'
189
+ },
190
+
191
+ debounce(key, fn, wait) {
192
+ clearTimeout(this._timers[key])
193
+ this._timers[key] = setTimeout(fn, wait)
194
+ },
195
+
196
+ noop() {},
197
+
198
+ openPrivacy() {
199
+ if (wx.openPrivacyContract) {
200
+ wx.openPrivacyContract({ fail: () => wx.showToast({ title: '请在微信内查看隐私保护指引', icon: 'none' }) })
201
+ }
202
+ },
203
+
204
+ showRules() {
205
+ wx.showModal({
206
+ title: '内容规范',
207
+ content: '二维码内容、水印和照片都会经过微信内容安全检测。请勿输入或上传违法违规、色情低俗、暴力恐怖、侵犯他人权益等内容;检测未通过或检测异常时无法生成与导出。如发现不良内容,可通过页面底部“投诉与反馈”告诉我们。',
208
+ showCancel: false,
209
+ confirmText: '我知道了',
210
+ })
211
+ },
212
+
213
+ // —— 生成管线 ——
214
+
215
+ schedule(wait = 120) {
216
+ this.debounce('run', () => this.run(), wait)
217
+ },
218
+
219
+ run() {
220
+ if (!this._nodes.preview || !this._nodes.work) return
221
+ // 没写内容、也没选照片:示例二维码没有意义,改为播放动态演示
222
+ if (!this.data.text.trim() && !this._source) {
223
+ this._token += 1
224
+ this.setData({
225
+ showIntro: true, generating: false, isSample: true, scanState: 'idle', usage: [], usageAll: [], usageMore: 0,
226
+ }, () => this.syncIntro())
227
+ return
228
+ }
229
+ this.stopIntro()
230
+ if (this.data.showIntro) this.setData({ showIntro: false })
231
+ const token = ++this._token
232
+ this.setData({ generating: true }, () => {
233
+ // 先让“生成中”状态渲染一帧,再做同步计算
234
+ setTimeout(() => {
235
+ if (token !== this._token) return
236
+ try {
237
+ this.compute()
238
+ this.afterCompute(token)
239
+ } catch (error) {
240
+ console.error('生成失败', error)
241
+ this.setData({ generating: false })
242
+ wx.showToast({ title: '内容太长或图片异常,请调整后重试', icon: 'none' })
243
+ }
244
+ }, 16)
245
+ })
246
+ },
247
+
248
+ compute() {
249
+ const d = this.data
250
+ const text = d.text.trim()
251
+ const qrKey = `${text}|${d.level}`
252
+ if (!this._qr || this._qrKey !== qrKey) {
253
+ this._qr = makeQr(text, d.level)
254
+ this._qrKey = qrKey
255
+ }
256
+ const src = this._source
257
+ const placement = createPlacement(this._qr.size, {
258
+ scale: d.qrScale, posX: d.posX, posY: d.posY, quiet: d.quiet, hasImage: Boolean(src),
259
+ })
260
+ let base = null
261
+ if (src) {
262
+ const c = src.crop
263
+ const baseKey = `${placement.size}|${c.focusX}|${c.focusY}|${c.zoom}|${c.flipH}|${c.flipV}|${d.removeBackground}|${d.bgTolerance}`
264
+ if (this._baseKey !== baseKey) {
265
+ const sampled = sampleImage(this._nodes.work, src.image, placement.size, c)
266
+ if (d.removeBackground) sampled.mask = removeSimpleBackground(sampled.rgb, placement.size, d.bgTolerance)
267
+ this._base = sampled
268
+ this._baseKey = baseKey
269
+ }
270
+ base = this._base
271
+ }
272
+ const pal = preparePalette(d.paletteId)
273
+ this._artwork = computeArtwork(base, this._qr, placement, pal, {
274
+ modeId: d.modeId, coherence: d.coherence, cleanup: d.cleanup,
275
+ })
276
+ },
277
+
278
+ afterCompute(token) {
279
+ const art = this._artwork
280
+ this.drawPreview()
281
+ const usageAll = art.usage.map((u) => ({ id: u.id, hex: u.hex, count: u.count }))
282
+ this.setData({
283
+ generating: false,
284
+ isSample: this._qr.isSample,
285
+ gridText: `${art.size}×${art.size}`,
286
+ beadsText: formatNumber(art.beads),
287
+ colorsText: String(art.usage.length),
288
+ qrGridText: `${this._qr.size}×${this._qr.size}`,
289
+ usage: usageAll.slice(0, 12),
290
+ usageAll: this.data.usageExpanded ? usageAll : [],
291
+ usageMore: Math.max(0, usageAll.length - 12),
292
+ scanState: 'checking',
293
+ })
294
+ this._usageAll = usageAll
295
+ // 扫码自检放到下一轮,避免阻塞预览出图
296
+ this.debounce('scan', () => {
297
+ if (token !== this._token) return
298
+ const expected = this.data.text.trim() || SAMPLE_TEXT
299
+ const result = selfCheck(art, expected)
300
+ if (token === this._token) this.setData({ scanState: result.ok ? 'ok' : 'fail' })
301
+ }, 60)
302
+ },
303
+
304
+ drawPreview() {
305
+ const node = this._nodes.preview
306
+ if (!node || !this._artwork) return
307
+ const px = Math.round(this._previewCss * this._dpr)
308
+ renderPreview(node, this._artwork, px, this.data.beadStyle)
309
+ },
310
+
311
+ // —— 动态演示 ——
312
+
313
+ syncIntro() {
314
+ const should = this.data.showIntro && !this._hidden && !this._scrolledAway && !this.data.cropVisible
315
+ if (should) this.startIntro()
316
+ else this.pauseIntro()
317
+ },
318
+
319
+ startIntro() {
320
+ if (this._introPlayer) return
321
+ const node = this._nodes.preview
322
+ if (!node || !this._nodes.work) return
323
+ if (!this._intro) {
324
+ try {
325
+ this._intro = createIntro({ workCanvas: this._nodes.work })
326
+ } catch (error) {
327
+ console.error('演示动画初始化失败', error)
328
+ return
329
+ }
330
+ }
331
+ const px = Math.round(this._previewCss * this._dpr)
332
+ node.width = px
333
+ node.height = px
334
+ const ctx = node.getContext('2d')
335
+ const raf = node.requestAnimationFrame ? (f) => node.requestAnimationFrame(f) : (f) => setTimeout(f, 16)
336
+ const caf = node.cancelAnimationFrame ? (id) => node.cancelAnimationFrame(id) : (id) => clearTimeout(id)
337
+ const player = { start: Date.now() - (this._introElapsed || 0), last: 0, id: 0, caf }
338
+ const tick = () => {
339
+ if (this._introPlayer !== player) return
340
+ const now = Date.now()
341
+ if (now - player.last >= 30) { // 约 30 帧/秒,流畅且省电
342
+ player.last = now
343
+ this._introElapsed = now - player.start
344
+ this._intro.draw(ctx, this._introElapsed / 1000, px)
345
+ }
346
+ player.id = raf(tick)
347
+ }
348
+ this._introPlayer = player
349
+ tick()
350
+ },
351
+
352
+ pauseIntro() {
353
+ const player = this._introPlayer
354
+ if (!player) return
355
+ this._introPlayer = null
356
+ player.caf(player.id)
357
+ },
358
+
359
+ stopIntro() {
360
+ this.pauseIntro()
361
+ this._introElapsed = 0
362
+ },
363
+
364
+ replayIntro() {
365
+ if (!this.data.showIntro) return
366
+ this.stopIntro()
367
+ this.syncIntro()
368
+ },
369
+
370
+ // 引导用户开始:滚到第 1 步并弹出键盘
371
+ startCreate() {
372
+ wx.pageScrollTo({
373
+ selector: '#step1',
374
+ offsetTop: -(this.data.statusBarHeight + this.data.navHeight + 12),
375
+ duration: 300,
376
+ })
377
+ setTimeout(() => this.setData({ textFocus: true }), 320)
378
+ },
379
+
380
+ onTextBlur() {
381
+ if (this.data.textFocus) this.setData({ textFocus: false })
382
+ },
383
+
384
+ // —— 1. 内容 ——
385
+
386
+ onTextInput(e) {
387
+ const text = e.detail.value
388
+ this.setData({ text, textStatus: text.trim() ? 'checking' : 'idle' })
389
+ this.schedule(220)
390
+ this.debounce('textCheck', () => this.verifyText(text), 650)
391
+ },
392
+
393
+ clearText() {
394
+ this.setData({ text: '', textStatus: 'idle' })
395
+ this.schedule(0)
396
+ },
397
+
398
+ verifyText(raw) {
399
+ const value = String(raw || '').trim()
400
+ if (!value) return
401
+ security.checkText(value, 2).then(({ status }) => {
402
+ if (this.data.text.trim() !== value) return
403
+ this.setData({ textStatus: status })
404
+ if (status === 'risky') wx.showToast({ title: security.MESSAGES.risky, icon: 'none' })
405
+ })
406
+ },
407
+
408
+ scanImport() {
409
+ wx.scanCode({
410
+ onlyFromCamera: false,
411
+ scanType: ['qrCode'],
412
+ success: (res) => {
413
+ const decoded = String(res.result || '').trim().slice(0, 120)
414
+ if (!decoded) { wx.showToast({ title: '二维码里没有内容', icon: 'none' }); return }
415
+ this.setData({ scanImportLoading: true })
416
+ // 扫码得到的内容同样先过内容安全检测,通过后才填入
417
+ security.checkText(decoded, 2).then(({ status }) => {
418
+ this.setData({ scanImportLoading: false })
419
+ if (status !== 'pass') { wx.showToast({ title: security.MESSAGES[status], icon: 'none' }); return }
420
+ this.setData({ text: decoded, textStatus: 'pass' })
421
+ this.schedule(0)
422
+ wx.showToast({ title: '已导入二维码内容', icon: 'none' })
423
+ })
424
+ },
425
+ fail: (err) => {
426
+ if (err && /cancel/i.test(err.errMsg || '')) return
427
+ wx.showToast({ title: '没有识别到二维码', icon: 'none' })
428
+ },
429
+ })
430
+ },
431
+
432
+ // —— 2. 照片 ——
433
+
434
+ loadImage(path, info) {
435
+ return new Promise((resolve, reject) => {
436
+ const image = this._nodes.work.createImage()
437
+ image.onload = () => {
438
+ if (info && info.width) { image._w = info.width; image._h = info.height }
439
+ resolve(image)
440
+ }
441
+ image.onerror = () => reject(new Error('IMAGE_LOAD_FAILED'))
442
+ image.src = path
443
+ })
444
+ },
445
+
446
+ chooseImage() {
447
+ wx.chooseMedia({
448
+ count: 1,
449
+ mediaType: ['image'],
450
+ sizeType: ['compressed'],
451
+ sourceType: ['album', 'camera'],
452
+ success: (res) => {
453
+ const file = res.tempFiles && res.tempFiles[0]
454
+ if (file && file.tempFilePath) this.prepareImage(file.tempFilePath)
455
+ },
456
+ fail: (err) => {
457
+ if (err && /cancel/i.test(err.errMsg || '')) return
458
+ wx.showToast({ title: '没有选到照片', icon: 'none' })
459
+ },
460
+ })
461
+ },
462
+
463
+ prepareImage(path) {
464
+ wx.showLoading({ title: '读取照片…', mask: true })
465
+ new Promise((resolve) => {
466
+ wx.getImageInfo({ src: path, success: resolve, fail: () => resolve(null) })
467
+ })
468
+ .then((info) => this.loadImage(path, info).then((image) => ({ image, info })))
469
+ .then(({ image }) => {
470
+ wx.hideLoading()
471
+ this._pending = { image, path }
472
+ // 选图后立即在后台做内容安全检测;同时打开构图编辑,检测通过后才能使用
473
+ this.startImageCheck(this._pending)
474
+ this.openCrop(true)
475
+ })
476
+ .catch(() => {
477
+ wx.hideLoading()
478
+ wx.showToast({ title: '照片读取失败,请换一张 JPG / PNG', icon: 'none' })
479
+ })
480
+ },
481
+
482
+ startImageCheck(pending) {
483
+ pending.status = 'checking'
484
+ this.setData({ cropCheck: 'checking' })
485
+ pending.check = security.checkImage(pending.path, pending.image, this._nodes.security).then(({ status }) => {
486
+ pending.status = status
487
+ if (this._pending === pending) {
488
+ this.setData({ cropCheck: status })
489
+ if (pending.applyWhenPassed && status === 'pass') this.applyCrop()
490
+ pending.applyWhenPassed = false
491
+ }
492
+ return status
493
+ })
494
+ },
495
+
496
+ retryImageCheck() {
497
+ if (this._pending) this.startImageCheck(this._pending)
498
+ },
499
+
500
+ editImage() {
501
+ if (!this._source) return
502
+ this._pending = { image: this._source.image, path: this._source.path, status: 'pass' }
503
+ this.setData({ cropCheck: 'pass' })
504
+ this.openCrop(false)
505
+ },
506
+
507
+ removeImage() {
508
+ this._source = null
509
+ this._base = null
510
+ this._baseKey = ''
511
+ this.setData({ hasImage: false, imageThumb: '', imageMeta: '', removeBackground: false })
512
+ this.schedule(0)
513
+ },
514
+
515
+ // —— 裁切编辑 ——
516
+
517
+ openCrop(isNew) {
518
+ const crop = !isNew && this._source ? this._source.crop : null
519
+ this.pauseIntro()
520
+ this.setData({
521
+ cropVisible: true,
522
+ draftFocusX: crop ? crop.focusX : 50,
523
+ draftFocusY: crop ? crop.focusY : 50,
524
+ draftZoom: crop ? Math.round(crop.zoom * 100) : 100,
525
+ draftFlipH: crop ? crop.flipH : false,
526
+ draftFlipV: crop ? crop.flipV : false,
527
+ draftRemoveBg: isNew ? false : this.data.removeBackground,
528
+ draftBgTolerance: isNew ? 22 : this.data.bgTolerance,
529
+ }, () => {
530
+ wx.createSelectorQuery().in(this)
531
+ .select('#crop').fields({ node: true, size: true })
532
+ .select('#crop').boundingClientRect()
533
+ .exec((res) => {
534
+ this._nodes.crop = res[0] && res[0].node
535
+ this._cropCss = (res[0] && res[0].width) || 300
536
+ this._cropRect = res[1] || { left: 0, top: 0 }
537
+ this.drawCrop()
538
+ })
539
+ })
540
+ },
541
+
542
+ drawCrop() {
543
+ const node = this._nodes.crop
544
+ const pending = this._pending
545
+ if (!node || !pending) return
546
+ node.width = CROP_SIZE
547
+ node.height = CROP_SIZE
548
+ const ctx = node.getContext('2d')
549
+ const d = this.data
550
+ ctx.imageSmoothingEnabled = true
551
+ drawCropped(ctx, pending.image, CROP_SIZE, CROP_SIZE, {
552
+ focusX: d.draftFocusX, focusY: d.draftFocusY, zoom: d.draftZoom / 100, flipH: d.draftFlipH, flipV: d.draftFlipV,
553
+ })
554
+ // 三分线,仅辅助构图
555
+ ctx.strokeStyle = 'rgba(255,255,255,0.5)'
556
+ ctx.lineWidth = 1.5
557
+ ctx.beginPath()
558
+ for (let i = 1; i < 3; i += 1) {
559
+ const p = Math.round(CROP_SIZE * i / 3) + 0.5
560
+ ctx.moveTo(p, 0); ctx.lineTo(p, CROP_SIZE)
561
+ ctx.moveTo(0, p); ctx.lineTo(CROP_SIZE, p)
562
+ }
563
+ ctx.stroke()
564
+ },
565
+
566
+ touchPoint(t) {
567
+ const rect = this._cropRect || { left: 0, top: 0 }
568
+ const x = t.clientX == null ? (t.pageX == null ? t.x : t.pageX) : t.clientX
569
+ const y = t.clientY == null ? (t.pageY == null ? t.y : t.pageY) : t.clientY
570
+ return { x: x - rect.left, y: y - rect.top }
571
+ },
572
+
573
+ touchDistance(touches) {
574
+ const a = this.touchPoint(touches[0])
575
+ const b = this.touchPoint(touches[1])
576
+ return Math.hypot(b.x - a.x, b.y - a.y)
577
+ },
578
+
579
+ onCropStart(e) {
580
+ const touches = e.touches || []
581
+ if (!touches[0]) return
582
+ if (touches.length >= 2) {
583
+ this._gesture = { type: 'pinch', distance: this.touchDistance(touches), zoom: this.data.draftZoom }
584
+ return
585
+ }
586
+ this._gesture = { type: 'drag', ...this.touchPoint(touches[0]), focusX: this.data.draftFocusX, focusY: this.data.draftFocusY }
587
+ },
588
+
589
+ onCropMove(e) {
590
+ const touches = e.touches || []
591
+ const g = this._gesture
592
+ const pending = this._pending
593
+ if (!touches[0] || !g || !pending) return
594
+ if (touches.length >= 2) {
595
+ if (g.type !== 'pinch') {
596
+ this._gesture = { type: 'pinch', distance: this.touchDistance(touches), zoom: this.data.draftZoom }
597
+ return
598
+ }
599
+ const zoom = Math.max(100, Math.min(400, Math.round(g.zoom * this.touchDistance(touches) / g.distance)))
600
+ this.setData({ draftZoom: zoom })
601
+ this.debounce('crop', () => this.drawCrop(), 16)
602
+ return
603
+ }
604
+ if (g.type !== 'drag') return
605
+ const p = this.touchPoint(touches[0])
606
+ const cssScale = CROP_SIZE / (this._cropCss || CROP_SIZE)
607
+ const src = getImageDimensions(pending.image)
608
+ const scale = Math.max(CROP_SIZE / src.width, CROP_SIZE / src.height) * (this.data.draftZoom / 100)
609
+ const w = src.width * scale
610
+ const h = src.height * scale
611
+ const clamp = (v) => Math.max(0, Math.min(100, v))
612
+ const dirX = this.data.draftFlipH ? -1 : 1
613
+ const dirY = this.data.draftFlipV ? -1 : 1
614
+ const fx = Math.abs(w - CROP_SIZE) < 0.01 ? g.focusX : clamp(g.focusX + dirX * ((p.x - g.x) * cssScale / (CROP_SIZE - w)) * 100)
615
+ const fy = Math.abs(h - CROP_SIZE) < 0.01 ? g.focusY : clamp(g.focusY + dirY * ((p.y - g.y) * cssScale / (CROP_SIZE - h)) * 100)
616
+ this.setData({ draftFocusX: fx, draftFocusY: fy })
617
+ this.debounce('crop', () => this.drawCrop(), 16)
618
+ },
619
+
620
+ onCropEnd() {
621
+ this._gesture = null
622
+ clearTimeout(this._timers.crop)
623
+ this.drawCrop()
624
+ },
625
+
626
+ onDraftZoom(e) {
627
+ this.setData({ draftZoom: Number(e.detail.value) })
628
+ this.debounce('crop', () => this.drawCrop(), 16)
629
+ },
630
+ resetCrop() { this.setData({ draftFocusX: 50, draftFocusY: 50, draftZoom: 100 }, () => this.drawCrop()) },
631
+ flipH() { this.setData({ draftFlipH: !this.data.draftFlipH }, () => this.drawCrop()) },
632
+ flipV() { this.setData({ draftFlipV: !this.data.draftFlipV }, () => this.drawCrop()) },
633
+ onDraftRemoveBg(e) { this.setData({ draftRemoveBg: e.detail.value }) },
634
+ onDraftBgTolerance(e) { this.setData({ draftBgTolerance: Number(e.detail.value) }) },
635
+
636
+ cancelCrop() {
637
+ if (this._pending) this._pending.applyWhenPassed = false
638
+ this.setData({ cropVisible: false }, () => this.syncIntro())
639
+ },
640
+
641
+ applyCrop() {
642
+ const pending = this._pending
643
+ if (!pending) return
644
+ if (pending.status === 'checking') {
645
+ // 检测未完成:记住用户意图,通过后自动应用
646
+ pending.applyWhenPassed = true
647
+ wx.showToast({ title: '正在做内容安全检测,通过后自动应用', icon: 'none' })
648
+ return
649
+ }
650
+ if (pending.status !== 'pass') {
651
+ wx.showToast({ title: security.MESSAGES[pending.status] || security.MESSAGES.error, icon: 'none' })
652
+ return
653
+ }
654
+ const d = this.data
655
+ const src = getImageDimensions(pending.image)
656
+ this._source = {
657
+ image: pending.image,
658
+ path: pending.path,
659
+ crop: { focusX: d.draftFocusX, focusY: d.draftFocusY, zoom: d.draftZoom / 100, flipH: d.draftFlipH, flipV: d.draftFlipV },
660
+ }
661
+ this._baseKey = ''
662
+ this.setData({
663
+ cropVisible: false,
664
+ hasImage: true,
665
+ imageThumb: pending.path,
666
+ imageMeta: `${src.width}×${src.height} · 已通过安全检测`,
667
+ removeBackground: d.draftRemoveBg,
668
+ bgTolerance: d.draftBgTolerance,
669
+ // 首次导入照片时,二维码默认铺满全图;用户可再缩小
670
+ })
671
+ this.schedule(0)
672
+ },
673
+
674
+ // —— 3. 效果 ——
675
+
676
+ onScaleChanging(e) { this.setData({ qrScale: Number(e.detail.value) }); this.schedule(160) },
677
+ onScaleChange(e) { this.setData({ qrScale: Number(e.detail.value) }); this.schedule(0) },
678
+ pickPosition(e) {
679
+ const { x, y } = e.currentTarget.dataset
680
+ this.setData({ posX: Number(x), posY: Number(y) })
681
+ this.schedule(0)
682
+ },
683
+ toggleFineTune() { this.setData({ fineTuneOpen: !this.data.fineTuneOpen }) },
684
+ onPosX(e) { this.setData({ posX: Number(e.detail.value) }); this.schedule(100) },
685
+ onPosY(e) { this.setData({ posY: Number(e.detail.value) }); this.schedule(100) },
686
+ pickMode(e) { this.setData({ modeId: e.currentTarget.dataset.id }); this.schedule(0) },
687
+ toggleAdvanced() { this.setData({ advancedOpen: !this.data.advancedOpen }) },
688
+ pickPalette(e) { this.setData({ paletteId: e.currentTarget.dataset.id }); this.schedule(0) },
689
+ onCoherence(e) { this.setData({ coherence: Number(e.detail.value) }); this.schedule(200) },
690
+ onCleanup(e) { this.setData({ cleanup: e.detail.value }); this.schedule(0) },
691
+ pickLevel(e) { this.setData({ level: e.currentTarget.dataset.id }); this.schedule(0) },
692
+ pickQuiet(e) { this.setData({ quiet: Number(e.currentTarget.dataset.id) }); this.schedule(0) },
693
+ onBgToggle(e) { this.setData({ removeBackground: e.detail.value }); this.schedule(0) },
694
+
695
+ applyScanFix() {
696
+ // 自检未通过时的一键修复:扫码优先 + 1 格白边 + H 级纠错
697
+ this.setData({ modeId: 'strict', quiet: Math.max(1, this.data.quiet), level: 'H' })
698
+ this.schedule(0)
699
+ wx.showToast({ title: '已切换为扫码优先', icon: 'none' })
700
+ },
701
+
702
+ toggleUsage() {
703
+ const expanded = !this.data.usageExpanded
704
+ this.setData({ usageExpanded: expanded, usageAll: expanded ? (this._usageAll || []) : [] })
705
+ },
706
+
707
+ // 生成当前预览图,用微信“识别图中二维码”实测
708
+ verifyScan() {
709
+ const node = this._nodes.preview
710
+ if (!node) return
711
+ wx.canvasToTempFilePath({
712
+ canvas: node,
713
+ fileType: 'png',
714
+ success: (res) => {
715
+ wx.previewImage({ urls: [res.tempFilePath], showmenu: true })
716
+ wx.showToast({ title: '长按图片,选择“识别图中二维码”', icon: 'none', duration: 2500 })
717
+ },
718
+ fail: () => wx.showToast({ title: '预览生成失败,请重试', icon: 'none' }),
719
+ })
720
+ },
721
+
722
+ // —— 导出 ——
723
+
724
+ openExport() {
725
+ if (!this.data.text.trim()) {
726
+ this.startCreate()
727
+ return
728
+ }
729
+ if (!this._artwork) return
730
+ if (this.data.textStatus === 'risky') {
731
+ wx.showToast({ title: security.MESSAGES.risky, icon: 'none' })
732
+ return
733
+ }
734
+ this.setData({ exportVisible: true, exportResult: null })
735
+ },
736
+ closeExport() {
737
+ if (this.data.exporting) return
738
+ this.setData({ exportVisible: false })
739
+ },
740
+ pickFormat(e) { this.setData({ exportFormat: e.currentTarget.dataset.id, exportResult: null }) },
741
+ pickLayout(e) { this.setData({ exportLayout: e.currentTarget.dataset.id, exportResult: null }) },
742
+ pickQuality(e) { this.setData({ exportQuality: e.currentTarget.dataset.id, exportResult: null }) },
743
+ pickBeadStyle(e) {
744
+ this.setData({ beadStyle: e.currentTarget.dataset.id, exportResult: null }, () => this.drawPreview())
745
+ },
746
+ onGridToggle(e) { this.setData({ gridEnabled: e.detail.value, exportResult: null }) },
747
+ pickInterval(e) { this.setData({ gridInterval: Number(e.currentTarget.dataset.id), exportResult: null }) },
748
+ pickGridColor(e) { this.setData({ gridColor: e.currentTarget.dataset.color, exportResult: null }) },
749
+ onWatermark(e) {
750
+ const watermark = e.detail.value
751
+ this.setData({ watermark, watermarkStatus: watermark.trim() ? 'checking' : 'idle', exportResult: null })
752
+ this.debounce('wmCheck', () => {
753
+ const value = watermark.trim()
754
+ if (!value) return
755
+ security.checkText(value, 1).then(({ status }) => {
756
+ if (this.data.watermark.trim() === value) this.setData({ watermarkStatus: status })
757
+ })
758
+ }, 650)
759
+ },
760
+
761
+ gridSettings() {
762
+ return { enabled: this.data.gridEnabled, interval: this.data.gridInterval, color: this.data.gridColor, opacity: 60 }
763
+ },
764
+
765
+ capture(fileType, quality) {
766
+ return new Promise((resolve, reject) => {
767
+ wx.canvasToTempFilePath({
768
+ canvas: this._nodes.work,
769
+ fileType,
770
+ quality,
771
+ success: (res) => resolve(res.tempFilePath),
772
+ fail: reject,
773
+ })
774
+ })
775
+ },
776
+
777
+ step(label) {
778
+ return new Promise((resolve) => this.setData({ exportStep: label }, () => setTimeout(resolve, 16)))
779
+ },
780
+
781
+ async startExport() {
782
+ if (this.data.exporting || !this._artwork) return
783
+ this.setData({ exporting: true, exportResult: null })
784
+ try {
785
+ // 平台合规:导出前再次确认所有用户文本均已通过内容安全检测(已通过的走缓存,无额外等待)
786
+ await this.step('内容安全校验…')
787
+ const check = await security.checkTexts([
788
+ { value: this.data.text, scene: 2 },
789
+ { value: this.data.watermark, scene: 1 },
790
+ ])
791
+ if (check.status !== 'pass') {
792
+ wx.showToast({ title: security.MESSAGES[check.status], icon: 'none' })
793
+ if (check.status === 'risky') this.setData({ textStatus: security.isTextPassed(this.data.text) ? 'pass' : 'risky' })
794
+ return
795
+ }
796
+ if (this._source && !this.data.hasImage) throw new Error('IMAGE_STATE')
797
+ this.cleanOldFiles()
798
+ if (this.data.exportFormat === 'png') await this.exportPng()
799
+ else await this.exportPdf()
800
+ } catch (error) {
801
+ console.error('导出失败', error)
802
+ wx.showToast({ title: '导出失败,请重试(可尝试“标准”清晰度)', icon: 'none' })
803
+ } finally {
804
+ this.setData({ exporting: false, exportStep: '' })
805
+ }
806
+ },
807
+
808
+ // 按清晰度求每格像素:高清长边约 4000px,标准约 2400px
809
+ pngCellSize() {
810
+ const size = this._artwork.size
811
+ const maxSide = this.data.exportQuality === 'hd' ? 4000 : 2400
812
+ let cell = Math.max(12, Math.min(72, Math.floor(maxSide / (size + 5))))
813
+ while (cell > 12) {
814
+ const L = patternPageLayout(size, cell)
815
+ if (Math.max(L.width, L.height) <= maxSide) break
816
+ cell -= 1
817
+ }
818
+ return cell
819
+ },
820
+
821
+ patternMeta() {
822
+ const art = this._artwork
823
+ return `${art.size}×${art.size} 格 · ${formatNumber(art.beads)} 颗 · ${art.usage.length} 色 · ${art.pal.label} · 扫码内容:${this.data.text.trim()}`
824
+ },
825
+
826
+ infoParams(width) {
827
+ return {
828
+ artwork: this._artwork,
829
+ width,
830
+ text: this.data.text.trim(),
831
+ paletteLabel: `${this._artwork.pal.label}(本作品用 ${this._artwork.usage.length} 色)`,
832
+ mpCode: this._mpCode || null,
833
+ thumbnail: true,
834
+ }
835
+ },
836
+
837
+ async exportPng() {
838
+ const node = this._nodes.work
839
+ const art = this._artwork
840
+ const cell = this.pngCellSize()
841
+ const L = patternPageLayout(art.size, cell)
842
+ const wm = this.data.watermark.trim()
843
+ const drawPattern = (ctx) => drawPatternPage(ctx, art, { cell, style: this.data.beadStyle, grid: this.gridSettings(), meta: this.patternMeta() })
844
+ const files = []
845
+ if (this.data.exportLayout === 'separate') {
846
+ await this.step('绘制图纸…')
847
+ node.width = L.width
848
+ node.height = L.height
849
+ const ctx = node.getContext('2d')
850
+ drawPattern(ctx)
851
+ drawWatermark(ctx, L.width, L.height, wm)
852
+ files.push({ name: '拼豆图纸', path: await this.capture('png') })
853
+ const info = buildInfoPages(this.infoParams(this.data.exportQuality === 'hd' ? 2000 : 1400))
854
+ for (let p = 0; p < info.pageCount; p += 1) {
855
+ await this.step(`绘制用豆清单 ${p + 1}/${info.pageCount}…`)
856
+ node.width = info.width
857
+ node.height = info.height
858
+ const c = node.getContext('2d')
859
+ info.draw(c, p)
860
+ drawWatermark(c, info.width, info.height, wm)
861
+ files.push({ name: info.pageCount > 1 ? `用豆清单 ${p + 1}` : '用豆清单', path: await this.capture('png') })
862
+ }
863
+ } else {
864
+ await this.step('绘制图纸与清单…')
865
+ const info = buildInfoPages(this.infoParams(L.width))
866
+ const gap = Math.round(L.width * 0.02)
867
+ const totalH = L.height + info.pageCount * (info.height + gap)
868
+ const scale = fitScale(L.width, totalH)
869
+ node.width = Math.round(L.width * scale)
870
+ node.height = Math.round(totalH * scale)
871
+ const ctx = node.getContext('2d')
872
+ ctx.fillStyle = '#e8e5de'
873
+ ctx.fillRect(0, 0, node.width, node.height)
874
+ ctx.scale(scale, scale)
875
+ drawPattern(ctx)
876
+ for (let p = 0; p < info.pageCount; p += 1) {
877
+ ctx.save()
878
+ ctx.translate(0, L.height + gap + p * (info.height + gap))
879
+ info.draw(ctx, p)
880
+ ctx.restore()
881
+ }
882
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
883
+ drawWatermark(ctx, node.width, node.height, wm)
884
+ files.push({ name: '图纸 + 用豆清单', path: await this.capture('png') })
885
+ }
886
+ await this.step('保存到相册…')
887
+ const saved = await this.saveImages(files.map((f) => f.path))
888
+ this.setData({ exportResult: { kind: 'png', saved, files } })
889
+ if (saved) wx.showToast({ title: `${files.length} 张图片已保存`, icon: 'success' })
890
+ },
891
+
892
+ saveImages(paths) {
893
+ return paths.reduce((chain, path) => chain.then((ok) => (ok ? new Promise((resolve) => {
894
+ wx.saveImageToPhotosAlbum({
895
+ filePath: path,
896
+ success: () => resolve(true),
897
+ fail: (err) => {
898
+ if (err && /auth|deny/i.test(err.errMsg || '')) {
899
+ wx.showModal({
900
+ title: '需要相册权限',
901
+ content: '请允许保存到相册,才能把图纸存到手机里',
902
+ confirmText: '去设置',
903
+ success: (r) => { if (r.confirm) wx.openSetting() },
904
+ })
905
+ }
906
+ resolve(false)
907
+ },
908
+ })
909
+ }) : false)), Promise.resolve(true))
910
+ },
911
+
912
+ previewResult() {
913
+ const result = this.data.exportResult
914
+ if (!result) return
915
+ if (result.kind === 'png') {
916
+ wx.previewImage({ urls: result.files.map((f) => f.path), showmenu: true })
917
+ } else {
918
+ this.openPdf(result.files[0].path)
919
+ }
920
+ },
921
+
922
+ openResultFile(e) {
923
+ const file = this.data.exportResult.files[Number(e.currentTarget.dataset.index)]
924
+ if (!file) return
925
+ if (this.data.exportResult.kind === 'png') wx.previewImage({ urls: [file.path], showmenu: true })
926
+ else this.openPdf(file.path)
927
+ },
928
+
929
+ shareResultFile(e) {
930
+ const file = this.data.exportResult.files[Number(e.currentTarget.dataset.index)]
931
+ if (!file || !wx.shareFileMessage) {
932
+ wx.showToast({ title: '当前微信版本不支持,请先打开再转发', icon: 'none' })
933
+ return
934
+ }
935
+ wx.shareFileMessage({ filePath: file.path, fileName: file.fileName || `${file.name}.pdf` })
936
+ },
937
+
938
+ openPdf(path) {
939
+ wx.openDocument({ filePath: path, fileType: 'pdf', showMenu: true })
940
+ },
941
+
942
+ cleanOldFiles() {
943
+ try {
944
+ const fs = wx.getFileSystemManager()
945
+ fs.readdirSync(wx.env.USER_DATA_PATH)
946
+ .filter((name) => name.indexOf(FILE_PREFIX) === 0)
947
+ .forEach((name) => { try { fs.unlinkSync(`${wx.env.USER_DATA_PATH}/${name}`) } catch (e) { /* 忽略 */ } })
948
+ } catch (e) { /* 忽略 */ }
949
+ },
950
+
951
+ async canvasJpeg(width, height, draw, quality = 0.9) {
952
+ const node = this._nodes.work
953
+ node.width = width
954
+ node.height = height
955
+ const ctx = node.getContext('2d')
956
+ draw(ctx)
957
+ const path = await this.capture('jpg', quality)
958
+ return wx.getFileSystemManager().readFileSync(path)
959
+ },
960
+
961
+ // 页眉(中文)渲染为小图,矢量页面上方使用
962
+ async renderHeader(title, sub) {
963
+ const w = 1540
964
+ const h = 80
965
+ const data = await this.canvasJpeg(w, h, (ctx) => {
966
+ ctx.fillStyle = '#ffffff'
967
+ ctx.fillRect(0, 0, w, h)
968
+ ctx.fillStyle = '#16211f'
969
+ ctx.font = 'bold 34px sans-serif'
970
+ ctx.textBaseline = 'middle'
971
+ ctx.fillText(title, 0, 30)
972
+ ctx.fillStyle = '#6f7a76'
973
+ ctx.font = '22px sans-serif'
974
+ ctx.fillText(sub, 0, 64)
975
+ ctx.fillStyle = '#0f766e'
976
+ ctx.font = 'bold 24px sans-serif'
977
+ ctx.textAlign = 'right'
978
+ ctx.fillText('豆维 · 可扫码的拼豆', w, 30)
979
+ }, 0.92)
980
+ return { data, width: w, height: h }
981
+ },
982
+
983
+ // 水印转为 1-bit 蒙版,矢量页上以半透明平铺
984
+ watermarkMask(doc, text) {
985
+ const value = String(text || '').trim()
986
+ if (!value) return null
987
+ const node = this._nodes.work
988
+ const fontSize = 64
989
+ node.width = 16
990
+ node.height = 16
991
+ let ctx = node.getContext('2d')
992
+ ctx.font = `bold ${fontSize}px sans-serif`
993
+ const width = Math.min(2000, Math.ceil(ctx.measureText(value).width) + 16)
994
+ const height = Math.round(fontSize * 1.4)
995
+ node.width = width
996
+ node.height = height
997
+ ctx = node.getContext('2d')
998
+ ctx.clearRect(0, 0, width, height)
999
+ ctx.fillStyle = '#000000'
1000
+ ctx.font = `bold ${fontSize}px sans-serif`
1001
+ ctx.textBaseline = 'middle'
1002
+ ctx.fillText(value, 8, height / 2)
1003
+ const pixels = ctx.getImageData(0, 0, width, height).data
1004
+ const rowBytes = Math.ceil(width / 8)
1005
+ const bits = new Uint8Array(rowBytes * height).fill(0xff) // 1 = 不绘制
1006
+ for (let y = 0; y < height; y += 1) {
1007
+ for (let x = 0; x < width; x += 1) {
1008
+ if (pixels[(y * width + x) * 4 + 3] > 110) bits[y * rowBytes + (x >> 3)] &= ~(0x80 >> (x & 7))
1009
+ }
1010
+ }
1011
+ return { id: doc.addImageMask(bits, width, height), width, height }
1012
+ },
1013
+
1014
+ async buildPatternPdf(doc) {
1015
+ const art = this._artwork
1016
+ const plan = planPatternPages(art.size)
1017
+ const headers = []
1018
+ for (let i = 0; i < plan.pages.length; i += 1) {
1019
+ const page = plan.pages[i]
1020
+ await this.step(`绘制图纸页 ${i + 1}/${plan.pages.length}…`)
1021
+ const title = page.kind === 'overview'
1022
+ ? (plan.tiled ? `图纸总览 · 共分 ${plan.pages.length - 1} 块` : '拼豆图纸')
1023
+ : `第 ${page.tile} 块 · 列 ${page.region.col + 1}–${page.region.col + page.region.cols} · 行 ${page.region.row + 1}–${page.region.row + page.region.rows}`
1024
+ const sub = page.kind === 'overview'
1025
+ ? `${art.size}×${art.size} 格 · ${formatNumber(art.beads)} 颗 · ${art.usage.length} 色 · ${plan.tiled ? '红框编号对应后续分块页,按块拼更轻松' : '格内为 MARD 色号'}`
1026
+ : `第 ${i + 1} / ${plan.pages.length} 页 · 格内为 MARD 色号 · 坐标为整张作品的行列号`
1027
+ const header = await this.renderHeader(title, sub)
1028
+ headers.push({ id: doc.addJpeg(header.data, header.width, header.height), width: header.width, height: header.height })
1029
+ }
1030
+ const watermark = this.watermarkMask(doc, this.data.watermark)
1031
+ addPatternPages(doc, art, { plan, headers, style: this.data.beadStyle, grid: this.gridSettings(), watermark })
1032
+ },
1033
+
1034
+ async buildInfoPdf(doc) {
1035
+ const info = buildInfoPages(this.infoParams(1654))
1036
+ for (let p = 0; p < info.pageCount; p += 1) {
1037
+ await this.step(`绘制用豆清单 ${p + 1}/${info.pageCount}…`)
1038
+ const data = await this.canvasJpeg(info.width, info.height, (ctx) => {
1039
+ info.draw(ctx, p)
1040
+ drawWatermark(ctx, info.width, info.height, this.data.watermark)
1041
+ }, 0.9)
1042
+ addImagePage(doc, doc.addJpeg(data, info.width, info.height), info.width, info.height)
1043
+ }
1044
+ },
1045
+
1046
+ async exportPdf() {
1047
+ const fs = wx.getFileSystemManager()
1048
+ const stamp = Date.now().toString(36)
1049
+ const art = this._artwork
1050
+ const base = `${FILE_PREFIX}${art.size}x${art.size}-${stamp}`
1051
+ const files = []
1052
+ const write = (doc, suffix, name) => {
1053
+ const fileName = `豆维-${name}.pdf`
1054
+ const path = `${wx.env.USER_DATA_PATH}/${base}-${suffix}.pdf`
1055
+ fs.writeFileSync(path, doc.build(), 'binary')
1056
+ files.push({ name, fileName, path })
1057
+ }
1058
+ if (this.data.exportLayout === 'separate') {
1059
+ const patternDoc = new PdfDoc()
1060
+ await this.buildPatternPdf(patternDoc)
1061
+ write(patternDoc, 'pattern', '拼豆图纸')
1062
+ const infoDoc = new PdfDoc()
1063
+ await this.buildInfoPdf(infoDoc)
1064
+ write(infoDoc, 'info', '用豆清单')
1065
+ } else {
1066
+ const doc = new PdfDoc()
1067
+ await this.buildPatternPdf(doc)
1068
+ await this.buildInfoPdf(doc)
1069
+ write(doc, 'all', '图纸与用豆清单')
1070
+ }
1071
+ this.setData({ exportResult: { kind: 'pdf', saved: true, files } })
1072
+ },
1073
+ })
BeadQR/miniprogram/pages/index/index.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "navigationStyle": "custom",
3
+ "navigationBarTextStyle": "white",
4
+ "backgroundColorTop": "#0f3d3a",
5
+ "usingComponents": {}
6
+ }
BeadQR/miniprogram/pages/index/index.wxml ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- 自定义导航栏 -->
2
+ <view class="nav {{navSolid ? 'nav-solid' : ''}}" style="padding-top: {{statusBarHeight}}px; height: {{navHeight}}px;">
3
+ <view class="nav-title">
4
+ <image class="nav-logo" src="/assets/icon.png" mode="aspectFill" />
5
+ <text>豆维 · 可扫码的拼豆</text>
6
+ </view>
7
+ </view>
8
+
9
+ <view class="page" style="padding-top: {{statusBarHeight + navHeight}}px;">
10
+ <!-- 首屏:一句话说清楚是做什么的 -->
11
+ <view class="hero">
12
+ <view class="hero-badge">MARD 拼豆图纸生成器</view>
13
+ <view class="hero-title">把一句话藏进拼豆里</view>
14
+ <view class="hero-sub">拼好的作品用微信一扫,就能读出你写的文字或链接</view>
15
+ <view class="hero-steps">
16
+ <view class="hero-step"><text class="num">1</text>写一句话</view>
17
+ <text class="hero-arrow">›</text>
18
+ <view class="hero-step"><text class="num">2</text>选张照片</view>
19
+ <text class="hero-arrow">›</text>
20
+ <view class="hero-step"><text class="num">3</text>导出图纸</view>
21
+ </view>
22
+ </view>
23
+
24
+ <!-- 实时预览:无内容时播放动态演示 -->
25
+ <view class="card preview-card">
26
+ <view class="preview-box" bindtap="replayIntro">
27
+ <canvas id="preview" type="2d" class="preview-canvas"></canvas>
28
+ <view wx:if="{{isSample && !showIntro}}" class="sample-tag">示例二维码 · 写下内容后换成你的</view>
29
+ <view wx:if="{{generating}}" class="generating"><view class="spinner"></view></view>
30
+ </view>
31
+
32
+ <block wx:if="{{showIntro}}">
33
+ <view class="intro-tip">
34
+ <view class="intro-dot"></view>
35
+ <text>上面是演示:写下一句话,这里就会变成你专属的可扫码图纸</text>
36
+ </view>
37
+ <view class="stats features">
38
+ <view class="stat"><view class="stat-v">221 色</view><view class="stat-k">MARD 标准色号</view></view>
39
+ <view class="stat"><view class="stat-v">A4</view><view class="stat-k">打印图纸</view></view>
40
+ <view class="stat"><view class="stat-v">扫一扫</view><view class="stat-k">微信直接读出</view></view>
41
+ </view>
42
+ <view class="preview-actions">
43
+ <view class="cta-btn" bindtap="startCreate">开始制作 · 写下第一句话</view>
44
+ </view>
45
+ </block>
46
+
47
+ <block wx:else>
48
+ <view class="scan-row">
49
+ <view class="scan-badge scan-{{isSample ? 'idle' : scanState}}">
50
+ <text wx:if="{{isSample}}">写下内容后,自动检查能否扫出</text>
51
+ <text wx:elif="{{scanState === 'checking'}}">正在检查能否扫出…</text>
52
+ <text wx:elif="{{scanState === 'ok'}}">✓ 自检通过,可以扫出内容</text>
53
+ <text wx:elif="{{scanState === 'fail'}}">! 自检未扫出,建议优化</text>
54
+ <text wx:else>生成后自动检查能否扫码</text>
55
+ </view>
56
+ <view wx:if="{{!isSample && scanState === 'fail'}}" class="mini-btn accent" bindtap="applyScanFix">一键优化</view>
57
+ </view>
58
+
59
+ <view class="stats">
60
+ <view class="stat"><view class="stat-v">{{gridText}}</view><view class="stat-k">格子</view></view>
61
+ <view class="stat"><view class="stat-v">{{beadsText}}</view><view class="stat-k">颗豆子</view></view>
62
+ <view class="stat"><view class="stat-v">{{colorsText}}</view><view class="stat-k">种颜色</view></view>
63
+ </view>
64
+
65
+ <view class="preview-actions">
66
+ <view wx:if="{{isSample}}" class="cta-btn" bindtap="startCreate">写下内容,生成你的二维码</view>
67
+ <view wx:else class="ghost-btn" bindtap="verifyScan">用微信扫一扫验证</view>
68
+ </view>
69
+ </block>
70
+ </view>
71
+
72
+ <!-- 第 1 步:内容 -->
73
+ <view class="card step" id="step1">
74
+ <view class="step-head">
75
+ <view class="step-num">1</view>
76
+ <view>
77
+ <view class="step-title">扫码后要看到什么?</view>
78
+ <view class="step-desc">一句祝福、一个网址,或者你的名字</view>
79
+ </view>
80
+ </view>
81
+ <view class="input-wrap {{textStatus === 'risky' ? 'input-bad' : ''}}">
82
+ <textarea class="textarea" value="{{text}}" maxlength="300" auto-height placeholder="Happy Birthday!" placeholder-class="ph" focus="{{textFocus}}" bindinput="onTextInput" bindblur="onTextBlur" />
83
+ <view class="input-foot">
84
+ <view class="text-status status-{{textStatus}}">
85
+ <text wx:if="{{textStatus === 'checking'}}">内容安全检测中…</text>
86
+ <text wx:elif="{{textStatus === 'pass'}}">✓ 已通过内容安全检测</text>
87
+ <text wx:elif="{{textStatus === 'risky'}}">内容可能不合规,请修改</text>
88
+ <text wx:elif="{{textStatus === 'error'}}">检测暂不可用,请稍后重试</text>
89
+ <text wx:else>{{text.length}}/300</text>
90
+ </view>
91
+ <view class="input-tools">
92
+ <text wx:if="{{text}}" class="tool" bindtap="clearText">清空</text>
93
+ <text class="tool tool-brand" bindtap="scanImport">{{scanImportLoading ? '识别中…' : '扫码导入'}}</text>
94
+ </view>
95
+ </view>
96
+ </view>
97
+ <view class="tip">字越少,格子越少,拼起来越快</view>
98
+ </view>
99
+
100
+ <!-- 第 2 步:照片 -->
101
+ <view class="card step">
102
+ <view class="step-head">
103
+ <view class="step-num">2</view>
104
+ <view>
105
+ <view class="step-title">选一张照片当底图 <text class="optional">可选</text></view>
106
+ <view class="step-desc">二维码会融进照片里,不选就是纯二维码</view>
107
+ </view>
108
+ </view>
109
+ <view wx:if="{{!hasImage}}" class="upload" bindtap="chooseImage">
110
+ <view class="upload-icon">+</view>
111
+ <view class="upload-text">从相册选择或拍照</view>
112
+ <view class="upload-hint">照片仅在手机上处理,会先经过微信内容安全检测</view>
113
+ </view>
114
+ <view wx:else class="photo">
115
+ <image class="photo-thumb" src="{{imageThumb}}" mode="aspectFill" />
116
+ <view class="photo-info">
117
+ <view class="photo-meta">{{imageMeta}}</view>
118
+ <view class="photo-actions">
119
+ <view class="mini-btn" bindtap="editImage">调整</view>
120
+ <view class="mini-btn" bindtap="chooseImage">换一张</view>
121
+ <view class="mini-btn danger" bindtap="removeImage">移除</view>
122
+ </view>
123
+ </view>
124
+ </view>
125
+ <view wx:if="{{hasImage}}" class="row">
126
+ <view class="row-label">去掉纯色背景</view>
127
+ <switch checked="{{removeBackground}}" color="#0f766e" bindchange="onBgToggle" />
128
+ </view>
129
+ </view>
130
+
131
+ <!-- 第 3 步:效果 -->
132
+ <view class="card step">
133
+ <view class="step-head">
134
+ <view class="step-num">3</view>
135
+ <view>
136
+ <view class="step-title">调整效果</view>
137
+ <view class="step-desc">默认设置已经很好用,想要更好看再微调</view>
138
+ </view>
139
+ </view>
140
+
141
+ <view class="field-label">风格</view>
142
+ <view class="seg">
143
+ <view wx:for="{{matchModes}}" wx:key="id" class="seg-item {{modeId === item.id ? 'on' : ''}}" data-id="{{item.id}}" bindtap="pickMode">
144
+ <view class="seg-main">{{item.label}}</view>
145
+ <view class="seg-sub">{{item.hint}}</view>
146
+ </view>
147
+ </view>
148
+
149
+ <block wx:if="{{hasImage}}">
150
+ <view class="field-label">二维码大小 <text class="field-val">{{qrScale}}%</text></view>
151
+ <slider min="30" max="100" step="1" value="{{qrScale}}" activeColor="#0f766e" block-size="22" bindchanging="onScaleChanging" bindchange="onScaleChange" />
152
+ <block wx:if="{{qrScale < 100}}">
153
+ <view class="field-label">二维码位置 <text class="link-text" bindtap="toggleFineTune">{{fineTuneOpen ? '收起微调' : '微调'}}</text></view>
154
+ <view class="pos-grid">
155
+ <block wx:for="{{positions}}" wx:for-item="py" wx:key="*this">
156
+ <view wx:for="{{positions}}" wx:for-item="px" wx:key="*this" class="pos-cell {{posX === px && posY === py ? 'on' : ''}}" data-x="{{px}}" data-y="{{py}}" bindtap="pickPosition"></view>
157
+ </block>
158
+ </view>
159
+ <block wx:if="{{fineTuneOpen}}">
160
+ <view class="mini-label">左右 {{posX}}%</view>
161
+ <slider min="0" max="100" value="{{posX}}" activeColor="#0f766e" block-size="20" bindchanging="onPosX" bindchange="onPosX" />
162
+ <view class="mini-label">上下 {{posY}}%</view>
163
+ <slider min="0" max="100" value="{{posY}}" activeColor="#0f766e" block-size="20" bindchanging="onPosY" bindchange="onPosY" />
164
+ </block>
165
+ </block>
166
+ </block>
167
+
168
+ <view class="advanced-toggle" bindtap="toggleAdvanced">
169
+ <text>高级设置(色板、纠错、白边)</text>
170
+ <text class="chev {{advancedOpen ? 'open' : ''}}">›</text>
171
+ </view>
172
+ <view wx:if="{{advancedOpen}}" class="advanced">
173
+ <view class="field-label">色板 <text class="field-hint">颜色越少,买豆越省</text></view>
174
+ <scroll-view scroll-x class="chips-scroll" enhanced show-scrollbar="{{false}}">
175
+ <view class="chips">
176
+ <view wx:for="{{presets}}" wx:key="id" class="chip {{paletteId === item.id ? 'on' : ''}}" data-id="{{item.id}}" bindtap="pickPalette">
177
+ <view class="chip-main">{{item.short}}</view>
178
+ <view class="chip-sub">{{item.detail}}</view>
179
+ </view>
180
+ </view>
181
+ </scroll-view>
182
+
183
+ <view class="field-label">色块平滑 <text class="field-val">{{coherence}}</text></view>
184
+ <slider min="0" max="100" step="5" value="{{coherence}}" activeColor="#0f766e" block-size="20" bindchange="onCoherence" />
185
+
186
+ <view class="row">
187
+ <view>
188
+ <view class="row-label">合并零散颜色</view>
189
+ <view class="row-hint">把只用到一两颗的颜色换成相近色</view>
190
+ </view>
191
+ <switch checked="{{cleanup}}" color="#0f766e" bindchange="onCleanup" />
192
+ </view>
193
+
194
+ <view class="field-label">纠错等级 <text class="field-hint">等级越高越耐磨损,但格子更多</text></view>
195
+ <view class="seg">
196
+ <view wx:for="{{levels}}" wx:key="id" class="seg-item {{level === item.id ? 'on' : ''}}" data-id="{{item.id}}" bindtap="pickLevel">
197
+ <view class="seg-main">{{item.label}}</view>
198
+ <view class="seg-sub">{{item.hint}}</view>
199
+ </view>
200
+ </view>
201
+
202
+ <view class="field-label">白边 <text class="field-hint">留 1 格白边更容易扫出</text></view>
203
+ <view class="seg">
204
+ <view wx:for="{{quietOptions}}" wx:key="id" class="seg-item {{quiet === item.id ? 'on' : ''}}" data-id="{{item.id}}" bindtap="pickQuiet">
205
+ <view class="seg-main">{{item.label}}</view>
206
+ </view>
207
+ </view>
208
+ <view wx:if="{{qrGridText}}" class="tip">二维码本体 {{qrGridText}} 格</view>
209
+ </view>
210
+ </view>
211
+
212
+ <!-- 用豆清单 -->
213
+ <view class="card" wx:if="{{usage.length && !showIntro}}">
214
+ <view class="card-title">需要准备的豆子 <text class="field-hint">MARD 色号 · 按用量排序</text></view>
215
+ <view class="usage">
216
+ <view wx:for="{{usageExpanded ? usageAll : usage}}" wx:key="id" class="usage-item">
217
+ <view class="swatch" style="background: {{item.hex}};"></view>
218
+ <view class="usage-id">{{item.id}}</view>
219
+ <view class="usage-count">{{item.count}}</view>
220
+ </view>
221
+ </view>
222
+ <view wx:if="{{usageMore > 0}}" class="link-text center" bindtap="toggleUsage">{{usageExpanded ? '收起' : '展开全部(还有 ' + usageMore + ' 种)'}}</view>
223
+ </view>
224
+
225
+ <!-- 页脚:内容规范 / 投诉反馈 / 隐私 -->
226
+ <view class="footer">
227
+ <view class="footer-safe"><image class="footer-shield" src="/assets/shield.svg" mode="aspectFit" /><text>文字、水印与照片均经过微信内容安全检测,未通过或检测异常时不能生成与导出</text></view>
228
+ <view class="footer-links">
229
+ <text class="footer-link" bindtap="showRules">内容规范</text>
230
+ <text class="dot">·</text>
231
+ <button class="footer-link footer-btn" open-type="feedback">投诉与反馈</button>
232
+ <text class="dot">·</text>
233
+ <text class="footer-link" bindtap="openPrivacy">隐私保护指引</text>
234
+ </view>
235
+ <view class="footer-copy">豆维 · 可扫码的拼豆</view>
236
+ </view>
237
+ </view>
238
+
239
+ <!-- 底部操作栏 -->
240
+ <view class="bottom-bar">
241
+ <view class="bar-info">
242
+ <block wx:if="{{isSample}}">
243
+ <view class="bar-main">还没有内容</view>
244
+ <view class="bar-sub">写一句话,马上生成图纸</view>
245
+ </block>
246
+ <block wx:else>
247
+ <view class="bar-main">{{gridText}} · {{colorsText}} 色</view>
248
+ <view class="bar-sub">约 {{beadsText}} 颗豆子</view>
249
+ </block>
250
+ </view>
251
+ <view class="primary-btn {{!isSample && textStatus === 'risky' ? 'disabled' : ''}}" bindtap="openExport">{{isSample ? '开始制作' : '导出图纸'}}</view>
252
+ </view>
253
+
254
+ <!-- 导出面板 -->
255
+ <view wx:if="{{exportVisible}}" class="mask" bindtap="closeExport" catchtouchmove="noop">
256
+ <view class="sheet" catchtap="noop">
257
+ <view class="sheet-handle"></view>
258
+ <view class="sheet-title">导出图纸</view>
259
+
260
+ <scroll-view scroll-y class="sheet-body">
261
+ <view class="field-label">格式</view>
262
+ <view class="seg">
263
+ <view class="seg-item {{exportFormat === 'pdf' ? 'on' : ''}}" data-id="pdf" bindtap="pickFormat">
264
+ <view class="seg-main">PDF 文件</view><view class="seg-sub">A4 打印,大图自动分页</view>
265
+ </view>
266
+ <view class="seg-item {{exportFormat === 'png' ? 'on' : ''}}" data-id="png" bindtap="pickFormat">
267
+ <view class="seg-main">图片</view><view class="seg-sub">存到相册,手机上看</view>
268
+ </view>
269
+ </view>
270
+
271
+ <view class="field-label">内容</view>
272
+ <view class="seg">
273
+ <view class="seg-item {{exportLayout === 'combined' ? 'on' : ''}}" data-id="combined" bindtap="pickLayout">
274
+ <view class="seg-main">合并一份</view><view class="seg-sub">图纸 + 用豆清单</view>
275
+ </view>
276
+ <view class="seg-item {{exportLayout === 'separate' ? 'on' : ''}}" data-id="separate" bindtap="pickLayout">
277
+ <view class="seg-main">分开两份</view><view class="seg-sub">图纸、清单各一份</view>
278
+ </view>
279
+ </view>
280
+
281
+ <block wx:if="{{exportFormat === 'png'}}">
282
+ <view class="field-label">清晰度</view>
283
+ <view class="seg">
284
+ <view class="seg-item {{exportQuality === 'hd' ? 'on' : ''}}" data-id="hd" bindtap="pickQuality"><view class="seg-main">高清</view></view>
285
+ <view class="seg-item {{exportQuality === 'std' ? 'on' : ''}}" data-id="std" bindtap="pickQuality"><view class="seg-main">标准</view></view>
286
+ </view>
287
+ </block>
288
+
289
+ <view class="field-label">豆子样式</view>
290
+ <view class="seg">
291
+ <view wx:for="{{beadStyles}}" wx:key="id" class="seg-item {{beadStyle === item.id ? 'on' : ''}}" data-id="{{item.id}}" bindtap="pickBeadStyle">
292
+ <view class="seg-main">{{item.label}}</view>
293
+ </view>
294
+ </view>
295
+
296
+ <view class="row">
297
+ <view class="row-label">辅助网格线</view>
298
+ <switch checked="{{gridEnabled}}" color="#0f766e" bindchange="onGridToggle" />
299
+ </view>
300
+ <block wx:if="{{gridEnabled}}">
301
+ <view class="grid-opts">
302
+ <view class="seg small">
303
+ <view class="seg-item {{gridInterval === 5 ? 'on' : ''}}" data-id="5" bindtap="pickInterval"><view class="seg-main">每 5 格</view></view>
304
+ <view class="seg-item {{gridInterval === 10 ? 'on' : ''}}" data-id="10" bindtap="pickInterval"><view class="seg-main">每 10 格</view></view>
305
+ </view>
306
+ <view class="colors">
307
+ <view wx:for="{{gridColors}}" wx:key="*this" class="color-dot {{gridColor === item ? 'on' : ''}}" style="background: {{item}};" data-color="{{item}}" bindtap="pickGridColor"></view>
308
+ </view>
309
+ </view>
310
+ </block>
311
+
312
+ <view class="field-label">水印 <text class="field-hint">可选,防止图纸被盗用</text></view>
313
+ <view class="input-wrap {{watermarkStatus === 'risky' ? 'input-bad' : ''}}">
314
+ <input class="input" value="{{watermark}}" maxlength="20" placeholder="例如:@我的小店" placeholder-class="ph" bindinput="onWatermark" />
315
+ </view>
316
+ <view wx:if="{{watermarkStatus !== 'idle'}}" class="text-status status-{{watermarkStatus}} pad">
317
+ <text wx:if="{{watermarkStatus === 'checking'}}">内容安全检测中…</text>
318
+ <text wx:elif="{{watermarkStatus === 'pass'}}">✓ 已通过内容安全检测</text>
319
+ <text wx:elif="{{watermarkStatus === 'risky'}}">水印内容可能不合规,请修改</text>
320
+ <text wx:else>检测暂不可用,请稍后重试</text>
321
+ </view>
322
+
323
+ <view wx:if="{{exportResult}}" class="result">
324
+ <view class="result-head">
325
+ <view class="result-icon {{exportResult.kind === 'png' && !exportResult.saved ? 'warn' : ''}}">
326
+ <view class="result-tick"></view>
327
+ </view>
328
+ <view class="result-text">
329
+ <view class="result-title">{{exportResult.kind === 'png' && !exportResult.saved ? '已生成,但未存入相册' : '导出完成'}}</view>
330
+ <view class="result-sub" wx:if="{{exportResult.kind === 'pdf'}}">共 {{exportResult.files.length}} 份 PDF,可直接打印或发给好友</view>
331
+ <view class="result-sub" wx:elif="{{exportResult.saved}}">{{exportResult.files.length}} 张图片已保存到手机相册</view>
332
+ <view class="result-sub" wx:else>点「查看」后长按图片即可保存</view>
333
+ </view>
334
+ </view>
335
+ <view class="result-list">
336
+ <view wx:for="{{exportResult.files}}" wx:key="path" class="result-file">
337
+ <view class="file-badge {{exportResult.kind}}">{{exportResult.kind === 'pdf' ? 'PDF' : 'PNG'}}</view>
338
+ <view class="file-info">
339
+ <view class="file-name">{{item.name}}</view>
340
+ <view class="file-desc">{{exportResult.kind === 'pdf' ? 'A4 · 可打印' : '高清图片'}}</view>
341
+ </view>
342
+ <view class="file-actions">
343
+ <view class="file-btn" data-index="{{index}}" bindtap="openResultFile">{{exportResult.kind === 'png' ? '查看' : '打开'}}</view>
344
+ <view wx:if="{{exportResult.kind === 'pdf'}}" class="file-btn primary" data-index="{{index}}" bindtap="shareResultFile">发送</view>
345
+ </view>
346
+ </view>
347
+ </view>
348
+ </view>
349
+ </scroll-view>
350
+
351
+ <view class="primary-btn block {{exporting ? 'disabled' : ''}}" bindtap="startExport">
352
+ {{exporting ? (exportStep || '正在导出…') : (exportResult ? '重新导出' : '开始导出')}}
353
+ </view>
354
+ </view>
355
+ </view>
356
+
357
+ <!-- 照片裁切 -->
358
+ <view wx:if="{{cropVisible}}" class="mask crop-mask" catchtouchmove="noop">
359
+ <view class="crop-panel">
360
+ <view class="crop-head">
361
+ <text class="crop-cancel" bindtap="cancelCrop">取消</text>
362
+ <text class="crop-title">调整照片</text>
363
+ <text class="crop-ok {{cropCheck === 'pass' ? '' : 'wait'}}" bindtap="applyCrop">完成</text>
364
+ </view>
365
+ <view class="crop-box">
366
+ <canvas id="crop" type="2d" class="crop-canvas" bindtouchstart="onCropStart" bindtouchmove="onCropMove" bindtouchend="onCropEnd" bindtouchcancel="onCropEnd"></canvas>
367
+ </view>
368
+ <view class="crop-check check-{{cropCheck}}">
369
+ <text wx:if="{{cropCheck === 'checking'}}">正在进行内容安全检测,你可以先调整构图…</text>
370
+ <text wx:elif="{{cropCheck === 'pass'}}">✓ 照片已通过内容安全检测</text>
371
+ <block wx:elif="{{cropCheck === 'risky'}}"><text>这张照片未通过内容安全检测,请换一张</text></block>
372
+ <block wx:elif="{{cropCheck === 'error'}}"><text>检测暂时不可用 </text><text class="link-text" bindtap="retryImageCheck">重试</text></block>
373
+ </view>
374
+ <view class="crop-hint">单指拖动调整位置,双指缩放</view>
375
+ <view class="field-label light">缩放 <text class="field-val">{{draftZoom}}%</text></view>
376
+ <slider min="100" max="400" value="{{draftZoom}}" activeColor="#14b8a6" backgroundColor="rgba(255,255,255,0.2)" block-size="20" bindchanging="onDraftZoom" bindchange="onDraftZoom" />
377
+ <view class="crop-tools">
378
+ <view class="crop-tool" bindtap="flipH">左右翻转</view>
379
+ <view class="crop-tool" bindtap="flipV">上下翻转</view>
380
+ <view class="crop-tool" bindtap="resetCrop">复位</view>
381
+ </view>
382
+ <view class="row light">
383
+ <view>
384
+ <view class="row-label">去掉纯色背景</view>
385
+ <view class="row-hint">适合白底、纯色底的照片</view>
386
+ </view>
387
+ <switch checked="{{draftRemoveBg}}" color="#14b8a6" bindchange="onDraftRemoveBg" />
388
+ </view>
389
+ <block wx:if="{{draftRemoveBg}}">
390
+ <view class="mini-label light">识别范围 {{draftBgTolerance}}</view>
391
+ <slider min="6" max="60" value="{{draftBgTolerance}}" activeColor="#14b8a6" backgroundColor="rgba(255,255,255,0.2)" block-size="18" bindchange="onDraftBgTolerance" />
392
+ </block>
393
+ <view wx:if="{{cropCheck === 'risky'}}" class="crop-replace"><view class="primary-btn block" bindtap="chooseImage">换一张照片</view></view>
394
+ </view>
395
+ </view>
396
+
397
+ <!-- 离屏工作画布 -->
398
+ <view class="offscreen">
399
+ <canvas id="work" type="2d" class="off-canvas"></canvas>
400
+ <canvas id="security" type="2d" class="off-canvas"></canvas>
401
+ </view>
402
+
403
+ <privacy-popup />
BeadQR/miniprogram/pages/index/index.wxss ADDED
@@ -0,0 +1,828 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ page {
2
+ background: var(--bg);
3
+ }
4
+
5
+ /* 导航 */
6
+ .nav {
7
+ position: fixed;
8
+ top: 0;
9
+ left: 0;
10
+ right: 0;
11
+ z-index: 50;
12
+ display: flex;
13
+ align-items: center;
14
+ padding-left: 32rpx;
15
+ box-sizing: content-box;
16
+ background: transparent;
17
+ transition: background 0.2s;
18
+ }
19
+ .nav-solid {
20
+ background: var(--brand-deep);
21
+ box-shadow: 0 4rpx 20rpx rgba(15, 61, 58, 0.25);
22
+ }
23
+ .nav-title {
24
+ display: flex;
25
+ align-items: center;
26
+ gap: 14rpx;
27
+ color: #fff;
28
+ font-size: 34rpx;
29
+ font-weight: 700;
30
+ letter-spacing: 2rpx;
31
+ }
32
+ .nav-logo {
33
+ width: 48rpx;
34
+ height: 48rpx;
35
+ border-radius: 12rpx;
36
+ }
37
+
38
+ .page {
39
+ padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
40
+ background: linear-gradient(180deg, var(--brand-deep) 0, var(--brand-deep) 560rpx, var(--bg) 560rpx);
41
+ }
42
+
43
+ /* 首屏 */
44
+ .hero {
45
+ padding: 24rpx 40rpx 150rpx;
46
+ color: #fff;
47
+ }
48
+ .hero-badge {
49
+ display: inline-block;
50
+ padding: 6rpx 18rpx;
51
+ border-radius: 999rpx;
52
+ background: rgba(255, 255, 255, 0.12);
53
+ color: #99f6e4;
54
+ font-size: 22rpx;
55
+ letter-spacing: 1rpx;
56
+ }
57
+ .hero-title {
58
+ margin-top: 20rpx;
59
+ font-size: 56rpx;
60
+ font-weight: 800;
61
+ line-height: 1.25;
62
+ }
63
+ .hero-sub {
64
+ margin-top: 12rpx;
65
+ font-size: 27rpx;
66
+ line-height: 1.6;
67
+ color: rgba(255, 255, 255, 0.78);
68
+ }
69
+ .hero-steps {
70
+ margin-top: 28rpx;
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 10rpx;
74
+ }
75
+ .hero-step {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 8rpx;
79
+ padding: 10rpx 16rpx 10rpx 10rpx;
80
+ border-radius: 999rpx;
81
+ background: rgba(255, 255, 255, 0.1);
82
+ font-size: 24rpx;
83
+ white-space: nowrap;
84
+ }
85
+ .hero-step .num {
86
+ width: 34rpx;
87
+ height: 34rpx;
88
+ line-height: 34rpx;
89
+ text-align: center;
90
+ border-radius: 50%;
91
+ background: var(--brand-2);
92
+ color: var(--brand-deep);
93
+ font-size: 20rpx;
94
+ font-weight: 800;
95
+ }
96
+ .hero-arrow {
97
+ color: rgba(255, 255, 255, 0.4);
98
+ font-size: 30rpx;
99
+ }
100
+
101
+ /* 卡片 */
102
+ .card {
103
+ margin: 0 24rpx 24rpx;
104
+ padding: 32rpx;
105
+ background: var(--card);
106
+ border-radius: var(--r-l);
107
+ box-shadow: var(--shadow);
108
+ }
109
+ .card-title {
110
+ font-size: 30rpx;
111
+ font-weight: 700;
112
+ color: var(--ink);
113
+ margin-bottom: 20rpx;
114
+ }
115
+
116
+ /* 预览 */
117
+ .preview-card {
118
+ margin-top: -120rpx;
119
+ padding: 24rpx;
120
+ }
121
+ .preview-box {
122
+ position: relative;
123
+ width: 100%;
124
+ height: 654rpx;
125
+ border-radius: 24rpx;
126
+ overflow: hidden;
127
+ background: #f1eee7;
128
+ }
129
+ .preview-canvas {
130
+ width: 654rpx;
131
+ height: 654rpx;
132
+ display: block;
133
+ margin: 0 auto;
134
+ }
135
+ .sample-tag {
136
+ position: absolute;
137
+ left: 20rpx;
138
+ top: 20rpx;
139
+ padding: 8rpx 18rpx;
140
+ border-radius: 999rpx;
141
+ background: rgba(22, 33, 31, 0.72);
142
+ color: #fff;
143
+ font-size: 22rpx;
144
+ }
145
+ .generating {
146
+ position: absolute;
147
+ right: 20rpx;
148
+ top: 20rpx;
149
+ width: 52rpx;
150
+ height: 52rpx;
151
+ border-radius: 50%;
152
+ background: rgba(255, 255, 255, 0.9);
153
+ display: flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ }
157
+ .spinner {
158
+ width: 28rpx;
159
+ height: 28rpx;
160
+ border: 4rpx solid var(--brand-soft);
161
+ border-top-color: var(--brand);
162
+ border-radius: 50%;
163
+ animation: spin 0.7s linear infinite;
164
+ }
165
+ @keyframes spin {
166
+ to { transform: rotate(360deg); }
167
+ }
168
+
169
+ .scan-row {
170
+ margin-top: 20rpx;
171
+ display: flex;
172
+ align-items: center;
173
+ justify-content: space-between;
174
+ gap: 16rpx;
175
+ }
176
+ .scan-badge {
177
+ flex: 1;
178
+ padding: 14rpx 20rpx;
179
+ border-radius: 16rpx;
180
+ font-size: 25rpx;
181
+ background: #f1f1ee;
182
+ color: var(--muted);
183
+ }
184
+ .scan-ok { background: var(--ok-soft); color: var(--ok); font-weight: 600; }
185
+ .scan-fail { background: var(--warn-soft); color: var(--warn); font-weight: 600; }
186
+ .scan-checking { background: var(--brand-soft); color: var(--brand); }
187
+
188
+ .stats {
189
+ margin-top: 20rpx;
190
+ display: flex;
191
+ background: #faf9f6;
192
+ border-radius: 20rpx;
193
+ padding: 20rpx 0;
194
+ }
195
+ .stat {
196
+ flex: 1;
197
+ text-align: center;
198
+ }
199
+ .stat + .stat { border-left: 1rpx solid var(--line); }
200
+ .stat-v {
201
+ font-size: 36rpx;
202
+ font-weight: 800;
203
+ color: var(--ink);
204
+ }
205
+ .stat-k {
206
+ margin-top: 4rpx;
207
+ font-size: 22rpx;
208
+ color: var(--muted);
209
+ }
210
+ .preview-actions {
211
+ margin-top: 20rpx;
212
+ }
213
+ .ghost-btn {
214
+ text-align: center;
215
+ width: 100%;
216
+ height: 84rpx;
217
+ line-height: 84rpx;
218
+ border-radius: 20rpx;
219
+ background: var(--brand-soft);
220
+ color: var(--brand);
221
+ font-size: 28rpx;
222
+ font-weight: 600;
223
+ }
224
+
225
+ /* 动态演示状态 */
226
+ .intro-tip {
227
+ margin-top: 20rpx;
228
+ display: flex;
229
+ align-items: flex-start;
230
+ gap: 14rpx;
231
+ padding: 16rpx 20rpx;
232
+ border-radius: 16rpx;
233
+ background: var(--brand-soft);
234
+ color: var(--brand-deep);
235
+ font-size: 25rpx;
236
+ line-height: 1.5;
237
+ }
238
+ .intro-dot {
239
+ flex-shrink: 0;
240
+ width: 14rpx;
241
+ height: 14rpx;
242
+ margin-top: 13rpx;
243
+ border-radius: 50%;
244
+ background: var(--brand-2);
245
+ animation: pulse 1.6s ease-in-out infinite;
246
+ }
247
+ @keyframes pulse {
248
+ 0%, 100% { opacity: 1; transform: scale(1); }
249
+ 50% { opacity: 0.35; transform: scale(0.7); }
250
+ }
251
+ .features .stat-v { font-size: 32rpx; color: var(--brand); }
252
+ .cta-btn {
253
+ width: 100%;
254
+ height: 88rpx;
255
+ line-height: 88rpx;
256
+ border-radius: 20rpx;
257
+ text-align: center;
258
+ background: linear-gradient(135deg, var(--brand), var(--brand-2));
259
+ color: #fff;
260
+ font-size: 29rpx;
261
+ font-weight: 700;
262
+ box-shadow: 0 10rpx 24rpx rgba(15, 118, 110, 0.25);
263
+ }
264
+
265
+ /* 步骤卡 */
266
+ .step-head {
267
+ display: flex;
268
+ align-items: flex-start;
269
+ gap: 20rpx;
270
+ margin-bottom: 24rpx;
271
+ }
272
+ .step-num {
273
+ flex-shrink: 0;
274
+ width: 52rpx;
275
+ height: 52rpx;
276
+ line-height: 52rpx;
277
+ text-align: center;
278
+ border-radius: 16rpx;
279
+ background: var(--brand);
280
+ color: #fff;
281
+ font-weight: 800;
282
+ font-size: 28rpx;
283
+ }
284
+ .step-title {
285
+ font-size: 32rpx;
286
+ font-weight: 700;
287
+ color: var(--ink);
288
+ }
289
+ .optional {
290
+ margin-left: 8rpx;
291
+ padding: 2rpx 12rpx;
292
+ border-radius: 8rpx;
293
+ background: #f1f1ee;
294
+ color: var(--muted);
295
+ font-size: 20rpx;
296
+ font-weight: 500;
297
+ vertical-align: middle;
298
+ }
299
+ .step-desc {
300
+ margin-top: 6rpx;
301
+ font-size: 24rpx;
302
+ color: var(--muted);
303
+ }
304
+
305
+ .input-wrap {
306
+ border: 2rpx solid var(--line);
307
+ border-radius: 20rpx;
308
+ background: #fbfaf8;
309
+ padding: 20rpx 24rpx;
310
+ }
311
+ .input-bad { border-color: var(--danger); background: var(--danger-soft); }
312
+ .textarea {
313
+ width: 100%;
314
+ min-height: 120rpx;
315
+ font-size: 30rpx;
316
+ line-height: 1.5;
317
+ color: var(--ink);
318
+ }
319
+ .input {
320
+ height: 48rpx;
321
+ font-size: 28rpx;
322
+ color: var(--ink);
323
+ }
324
+ .ph { color: #b5b8b4; }
325
+ .input-foot {
326
+ margin-top: 12rpx;
327
+ display: flex;
328
+ justify-content: space-between;
329
+ align-items: center;
330
+ }
331
+ .text-status { font-size: 23rpx; color: var(--muted); }
332
+ .text-status.pad { margin-top: 10rpx; }
333
+ .status-checking { color: var(--brand); }
334
+ .status-pass { color: var(--ok); }
335
+ .status-risky { color: var(--danger); font-weight: 600; }
336
+ .status-error { color: var(--warn); }
337
+ .input-tools { display: flex; gap: 24rpx; }
338
+ .tool { font-size: 25rpx; color: var(--muted); }
339
+ .tool-brand { color: var(--brand); font-weight: 600; }
340
+ .tip {
341
+ margin-top: 16rpx;
342
+ font-size: 23rpx;
343
+ color: var(--muted);
344
+ }
345
+
346
+ .upload {
347
+ border: 2rpx dashed #c9d6d3;
348
+ border-radius: 24rpx;
349
+ padding: 44rpx 24rpx;
350
+ text-align: center;
351
+ background: #f8fbfa;
352
+ }
353
+ .upload-icon {
354
+ margin: 0 auto;
355
+ width: 80rpx;
356
+ height: 80rpx;
357
+ line-height: 76rpx;
358
+ border-radius: 50%;
359
+ background: var(--brand-soft);
360
+ color: var(--brand);
361
+ font-size: 48rpx;
362
+ }
363
+ .upload-text {
364
+ margin-top: 16rpx;
365
+ font-size: 29rpx;
366
+ font-weight: 600;
367
+ color: var(--ink);
368
+ }
369
+ .upload-hint {
370
+ margin-top: 8rpx;
371
+ font-size: 22rpx;
372
+ color: var(--muted);
373
+ }
374
+
375
+ .photo {
376
+ display: flex;
377
+ gap: 24rpx;
378
+ align-items: center;
379
+ }
380
+ .photo-thumb {
381
+ width: 150rpx;
382
+ height: 150rpx;
383
+ border-radius: 20rpx;
384
+ background: #eee;
385
+ }
386
+ .photo-info { flex: 1; }
387
+ .photo-meta { font-size: 23rpx; color: var(--ok); }
388
+ .photo-actions {
389
+ margin-top: 16rpx;
390
+ display: flex;
391
+ gap: 12rpx;
392
+ }
393
+ .photo-actions .mini-btn {
394
+ flex: 1;
395
+ padding: 0;
396
+ text-align: center;
397
+ }
398
+
399
+ .mini-btn {
400
+ display: inline-block;
401
+ text-align: center;
402
+ white-space: nowrap;
403
+ padding: 0 22rpx;
404
+ height: 56rpx;
405
+ line-height: 56rpx;
406
+ border-radius: 14rpx;
407
+ background: #f1f1ee;
408
+ color: var(--ink);
409
+ font-size: 24rpx;
410
+ font-weight: 600;
411
+ }
412
+ .mini-btn.accent { background: var(--accent); color: #fff; }
413
+ .mini-btn.danger { color: var(--danger); background: var(--danger-soft); }
414
+
415
+ .row {
416
+ margin-top: 24rpx;
417
+ display: flex;
418
+ align-items: center;
419
+ justify-content: space-between;
420
+ gap: 20rpx;
421
+ }
422
+ .row-label { font-size: 28rpx; color: var(--ink); font-weight: 600; }
423
+ .row-hint { margin-top: 4rpx; font-size: 22rpx; color: var(--muted); }
424
+
425
+ .field-label {
426
+ margin: 28rpx 0 14rpx;
427
+ font-size: 26rpx;
428
+ font-weight: 600;
429
+ color: var(--ink);
430
+ display: flex;
431
+ align-items: center;
432
+ gap: 12rpx;
433
+ }
434
+ .field-label:first-child { margin-top: 0; }
435
+ .field-val { color: var(--brand); font-weight: 700; }
436
+ .field-hint { font-size: 22rpx; color: var(--muted); font-weight: 400; }
437
+ .mini-label { font-size: 23rpx; color: var(--muted); margin-top: 12rpx; }
438
+ .link-text { color: var(--brand); font-size: 24rpx; font-weight: 600; }
439
+ .link-text.center { display: block; text-align: center; margin-top: 20rpx; }
440
+
441
+ /* 分段选择 */
442
+ .seg {
443
+ display: flex;
444
+ gap: 12rpx;
445
+ }
446
+ .seg-item {
447
+ flex: 1;
448
+ padding: 18rpx 8rpx;
449
+ border-radius: 18rpx;
450
+ border: 2rpx solid var(--line);
451
+ background: #fbfaf8;
452
+ text-align: center;
453
+ transition: all 0.15s;
454
+ }
455
+ .seg-item.on {
456
+ border-color: var(--brand);
457
+ background: var(--brand-soft);
458
+ }
459
+ .seg-main { font-size: 27rpx; font-weight: 700; color: var(--ink); }
460
+ .seg-item.on .seg-main { color: var(--brand); }
461
+ .seg-sub { margin-top: 4rpx; font-size: 21rpx; color: var(--muted); }
462
+ .seg.small .seg-item { padding: 12rpx 8rpx; }
463
+
464
+ slider { margin: 0 12rpx; }
465
+
466
+ .pos-grid {
467
+ width: 216rpx;
468
+ display: flex;
469
+ flex-wrap: wrap;
470
+ gap: 12rpx;
471
+ padding: 12rpx;
472
+ border-radius: 20rpx;
473
+ background: #f4f3ef;
474
+ }
475
+ .pos-cell {
476
+ width: 56rpx;
477
+ height: 56rpx;
478
+ border-radius: 12rpx;
479
+ background: #fff;
480
+ border: 2rpx solid var(--line);
481
+ }
482
+ .pos-cell.on { background: var(--brand); border-color: var(--brand); }
483
+
484
+ .advanced-toggle {
485
+ margin-top: 32rpx;
486
+ padding-top: 24rpx;
487
+ border-top: 1rpx solid var(--line);
488
+ display: flex;
489
+ justify-content: space-between;
490
+ align-items: center;
491
+ font-size: 26rpx;
492
+ color: var(--muted);
493
+ }
494
+ .chev { font-size: 36rpx; transform: rotate(90deg); transition: transform 0.2s; }
495
+ .chev.open { transform: rotate(-90deg); }
496
+ .advanced { margin-top: 8rpx; }
497
+
498
+ .chips-scroll { width: 100%; white-space: nowrap; }
499
+ .chips { display: inline-flex; gap: 12rpx; padding-bottom: 4rpx; }
500
+ .chip {
501
+ display: inline-block;
502
+ padding: 16rpx 22rpx;
503
+ border-radius: 18rpx;
504
+ border: 2rpx solid var(--line);
505
+ background: #fbfaf8;
506
+ }
507
+ .chip.on { border-color: var(--brand); background: var(--brand-soft); }
508
+ .chip-main { font-size: 27rpx; font-weight: 700; color: var(--ink); }
509
+ .chip.on .chip-main { color: var(--brand); }
510
+ .chip-sub { margin-top: 4rpx; font-size: 20rpx; color: var(--muted); }
511
+
512
+ /* 用豆清单 */
513
+ .usage {
514
+ display: grid;
515
+ grid-template-columns: repeat(4, 1fr);
516
+ gap: 12rpx;
517
+ }
518
+ .usage-item {
519
+ display: flex;
520
+ align-items: center;
521
+ gap: 10rpx;
522
+ padding: 12rpx;
523
+ border-radius: 14rpx;
524
+ background: #faf9f6;
525
+ }
526
+ .swatch {
527
+ width: 32rpx;
528
+ height: 32rpx;
529
+ border-radius: 50%;
530
+ box-shadow: inset 0 0 0 1rpx rgba(0, 0, 0, 0.12);
531
+ flex-shrink: 0;
532
+ }
533
+ .usage-id { font-size: 23rpx; font-weight: 700; color: var(--ink); }
534
+ .usage-count { margin-left: auto; font-size: 21rpx; color: var(--muted); }
535
+
536
+ /* 页脚 */
537
+ .footer {
538
+ padding: 16rpx 48rpx 24rpx;
539
+ text-align: center;
540
+ }
541
+ .footer-safe {
542
+ display: flex;
543
+ align-items: flex-start;
544
+ justify-content: center;
545
+ gap: 10rpx;
546
+ text-align: left;
547
+ font-size: 22rpx;
548
+ line-height: 1.6;
549
+ color: var(--muted);
550
+ }
551
+ .footer-links {
552
+ margin-top: 16rpx;
553
+ display: flex;
554
+ justify-content: center;
555
+ align-items: center;
556
+ gap: 12rpx;
557
+ }
558
+ .footer-link {
559
+ font-size: 23rpx;
560
+ line-height: 1.6;
561
+ color: var(--brand);
562
+ }
563
+ .footer-shield {
564
+ flex-shrink: 0;
565
+ width: 30rpx;
566
+ height: 30rpx;
567
+ margin-top: 3rpx;
568
+ }
569
+ .footer-links .footer-btn {
570
+ display: inline-block;
571
+ width: auto !important;
572
+ min-height: 0;
573
+ padding: 0 !important;
574
+ margin: 0 !important;
575
+ font-size: 23rpx;
576
+ font-weight: 400;
577
+ line-height: 1.6;
578
+ color: var(--brand);
579
+ background: none;
580
+ }
581
+ .dot { color: #c4c7c3; font-size: 22rpx; }
582
+ .footer-copy { margin-top: 12rpx; font-size: 20rpx; color: #b5b8b4; }
583
+
584
+ /* 底部操作栏 */
585
+ .bottom-bar {
586
+ position: fixed;
587
+ left: 0;
588
+ right: 0;
589
+ bottom: 0;
590
+ z-index: 40;
591
+ display: flex;
592
+ align-items: center;
593
+ gap: 24rpx;
594
+ padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
595
+ background: rgba(255, 255, 255, 0.96);
596
+ box-shadow: 0 -6rpx 30rpx rgba(22, 33, 31, 0.08);
597
+ }
598
+ .bar-info { flex: 1; min-width: 0; }
599
+ .bar-main { font-size: 30rpx; font-weight: 800; color: var(--ink); }
600
+ .bar-sub { margin-top: 2rpx; font-size: 22rpx; color: var(--muted); }
601
+ .primary-btn {
602
+ text-align: center;
603
+ padding: 0 56rpx;
604
+ height: 92rpx;
605
+ line-height: 92rpx;
606
+ border-radius: 999rpx;
607
+ background: linear-gradient(135deg, var(--brand), var(--brand-2));
608
+ color: #fff;
609
+ font-size: 30rpx;
610
+ font-weight: 700;
611
+ box-shadow: 0 10rpx 24rpx rgba(15, 118, 110, 0.3);
612
+ }
613
+ .primary-btn.disabled { opacity: 0.5; box-shadow: none; }
614
+ .primary-btn.block { display: block; width: 100%; padding: 0; }
615
+
616
+ /* 弹层 */
617
+ .mask {
618
+ position: fixed;
619
+ top: 0;
620
+ left: 0;
621
+ right: 0;
622
+ bottom: 0;
623
+ z-index: 100;
624
+ background: rgba(10, 20, 18, 0.5);
625
+ display: flex;
626
+ align-items: flex-end;
627
+ }
628
+ .sheet {
629
+ width: 100%;
630
+ max-height: 88vh;
631
+ display: flex;
632
+ flex-direction: column;
633
+ background: var(--card);
634
+ border-radius: 36rpx 36rpx 0 0;
635
+ padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
636
+ box-sizing: border-box;
637
+ }
638
+ .sheet-handle {
639
+ margin: 0 auto 16rpx;
640
+ width: 72rpx;
641
+ height: 8rpx;
642
+ border-radius: 8rpx;
643
+ background: #e2e2dd;
644
+ }
645
+ .sheet-title {
646
+ font-size: 34rpx;
647
+ font-weight: 800;
648
+ color: var(--ink);
649
+ margin-bottom: 20rpx;
650
+ }
651
+ .sheet-body {
652
+ flex: 1;
653
+ max-height: 62vh;
654
+ margin-bottom: 24rpx;
655
+ }
656
+ .grid-opts {
657
+ margin-top: 16rpx;
658
+ display: flex;
659
+ align-items: center;
660
+ gap: 24rpx;
661
+ }
662
+ .grid-opts .seg { flex: 1; }
663
+ .colors { display: flex; gap: 14rpx; }
664
+ .color-dot {
665
+ width: 44rpx;
666
+ height: 44rpx;
667
+ border-radius: 50%;
668
+ border: 4rpx solid #fff;
669
+ box-shadow: 0 0 0 2rpx var(--line);
670
+ }
671
+ .color-dot.on { box-shadow: 0 0 0 4rpx var(--brand); }
672
+
673
+ .result {
674
+ margin-top: 32rpx;
675
+ border-radius: 24rpx;
676
+ background: #fff;
677
+ border: 2rpx solid var(--line);
678
+ overflow: hidden;
679
+ }
680
+ .result-head {
681
+ display: flex;
682
+ align-items: center;
683
+ gap: 20rpx;
684
+ padding: 28rpx;
685
+ background: linear-gradient(135deg, var(--ok-soft), #ffffff);
686
+ }
687
+ .result-icon {
688
+ flex-shrink: 0;
689
+ width: 64rpx;
690
+ height: 64rpx;
691
+ border-radius: 50%;
692
+ background: var(--ok);
693
+ display: flex;
694
+ align-items: center;
695
+ justify-content: center;
696
+ box-shadow: 0 8rpx 18rpx rgba(21, 128, 61, 0.25);
697
+ }
698
+ .result-icon.warn { background: var(--warn); box-shadow: none; }
699
+ .result-tick {
700
+ width: 16rpx;
701
+ height: 28rpx;
702
+ margin-top: -6rpx;
703
+ border-right: 5rpx solid #fff;
704
+ border-bottom: 5rpx solid #fff;
705
+ transform: rotate(45deg);
706
+ }
707
+ .result-text { flex: 1; min-width: 0; }
708
+ .result-title { font-size: 30rpx; font-weight: 800; color: var(--ink); }
709
+ .result-sub { margin-top: 6rpx; font-size: 23rpx; color: var(--muted); line-height: 1.5; }
710
+ .result-list { padding: 8rpx 28rpx; }
711
+ .result-file {
712
+ display: flex;
713
+ align-items: center;
714
+ gap: 20rpx;
715
+ padding: 20rpx 0;
716
+ }
717
+ .result-file + .result-file { border-top: 1rpx solid var(--line); }
718
+ .file-badge {
719
+ flex-shrink: 0;
720
+ width: 72rpx;
721
+ height: 84rpx;
722
+ line-height: 84rpx;
723
+ border-radius: 12rpx;
724
+ text-align: center;
725
+ font-size: 20rpx;
726
+ font-weight: 800;
727
+ letter-spacing: 1rpx;
728
+ color: #fff;
729
+ background: #e5484d;
730
+ }
731
+ .file-badge.png { background: #3b82f6; }
732
+ .file-info { flex: 1; min-width: 0; }
733
+ .file-name {
734
+ font-size: 27rpx;
735
+ font-weight: 700;
736
+ color: var(--ink);
737
+ overflow: hidden;
738
+ white-space: nowrap;
739
+ text-overflow: ellipsis;
740
+ }
741
+ .file-desc { margin-top: 4rpx; font-size: 22rpx; color: var(--muted); }
742
+ .file-actions { flex-shrink: 0; display: flex; gap: 12rpx; }
743
+ .file-btn {
744
+ min-width: 100rpx;
745
+ height: 60rpx;
746
+ line-height: 60rpx;
747
+ padding: 0 20rpx;
748
+ border-radius: 999rpx;
749
+ text-align: center;
750
+ font-size: 24rpx;
751
+ font-weight: 700;
752
+ color: var(--brand);
753
+ background: var(--brand-soft);
754
+ }
755
+ .file-btn.primary { color: #fff; background: var(--brand); }
756
+
757
+ /* 裁切 */
758
+ .crop-mask { align-items: stretch; background: #0b1413; }
759
+ .crop-panel {
760
+ width: 100%;
761
+ padding: calc(env(safe-area-inset-top) + 100rpx) 40rpx 40rpx;
762
+ box-sizing: border-box;
763
+ overflow-y: auto;
764
+ color: #fff;
765
+ }
766
+ .crop-head {
767
+ display: flex;
768
+ justify-content: space-between;
769
+ align-items: center;
770
+ margin-bottom: 28rpx;
771
+ }
772
+ .crop-title { font-size: 32rpx; font-weight: 700; }
773
+ .crop-cancel { font-size: 28rpx; color: rgba(255, 255, 255, 0.7); }
774
+ .crop-ok {
775
+ padding: 10rpx 28rpx;
776
+ border-radius: 999rpx;
777
+ background: var(--brand-2);
778
+ color: var(--brand-deep);
779
+ font-size: 28rpx;
780
+ font-weight: 700;
781
+ }
782
+ .crop-ok.wait { opacity: 0.55; }
783
+ .crop-box {
784
+ width: 670rpx;
785
+ height: 670rpx;
786
+ margin: 0 auto;
787
+ border-radius: 20rpx;
788
+ overflow: hidden;
789
+ background: #1a2523;
790
+ }
791
+ .crop-canvas { width: 670rpx; height: 670rpx; }
792
+ .crop-check {
793
+ margin-top: 20rpx;
794
+ padding: 14rpx 20rpx;
795
+ border-radius: 14rpx;
796
+ font-size: 24rpx;
797
+ background: rgba(255, 255, 255, 0.08);
798
+ color: rgba(255, 255, 255, 0.8);
799
+ }
800
+ .check-pass { color: #6ee7b7; }
801
+ .check-risky { background: rgba(229, 72, 77, 0.2); color: #fca5a5; }
802
+ .check-error { color: #fcd34d; }
803
+ .crop-hint { margin-top: 12rpx; font-size: 22rpx; color: rgba(255, 255, 255, 0.5); text-align: center; }
804
+ .field-label.light, .row.light .row-label { color: #fff; }
805
+ .row.light .row-hint, .mini-label.light { color: rgba(255, 255, 255, 0.55); }
806
+ .crop-tools { margin-top: 24rpx; display: flex; gap: 16rpx; }
807
+ .crop-tool {
808
+ text-align: center;
809
+ flex: 1;
810
+ height: 72rpx;
811
+ line-height: 72rpx;
812
+ border-radius: 16rpx;
813
+ background: rgba(255, 255, 255, 0.1);
814
+ color: #fff;
815
+ font-size: 25rpx;
816
+ }
817
+ .crop-replace { margin-top: 32rpx; }
818
+
819
+ /* 离屏画布:不能 display:none,否则取不到节点 */
820
+ .offscreen {
821
+ position: fixed;
822
+ left: -9999px;
823
+ top: 0;
824
+ width: 10px;
825
+ height: 10px;
826
+ overflow: hidden;
827
+ }
828
+ .off-canvas { width: 10px; height: 10px; }
BeadQR/miniprogram/sitemap.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
3
+ "rules": [{
4
+ "action": "allow",
5
+ "page": "*"
6
+ }]
7
+ }
BeadQR/miniprogram/utils/color.js ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 颜色基础:HEX/RGB 转换、OKLab 感知色彩空间、亮度
2
+ // OKLab 比 RGB 欧氏距离更接近人眼感受,配色更自然;距离统一放大 100 倍便于调参
3
+
4
+ function hexToRgb(hex) {
5
+ const value = String(hex || '').replace('#', '')
6
+ const full = value.length === 3 ? value.split('').map((c) => c + c).join('') : value
7
+ const num = parseInt(full, 16) || 0
8
+ return [(num >> 16) & 255, (num >> 8) & 255, num & 255]
9
+ }
10
+
11
+ function rgbToHex(r, g, b) {
12
+ const h = (v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
13
+ return `#${h(r)}${h(g)}${h(b)}`
14
+ }
15
+
16
+ // sRGB 伽马解码查表,避免每次调用 Math.pow
17
+ const SRGB_TO_LINEAR = new Float32Array(256)
18
+ for (let i = 0; i < 256; i += 1) {
19
+ const c = i / 255
20
+ SRGB_TO_LINEAR[i] = c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
21
+ }
22
+
23
+ // 写入 out[offset..offset+2](L, a, b,均 ×100)
24
+ function rgbToOklabInto(r, g, b, out, offset) {
25
+ const lr = SRGB_TO_LINEAR[r]
26
+ const lg = SRGB_TO_LINEAR[g]
27
+ const lb = SRGB_TO_LINEAR[b]
28
+ const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb)
29
+ const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb)
30
+ const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb)
31
+ out[offset] = (0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s) * 100
32
+ out[offset + 1] = (1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s) * 100
33
+ out[offset + 2] = (0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s) * 100
34
+ }
35
+
36
+ // 感知亮度(与旧版一致的 Rec.601 加权,0~1),用于二维码深/浅模块约束
37
+ function luma(r, g, b) {
38
+ return (r * 0.299 + g * 0.587 + b * 0.114) / 255
39
+ }
40
+
41
+ function lumaHex(hex) {
42
+ const [r, g, b] = hexToRgb(hex)
43
+ return luma(r, g, b)
44
+ }
45
+
46
+ module.exports = { hexToRgb, rgbToHex, rgbToOklabInto, luma, lumaHex }
BeadQR/miniprogram/utils/engine.js ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 配色引擎:把底图 + 二维码合成为 MARD 色号网格
2
+ // 性能要点:
3
+ // 1. 5bit 量化桶 → 候选 Top-K 查找表(懒加载),每格只精算约 10 个候选,而非整套 291 色
4
+ // 2. OKLab 感知距离 + 色间距离矩阵,全部 Typed Array,无对象分配
5
+ // 3. “颜色连贯度”参考已选中的左/上邻居色号,减少零碎杂色,拼起来更省事
6
+ // 4. 定位/时序/对齐等功能区使用更严格的深浅阈值,显著提升扫码成功率
7
+ const { rgbToOklabInto } = require('./color')
8
+ const { MATCH_MODES } = require('./palette')
9
+ const { PAPER } = require('./imaging')
10
+
11
+ const TOP_K = 10
12
+ const C_ANY = 0
13
+ const C_DARK = 1
14
+ const C_LIGHT = 2
15
+ const C_FDARK = 3
16
+ const C_FLIGHT = 4
17
+ const C_QUIET = 5
18
+ const C_EMPTY = 6
19
+
20
+ function subsetFor(pal, mode, kind) {
21
+ const key = `${mode.id}:${kind}`
22
+ if (pal.subsets[key]) return pal.subsets[key]
23
+ const { n, lum } = pal
24
+ let test
25
+ if (kind === C_DARK) test = (i) => lum[i] <= mode.darkMax
26
+ else if (kind === C_LIGHT) test = (i) => lum[i] >= mode.lightMin
27
+ else if (kind === C_FDARK) test = (i) => lum[i] <= Math.min(mode.darkMax, 0.3)
28
+ else if (kind === C_FLIGHT) test = (i) => lum[i] >= Math.max(mode.lightMin, 0.82)
29
+ else test = () => true
30
+ let list = []
31
+ for (let i = 0; i < n; i += 1) if (test(i)) list.push(i)
32
+ if (!list.length) {
33
+ // 色库太小时退化为最深/最浅的 3 个颜色
34
+ const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => lum[a] - lum[b])
35
+ list = kind === C_DARK || kind === C_FDARK ? order.slice(0, 3) : order.slice(-3)
36
+ }
37
+ const member = new Uint8Array(n)
38
+ list.forEach((i) => { member[i] = 1 })
39
+ const subset = { key, list: Int16Array.from(list), member, lut: new Map() }
40
+ pal.subsets[key] = subset
41
+ return subset
42
+ }
43
+
44
+ const tmpLab = new Float32Array(3)
45
+
46
+ function topKForBucket(pal, subset, bucket) {
47
+ let hit = subset.lut.get(bucket)
48
+ if (hit) return hit
49
+ const r = ((bucket >> 10) & 31) * 8 + 4
50
+ const g = ((bucket >> 5) & 31) * 8 + 4
51
+ const b = (bucket & 31) * 8 + 4
52
+ rgbToOklabInto(r, g, b, tmpLab, 0)
53
+ const { lab } = pal
54
+ const list = subset.list
55
+ const k = Math.min(TOP_K, list.length)
56
+ const bestIdx = new Int16Array(k).fill(-1)
57
+ const bestDist = new Float32Array(k).fill(Infinity)
58
+ for (let t = 0; t < list.length; t += 1) {
59
+ const i = list[t]
60
+ const dl = lab[i * 3] - tmpLab[0]
61
+ const da = lab[i * 3 + 1] - tmpLab[1]
62
+ const db = lab[i * 3 + 2] - tmpLab[2]
63
+ const d = dl * dl + da * da + db * db
64
+ if (d >= bestDist[k - 1]) continue
65
+ let p = k - 1
66
+ while (p > 0 && bestDist[p - 1] > d) { bestDist[p] = bestDist[p - 1]; bestIdx[p] = bestIdx[p - 1]; p -= 1 }
67
+ bestDist[p] = d
68
+ bestIdx[p] = i
69
+ }
70
+ hit = bestIdx
71
+ subset.lut.set(bucket, hit)
72
+ return hit
73
+ }
74
+
75
+ /**
76
+ * @param base { size, rgb: Uint8Array, mask?: Uint8Array } 或 null(无底图)
77
+ * @param qr makeQr() 结果
78
+ * @param placement createPlacement() 结果
79
+ * @param pal preparePalette() 结果
80
+ * @param options { modeId, coherence(0-100), cleanup(bool) }
81
+ */
82
+ function computeArtwork(base, qr, placement, pal, options = {}) {
83
+ const mode = MATCH_MODES.find((m) => m.id === options.modeId) || MATCH_MODES[1]
84
+ const size = placement.size
85
+ const total = size * size
86
+ const lab = new Float32Array(total * 3)
87
+ const bucket = new Int32Array(total)
88
+ const kind = new Uint8Array(total)
89
+ const { left, top, quiet } = placement
90
+
91
+ for (let y = 0; y < size; y += 1) {
92
+ for (let x = 0; x < size; x += 1) {
93
+ const i = y * size + x
94
+ const qx = x - left
95
+ const qy = y - top
96
+ const inQr = qx >= 0 && qy >= 0 && qx < qr.size && qy < qr.size
97
+ const inQuiet = !inQr && x >= left - quiet && x < left + qr.size + quiet && y >= top - quiet && y < top + qr.size + quiet
98
+ let r; let g; let b
99
+ if (inQr) {
100
+ const q = qy * qr.size + qx
101
+ const isDark = qr.dark[q]
102
+ const isFunc = qr.func[q]
103
+ kind[i] = isFunc ? (isDark ? C_FDARK : C_FLIGHT) : (isDark ? C_DARK : C_LIGHT)
104
+ if (base && !(base.mask && base.mask[i])) {
105
+ r = base.rgb[i * 3]; g = base.rgb[i * 3 + 1]; b = base.rgb[i * 3 + 2]
106
+ } else if (base) {
107
+ r = PAPER[0]; g = PAPER[1]; b = PAPER[2]
108
+ } else {
109
+ r = g = b = isDark ? 0 : 255
110
+ }
111
+ } else if (inQuiet || !base) {
112
+ kind[i] = C_QUIET
113
+ continue
114
+ } else if (base.mask && base.mask[i]) {
115
+ kind[i] = C_EMPTY
116
+ continue
117
+ } else {
118
+ kind[i] = C_ANY
119
+ r = base.rgb[i * 3]; g = base.rgb[i * 3 + 1]; b = base.rgb[i * 3 + 2]
120
+ }
121
+ rgbToOklabInto(r, g, b, lab, i * 3)
122
+ bucket[i] = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3)
123
+ }
124
+ }
125
+
126
+ const n = pal.n
127
+ const plab = pal.lab
128
+ const pair = pal.pair
129
+ const weight = (Math.max(0, Math.min(100, options.coherence == null ? 40 : options.coherence)) / 100) * 0.6
130
+ const subsets = [C_ANY, C_DARK, C_LIGHT, C_FDARK, C_FLIGHT].map((k) => subsetFor(pal, mode, k))
131
+ const cells = new Int16Array(total).fill(-1)
132
+
133
+ const distTo = (c, i) => {
134
+ const dl = plab[c * 3] - lab[i * 3]
135
+ const da = plab[c * 3 + 1] - lab[i * 3 + 1]
136
+ const db = plab[c * 3 + 2] - lab[i * 3 + 2]
137
+ return Math.sqrt(dl * dl + da * da + db * db)
138
+ }
139
+
140
+ for (let y = 0; y < size; y += 1) {
141
+ for (let x = 0; x < size; x += 1) {
142
+ const i = y * size + x
143
+ const k = kind[i]
144
+ if (k >= C_QUIET) continue
145
+ const subset = subsets[k]
146
+ const cands = topKForBucket(pal, subset, bucket[i])
147
+ const nl = x > 0 ? cells[i - 1] : -1
148
+ const nu = y > 0 ? cells[i - size] : -1
149
+ const nbCount = (nl >= 0 ? 1 : 0) + (nu >= 0 ? 1 : 0)
150
+ let best = -1
151
+ let bestScore = Infinity
152
+ const consider = (c) => {
153
+ let score = distTo(c, i)
154
+ if (weight > 0 && nbCount) {
155
+ let s = 0
156
+ if (nl >= 0) s += pair[c * n + nl]
157
+ if (nu >= 0) s += pair[c * n + nu]
158
+ score += weight * (s / nbCount)
159
+ }
160
+ if (score < bestScore) { bestScore = score; best = c }
161
+ }
162
+ for (let t = 0; t < cands.length; t += 1) if (cands[t] >= 0) consider(cands[t])
163
+ if (weight > 0) {
164
+ if (nl >= 0 && subset.member[nl]) consider(nl)
165
+ if (nu >= 0 && nu !== nl && subset.member[nu]) consider(nu)
166
+ }
167
+ cells[i] = best
168
+ }
169
+ }
170
+
171
+ // 合并零散色:只用了 1~2 颗的色号并入最接近的常用色,减少需要购买的色号
172
+ const counts = new Int32Array(n)
173
+ for (let i = 0; i < total; i += 1) if (cells[i] >= 0) counts[cells[i]] += 1
174
+ if (options.cleanup !== false) {
175
+ const kept = []
176
+ for (let c = 0; c < n; c += 1) if (counts[c] >= 3) kept.push(c)
177
+ if (kept.length) {
178
+ for (let i = 0; i < total; i += 1) {
179
+ const c = cells[i]
180
+ if (c < 0 || counts[c] >= 3) continue
181
+ const member = subsets[kind[i]].member
182
+ let best = -1
183
+ let bestD = Infinity
184
+ for (let t = 0; t < kept.length; t += 1) {
185
+ const kc = kept[t]
186
+ if (!member[kc]) continue
187
+ const d = distTo(kc, i)
188
+ if (d < bestD) { bestD = d; best = kc }
189
+ }
190
+ // 与原色差别过大时保留原色,避免破坏画面
191
+ if (best >= 0 && bestD <= distTo(c, i) + 12) cells[i] = best
192
+ }
193
+ counts.fill(0)
194
+ for (let i = 0; i < total; i += 1) if (cells[i] >= 0) counts[cells[i]] += 1
195
+ }
196
+ }
197
+
198
+ // 二维码留白区:使用画面中最常用的浅色;没有则用色库里最浅的颜色
199
+ let quietColor = -1
200
+ let quietCount = -1
201
+ for (let c = 0; c < n; c += 1) {
202
+ if (pal.lum[c] >= Math.max(0.8, mode.lightMin) && counts[c] > quietCount) { quietColor = c; quietCount = counts[c] }
203
+ }
204
+ if (quietCount <= 0) {
205
+ quietColor = 0
206
+ for (let c = 1; c < n; c += 1) if (pal.lum[c] > pal.lum[quietColor]) quietColor = c
207
+ }
208
+ for (let i = 0; i < total; i += 1) {
209
+ if (kind[i] === C_QUIET) { cells[i] = quietColor; counts[quietColor] += 1 }
210
+ }
211
+
212
+ const usage = []
213
+ let beads = 0
214
+ for (let c = 0; c < n; c += 1) {
215
+ if (counts[c] > 0) {
216
+ usage.push({ index: c, id: pal.ids[c], hex: pal.hexes[c], count: counts[c] })
217
+ beads += counts[c]
218
+ }
219
+ }
220
+ usage.sort((a, b) => b.count - a.count)
221
+
222
+ return { size, cells, usage, beads, pal, qr, placement }
223
+ }
224
+
225
+ module.exports = { computeArtwork }
BeadQR/miniprogram/utils/imaging.js ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 图像取样:超采样 + 区域平均,得到干净的格子颜色(旧版一次性缩放会产生噪点和锯齿色)
2
+ // 所有结果都用 Typed Array 表示,不再生成 HEX 字符串再反解析
3
+
4
+ const PAPER = [247, 241, 230]
5
+
6
+ function getImageDimensions(image) {
7
+ const width = Number(image.width) || Number(image.naturalWidth) || Number(image._w)
8
+ const height = Number(image.height) || Number(image.naturalHeight) || Number(image._h)
9
+ if (!width || !height) throw new Error('IMAGE_DIMENSIONS_UNAVAILABLE')
10
+ return { width, height }
11
+ }
12
+
13
+ // 把图片按裁切参数画到 context 上(cover 语义,zoom≥1)
14
+ function drawCropped(context, image, width, height, crop) {
15
+ const source = getImageDimensions(image)
16
+ const scale = Math.max(width / source.width, height / source.height) * (crop.zoom || 1)
17
+ const w = source.width * scale
18
+ const h = source.height * scale
19
+ const offsetX = (width - w) * ((crop.focusX == null ? 50 : crop.focusX) / 100)
20
+ const offsetY = (height - h) * ((crop.focusY == null ? 50 : crop.focusY) / 100)
21
+ context.fillStyle = `rgb(${PAPER[0]},${PAPER[1]},${PAPER[2]})`
22
+ context.fillRect(0, 0, width, height)
23
+ context.save()
24
+ context.translate(crop.flipH ? width : 0, crop.flipV ? height : 0)
25
+ context.scale(crop.flipH ? -1 : 1, crop.flipV ? -1 : 1)
26
+ context.drawImage(image, offsetX, offsetY, w, h)
27
+ context.restore()
28
+ }
29
+
30
+ // 返回 { size, rgb: Uint8Array(size*size*3) }
31
+ function sampleImage(canvas, image, size, crop) {
32
+ // 超采样到约 700px 再按块平均,等价于高质量面积缩放
33
+ const factor = Math.max(1, Math.min(24, Math.floor(720 / size)))
34
+ const big = size * factor
35
+ canvas.width = big
36
+ canvas.height = big
37
+ const context = canvas.getContext('2d')
38
+ context.imageSmoothingEnabled = true
39
+ try { context.imageSmoothingQuality = 'high' } catch (e) { /* 部分基础库不支持 */ }
40
+ drawCropped(context, image, big, big, crop || {})
41
+ const pixels = context.getImageData(0, 0, big, big).data
42
+ const rgb = new Uint8Array(size * size * 3)
43
+ const area = factor * factor
44
+ for (let gy = 0; gy < size; gy += 1) {
45
+ for (let gx = 0; gx < size; gx += 1) {
46
+ let r = 0; let g = 0; let b = 0
47
+ for (let sy = 0; sy < factor; sy += 1) {
48
+ let p = ((gy * factor + sy) * big + gx * factor) * 4
49
+ for (let sx = 0; sx < factor; sx += 1) {
50
+ r += pixels[p]; g += pixels[p + 1]; b += pixels[p + 2]
51
+ p += 4
52
+ }
53
+ }
54
+ const o = (gy * size + gx) * 3
55
+ rgb[o] = (r / area + 0.5) | 0
56
+ rgb[o + 1] = (g / area + 0.5) | 0
57
+ rgb[o + 2] = (b / area + 0.5) | 0
58
+ }
59
+ }
60
+ return { size, rgb }
61
+ }
62
+
63
+ // 去除简单纯色背景:四角均色为背景色,从边缘洪泛填充相近颜色(主体内部不受影响)
64
+ // 返回 Uint8Array 掩码:1 = 背景(空钉位,不放豆)
65
+ function removeSimpleBackground(rgb, size, tolerance) {
66
+ const corners = [0, size - 1, (size - 1) * size, size * size - 1]
67
+ let br = 0; let bg = 0; let bb = 0
68
+ corners.forEach((i) => { br += rgb[i * 3]; bg += rgb[i * 3 + 1]; bb += rgb[i * 3 + 2] })
69
+ br /= 4; bg /= 4; bb /= 4
70
+ const threshold = (tolerance / 100) * 441.673
71
+ const limit = threshold * threshold
72
+ const mask = new Uint8Array(size * size)
73
+ const isBg = (i) => {
74
+ const dr = rgb[i * 3] - br; const dg = rgb[i * 3 + 1] - bg; const db = rgb[i * 3 + 2] - bb
75
+ return dr * dr + dg * dg + db * db <= limit
76
+ }
77
+ const stack = []
78
+ const push = (x, y) => {
79
+ if (x < 0 || y < 0 || x >= size || y >= size) return
80
+ const i = y * size + x
81
+ if (mask[i] || !isBg(i)) return
82
+ mask[i] = 1
83
+ stack.push(i)
84
+ }
85
+ for (let i = 0; i < size; i += 1) { push(i, 0); push(i, size - 1); push(0, i); push(size - 1, i) }
86
+ while (stack.length) {
87
+ const i = stack.pop()
88
+ const x = i % size
89
+ const y = (i - x) / size
90
+ push(x + 1, y); push(x - 1, y); push(x, y + 1); push(x, y - 1)
91
+ }
92
+ return mask
93
+ }
94
+
95
+ module.exports = { PAPER, getImageDimensions, drawCropped, sampleImage, removeSimpleBackground }
BeadQR/miniprogram/utils/intro.js ADDED
@@ -0,0 +1,703 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 首屏动态演示(1:1)
2
+ // 纯代码绘制:不占包体积(无视频/图片资源),小程序实时播放与宣传视频渲染共用同一条时间轴。
3
+ // 演示用的拼豆作品由真实配色引擎实时生成,并且确实可以被微信扫出 “Happy Birthday!”。
4
+ const { makeQr, createPlacement } = require('./qr')
5
+ const { preparePalette } = require('./palette')
6
+ const { computeArtwork } = require('./engine')
7
+
8
+ const DEMO_TEXT = 'Happy Birthday!'
9
+ const DURATION = 12
10
+ const U = 1080 // 设计稿边长,绘制时按实际像素缩放
11
+ const TAU = Math.PI * 2
12
+ const FLIGHT = 0.85
13
+ // 豆子半径(相对格宽):略大于 0.5,相邻豆子轻微相接,像熨烫后的成品,扫码更稳
14
+ const BEAD_R = 0.52
15
+
16
+ const C = {
17
+ bg: '#f4efe6',
18
+ ink: '#16211f',
19
+ muted: '#6b7471',
20
+ brand: '#0f766e',
21
+ brand2: '#14b8a6',
22
+ brandSoft: '#dcf1ec',
23
+ board: '#fffdf8',
24
+ dark: '#22302e',
25
+ light: '#fbf8f1',
26
+ wechat: '#07c160',
27
+ }
28
+
29
+ const SCENES = [
30
+ { a: 0, b: 2.6, title: '写一句话', sub: '扫码后想让人看到的话' },
31
+ { a: 2.6, b: 5.0, title: '自动编成二维码', sub: '每一颗拼豆,都是一个像素' },
32
+ { a: 5.0, b: 7.6, title: '融进你的照片', sub: '换上照片的颜色,依然能扫' },
33
+ { a: 7.6, b: 10.0, title: '微信扫一扫', sub: '拼好的作品,一扫就读出这句话' },
34
+ ]
35
+
36
+ const CONFETTI = [
37
+ [0.17, 0.19, 0.045, '#ffd166'], [0.85, 0.2, 0.04, '#4cc9f0'], [0.13, 0.8, 0.04, '#2ec4b6'],
38
+ [0.86, 0.82, 0.045, '#ffd166'], [0.5, 0.11, 0.03, '#2ec4b6'], [0.91, 0.51, 0.03, '#ff9f1c'], [0.08, 0.5, 0.03, '#4cc9f0'],
39
+ ]
40
+
41
+ const DECOR = [
42
+ [90, 150, 22, '#14b8a6'], [990, 120, 16, '#ffb703'], [60, 960, 18, '#ff7a45'], [1010, 930, 24, '#8ecae6'],
43
+ [170, 1040, 12, '#ffb703'], [930, 330, 12, '#ff7a45'], [120, 520, 10, '#8ecae6'], [980, 700, 14, '#14b8a6'],
44
+ ]
45
+
46
+ // —— 工具 ——
47
+ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v)
48
+ const lerp = (a, b, t) => a + (b - a) * t
49
+ const prog = (t, a, b) => clamp01((t - a) / (b - a))
50
+ const easeOut = (t) => 1 - Math.pow(1 - t, 3)
51
+ const easeIn = (t) => t * t * t
52
+ const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)
53
+ const easeBack = (t) => { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2) }
54
+ const bump = (t, a, b) => { const p = prog(t, a, b); return p > 0 && p < 1 ? Math.sin(Math.PI * p) : 0 }
55
+
56
+ function rng(seed) {
57
+ let a = seed >>> 0
58
+ return () => {
59
+ a = (a + 0x6D2B79F5) >>> 0
60
+ let t = a
61
+ t = Math.imul(t ^ (t >>> 15), t | 1)
62
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
63
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
64
+ }
65
+ }
66
+
67
+ function rr(ctx, x, y, w, h, r) {
68
+ r = Math.max(0, Math.min(r, w / 2, h / 2))
69
+ ctx.moveTo(x + r, y)
70
+ ctx.arcTo(x + w, y, x + w, y + h, r)
71
+ ctx.arcTo(x + w, y + h, x, y + h, r)
72
+ ctx.arcTo(x, y + h, x, y, r)
73
+ ctx.arcTo(x, y, x + w, y, r)
74
+ ctx.closePath()
75
+ }
76
+
77
+ // 三层叠加的柔和阴影:比 shadowBlur 便宜得多,手机上逐帧绘制也流畅
78
+ function softShadow(ctx, x, y, w, h, r, alpha) {
79
+ for (let i = 3; i >= 1; i -= 1) {
80
+ const g = i * 8
81
+ ctx.fillStyle = `rgba(52,38,18,${(alpha / (i + 1)).toFixed(3)})`
82
+ ctx.beginPath()
83
+ rr(ctx, x - g / 2, y - g / 2 + g * 0.8, w + g, h + g, r + g / 2)
84
+ ctx.fill()
85
+ }
86
+ }
87
+
88
+ function heartPath(ctx, cx, cy, s) {
89
+ ctx.moveTo(cx, cy + s * 0.9)
90
+ ctx.bezierCurveTo(cx - s * 1.3, cy + s * 0.05, cx - s * 0.95, cy - s * 1.0, cx, cy - s * 0.42)
91
+ ctx.bezierCurveTo(cx + s * 0.95, cy - s * 1.0, cx + s * 1.3, cy + s * 0.05, cx, cy + s * 0.9)
92
+ ctx.closePath()
93
+ }
94
+
95
+ // 演示“照片”:暖色渐变 + 爱心 + 彩纸屑(矢量绘制,任意尺寸都清晰)
96
+ function drawIllustration(ctx, x, y, s) {
97
+ ctx.save()
98
+ ctx.beginPath()
99
+ ctx.rect(x, y, s, s)
100
+ ctx.clip()
101
+ let g = ctx.createLinearGradient(x, y, x, y + s)
102
+ g.addColorStop(0, '#63c7b8')
103
+ g.addColorStop(1, '#4aa3c9')
104
+ ctx.fillStyle = g
105
+ ctx.fillRect(x, y, s, s)
106
+ g = ctx.createRadialGradient(x + s * 0.5, y + s * 0.48, 0, x + s * 0.5, y + s * 0.48, s * 0.55)
107
+ g.addColorStop(0, 'rgba(255,255,255,0.35)')
108
+ g.addColorStop(1, 'rgba(255,255,255,0)')
109
+ ctx.fillStyle = g
110
+ ctx.fillRect(x, y, s, s)
111
+ CONFETTI.forEach(([cx, cy, r, col]) => {
112
+ ctx.fillStyle = col
113
+ ctx.beginPath()
114
+ ctx.arc(x + cx * s, y + cy * s, r * s, 0, TAU)
115
+ ctx.fill()
116
+ })
117
+ g = ctx.createLinearGradient(x, y + s * 0.2, x, y + s * 0.85)
118
+ g.addColorStop(0, '#ff6b88')
119
+ g.addColorStop(1, '#d42a55')
120
+ ctx.fillStyle = g
121
+ ctx.beginPath()
122
+ heartPath(ctx, x + s * 0.5, y + s * 0.55, s * 0.34)
123
+ ctx.fill()
124
+ ctx.fillStyle = 'rgba(255,255,255,0.55)'
125
+ ctx.save()
126
+ ctx.translate(x + s * 0.37, y + s * 0.42)
127
+ ctx.rotate(-0.6)
128
+ ctx.scale(1, 0.55)
129
+ ctx.beginPath()
130
+ ctx.arc(0, 0, s * 0.06, 0, TAU)
131
+ ctx.fill()
132
+ ctx.restore()
133
+ ctx.restore()
134
+ }
135
+
136
+ // 用真实引擎生成演示作品:插画按格面积取平均色 → 配色引擎
137
+ function buildDemo(workCanvas, createCanvas) {
138
+ const qr = makeQr(DEMO_TEXT, 'H')
139
+ const placement = createPlacement(qr.size, { scale: 100, quiet: 1, hasImage: true })
140
+ const size = placement.size
141
+ const k = 6
142
+ const px = size * k
143
+ const canvas = createCanvas ? createCanvas(px, px) : workCanvas
144
+ canvas.width = px
145
+ canvas.height = px
146
+ const ctx = canvas.getContext('2d')
147
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
148
+ ctx.clearRect(0, 0, px, px)
149
+ drawIllustration(ctx, 0, 0, px)
150
+ const data = ctx.getImageData(0, 0, px, px).data
151
+ const rgb = new Uint8Array(size * size * 3)
152
+ for (let r = 0; r < size; r += 1) {
153
+ for (let c = 0; c < size; c += 1) {
154
+ let sr = 0; let sg = 0; let sb = 0
155
+ for (let y = 0; y < k; y += 1) {
156
+ let o = ((r * k + y) * px + c * k) * 4
157
+ for (let x = 0; x < k; x += 1, o += 4) { sr += data[o]; sg += data[o + 1]; sb += data[o + 2] }
158
+ }
159
+ const i = (r * size + c) * 3
160
+ rgb[i] = sr / (k * k); rgb[i + 1] = sg / (k * k); rgb[i + 2] = sb / (k * k)
161
+ }
162
+ }
163
+ const art = computeArtwork({ size, rgb }, qr, placement, preparePalette('221'), { modeId: 'balanced', coherence: 40, cleanup: true })
164
+ return { qr, placement, art }
165
+ }
166
+
167
+ // —— 豆子批量绘制:按颜色合并路径,每帧只需十几次填充 ——
168
+ function addBead(groups, hex, x, y, r) {
169
+ let list = groups.get(hex)
170
+ if (!list) { list = []; groups.set(hex, list) }
171
+ list.push(x, y, r)
172
+ }
173
+
174
+ // 小批量合并路径:一条路径里子路径太多时,部分渲染引擎反而更慢,每 12 颗提交一次最稳
175
+ const CHUNK = 12
176
+ function fillCircles(ctx, list, rs, ox, oy) {
177
+ let n = 0
178
+ ctx.beginPath()
179
+ for (let i = 0; i < list.length; i += 3) {
180
+ const r = list[i + 2] * rs
181
+ const x = list[i] + list[i + 2] * ox
182
+ const y = list[i + 1] + list[i + 2] * oy
183
+ ctx.moveTo(x + r, y)
184
+ ctx.arc(x, y, r, 0, TAU)
185
+ n += 1
186
+ if (n === CHUNK) { ctx.fill(); ctx.beginPath(); n = 0 }
187
+ }
188
+ if (n) ctx.fill()
189
+ }
190
+
191
+ function flushBeads(ctx, groups) {
192
+ const all = []
193
+ groups.forEach((list, hex) => {
194
+ ctx.fillStyle = hex
195
+ fillCircles(ctx, list, 1, 0, 0)
196
+ all.push(list)
197
+ })
198
+ // 中孔:拼豆是一颗颗小管子
199
+ ctx.fillStyle = 'rgba(70,48,24,0.17)'
200
+ all.forEach((list) => fillCircles(ctx, list, 0.3, 0, 0))
201
+ // 高光
202
+ ctx.fillStyle = 'rgba(255,255,255,0.4)'
203
+ all.forEach((list) => fillCircles(ctx, list, 0.2, -0.42, -0.42))
204
+ groups.clear()
205
+ }
206
+
207
+ /**
208
+ * @param opts { workCanvas?, createCanvas?(w,h), font?, loop? }
209
+ * @returns { duration, text, artwork, draw(ctx, t, pixelWidth) }
210
+ */
211
+ function createIntro(opts = {}) {
212
+ const font = opts.font || 'sans-serif'
213
+ const loop = opts.loop !== false
214
+ const F = (weight, px) => `${weight} ${Math.round(px)}px ${font}`
215
+ const { qr, placement, art } = buildDemo(opts.workCanvas, opts.createCanvas)
216
+ const size = art.size
217
+ const R = rng(20260929)
218
+ const cells = []
219
+ for (let r = 0; r < size; r += 1) {
220
+ for (let c = 0; c < size; c += 1) {
221
+ const qc = c - placement.left
222
+ const qrow = r - placement.top
223
+ const dark = qc >= 0 && qrow >= 0 && qc < qr.size && qrow < qr.size && qr.dark[qrow * qr.size + qc] === 1
224
+ const idx = art.cells[r * size + c]
225
+ cells.push({
226
+ c,
227
+ r,
228
+ qrHex: dark ? C.dark : C.light,
229
+ finalHex: idx >= 0 ? art.pal.hexes[idx] : C.board,
230
+ k: Math.max(0, Math.min(DEMO_TEXT.length - 1, Math.floor((c / size) * DEMO_TEXT.length + (R() - 0.5) * 2.5))),
231
+ jx: R(),
232
+ jy: R() - 0.5,
233
+ bend: R() - 0.5,
234
+ t0: 2.72 + (c / size) * 0.55 + R() * 0.55,
235
+ tw: 5.95 + ((c + r) / (2 * (size - 1))) * 0.95 + R() * 0.08,
236
+ })
237
+ }
238
+ }
239
+ const finders = [[0, 0], [qr.size - 7, 0], [0, qr.size - 7]].map(([x, y]) => [placement.left + x, placement.top + y])
240
+ const topColors = art.usage.slice(0, 8).map((u) => u.hex)
241
+ const burst = Array.from({ length: 26 }, () => ({
242
+ a: -Math.PI / 2 + (R() - 0.5) * 2.2,
243
+ v: 520 + R() * 520,
244
+ r: 9 + R() * 8,
245
+ hex: topColors[Math.floor(R() * topColors.length)] || C.brand2,
246
+ }))
247
+ const groups = new Map()
248
+ const pegDots = []
249
+ for (let y = 20; y < U; y += 40) for (let x = 20; x < U; x += 40) pegDots.push(x, y, 2.4)
250
+ let layout = null // 文本排版缓存(首帧测量)
251
+
252
+ const textLayout = (ctx) => {
253
+ if (layout) return layout
254
+ ctx.font = F('bold', 64)
255
+ const x0 = 170
256
+ const charX = []
257
+ for (let i = 0; i <= DEMO_TEXT.length; i += 1) charX.push(x0 + ctx.measureText(DEMO_TEXT.slice(0, i)).width)
258
+ layout = { charX, y: 616 }
259
+ return layout
260
+ }
261
+
262
+ const boardAt = (t) => {
263
+ let cx = 540; let cy = 590; let B = 640
264
+ const a = easeInOut(prog(t, 7.6, 8.2))
265
+ cx = lerp(cx, 356, a); B = lerp(B, 560, a)
266
+ const b = easeInOut(prog(t, 10.05, 10.75))
267
+ cx = lerp(cx, 540, b); cy = lerp(cy, 410, b); B = lerp(B, 450, b)
268
+ return { cx, cy, B }
269
+ }
270
+ const gridOf = (L) => {
271
+ const pad = L.B * 0.05
272
+ const cell = (L.B - pad * 2) / size
273
+ return { cell, ox: L.cx - L.B / 2 + pad, oy: L.cy - L.B / 2 + pad }
274
+ }
275
+
276
+ const drawPlate = (ctx, L, alpha) => {
277
+ const x = L.cx - L.B / 2
278
+ const y = L.cy - L.B / 2
279
+ const r = L.B * 0.045
280
+ ctx.globalAlpha = alpha
281
+ softShadow(ctx, x, y, L.B, L.B, r, 0.16)
282
+ ctx.fillStyle = C.board
283
+ ctx.beginPath(); rr(ctx, x, y, L.B, L.B, r); ctx.fill()
284
+ ctx.globalAlpha = 1
285
+ }
286
+
287
+ // 背景:米色钉板 + 缓慢漂浮的装饰豆
288
+ const drawBackground = (ctx, t) => {
289
+ ctx.fillStyle = C.bg
290
+ ctx.fillRect(0, 0, U, U)
291
+ ctx.fillStyle = 'rgba(60,45,25,0.065)'
292
+ fillCircles(ctx, pegDots, 1, 0, 0)
293
+ DECOR.forEach(([x, y, r, hex], i) => {
294
+ const dx = Math.sin(t * 0.9 + i * 1.7) * 10
295
+ const dy = Math.cos(t * 0.7 + i * 2.3) * 12
296
+ ctx.globalAlpha = 0.55
297
+ addBead(groups, hex, x + dx, y + dy, r)
298
+ })
299
+ flushBeads(ctx, groups)
300
+ ctx.globalAlpha = 1
301
+ }
302
+
303
+ const drawCaption = (ctx, t) => {
304
+ ctx.textAlign = 'center'
305
+ ctx.textBaseline = 'middle'
306
+ SCENES.forEach((s, i) => {
307
+ if (t < s.a || t > s.b) return
308
+ const inP = easeOut(prog(t, s.a + 0.05, s.a + 0.5))
309
+ const outP = easeIn(prog(t, s.b - 0.32, s.b))
310
+ const alpha = inP * (1 - outP)
311
+ if (alpha <= 0) return
312
+ const dy = (1 - inP) * 26 - outP * 14
313
+ ctx.globalAlpha = alpha
314
+ ctx.font = F('bold', 24)
315
+ const label = `第 ${i + 1} 步`
316
+ const w = ctx.measureText(label).width + 40
317
+ ctx.fillStyle = C.brandSoft
318
+ ctx.beginPath(); rr(ctx, 540 - w / 2, 70 + dy, w, 46, 23); ctx.fill()
319
+ ctx.fillStyle = C.brand
320
+ ctx.fillText(label, 540, 94 + dy)
321
+ ctx.fillStyle = C.ink
322
+ ctx.font = F('bold', 60)
323
+ ctx.fillText(s.title, 540, 170 + dy * 1.3)
324
+ ctx.fillStyle = C.muted
325
+ ctx.font = F('normal', 30)
326
+ ctx.fillText(s.sub, 540, 232 + dy * 1.6)
327
+ })
328
+ ctx.globalAlpha = 1
329
+ }
330
+
331
+ const drawProgress = (ctx, t) => {
332
+ const alpha = prog(t, 0, 0.35) * (1 - prog(t, 10.0, 10.4))
333
+ if (alpha <= 0) return
334
+ ctx.globalAlpha = alpha
335
+ const w = 110; const gap = 16; const x0 = 540 - (w * 4 + gap * 3) / 2
336
+ SCENES.forEach((s, i) => {
337
+ const x = x0 + i * (w + gap)
338
+ ctx.fillStyle = 'rgba(22,33,31,0.1)'
339
+ ctx.beginPath(); rr(ctx, x, 1010, w, 8, 4); ctx.fill()
340
+ const p = prog(t, s.a, s.b)
341
+ if (p > 0) {
342
+ ctx.fillStyle = C.brand
343
+ ctx.beginPath(); rr(ctx, x, 1010, Math.max(8, w * p), 8, 4); ctx.fill()
344
+ }
345
+ })
346
+ ctx.globalAlpha = 1
347
+ }
348
+
349
+ // 第 1 幕:输入框 + 打字
350
+ const drawInput = (ctx, t) => {
351
+ if (t > 3.35) return
352
+ const inP = easeBack(prog(t, 0.1, 0.65))
353
+ const outP = easeInOut(prog(t, 2.6, 3.15))
354
+ const alpha = prog(t, 0.1, 0.4)
355
+ if (alpha <= 0) return
356
+ const L = textLayout(ctx)
357
+ const press = 1 - 0.08 * bump(t, 2.1, 2.4)
358
+ const scale = lerp(0.88, 1, inP) * lerp(1, 0.95, outP)
359
+ ctx.save()
360
+ ctx.translate(540, 590)
361
+ ctx.scale(scale, scale)
362
+ ctx.translate(-540, -590)
363
+ ctx.globalAlpha = alpha * (1 - outP)
364
+ softShadow(ctx, 130, 495, 820, 190, 40, 0.14)
365
+ ctx.fillStyle = '#ffffff'
366
+ ctx.beginPath(); rr(ctx, 130, 495, 820, 190, 40); ctx.fill()
367
+ ctx.textAlign = 'left'
368
+ ctx.textBaseline = 'middle'
369
+ ctx.fillStyle = C.muted
370
+ ctx.font = F('normal', 24)
371
+ ctx.fillText('扫码后显示的内容', 170, 548)
372
+ // 发送按钮
373
+ ctx.save()
374
+ ctx.translate(880, 590)
375
+ ctx.scale(press, press)
376
+ ctx.fillStyle = C.brand
377
+ ctx.beginPath(); ctx.arc(0, 0, 44, 0, TAU); ctx.fill()
378
+ ctx.strokeStyle = '#fff'
379
+ ctx.lineWidth = 7
380
+ ctx.lineCap = 'round'
381
+ ctx.lineJoin = 'round'
382
+ ctx.beginPath(); ctx.moveTo(-14, 0); ctx.lineTo(14, 0); ctx.moveTo(2, -13); ctx.lineTo(15, 0); ctx.lineTo(2, 13); ctx.stroke()
383
+ ctx.restore()
384
+ ctx.restore()
385
+ // 文字逐字出现,随后逐字化作豆子
386
+ const count = Math.floor(prog(t, 0.55, 1.9) * DEMO_TEXT.length + 0.0001)
387
+ ctx.font = F('bold', 64)
388
+ ctx.textAlign = 'left'
389
+ ctx.fillStyle = C.ink
390
+ for (let i = 0; i < count; i += 1) {
391
+ const start = 2.7 + (i / DEMO_TEXT.length) * 0.55
392
+ const a = alpha * (1 - prog(t, start, start + 0.35))
393
+ if (a <= 0) continue
394
+ ctx.globalAlpha = a
395
+ ctx.fillText(DEMO_TEXT[i], L.charX[i], L.y)
396
+ }
397
+ const typing = t >= 0.55 && t <= 1.9
398
+ if (t < 2.55 && (typing || Math.floor(t / 0.4) % 2 === 0)) {
399
+ ctx.globalAlpha = alpha
400
+ ctx.fillStyle = C.brand2
401
+ ctx.fillRect(L.charX[count] + 4, L.y - 34, 5, 68)
402
+ }
403
+ ctx.globalAlpha = 1
404
+ }
405
+
406
+ // 主画板上的豆子:飞入 → 黑白二维码 → 扫过换色
407
+ const drawMainBeads = (ctx, t, L) => {
408
+ const G = gridOf(L)
409
+ const TL = textLayout(ctx)
410
+ const baseR = G.cell * BEAD_R
411
+ for (let i = 0; i < cells.length; i += 1) {
412
+ const b = cells[i]
413
+ if (t < b.t0) continue
414
+ const dx = G.ox + (b.c + 0.5) * G.cell
415
+ const dy = G.oy + (b.r + 0.5) * G.cell
416
+ let x = dx; let y = dy; let s = 1
417
+ const f = prog(t, b.t0, b.t0 + FLIGHT)
418
+ if (f < 1) {
419
+ const sx = TL.charX[b.k] + b.jx * (TL.charX[b.k + 1] - TL.charX[b.k])
420
+ const sy = TL.y + b.jy * 44
421
+ const cx = (sx + dx) / 2 + b.bend * 220
422
+ const cy = Math.min(sy, dy) - 110 - Math.abs(b.bend) * 160
423
+ const e = easeInOut(f)
424
+ const u = 1 - e
425
+ x = u * u * sx + 2 * u * e * cx + e * e * dx
426
+ y = u * u * sy + 2 * u * e * cy + e * e * dy
427
+ s = lerp(0.55, 1, easeOut(f))
428
+ } else {
429
+ s = 1 + 0.16 * bump(t, b.t0 + FLIGHT, b.t0 + FLIGHT + 0.2)
430
+ }
431
+ s += 0.3 * bump(t, b.tw - 0.05, b.tw + 0.25)
432
+ addBead(groups, t >= b.tw ? b.finalHex : b.qrHex, x, y, baseR * s)
433
+ }
434
+ flushBeads(ctx, groups)
435
+ }
436
+
437
+ const drawFinalBeads = (ctx, L) => {
438
+ const G = gridOf(L)
439
+ const r = G.cell * BEAD_R
440
+ for (let i = 0; i < cells.length; i += 1) {
441
+ const b = cells[i]
442
+ addBead(groups, b.finalHex, G.ox + (b.c + 0.5) * G.cell, G.oy + (b.r + 0.5) * G.cell, r)
443
+ }
444
+ flushBeads(ctx, groups)
445
+ }
446
+
447
+ // 二维码定位图形高亮
448
+ const drawFinders = (ctx, t, L) => {
449
+ const p = prog(t, 4.15, 4.95)
450
+ if (p <= 0 || p >= 1) return
451
+ const G = gridOf(L)
452
+ const alpha = Math.sin(Math.PI * p)
453
+ const grow = (1 - easeOut(p)) * 18
454
+ ctx.strokeStyle = C.brand2
455
+ ctx.lineWidth = 6
456
+ ctx.globalAlpha = alpha
457
+ finders.forEach(([c, r]) => {
458
+ const x = G.ox + c * G.cell - 6 - grow
459
+ const y = G.oy + r * G.cell - 6 - grow
460
+ const w = 7 * G.cell + 12 + grow * 2
461
+ ctx.beginPath(); rr(ctx, x, y, w, w, 16); ctx.stroke()
462
+ })
463
+ ctx.globalAlpha = 1
464
+ }
465
+
466
+ // 沿对角线扫过的光带
467
+ const drawGleam = (ctx, L, p, strength) => {
468
+ if (p <= 0 || p >= 1) return
469
+ const x = L.cx - L.B / 2
470
+ const y = L.cy - L.B / 2
471
+ const g = ctx.createLinearGradient(x, y, x + L.B, y + L.B)
472
+ const s0 = clamp01(p - 0.12); const s1 = clamp01(p); const s2 = clamp01(p + 0.04)
473
+ g.addColorStop(0, 'rgba(255,255,255,0)')
474
+ if (s0 > 0) g.addColorStop(s0, 'rgba(255,255,255,0)')
475
+ g.addColorStop(s1, `rgba(255,255,255,${strength})`)
476
+ if (s2 < 1) g.addColorStop(s2, 'rgba(255,255,255,0)')
477
+ g.addColorStop(1, 'rgba(255,255,255,0)')
478
+ ctx.save()
479
+ ctx.beginPath(); rr(ctx, x, y, L.B, L.B, L.B * 0.045); ctx.clip()
480
+ ctx.fillStyle = g
481
+ ctx.fillRect(x, y, L.B, L.B)
482
+ ctx.restore()
483
+ }
484
+
485
+ // 第 3 幕:照片飞入并铺到画板上
486
+ const drawPhoto = (ctx, t, L) => {
487
+ if (t < 4.95 || t > 6.65) return
488
+ const a = easeOut(prog(t, 5.0, 5.55))
489
+ const b = easeInOut(prog(t, 5.6, 6.05))
490
+ let x = lerp(1320, 800, a); let y = lerp(250, 330, a); let rot = lerp(0.4, -0.12, a); let s = 300
491
+ x = lerp(x, L.cx, b); y = lerp(y, L.cy, b); rot = lerp(rot, 0, b); s = lerp(s, L.B * 0.9, b)
492
+ const frame = lerp(14, 0, b)
493
+ const alpha = 1 - prog(t, 6.0, 6.6)
494
+ ctx.save()
495
+ ctx.globalAlpha = alpha
496
+ ctx.translate(x, y)
497
+ ctx.rotate(rot)
498
+ const half = s / 2 + frame
499
+ if (b < 1) softShadow(ctx, -half, -half, half * 2, half * 2, 18, 0.2 * (1 - b))
500
+ ctx.fillStyle = '#ffffff'
501
+ ctx.beginPath(); rr(ctx, -half, -half, half * 2, half * 2, 18); ctx.fill()
502
+ ctx.beginPath(); rr(ctx, -s / 2, -s / 2, s, s, 10); ctx.clip()
503
+ drawIllustration(ctx, -s / 2, -s / 2, s)
504
+ ctx.restore()
505
+ ctx.globalAlpha = 1
506
+ }
507
+
508
+ // 第 4 幕:手机扫码
509
+ const drawPhone = (ctx, t) => {
510
+ const enter = easeOut(prog(t, 7.75, 8.35))
511
+ const exit = easeIn(prog(t, 9.95, 10.4))
512
+ if (enter <= 0 || exit >= 1) return
513
+ const w = 340; const h = 660
514
+ const px = 800
515
+ const py = 590 + (1 - enter) * 860 + exit * 860
516
+ const rot = lerp(0.16, -0.035, enter)
517
+ ctx.save()
518
+ ctx.translate(px, py)
519
+ ctx.rotate(rot)
520
+ softShadow(ctx, -w / 2, -h / 2, w, h, 60, 0.28)
521
+ ctx.fillStyle = '#1b2321'
522
+ ctx.beginPath(); rr(ctx, -w / 2, -h / 2, w, h, 60); ctx.fill()
523
+ ctx.fillStyle = '#2c3633'
524
+ ctx.fillRect(w / 2 - 1, -h / 2 + 150, 5, 70)
525
+ // 屏幕
526
+ const sw = w - 24; const sh = h - 24
527
+ ctx.save()
528
+ ctx.beginPath(); rr(ctx, -sw / 2, -sh / 2, sw, sh, 48); ctx.clip()
529
+ ctx.fillStyle = '#0e1312'
530
+ ctx.fillRect(-sw / 2, -sh / 2, sw, sh)
531
+ const jx = Math.sin(t * 3.1) * 3
532
+ const jy = Math.cos(t * 2.3) * 3
533
+ const cam = { cx: jx, cy: -30 + jy, B: 250 }
534
+ ctx.globalAlpha = 0.92
535
+ ctx.fillStyle = C.bg
536
+ ctx.fillRect(-sw / 2, -sh / 2, sw, sh)
537
+ ctx.globalAlpha = 1
538
+ drawPlate(ctx, cam, 1)
539
+ drawFinalBeads(ctx, cam)
540
+ ctx.fillStyle = 'rgba(10,15,14,0.45)'
541
+ ctx.fillRect(-sw / 2, -sh / 2, sw, sh)
542
+ // 取景框
543
+ const detect = prog(t, 9.0, 9.3)
544
+ const snap = t >= 9.0 ? 1 + 0.1 * (1 - easeOut(detect)) : 1
545
+ const half = 146 * snap
546
+ ctx.strokeStyle = t >= 9.0 ? C.wechat : '#ffffff'
547
+ ctx.lineWidth = 6
548
+ ctx.lineCap = 'round'
549
+ const arm = 34
550
+ ctx.beginPath()
551
+ ;[[-1, -1], [1, -1], [1, 1], [-1, 1]].forEach(([sx, sy]) => {
552
+ const x = cam.cx + sx * half
553
+ const y = cam.cy + sy * half
554
+ ctx.moveTo(x, y - sy * arm); ctx.lineTo(x, y); ctx.lineTo(x - sx * arm, y)
555
+ })
556
+ ctx.stroke()
557
+ // 扫描线
558
+ const sp = prog(t, 8.3, 9.0)
559
+ if (sp > 0 && sp < 1) {
560
+ const top = cam.cy - 140
561
+ const y = lerp(top, cam.cy + 140, easeInOut(sp))
562
+ const g = ctx.createLinearGradient(0, y - 80, 0, y)
563
+ g.addColorStop(0, 'rgba(7,193,96,0)')
564
+ g.addColorStop(1, 'rgba(7,193,96,0.35)')
565
+ ctx.fillStyle = g
566
+ ctx.fillRect(cam.cx - 140, Math.max(top, y - 80), 280, y - Math.max(top, y - 80))
567
+ ctx.fillStyle = C.wechat
568
+ ctx.fillRect(cam.cx - 140, y - 2, 280, 4)
569
+ }
570
+ ctx.textAlign = 'center'
571
+ ctx.textBaseline = 'middle'
572
+ ctx.fillStyle = '#ffffff'
573
+ ctx.font = F('bold', 24)
574
+ ctx.fillText('扫一扫', 0, -sh / 2 + 78)
575
+ if (t < 9.0) {
576
+ ctx.globalAlpha = 0.7
577
+ ctx.font = F('normal', 19)
578
+ ctx.fillText('将二维码放入框内', 0, cam.cy + 185)
579
+ ctx.globalAlpha = 1
580
+ }
581
+ const flash = t >= 9.0 ? 0.35 * (1 - prog(t, 9.0, 9.25)) : 0
582
+ if (flash > 0) {
583
+ ctx.fillStyle = `rgba(255,255,255,${flash.toFixed(3)})`
584
+ ctx.fillRect(-sw / 2, -sh / 2, sw, sh)
585
+ }
586
+ // 识别结果卡片
587
+ const rp = prog(t, 9.1, 9.5)
588
+ if (rp > 0) {
589
+ const cw = sw - 36; const ch = 170
590
+ const cy = sh / 2 - 18 - ch + (1 - easeBack(rp)) * 220
591
+ ctx.fillStyle = '#ffffff'
592
+ ctx.beginPath(); rr(ctx, -cw / 2, cy, cw, ch, 28); ctx.fill()
593
+ ctx.fillStyle = C.wechat
594
+ ctx.beginPath(); ctx.arc(0, cy + 44, 22, 0, TAU); ctx.fill()
595
+ ctx.strokeStyle = '#fff'
596
+ ctx.lineWidth = 5
597
+ ctx.lineJoin = 'round'
598
+ ctx.beginPath(); ctx.moveTo(-9, cy + 44); ctx.lineTo(-2, cy + 51); ctx.lineTo(10, cy + 37); ctx.stroke()
599
+ ctx.fillStyle = C.ink
600
+ ctx.font = F('bold', 30)
601
+ ctx.fillText(DEMO_TEXT, 0, cy + 100)
602
+ ctx.fillStyle = C.muted
603
+ ctx.font = F('normal', 18)
604
+ ctx.fillText('扫码结果', 0, cy + 138)
605
+ }
606
+ ctx.restore()
607
+ // 灵动岛
608
+ ctx.fillStyle = '#000'
609
+ ctx.beginPath(); rr(ctx, -52, -h / 2 + 26, 104, 30, 15); ctx.fill()
610
+ ctx.restore()
611
+
612
+ // 识别成功:豆子礼花
613
+ const bt = t - 9.2
614
+ if (bt > 0 && bt < 1.4) {
615
+ const ox = px; const oy = py + 200
616
+ const fade = 1 - prog(bt, 0.9, 1.4)
617
+ ctx.globalAlpha = fade
618
+ burst.forEach((p) => {
619
+ const x = ox + Math.cos(p.a) * p.v * bt
620
+ const y = oy + Math.sin(p.a) * p.v * bt + 900 * bt * bt
621
+ addBead(groups, p.hex, x, y, p.r)
622
+ })
623
+ flushBeads(ctx, groups)
624
+ ctx.globalAlpha = 1
625
+ }
626
+ }
627
+
628
+ // 片尾
629
+ const drawEnd = (ctx, t) => {
630
+ if (t < 10.4) return
631
+ ctx.textAlign = 'center'
632
+ ctx.textBaseline = 'middle'
633
+ const a = easeOut(prog(t, 10.45, 10.95))
634
+ ctx.globalAlpha = a
635
+ ctx.fillStyle = C.ink
636
+ ctx.font = F('bold', 64)
637
+ ctx.fillText('豆维 · 可扫码的拼豆', 540, 745 + (1 - a) * 30)
638
+ const b = easeOut(prog(t, 10.6, 11.1))
639
+ ctx.globalAlpha = b
640
+ ctx.fillStyle = C.muted
641
+ ctx.font = F('normal', 32)
642
+ ctx.fillText('把一句话藏进拼豆里,微信一扫就能读出来', 540, 812 + (1 - b) * 30)
643
+ const chips = ['MARD 标准色号', 'A4 打印图纸', '微信一扫即读']
644
+ ctx.font = F('bold', 26)
645
+ const widths = chips.map((s) => ctx.measureText(s).width + 44)
646
+ const total = widths.reduce((s, v) => s + v, 0) + 18 * (chips.length - 1)
647
+ let x = 540 - total / 2
648
+ chips.forEach((label, i) => {
649
+ const p = easeBack(prog(t, 10.85 + i * 0.12, 11.3 + i * 0.12))
650
+ if (p > 0) {
651
+ ctx.globalAlpha = clamp01(p)
652
+ const cy = 892 + (1 - p) * 24
653
+ ctx.fillStyle = C.brandSoft
654
+ ctx.beginPath(); rr(ctx, x, cy - 26, widths[i], 52, 26); ctx.fill()
655
+ ctx.fillStyle = C.brand
656
+ ctx.fillText(label, x + widths[i] / 2, cy)
657
+ }
658
+ x += widths[i] + 18
659
+ })
660
+ ctx.globalAlpha = 1
661
+ }
662
+
663
+ const draw = (ctx, time, pixelWidth) => {
664
+ const t = loop ? ((time % DURATION) + DURATION) % DURATION : Math.min(time, DURATION)
665
+ const k = pixelWidth / U
666
+ ctx.setTransform(k, 0, 0, k, 0, 0)
667
+ ctx.globalAlpha = 1
668
+ drawBackground(ctx, t)
669
+ const L = boardAt(t)
670
+ if (t >= 2.65) {
671
+ const p = easeBack(prog(t, 2.65, 3.15))
672
+ const s = lerp(0.92, 1, p)
673
+ const LS = { cx: L.cx, cy: L.cy, B: L.B * s }
674
+ drawPlate(ctx, LS, prog(t, 2.65, 3.0))
675
+ }
676
+ drawInput(ctx, t)
677
+ if (t >= 2.72) drawMainBeads(ctx, t, L)
678
+ drawFinders(ctx, t, L)
679
+ drawGleam(ctx, L, prog(t, 5.9, 7.0), 0.55)
680
+ drawGleam(ctx, L, prog(t, 11.0, 11.7), 0.4)
681
+ drawPhoto(ctx, t, L)
682
+ drawPhone(ctx, t)
683
+ drawCaption(ctx, t)
684
+ drawProgress(ctx, t)
685
+ drawEnd(ctx, t)
686
+ if (loop) {
687
+ const fade = easeInOut(prog(t, 11.45, 12))
688
+ if (fade > 0) {
689
+ ctx.globalAlpha = fade
690
+ ctx.fillStyle = C.bg
691
+ ctx.fillRect(0, 0, U, U)
692
+ ctx.globalAlpha = 1
693
+ }
694
+ }
695
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
696
+ }
697
+
698
+ // 关键时间点(供视频配乐对齐)
699
+ const timeline = { landings: cells.map((b) => b.t0 + FLIGHT), wipes: cells.map((b) => b.tw) }
700
+ return { duration: DURATION, text: DEMO_TEXT, artwork: art, draw, timeline }
701
+ }
702
+
703
+ module.exports = { createIntro, DEMO_TEXT, DURATION }
BeadQR/miniprogram/utils/palette-data.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // MARD reference palette from the public 2026 color chart.
2
+ // 291 colors = standard 221 (A-H, M) + 70 extended (P, Q, R, T, Y, ZG).
3
+ const MARD_HEX = {
4
+ A1:"#f9f0cd", A2:"#fbfbd4", A3:"#fafc9f", A4:"#ffe953", A5:"#f4d738", A6:"#fdad49", A7:"#ff7c2f", A8:"#eaca49",
5
+ A9:"#ff995a", A10:"#ff9d55", A11:"#ffdd99", A12:"#fcb58f", A13:"#ffbb59", A14:"#ff6d40", A15:"#fdff44", A16:"#fef9ae",
6
+ A17:"#ffe36e", A18:"#fecf98", A19:"#fd7b72", A20:"#efcd67", A21:"#ffe395", A22:"#fff3a4", A23:"#f3d5bf", A24:"#fbf8c9",
7
+ A25:"#ffd67d", A26:"#ffbb27", B1:"#e6ee32", B2:"#5be419", B3:"#7cee9d", B4:"#1ef942", B5:"#00bd35", B6:"#5ae8ba",
8
+ B7:"#03ac88", B8:"#029d26", B9:"#26523a", B10:"#95d3c2", B11:"#5d722a", B12:"#156f40", B13:"#d9f794", B14:"#ade945",
9
+ B15:"#2e5132", B16:"#c6ed9c", B17:"#9bb13a", B18:"#e6ee49", B19:"#25b88c", B20:"#c2f0cc", B21:"#146a6b", B22:"#0b3c43",
10
+ B23:"#303921", B24:"#eefca5", B25:"#4e846d", B26:"#8c7a36", B27:"#d1dcc1", B28:"#9ee5b9", B29:"#c5e254", B30:"#ecfbd0",
11
+ B31:"#c4e6b5", B32:"#9bab5a", C1:"#e8ffe7", C2:"#bcf9f6", C3:"#a0e2fb", C4:"#42ccff", C5:"#01aceb", C6:"#50a9f0",
12
+ C7:"#0188d3", C8:"#1054c0", C9:"#314bca", C10:"#3ebce2", C11:"#03b9b9", C12:"#1c334d", C13:"#cde8ff", C14:"#d5fdff",
13
+ C15:"#23c4c6", C16:"#1757a8", C17:"#50d3ec", C18:"#1c3344", C19:"#1787a2", C20:"#0082be", C21:"#beddff", C22:"#67b4be",
14
+ C23:"#c2dceb", C24:"#7dc4ff", C25:"#a9e5e5", C26:"#2f99b3", C27:"#ebf5fc", C28:"#bbcfed", C29:"#4b5ba3", D1:"#aeb4f2",
15
+ D2:"#858edd", D3:"#3054af", D4:"#182a84", D5:"#b843c5", D6:"#ac7bde", D7:"#6e399a", D8:"#e2d3ff", D9:"#d5b9f8",
16
+ D10:"#361b50", D11:"#b9bae1", D12:"#de9ad4", D13:"#b90295", D14:"#8b279b", D15:"#2f1f90", D16:"#e2e1ee", D17:"#c4d4f6",
17
+ D18:"#a45ec7", D19:"#d8c3d7", D20:"#9c32b2", D21:"#9a009b", D22:"#333995", D23:"#eadafc", D24:"#7786e5", D25:"#484fc7",
18
+ D26:"#e9c3f6", E1:"#fdd3cc", E2:"#fecddf", E3:"#ff97c3", E4:"#e8649e", E5:"#f551a2", E6:"#ff346b", E7:"#c63578",
19
+ E8:"#ffdbe9", E9:"#e970cc", E10:"#d33893", E11:"#fcddd2", E12:"#ffa1c5", E13:"#b6006d", E14:"#ffd1ba", E15:"#f2cfd0",
20
+ E16:"#ffecde", E17:"#ffe2ea", E18:"#ffc9d6", E19:"#ffd2e7", E20:"#d8c7d1", E21:"#bd9da1", E22:"#cc78a7", E23:"#937a8d",
21
+ E24:"#f6e4f9", F1:"#fd957b", F2:"#fc3d45", F3:"#f74941", F4:"#fc283c", F5:"#d80127", F6:"#b0443d", F7:"#971937",
22
+ F8:"#bc0127", F9:"#e2677a", F10:"#a74d22", F11:"#6f201f", F12:"#fd4d6a", F13:"#dd422f", F14:"#ffa9ad", F15:"#c80020",
23
+ F16:"#ffd9c8", F17:"#f79b71", F18:"#d37c46", F19:"#c1444a", F20:"#cd9391", F21:"#f4b1b4", F22:"#ffd0cb", F23:"#f57e66",
24
+ F24:"#fcc1c4", F25:"#e54b4f", G1:"#ffe2ce", G2:"#ffcaaa", G3:"#f4c3a5", G4:"#e1b383", G5:"#ed9435", G6:"#f59734",
25
+ G7:"#9d5b3e", G8:"#592a21", G9:"#e6b483", G10:"#c88135", G11:"#e0c593", G12:"#ebbb83", G13:"#b7714a", G14:"#8d614c",
26
+ G15:"#fcf9e0", G16:"#f2d9ba", G17:"#56403c", G18:"#ffe4cc", G19:"#e1943a", G20:"#a94023", G21:"#cb8e77", H1:"#e2e2e2",
27
+ H2:"#ffffff", H3:"#b3b3b3", H4:"#868686", H5:"#474747", H6:"#2c2c2c", H7:"#000000", H8:"#e7d6db", H9:"#e4e7e3",
28
+ H10:"#eee9ea", H11:"#cecdd5", H12:"#fff5ed", H13:"#f3e1c9", H14:"#cfd7d3", H15:"#98a6a8", H16:"#3b2f23", H17:"#f1eded",
29
+ H18:"#fffdf0", H19:"#f6efe2", H20:"#949fa3", H21:"#f7f3e4", H22:"#cacad5", H23:"#9a9d94", M1:"#bcc6b8", M2:"#8aa385",
30
+ M3:"#697d80", M4:"#dacebe", M5:"#d0ccaa", M6:"#b0a782", M7:"#b4a497", M8:"#b38281", M9:"#a58767", M10:"#c5b1bc",
31
+ M11:"#9f7494", M12:"#644749", M13:"#d19066", M14:"#c77361", M15:"#757d7b", P1:"#fcf8f9", P2:"#bda9ab", P3:"#aedda9",
32
+ P4:"#fda49e", P5:"#ec8d3d", P6:"#60cfa8", P7:"#eb9271", P8:"#f0d958", P9:"#d9d9d9", P10:"#d5c8e9", P11:"#f3ecc8",
33
+ P12:"#e6eef1", P13:"#a9cbf1", P14:"#3177b0", P15:"#668575", P16:"#ffbe46", P17:"#ffa324", P18:"#feb89f", P19:"#ffe0e8",
34
+ P20:"#febecf", P21:"#ecbec0", P22:"#e4a89e", P23:"#a56269", Q1:"#f2a5e8", Q2:"#73b29e", Q3:"#ffff00", Q4:"#ffebfa",
35
+ Q5:"#4f5e5b", R1:"#d50e21", R2:"#f92e83", R3:"#fd8225", R4:"#f8ec31", R5:"#34c75b", R6:"#25b891", R7:"#17779d",
36
+ R8:"#1b60c3", R9:"#9a56b4", R10:"#ffdb4d", R11:"#ffebfa", R12:"#d8d5ce", R13:"#55514c", R14:"#9ee4df", R15:"#77cee9",
37
+ R16:"#3dcfca", R17:"#4a867a", R18:"#7fcd9d", R19:"#cde55d", R20:"#e8c7b4", R21:"#ad6f3c", R22:"#6c372f", R23:"#feb872",
38
+ R24:"#f2c1c0", R25:"#c9675d", R26:"#d293be", R27:"#ea8cb1", R28:"#9c87d6", T1:"#e2dfd7", Y1:"#fd6fb4", Y2:"#feb481",
39
+ Y3:"#d7faa0", Y4:"#8bdbfa", Y5:"#e987ea", ZG1:"#daabb3", ZG2:"#d6aa87", ZG3:"#c1bd8d", ZG4:"#96869f", ZG5:"#8490a6",
40
+ ZG6:"#94bfe2", ZG7:"#e2a9d2", ZG8:"#ab91c0",
41
+ }
42
+
43
+ const MARD_PALETTE = Object.entries(MARD_HEX).map(([id, hex]) => ({ id, hex }))
44
+
45
+ // 智能近色色库的“远点优先”排序(离线预计算,避免真机启动时 2000 万次距离运算)
46
+ const MARD_SMART_ORDER = ["A4","A7","B5","B9","C4","C8","C12","D7","E5","F3","G5","G8","H2","H3","H5","H7","M3","M12","P3","P14","Q1","R5","R13","ZG6","E13","B17","Q3","B2","A22","C11","D18","P6","G20","F5","P4","M8","D15","C2","D24","A19","B4","B14","G13","B21","R12","E7","C7","D21","E9","B19","D25","M2","D8","D20","R16","R23","B26","B11","E6","F7","E22","F19","ZG2","A26","A2","C5","B3","C28","E2","M13","B1","B13","ZG4","C6","R19","R28","G2","ZG1","E3","P23","A8","A9","R14","C29","C24","H6","M6","B7","G7","Q2","M5","D10","B8","B20","B10","F9","C22","C26","B12","B25","F4","D5","C19","B32","F13","A25","H15","D13","G10","B6","Y5","H10","Y1","D14","D1","D3","D12","E21","E10","M9","D9","R27","C10","R18","H13","H4","P21","ZG3","G11","C15","ZG8","P7","D4","C3","M14","A20","F8","R22","C14","D22","A15","A6","R26","R2","C16","B31","F12","R15","F20","C17","A11","F11","P17","A5","C9","P22","E4","B18","C1","C21","M11","B16","R9","F25","A12","A14","Y4","G14","B28","H23","P20","C20","C27","ZG5","B22","G12","M1","D6","F6","A13","P10","D11","F18","F1","Q5","Q4","M10","G17","H8","A17","D26","F21","P19","B24","P8","H22","P13","C13","F22","G21","R4","D2","H16","M4","P16","R21","E14","R7","R8","P18","H12","B27","D19","ZG7","E16","P2","B30","R20","F14","G16","R10","M7","E23","B23","D16","C23","F23","R1","Y2","A18","E15","G3","F15","P15","R17","D17","F2","E11","A1","F10","F17","G19","M15","G4","R25","C25","Y3","B29","H9","G15","R3","P9","P12","A16","F24","A3","E24","B15","D23","T1","P5","H14","H19","E12"]
47
+
48
+ module.exports = { MARD_PALETTE, MARD_SMART_ORDER }
BeadQR/miniprogram/utils/palette.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // MARD 色库预设 + 预处理(Typed Array 结构,供匹配引擎高速读取)
2
+ const { MARD_PALETTE, MARD_SMART_ORDER } = require('./palette-data')
3
+ const { hexToRgb, rgbToOklabInto, luma } = require('./color')
4
+
5
+ // 颜色匹配模式:darkMax/lightMin 为二维码深/浅模块允许的亮度范围
6
+ const MATCH_MODES = [
7
+ { id: 'rich', label: '色彩优先', hint: '更像原图', darkMax: 0.4, lightMin: 0.74 },
8
+ { id: 'balanced', label: '均衡', hint: '推荐', darkMax: 0.36, lightMin: 0.78 },
9
+ { id: 'strict', label: '扫码优先', hint: '最稳', darkMax: 0.3, lightMin: 0.84 },
10
+ ]
11
+
12
+ const byId = new Map(MARD_PALETTE.map((c) => [c.id, c]))
13
+ const pick = (ids) => ids.map((id) => byId.get(id)).filter(Boolean)
14
+ const STANDARD_221 = MARD_PALETTE.filter((c) => /^[A-HM]\d+$/.test(c.id))
15
+
16
+ const PRESETS = [
17
+ { id: '221', label: '标准 221 色', short: '221 色', detail: 'MARD 标准色,最常见', list: () => STANDARD_221 },
18
+ { id: '291', label: '完整 291 色', short: '291 色', detail: '含扩展色,颜色最全', list: () => MARD_PALETTE },
19
+ ...[144, 96, 72, 48, 24].map((n) => ({
20
+ id: String(n),
21
+ label: `精选 ${n} 色`,
22
+ short: `${n} 色`,
23
+ detail: n <= 48 ? '入门套装,买豆更省' : '常用色,兼顾效果与成本',
24
+ list: () => pick(MARD_SMART_ORDER.slice(0, n)),
25
+ })),
26
+ ]
27
+
28
+ const cache = {}
29
+
30
+ function preparePalette(presetId) {
31
+ if (cache[presetId]) return cache[presetId]
32
+ const preset = PRESETS.find((p) => p.id === presetId) || PRESETS[0]
33
+ const list = preset.list()
34
+ const n = list.length
35
+ const rgb = new Uint8Array(n * 3)
36
+ const lab = new Float32Array(n * 3)
37
+ const lum = new Float32Array(n)
38
+ list.forEach((c, i) => {
39
+ const [r, g, b] = hexToRgb(c.hex)
40
+ rgb[i * 3] = r; rgb[i * 3 + 1] = g; rgb[i * 3 + 2] = b
41
+ rgbToOklabInto(r, g, b, lab, i * 3)
42
+ lum[i] = luma(r, g, b)
43
+ })
44
+ // 色与色之间的距离矩阵(291² ≈ 8.5 万项,一次性计算)
45
+ const pair = new Float32Array(n * n)
46
+ for (let i = 0; i < n; i += 1) {
47
+ for (let j = i + 1; j < n; j += 1) {
48
+ const dl = lab[i * 3] - lab[j * 3]
49
+ const da = lab[i * 3 + 1] - lab[j * 3 + 1]
50
+ const db = lab[i * 3 + 2] - lab[j * 3 + 2]
51
+ const d = Math.sqrt(dl * dl + da * da + db * db)
52
+ pair[i * n + j] = d
53
+ pair[j * n + i] = d
54
+ }
55
+ }
56
+ const prepared = {
57
+ id: preset.id,
58
+ label: preset.label,
59
+ short: preset.short,
60
+ n,
61
+ ids: list.map((c) => c.id),
62
+ hexes: list.map((c) => c.hex),
63
+ rgb,
64
+ lab,
65
+ lum,
66
+ pair,
67
+ subsets: {},
68
+ luts: {},
69
+ }
70
+ cache[presetId] = prepared
71
+ return prepared
72
+ }
73
+
74
+ module.exports = { MATCH_MODES, PRESETS, preparePalette }
BeadQR/miniprogram/utils/pattern.js ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Canvas 绘制:预览、PNG 图纸页、作品信息页、水印
2
+ // 性能要点:按颜色批量合并路径,每种颜色只 fill 一次;字体只设置一次;网格线合并成单条路径
3
+ const { lumaHex } = require('./color')
4
+
5
+ const BOARD = '#f1eee7' // 钉板底色
6
+ const PEG = '#d9d3c7' // 空钉位
7
+ const INK = '#1d2b2a'
8
+ const MUTED = '#6b7671'
9
+ const BRAND = '#0f766e'
10
+
11
+ function roundRectPath(ctx, x, y, w, h, r) {
12
+ const rr = Math.min(r, w / 2, h / 2)
13
+ ctx.moveTo(x + rr, y)
14
+ ctx.arcTo(x + w, y, x + w, y + h, rr)
15
+ ctx.arcTo(x + w, y + h, x, y + h, rr)
16
+ ctx.arcTo(x, y + h, x, y, rr)
17
+ ctx.arcTo(x, y, x + w, y, rr)
18
+ ctx.closePath()
19
+ }
20
+
21
+ function fitText(ctx, value, maxWidth) {
22
+ const text = String(value || '')
23
+ if (ctx.measureText(text).width <= maxWidth) return text
24
+ let end = text.length
25
+ while (end > 0 && ctx.measureText(`${text.slice(0, end)}…`).width > maxWidth) end -= 1
26
+ return `${text.slice(0, end)}…`
27
+ }
28
+
29
+ // 把同色格子分组:返回 Map<colorIndex, number[] cellIndex>
30
+ function groupCells(artwork, region) {
31
+ const { size, cells } = artwork
32
+ const r = region || { col: 0, row: 0, cols: size, rows: size }
33
+ const groups = new Map()
34
+ const empty = []
35
+ for (let y = r.row; y < r.row + r.rows; y += 1) {
36
+ for (let x = r.col; x < r.col + r.cols; x += 1) {
37
+ const c = cells[y * size + x]
38
+ if (c < 0) { empty.push(y * size + x); continue }
39
+ let list = groups.get(c)
40
+ if (!list) { list = []; groups.set(c, list) }
41
+ list.push(y * size + x)
42
+ }
43
+ }
44
+ return { groups, empty, region: r }
45
+ }
46
+
47
+ /**
48
+ * 绘制珠子
49
+ * opts: { x0, y0, cell, style: square|round|hollow, region }
50
+ */
51
+ function drawBeads(ctx, artwork, opts) {
52
+ const { x0, y0, cell, style = 'square' } = opts
53
+ const { size, pal } = artwork
54
+ const { groups, empty, region } = groupCells(artwork, opts.region)
55
+ const ARC_CHUNK = 12
56
+ const pos = (i) => {
57
+ const x = i % size
58
+ const y = (i - x) / size
59
+ return [x0 + (x - region.col) * cell, y0 + (y - region.row) * cell]
60
+ }
61
+ ctx.fillStyle = BOARD
62
+ ctx.fillRect(x0, y0, region.cols * cell, region.rows * cell)
63
+ // 空钉位:小圆点,提示“这里不放豆”
64
+ if (empty.length) {
65
+ ctx.fillStyle = PEG
66
+ ctx.beginPath()
67
+ const r = cell * 0.14
68
+ empty.forEach((i, n) => {
69
+ const [x, y] = pos(i)
70
+ ctx.moveTo(x + cell / 2 + r, y + cell / 2)
71
+ ctx.arc(x + cell / 2, y + cell / 2, r, 0, Math.PI * 2)
72
+ if (n % ARC_CHUNK === ARC_CHUNK - 1) { ctx.fill(); ctx.beginPath() }
73
+ })
74
+ ctx.fill()
75
+ }
76
+ const radius = cell * 0.46
77
+ const hole = cell * 0.2
78
+ groups.forEach((list, c) => {
79
+ ctx.fillStyle = pal.hexes[c]
80
+ ctx.beginPath()
81
+ if (style === 'square') {
82
+ list.forEach((i) => {
83
+ const [x, y] = pos(i)
84
+ // 取整边界避免相邻方块之间出现发丝缝
85
+ const lx = Math.round(x); const ty = Math.round(y)
86
+ ctx.rect(lx, ty, Math.round(x + cell) - lx, Math.round(y + cell) - ty)
87
+ })
88
+ } else {
89
+ // 圆形子路径过多时渲染引擎会明显变慢,按小批提交(大图从数秒降到几十毫秒)
90
+ list.forEach((i, n) => {
91
+ const [x, y] = pos(i)
92
+ const cx = x + cell / 2; const cy = y + cell / 2
93
+ ctx.moveTo(cx + radius, cy)
94
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2)
95
+ if (style === 'hollow') {
96
+ ctx.moveTo(cx + hole, cy)
97
+ ctx.arc(cx, cy, hole, 0, Math.PI * 2, true)
98
+ }
99
+ if (n % ARC_CHUNK === ARC_CHUNK - 1) { ctx.fill(); ctx.beginPath() }
100
+ })
101
+ }
102
+ ctx.fill()
103
+ })
104
+ }
105
+
106
+ // 色号文字:先按深/浅文字颜色分两批
107
+ function drawCodes(ctx, artwork, opts) {
108
+ const { x0, y0, cell, style = 'square' } = opts
109
+ const { size, cells, pal } = artwork
110
+ const region = opts.region || { col: 0, row: 0, cols: size, rows: size }
111
+ const light = pal.hexes.map((hex) => lumaHex(hex) > 0.56)
112
+ const fontSize = Math.max(8, Math.round(cell * (style === 'hollow' ? 0.26 : 0.32)))
113
+ ctx.font = `bold ${fontSize}px sans-serif`
114
+ ctx.textAlign = 'center'
115
+ ctx.textBaseline = 'middle'
116
+ const pass = (wantLight) => {
117
+ ctx.fillStyle = wantLight ? INK : '#ffffff'
118
+ for (let y = region.row; y < region.row + region.rows; y += 1) {
119
+ for (let x = region.col; x < region.col + region.cols; x += 1) {
120
+ const c = cells[y * size + x]
121
+ if (c < 0) continue
122
+ const useInk = style === 'hollow' ? true : light[c]
123
+ if (useInk !== wantLight) continue
124
+ ctx.fillText(pal.ids[c], x0 + (x - region.col + 0.5) * cell, y0 + (y - region.row + 0.5) * cell + fontSize * 0.04)
125
+ }
126
+ }
127
+ }
128
+ pass(true)
129
+ pass(false)
130
+ ctx.textAlign = 'start'
131
+ ctx.textBaseline = 'alphabetic'
132
+ }
133
+
134
+ function rgba(hex, alpha) {
135
+ const v = parseInt(hex.slice(1), 16)
136
+ return `rgba(${(v >> 16) & 255},${(v >> 8) & 255},${v & 255},${alpha})`
137
+ }
138
+
139
+ // 网格 + 坐标:细线每格一条,粗线每 interval 格一条(按全局坐标对齐)
140
+ function drawGrid(ctx, opts) {
141
+ const { x0, y0, cell, region, interval = 10, color = BRAND, opacity = 60, labels = true } = opts
142
+ const w = region.cols * cell
143
+ const h = region.rows * cell
144
+ const alpha = opacity / 100
145
+ ctx.lineWidth = Math.max(1, cell * 0.02)
146
+ ctx.strokeStyle = rgba(color, alpha * 0.28)
147
+ ctx.beginPath()
148
+ for (let i = 0; i <= region.cols; i += 1) { const x = Math.round(x0 + i * cell) + 0.5; ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h) }
149
+ for (let i = 0; i <= region.rows; i += 1) { const y = Math.round(y0 + i * cell) + 0.5; ctx.moveTo(x0, y); ctx.lineTo(x0 + w, y) }
150
+ ctx.stroke()
151
+ ctx.lineWidth = Math.max(2, cell * 0.06)
152
+ ctx.strokeStyle = rgba(color, alpha)
153
+ ctx.beginPath()
154
+ for (let i = 0; i <= region.cols; i += 1) {
155
+ const g = region.col + i
156
+ if (g % interval !== 0 && i !== 0 && i !== region.cols) continue
157
+ const x = x0 + i * cell; ctx.moveTo(x, y0); ctx.lineTo(x, y0 + h)
158
+ }
159
+ for (let i = 0; i <= region.rows; i += 1) {
160
+ const g = region.row + i
161
+ if (g % interval !== 0 && i !== 0 && i !== region.rows) continue
162
+ const y = y0 + i * cell; ctx.moveTo(x0, y); ctx.lineTo(x0 + w, y)
163
+ }
164
+ ctx.stroke()
165
+ if (!labels) return
166
+ const fontSize = Math.max(10, Math.round(Math.min(cell * 0.42, 34)))
167
+ ctx.font = `${fontSize}px sans-serif`
168
+ ctx.fillStyle = rgba(color, Math.max(0.7, alpha))
169
+ ctx.textAlign = 'center'
170
+ ctx.textBaseline = 'middle'
171
+ const step = cell < 22 ? interval : (cell < 36 ? 5 : 1)
172
+ for (let i = 0; i < region.cols; i += 1) {
173
+ const g = region.col + i + 1
174
+ if (g % step !== 0 && g !== 1) continue
175
+ ctx.fillText(String(g), x0 + (i + 0.5) * cell, y0 - fontSize * 0.9)
176
+ }
177
+ for (let i = 0; i < region.rows; i += 1) {
178
+ const g = region.row + i + 1
179
+ if (g % step !== 0 && g !== 1) continue
180
+ ctx.fillText(String(g), x0 - fontSize * 1.1, y0 + (i + 0.5) * cell)
181
+ }
182
+ ctx.textAlign = 'start'
183
+ ctx.textBaseline = 'alphabetic'
184
+ }
185
+
186
+ // 屏幕预览:整张作品,不带坐标(清爽)
187
+ function renderPreview(canvas, artwork, pixelWidth, style) {
188
+ const size = artwork.size
189
+ const pad = Math.round(pixelWidth * 0.03)
190
+ const cell = (pixelWidth - pad * 2) / size
191
+ canvas.width = pixelWidth
192
+ canvas.height = pixelWidth
193
+ const ctx = canvas.getContext('2d')
194
+ ctx.fillStyle = BOARD
195
+ ctx.fillRect(0, 0, pixelWidth, pixelWidth)
196
+ drawBeads(ctx, artwork, { x0: pad, y0: pad, cell, style })
197
+ }
198
+
199
+ // —— PNG 图纸页 ——
200
+ function patternPageLayout(size, cell) {
201
+ const axis = Math.max(48, Math.round(cell * 1.1))
202
+ const pad = Math.max(48, Math.round(cell * 0.9))
203
+ const header = Math.max(120, Math.round(cell * 2.4))
204
+ const grid = size * cell
205
+ return { axis, pad, header, width: grid + axis + pad * 2, height: header + axis + grid + pad * 2 }
206
+ }
207
+
208
+ function drawPatternPage(ctx, artwork, opts) {
209
+ const { cell, style, grid, meta } = opts
210
+ const L = patternPageLayout(artwork.size, cell)
211
+ ctx.fillStyle = '#fbfaf7'
212
+ ctx.fillRect(0, 0, L.width, L.height)
213
+ // 头部
214
+ const unit = L.header / 120
215
+ ctx.fillStyle = INK
216
+ ctx.font = `bold ${Math.round(40 * unit)}px sans-serif`
217
+ ctx.textBaseline = 'middle'
218
+ ctx.fillText('豆维 · 拼豆图纸', L.pad, L.pad * 0.6 + L.header * 0.36)
219
+ ctx.font = `${Math.round(22 * unit)}px sans-serif`
220
+ ctx.fillStyle = MUTED
221
+ ctx.fillText(fitText(ctx, meta, L.width - L.pad * 2), L.pad, L.pad * 0.6 + L.header * 0.74)
222
+ ctx.textBaseline = 'alphabetic'
223
+ const x0 = L.pad + L.axis
224
+ const y0 = L.header + L.pad * 0.6 + L.axis
225
+ const region = { col: 0, row: 0, cols: artwork.size, rows: artwork.size }
226
+ drawBeads(ctx, artwork, { x0, y0, cell, style, region })
227
+ if (cell >= 16) drawCodes(ctx, artwork, { x0, y0, cell, style, region })
228
+ if (grid.enabled) drawGrid(ctx, { x0, y0, cell, region, interval: grid.interval, color: grid.color, opacity: grid.opacity })
229
+ return L
230
+ }
231
+
232
+ // —— 作品信息页(A4 比例,可分页)——
233
+ function buildInfoPages({ artwork, width, text, paletteLabel, mpCode, thumbnail }) {
234
+ const height = Math.round(width * 297 / 210)
235
+ const u = width / 1600
236
+ const pad = 96 * u
237
+ const colors = artwork.usage.slice().sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }))
238
+ const columns = 4
239
+ const gap = 24 * u
240
+ const cardW = (width - pad * 2 - gap * (columns - 1)) / columns
241
+ const cardH = 124 * u
242
+ const headerH = 300 * u
243
+ // 缩略图区:headerH+64 起,高 400;清单标题与说明各占一行,避免与上方重叠
244
+ const listTitleY = headerH + 64 * u + 400 * u + 96 * u
245
+ const firstTop = listTitleY + 88 * u
246
+ const nextTop = 200 * u
247
+ const bottom = height - 110 * u
248
+ const rowsFirst = Math.max(1, Math.floor((bottom - firstTop + gap) / (cardH + gap)))
249
+ const rowsNext = Math.max(1, Math.floor((bottom - nextTop + gap) / (cardH + gap)))
250
+ const pages = []
251
+ let rest = colors.length
252
+ let first = true
253
+ do {
254
+ const take = Math.min(rest, (first ? rowsFirst : rowsNext) * columns)
255
+ pages.push(take)
256
+ rest -= take
257
+ first = false
258
+ } while (rest > 0)
259
+
260
+ const drawCard = (ctx, color, x, y) => {
261
+ ctx.fillStyle = '#ffffff'
262
+ ctx.beginPath(); roundRectPath(ctx, x, y, cardW, cardH, 18 * u); ctx.fill()
263
+ ctx.strokeStyle = '#e7e2d8'; ctx.lineWidth = 2 * u; ctx.stroke()
264
+ const sw = 76 * u
265
+ ctx.fillStyle = color.hex
266
+ ctx.beginPath(); ctx.arc(x + 24 * u + sw / 2, y + cardH / 2, sw / 2, 0, Math.PI * 2); ctx.fill()
267
+ ctx.strokeStyle = 'rgba(0,0,0,0.12)'; ctx.stroke()
268
+ ctx.fillStyle = INK
269
+ ctx.font = `bold ${Math.round(34 * u)}px sans-serif`
270
+ ctx.fillText(color.id, x + 24 * u + sw + 20 * u, y + 56 * u)
271
+ ctx.fillStyle = BRAND
272
+ ctx.font = `${Math.round(26 * u)}px sans-serif`
273
+ ctx.fillText(`${color.count} 颗`, x + 24 * u + sw + 20 * u, y + 96 * u)
274
+ }
275
+
276
+ const draw = (ctx, page) => {
277
+ ctx.textAlign = 'start'
278
+ ctx.textBaseline = 'alphabetic'
279
+ ctx.fillStyle = '#fbfaf7'
280
+ ctx.fillRect(0, 0, width, height)
281
+ let top = nextTop
282
+ if (page === 0) {
283
+ ctx.fillStyle = INK
284
+ ctx.fillRect(0, 0, width, headerH)
285
+ ctx.fillStyle = '#ffffff'
286
+ ctx.font = `bold ${Math.round(62 * u)}px sans-serif`
287
+ ctx.fillText('豆维 · 作品信息与用豆清单', pad, 132 * u)
288
+ ctx.fillStyle = '#9fd3c7'
289
+ ctx.font = `${Math.round(28 * u)}px sans-serif`
290
+ ctx.fillText('照着清单备豆,照着图纸拼,拼完用微信扫一扫', pad, 200 * u)
291
+ if (mpCode) {
292
+ const s = 200 * u
293
+ const x = width - pad - s
294
+ const y = (headerH - s) / 2
295
+ ctx.fillStyle = '#ffffff'
296
+ ctx.beginPath(); roundRectPath(ctx, x - 10 * u, y - 10 * u, s + 20 * u, s + 20 * u, 20 * u); ctx.fill()
297
+ ctx.drawImage(mpCode, x, y, s, s)
298
+ }
299
+ // 缩略图 + 参数
300
+ const thumbS = 400 * u
301
+ const ty = headerH + 64 * u
302
+ if (thumbnail) {
303
+ ctx.fillStyle = '#ffffff'
304
+ ctx.beginPath(); roundRectPath(ctx, pad, ty, thumbS, thumbS, 24 * u); ctx.fill()
305
+ const inner = thumbS - 32 * u
306
+ const cell = inner / artwork.size
307
+ drawBeads(ctx, artwork, { x0: pad + 16 * u, y0: ty + 16 * u, cell, style: 'square' })
308
+ }
309
+ const mx = thumbnail ? pad + thumbS + 56 * u : pad
310
+ const rows = [
311
+ ['扫码内容', text],
312
+ ['作品尺寸', `${artwork.size} × ${artwork.size} 格`],
313
+ ['用豆总数', `${artwork.beads.toLocaleString()} 颗 · ${artwork.usage.length} 个色号`],
314
+ ['二维码', `${artwork.qr.size} × ${artwork.qr.size} 格 · 纠错 ${artwork.qr.level}`],
315
+ ['色库', paletteLabel],
316
+ ]
317
+ rows.forEach(([k, v], idx) => {
318
+ const y = ty + 52 * u + idx * 74 * u
319
+ ctx.fillStyle = MUTED
320
+ ctx.font = `${Math.round(26 * u)}px sans-serif`
321
+ ctx.fillText(k, mx, y)
322
+ ctx.fillStyle = INK
323
+ ctx.font = `bold ${Math.round(28 * u)}px sans-serif`
324
+ ctx.fillText(fitText(ctx, v, width - pad - mx - 150 * u), mx + 150 * u, y)
325
+ })
326
+ ctx.fillStyle = INK
327
+ ctx.font = `bold ${Math.round(42 * u)}px sans-serif`
328
+ ctx.fillText('用豆清单', pad, listTitleY)
329
+ ctx.fillStyle = MUTED
330
+ ctx.font = `${Math.round(24 * u)}px sans-serif`
331
+ ctx.fillText('按色号排序 · 建议每色多备 5% 以防掉落', pad, listTitleY + 44 * u)
332
+ top = firstTop
333
+ } else {
334
+ ctx.fillStyle = INK
335
+ ctx.font = `bold ${Math.round(36 * u)}px sans-serif`
336
+ ctx.fillText(`用豆清单(续 ${page + 1}/${pages.length})`, pad, 120 * u)
337
+ }
338
+ const offset = pages.slice(0, page).reduce((s, v) => s + v, 0)
339
+ for (let i = 0; i < pages[page]; i += 1) {
340
+ const col = i % columns
341
+ const row = Math.floor(i / columns)
342
+ drawCard(ctx, colors[offset + i], pad + col * (cardW + gap), top + row * (cardH + gap))
343
+ }
344
+ ctx.fillStyle = '#a3aaa5'
345
+ ctx.font = `${Math.round(22 * u)}px sans-serif`
346
+ ctx.fillText(`豆维 · 可扫码的拼豆 第 ${page + 1} / ${pages.length} 页`, pad, height - 56 * u)
347
+ }
348
+ return { width, height, pageCount: pages.length, draw }
349
+ }
350
+
351
+ // 轻度斜向平铺水印
352
+ function drawWatermark(ctx, w, h, text) {
353
+ const value = String(text || '').trim()
354
+ if (!value) return
355
+ const fontSize = Math.max(18, Math.round(w * 0.018))
356
+ const stepX = Math.max(fontSize * value.length * 1.6, w / 4)
357
+ const stepY = Math.max(fontSize * 6, w / 6)
358
+ ctx.save()
359
+ ctx.globalAlpha = 0.08
360
+ ctx.fillStyle = INK
361
+ ctx.font = `${fontSize}px sans-serif`
362
+ ctx.textAlign = 'center'
363
+ ctx.textBaseline = 'middle'
364
+ ctx.translate(w / 2, h / 2)
365
+ ctx.rotate(-Math.PI / 9)
366
+ const r = Math.hypot(w, h) / 2
367
+ for (let y = -r; y <= r; y += stepY) for (let x = -r; x <= r; x += stepX) ctx.fillText(value, x, y)
368
+ ctx.restore()
369
+ }
370
+
371
+ module.exports = {
372
+ BOARD,
373
+ drawBeads,
374
+ drawCodes,
375
+ drawGrid,
376
+ renderPreview,
377
+ patternPageLayout,
378
+ drawPatternPage,
379
+ buildInfoPages,
380
+ drawWatermark,
381
+ }
BeadQR/miniprogram/utils/pdf.js ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 轻量 PDF 生成器(无依赖)
2
+ // - 图纸页为纯矢量:珠子、色号、网格都是矢量指令,任意放大都清晰,文件小、生成快
3
+ // - 中文(页眉、信息页)以 JPEG 图片嵌入
4
+ // - 支持 1-bit 蒙版图片(用于矢量页上的半透明水印)
5
+
6
+ const A4 = { w: 595.28, h: 841.89 }
7
+
8
+ // Helvetica / Helvetica-Bold 字宽(AFM,1/1000 em),色号只含 A-Z 0-9
9
+ const W_BOLD = { ' ': 278, '-': 333, '/': 278 }
10
+ const W_REG = { ' ': 278, '-': 333, '/': 278 }
11
+ '0123456789'.split('').forEach((c) => { W_BOLD[c] = 556; W_REG[c] = 556 })
12
+ const UPPER_BOLD = [722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611]
13
+ const UPPER_REG = [667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611]
14
+ for (let i = 0; i < 26; i += 1) {
15
+ W_BOLD[String.fromCharCode(65 + i)] = UPPER_BOLD[i]
16
+ W_REG[String.fromCharCode(65 + i)] = UPPER_REG[i]
17
+ }
18
+ function textWidth(text, size, bold = true) {
19
+ const table = bold ? W_BOLD : W_REG
20
+ let w = 0
21
+ for (let i = 0; i < text.length; i += 1) w += table[text[i]] || 556
22
+ return (w / 1000) * size
23
+ }
24
+
25
+ const n2 = (v) => (Math.round(v * 100) / 100).toString()
26
+ const esc = (s) => String(s).replace(/[\\()]/g, (m) => `\\${m}`).replace(/[^\x20-\x7e]/g, '?')
27
+
28
+ function ascii(text) {
29
+ const out = new Uint8Array(text.length)
30
+ for (let i = 0; i < text.length; i += 1) out[i] = text.charCodeAt(i) & 0xff
31
+ return out
32
+ }
33
+
34
+ class PdfDoc {
35
+ constructor() {
36
+ this.objects = [null, null, null] // 1: Catalog, 2: Pages(0 号占位)
37
+ this.pageIds = []
38
+ this.fontRegular = this.add(['<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>'])
39
+ this.fontBold = this.add(['<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>'])
40
+ this.forms = {}
41
+ this.gstates = {}
42
+ }
43
+
44
+ add(parts) {
45
+ this.objects.push(parts)
46
+ return this.objects.length - 1
47
+ }
48
+
49
+ stream(dict, data) {
50
+ const bytes = typeof data === 'string' ? ascii(data) : new Uint8Array(data)
51
+ return this.add([`<< ${dict} /Length ${bytes.length} >>\nstream\n`, bytes, '\nendstream'])
52
+ }
53
+
54
+ addJpeg(buffer, width, height) {
55
+ return this.stream(`/Type /XObject /Subtype /Image /Width ${width} /Height ${height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode`, buffer)
56
+ }
57
+
58
+ // bits:每行按字节对齐,0 = 绘制
59
+ addImageMask(bits, width, height) {
60
+ return this.stream(`/Type /XObject /Subtype /Image /Width ${width} /Height ${height} /ImageMask true /BitsPerComponent 1`, bits)
61
+ }
62
+
63
+ // 单位圆(直径 1)表单:不设颜色,继承外部填充色
64
+ form(kind) {
65
+ if (this.forms[kind]) return this.forms[kind]
66
+ const k = 0.5523
67
+ const circle = (r) => {
68
+ const c = r * k
69
+ return `${n2(0.5 + r)} 0.5 m ${n2(0.5 + r)} ${n2(0.5 + c)} ${n2(0.5 + c)} ${n2(0.5 + r)} 0.5 ${n2(0.5 + r)} c `
70
+ + `${n2(0.5 - c)} ${n2(0.5 + r)} ${n2(0.5 - r)} ${n2(0.5 + c)} ${n2(0.5 - r)} 0.5 c `
71
+ + `${n2(0.5 - r)} ${n2(0.5 - c)} ${n2(0.5 - c)} ${n2(0.5 - r)} 0.5 ${n2(0.5 - r)} c `
72
+ + `${n2(0.5 + c)} ${n2(0.5 - r)} ${n2(0.5 + r)} ${n2(0.5 - c)} ${n2(0.5 + r)} 0.5 c h `
73
+ }
74
+ let body
75
+ if (kind === 'round') body = `${circle(0.46)}f`
76
+ else if (kind === 'hollow') body = `${circle(0.46)}${circle(0.2)}f*`
77
+ else body = `${circle(0.14)}f` // peg 空钉位
78
+ const id = this.stream('/Type /XObject /Subtype /Form /BBox [0 0 1 1]', body)
79
+ this.forms[kind] = id
80
+ return id
81
+ }
82
+
83
+ gstate(alpha) {
84
+ const key = String(alpha)
85
+ if (!this.gstates[key]) this.gstates[key] = this.add([`<< /Type /ExtGState /ca ${alpha} /CA ${alpha} >>`])
86
+ return this.gstates[key]
87
+ }
88
+
89
+ addPage(content, { images = {} } = {}) {
90
+ const contentId = this.stream('', content)
91
+ const xobjects = Object.assign({}, images)
92
+ Object.keys(this.forms).forEach((k) => { xobjects[`Fm_${k}`] = this.forms[k] })
93
+ const xo = Object.keys(xobjects).map((k) => `/${k} ${xobjects[k]} 0 R`).join(' ')
94
+ const gs = Object.keys(this.gstates).map((k) => `/GS${k.replace('.', '_')} ${this.gstates[k]} 0 R`).join(' ')
95
+ const id = this.add([`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${A4.w} ${A4.h}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << /F1 ${this.fontRegular} 0 R /F2 ${this.fontBold} 0 R >> /XObject << ${xo} >> /ExtGState << ${gs} >> >> /Contents ${contentId} 0 R >>`])
96
+ this.pageIds.push(id)
97
+ return id
98
+ }
99
+
100
+ build() {
101
+ this.objects[1] = ['<< /Type /Catalog /Pages 2 0 R >>']
102
+ this.objects[2] = [`<< /Type /Pages /Kids [${this.pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${this.pageIds.length} >>`]
103
+ const chunks = []
104
+ let pos = 0
105
+ const push = (part) => {
106
+ const bytes = typeof part === 'string' ? ascii(part) : part
107
+ chunks.push(bytes)
108
+ pos += bytes.length
109
+ }
110
+ push('%PDF-1.4\n%\xe2\xe3\xcf\xd3\n')
111
+ const offsets = []
112
+ for (let id = 1; id < this.objects.length; id += 1) {
113
+ offsets[id] = pos
114
+ push(`${id} 0 obj\n`)
115
+ this.objects[id].forEach(push)
116
+ push('\nendobj\n')
117
+ }
118
+ const xref = pos
119
+ let table = `xref\n0 ${this.objects.length}\n0000000000 65535 f \n`
120
+ for (let id = 1; id < this.objects.length; id += 1) table += `${String(offsets[id]).padStart(10, '0')} 00000 n \n`
121
+ push(table)
122
+ push(`trailer\n<< /Size ${this.objects.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`)
123
+ const out = new Uint8Array(pos)
124
+ let cursor = 0
125
+ chunks.forEach((c) => { out.set(c, cursor); cursor += c.length })
126
+ return out.buffer
127
+ }
128
+ }
129
+
130
+ // —— 矢量图纸 ——
131
+ // 使用“左上角原点、y 向下”的坐标系(页面开头统一翻转),文字与图片局部再翻回来
132
+
133
+ function hexToPdf(hex) {
134
+ const v = parseInt(hex.slice(1), 16)
135
+ return `${n2(((v >> 16) & 255) / 255)} ${n2(((v >> 8) & 255) / 255)} ${n2((v & 255) / 255)}`
136
+ }
137
+
138
+ function lumaOf(hex) {
139
+ const v = parseInt(hex.slice(1), 16)
140
+ return (((v >> 16) & 255) * 0.299 + ((v >> 8) & 255) * 0.587 + (v & 255) * 0.114) / 255
141
+ }
142
+
143
+ function vectorSheet(doc, artwork, { x0, y0, cell, region, style, codes, grid }) {
144
+ const { size, cells, pal } = artwork
145
+ const out = []
146
+ out.push(`${hexToPdf('#f1eee7')} rg ${n2(x0)} ${n2(y0)} ${n2(region.cols * cell)} ${n2(region.rows * cell)} re f`)
147
+ const groups = new Map()
148
+ const empty = []
149
+ for (let y = region.row; y < region.row + region.rows; y += 1) {
150
+ for (let x = region.col; x < region.col + region.cols; x += 1) {
151
+ const c = cells[y * size + x]
152
+ const px = x0 + (x - region.col) * cell
153
+ const py = y0 + (y - region.row) * cell
154
+ if (c < 0) { empty.push([px, py]); continue }
155
+ if (!groups.has(c)) groups.set(c, [])
156
+ groups.get(c).push([px, py])
157
+ }
158
+ }
159
+ if (empty.length) {
160
+ doc.form('peg')
161
+ out.push(`${hexToPdf('#d9d3c7')} rg`)
162
+ empty.forEach(([x, y]) => out.push(`q ${n2(cell)} 0 0 ${n2(cell)} ${n2(x)} ${n2(y)} cm /Fm_peg Do Q`))
163
+ }
164
+ if (style !== 'square') doc.form(style)
165
+ groups.forEach((list, c) => {
166
+ out.push(`${hexToPdf(pal.hexes[c])} rg`)
167
+ if (style === 'square') {
168
+ // 略微外扩 0.05pt 消除阅读器抗锯齿造成的白缝
169
+ list.forEach(([x, y]) => out.push(`${n2(x - 0.05)} ${n2(y - 0.05)} ${n2(cell + 0.1)} ${n2(cell + 0.1)} re`))
170
+ out.push('f')
171
+ } else {
172
+ list.forEach(([x, y]) => out.push(`q ${n2(cell)} 0 0 ${n2(cell)} ${n2(x)} ${n2(y)} cm /Fm_${style} Do Q`))
173
+ }
174
+ })
175
+ if (codes) {
176
+ const fs = cell * (style === 'hollow' ? 0.27 : 0.34)
177
+ const writeGroup = (ink) => {
178
+ const lines = []
179
+ groups.forEach((list, c) => {
180
+ const useInk = style === 'hollow' || lumaOf(pal.hexes[c]) > 0.56
181
+ if (useInk !== ink) return
182
+ const id = pal.ids[c]
183
+ const tw = textWidth(id, fs)
184
+ list.forEach(([x, y]) => lines.push(`1 0 0 -1 ${n2(x + (cell - tw) / 2)} ${n2(y + cell / 2 + fs * 0.35)} Tm (${id}) Tj`))
185
+ })
186
+ if (!lines.length) return
187
+ out.push(`${ink ? hexToPdf('#1d2b2a') : '1 1 1'} rg BT /F2 ${n2(fs)} Tf`)
188
+ out.push(...lines)
189
+ out.push('ET')
190
+ }
191
+ writeGroup(true)
192
+ writeGroup(false)
193
+ }
194
+ if (grid && grid.enabled) {
195
+ const w = region.cols * cell
196
+ const h = region.rows * cell
197
+ const color = hexToPdf(grid.color)
198
+ const alpha = grid.opacity / 100
199
+ const thin = []
200
+ for (let i = 0; i <= region.cols; i += 1) thin.push(`${n2(x0 + i * cell)} ${n2(y0)} m ${n2(x0 + i * cell)} ${n2(y0 + h)} l`)
201
+ for (let i = 0; i <= region.rows; i += 1) thin.push(`${n2(x0)} ${n2(y0 + i * cell)} m ${n2(x0 + w)} ${n2(y0 + i * cell)} l`)
202
+ doc.gstate(Math.round(alpha * 30) / 100)
203
+ doc.gstate(alpha)
204
+ out.push(`q /GS${String(Math.round(alpha * 30) / 100).replace('.', '_')} gs ${color} RG ${n2(Math.max(0.2, cell * 0.02))} w`, ...thin, 'S Q')
205
+ const thick = []
206
+ for (let i = 0; i <= region.cols; i += 1) {
207
+ const g = region.col + i
208
+ if (g % grid.interval && i && i !== region.cols) continue
209
+ thick.push(`${n2(x0 + i * cell)} ${n2(y0)} m ${n2(x0 + i * cell)} ${n2(y0 + h)} l`)
210
+ }
211
+ for (let i = 0; i <= region.rows; i += 1) {
212
+ const g = region.row + i
213
+ if (g % grid.interval && i && i !== region.rows) continue
214
+ thick.push(`${n2(x0)} ${n2(y0 + i * cell)} m ${n2(x0 + w)} ${n2(y0 + i * cell)} l`)
215
+ }
216
+ out.push(`q /GS${String(alpha).replace('.', '_')} gs ${color} RG ${n2(Math.max(0.6, cell * 0.06))} w`, ...thick, 'S Q')
217
+ // 坐标数字
218
+ const fs = Math.min(7, Math.max(4, cell * 0.42))
219
+ const step = cell < 8 ? grid.interval : (cell < 13 ? 5 : 1)
220
+ const labels = []
221
+ for (let i = 0; i < region.cols; i += 1) {
222
+ const g = region.col + i + 1
223
+ if (g % step && g !== 1 && i !== 0) continue
224
+ const t = String(g)
225
+ labels.push(`1 0 0 -1 ${n2(x0 + (i + 0.5) * cell - textWidth(t, fs, false) / 2)} ${n2(y0 - fs * 0.5)} Tm (${t}) Tj`)
226
+ }
227
+ for (let i = 0; i < region.rows; i += 1) {
228
+ const g = region.row + i + 1
229
+ if (g % step && g !== 1 && i !== 0) continue
230
+ const t = String(g)
231
+ labels.push(`1 0 0 -1 ${n2(x0 - textWidth(t, fs, false) - fs * 0.4)} ${n2(y0 + (i + 0.5) * cell + fs * 0.35)} Tm (${t}) Tj`)
232
+ }
233
+ out.push(`${color} rg BT /F1 ${n2(fs)} Tf`, ...labels, 'ET')
234
+ }
235
+ return out.join('\n')
236
+ }
237
+
238
+ // 页眉图片 + 水印蒙版的放置
239
+ function imageCmd(name, x, y, w, h) {
240
+ return `q ${n2(w)} 0 0 ${n2(-h)} ${n2(x)} ${n2(y + h)} cm /${name} Do Q`
241
+ }
242
+
243
+ function watermarkCmd(mask) {
244
+ if (!mask) return ''
245
+ const h = 16
246
+ const w = h * mask.width / mask.height
247
+ const out = [`q /GS0_08 gs ${hexToPdf('#1d2b2a')} rg`]
248
+ const stepX = w + 90
249
+ const stepY = 120
250
+ for (let row = 0, y = -200; y < A4.h + 200; y += stepY, row += 1) {
251
+ for (let x = -200; x < A4.w + 200; x += stepX) {
252
+ // 旋转 -20°:cos=0.94 sin=0.342;y 轴已翻转,所以蒙版局部再翻回
253
+ out.push(`q 0.94 -0.342 0.342 0.94 ${n2(x + (row % 2) * stepX / 2)} ${n2(y)} cm ${n2(w)} 0 0 ${n2(-h)} 0 ${n2(h)} cm /WM Do Q`)
254
+ }
255
+ }
256
+ out.push('Q')
257
+ return out.join('\n')
258
+ }
259
+
260
+ const MARGIN = 28.35 // 10mm
261
+ const HEADER_H = 34
262
+
263
+ const AXIS = 14
264
+ const TARGET_CELL = 18 // 约 6.4mm/格,打印后色号清晰可读
265
+ const AREA_W = A4.w - MARGIN * 2
266
+ const AREA_H = A4.h - MARGIN * 2 - HEADER_H
267
+
268
+ /**
269
+ * 规划图纸页:小图一页搞定;大图 = 总览页 + 若干分块页
270
+ * 返回 { tiled, fitCell, tilesX, tilesY, tileCols, tileRows, pages: [{ kind, tile, region }] }
271
+ */
272
+ function planPatternPages(size) {
273
+ const fitCell = Math.min((AREA_W - AXIS) / size, (AREA_H - AXIS) / size)
274
+ const tiled = fitCell < 11
275
+ const full = { col: 0, row: 0, cols: size, rows: size }
276
+ const pages = [{ kind: 'overview', tile: 0, region: full }]
277
+ let tilesX = 1
278
+ let tilesY = 1
279
+ let tileCols = size
280
+ let tileRows = size
281
+ if (tiled) {
282
+ tilesX = Math.ceil(size / Math.floor((AREA_W - AXIS) / TARGET_CELL))
283
+ tilesY = Math.ceil(size / Math.floor((AREA_H - AXIS) / TARGET_CELL))
284
+ tileCols = Math.ceil(size / tilesX)
285
+ tileRows = Math.ceil(size / tilesY)
286
+ let n = 1
287
+ for (let ty = 0; ty < tilesY; ty += 1) {
288
+ for (let tx = 0; tx < tilesX; tx += 1) {
289
+ const col = tx * tileCols
290
+ const row = ty * tileRows
291
+ if (col >= size || row >= size) continue
292
+ pages.push({ kind: 'tile', tile: n, region: { col, row, cols: Math.min(tileCols, size - col), rows: Math.min(tileRows, size - row) } })
293
+ n += 1
294
+ }
295
+ }
296
+ }
297
+ return { tiled, fitCell, tilesX, tilesY, tileCols, tileRows, pages }
298
+ }
299
+
300
+ /**
301
+ * 生成图纸页(矢量)。
302
+ * opts: { plan, headers: [{ id, width, height }](按页序), style, grid, watermark }
303
+ */
304
+ function addPatternPages(doc, artwork, opts) {
305
+ const size = artwork.size
306
+ const plan = opts.plan || planPatternPages(size)
307
+ const headers = opts.headers || []
308
+ const flip = `1 0 0 -1 0 ${A4.h} cm`
309
+ const wm = opts.watermark ? watermarkCmd(opts.watermark) : ''
310
+ if (opts.watermark) doc.gstate(0.08)
311
+ if (plan.tiled) doc.gstate(0.85)
312
+ const images = (header) => {
313
+ const map = {}
314
+ if (header) map.HD = header.id
315
+ if (opts.watermark) map.WM = opts.watermark.id
316
+ return map
317
+ }
318
+ const headerCmd = (header) => (header ? imageCmd('HD', MARGIN, MARGIN, AREA_W, AREA_W * header.height / header.width) : '')
319
+
320
+ plan.pages.forEach((page, i) => {
321
+ const header = headers[i] || null
322
+ const content = [flip, headerCmd(header)]
323
+ if (page.kind === 'overview') {
324
+ const fitCell = plan.fitCell
325
+ const gridW = fitCell * size
326
+ const x0 = MARGIN + AXIS + (AREA_W - AXIS - gridW) / 2
327
+ const y0 = MARGIN + HEADER_H + AXIS
328
+ content.push(vectorSheet(doc, artwork, {
329
+ x0, y0, cell: fitCell, region: page.region, style: opts.style, codes: !plan.tiled, grid: opts.grid,
330
+ }))
331
+ if (plan.tiled) {
332
+ const lines = [`q ${hexToPdf('#e11d48')} RG 1.2 w`]
333
+ const labels = []
334
+ plan.pages.forEach((tp) => {
335
+ if (tp.kind !== 'tile') return
336
+ const r = tp.region
337
+ const cx = x0 + r.col * fitCell
338
+ const cy = y0 + r.row * fitCell
339
+ const cw = r.cols * fitCell
340
+ const ch = r.rows * fitCell
341
+ lines.push(`${n2(cx)} ${n2(cy)} ${n2(cw)} ${n2(ch)} re S`)
342
+ const t = String(tp.tile)
343
+ labels.push(`1 0 0 -1 ${n2(cx + cw / 2 - textWidth(t, 22) / 2)} ${n2(cy + ch / 2 + 8)} Tm (${t}) Tj`)
344
+ })
345
+ lines.push('Q')
346
+ content.push(lines.join('\n'))
347
+ content.push(`q /GS0_85 gs ${hexToPdf('#e11d48')} rg BT /F2 22 Tf\n${labels.join('\n')}\nET Q`)
348
+ }
349
+ } else {
350
+ const cell = Math.min((AREA_W - AXIS) / plan.tileCols, (AREA_H - AXIS) / plan.tileRows)
351
+ content.push(vectorSheet(doc, artwork, {
352
+ x0: MARGIN + AXIS, y0: MARGIN + HEADER_H + AXIS, cell, region: page.region, style: opts.style, codes: true, grid: opts.grid,
353
+ }))
354
+ }
355
+ content.push(wm)
356
+ doc.addPage(content.join('\n'), { images: images(header) })
357
+ })
358
+ return plan.pages.length
359
+ }
360
+
361
+ // 整页图片(信息页)
362
+ function addImagePage(doc, jpegId, pixelW, pixelH) {
363
+ const areaW = A4.w - MARGIN * 2
364
+ const areaH = A4.h - MARGIN * 2
365
+ const scale = Math.min(areaW / pixelW, areaH / pixelH)
366
+ const w = pixelW * scale
367
+ const h = pixelH * scale
368
+ const x = (A4.w - w) / 2
369
+ const y = (A4.h - h) / 2
370
+ doc.addPage(`1 0 0 -1 0 ${A4.h} cm\n${imageCmd('IM', x, y, w, h)}`, { images: { IM: jpegId } })
371
+ }
372
+
373
+ module.exports = { A4, PdfDoc, planPatternPages, addPatternPages, addImagePage, vectorSheet, textWidth, HEADER_H, MARGIN }
BeadQR/miniprogram/utils/qr.js ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 二维码生成、功能区识别与摆放
2
+ const QRCodeCore = require('./qrcore/qrcode')
3
+ const AlignmentPattern = require('./qrcore/alignment-pattern')
4
+
5
+ const SAMPLE_TEXT = '豆维·可扫码的拼豆'
6
+
7
+ // 功能区(定位/时序/对齐/格式/版本)直接决定能否扫出,配色时强制高对比
8
+ function buildFunctionMask(size, version) {
9
+ const mask = new Uint8Array(size * size)
10
+ const mark = (x, y) => { if (x >= 0 && y >= 0 && x < size && y < size) mask[y * size + x] = 1 }
11
+ for (let y = 0; y < size; y += 1) {
12
+ for (let x = 0; x < size; x += 1) {
13
+ const finder = (x <= 8 && y <= 8) || (x >= size - 8 && y <= 8) || (x <= 8 && y >= size - 8)
14
+ if (finder || x === 6 || y === 6) mark(x, y)
15
+ }
16
+ }
17
+ const positions = AlignmentPattern.getPositions(version) || []
18
+ positions.forEach(([row, col]) => {
19
+ for (let dy = -2; dy <= 2; dy += 1) for (let dx = -2; dx <= 2; dx += 1) mark(col + dx, row + dy)
20
+ })
21
+ if (version >= 7) {
22
+ for (let i = 0; i < 6; i += 1) {
23
+ for (let j = size - 11; j < size - 8; j += 1) { mark(i, j); mark(j, i) }
24
+ }
25
+ }
26
+ return mask
27
+ }
28
+
29
+ function makeQr(text, level) {
30
+ const value = String(text || '').trim() || SAMPLE_TEXT
31
+ const qr = QRCodeCore.create(value, { errorCorrectionLevel: level || 'H' })
32
+ const size = qr.modules.size
33
+ const version = qr.version || Math.round((size - 17) / 4)
34
+ return {
35
+ size,
36
+ version,
37
+ level: level || 'H',
38
+ dark: Uint8Array.from(qr.modules.data),
39
+ func: buildFunctionMask(size, version),
40
+ isSample: !String(text || '').trim(),
41
+ }
42
+ }
43
+
44
+ // scale:二维码占整张作品边长的百分比;quiet:二维码四周留白格数
45
+ function createPlacement(qrSize, { scale = 100, posX = 50, posY = 50, quiet = 0, hasImage = true } = {}) {
46
+ const block = qrSize + quiet * 2
47
+ const size = hasImage ? Math.max(block, Math.ceil(block * 100 / scale)) : block
48
+ const free = size - block
49
+ return {
50
+ size,
51
+ left: quiet + Math.round(free * posX / 100),
52
+ top: quiet + Math.round(free * posY / 100),
53
+ quiet,
54
+ scale: hasImage ? scale : 100,
55
+ }
56
+ }
57
+
58
+ module.exports = { SAMPLE_TEXT, makeQr, createPlacement }
BeadQR/miniprogram/utils/qrcore/alignment-pattern.js ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Alignment pattern are fixed reference pattern in defined positions
3
+ * in a matrix symbology, which enables the decode software to re-synchronise
4
+ * the coordinate mapping of the image modules in the event of moderate amounts
5
+ * of distortion of the image.
6
+ *
7
+ * Alignment patterns are present only in QR Code symbols of version 2 or larger
8
+ * and their number depends on the symbol version.
9
+ */
10
+
11
+ const getSymbolSize = require('./utils').getSymbolSize
12
+
13
+ /**
14
+ * Calculate the row/column coordinates of the center module of each alignment pattern
15
+ * for the specified QR Code version.
16
+ *
17
+ * The alignment patterns are positioned symmetrically on either side of the diagonal
18
+ * running from the top left corner of the symbol to the bottom right corner.
19
+ *
20
+ * Since positions are simmetrical only half of the coordinates are returned.
21
+ * Each item of the array will represent in turn the x and y coordinate.
22
+ * @see {@link getPositions}
23
+ *
24
+ * @param {Number} version QR Code version
25
+ * @return {Array} Array of coordinate
26
+ */
27
+ exports.getRowColCoords = function getRowColCoords (version) {
28
+ if (version === 1) return []
29
+
30
+ const posCount = Math.floor(version / 7) + 2
31
+ const size = getSymbolSize(version)
32
+ const intervals = size === 145 ? 26 : Math.ceil((size - 13) / (2 * posCount - 2)) * 2
33
+ const positions = [size - 7] // Last coord is always (size - 7)
34
+
35
+ for (let i = 1; i < posCount - 1; i++) {
36
+ positions[i] = positions[i - 1] - intervals
37
+ }
38
+
39
+ positions.push(6) // First coord is always 6
40
+
41
+ return positions.reverse()
42
+ }
43
+
44
+ /**
45
+ * Returns an array containing the positions of each alignment pattern.
46
+ * Each array's element represent the center point of the pattern as (x, y) coordinates
47
+ *
48
+ * Coordinates are calculated expanding the row/column coordinates returned by {@link getRowColCoords}
49
+ * and filtering out the items that overlaps with finder pattern
50
+ *
51
+ * @example
52
+ * For a Version 7 symbol {@link getRowColCoords} returns values 6, 22 and 38.
53
+ * The alignment patterns, therefore, are to be centered on (row, column)
54
+ * positions (6,22), (22,6), (22,22), (22,38), (38,22), (38,38).
55
+ * Note that the coordinates (6,6), (6,38), (38,6) are occupied by finder patterns
56
+ * and are not therefore used for alignment patterns.
57
+ *
58
+ * let pos = getPositions(7)
59
+ * // [[6,22], [22,6], [22,22], [22,38], [38,22], [38,38]]
60
+ *
61
+ * @param {Number} version QR Code version
62
+ * @return {Array} Array of coordinates
63
+ */
64
+ exports.getPositions = function getPositions (version) {
65
+ const coords = []
66
+ const pos = exports.getRowColCoords(version)
67
+ const posLength = pos.length
68
+
69
+ for (let i = 0; i < posLength; i++) {
70
+ for (let j = 0; j < posLength; j++) {
71
+ // Skip if position is occupied by finder patterns
72
+ if ((i === 0 && j === 0) || // top-left
73
+ (i === 0 && j === posLength - 1) || // bottom-left
74
+ (i === posLength - 1 && j === 0)) { // top-right
75
+ continue
76
+ }
77
+
78
+ coords.push([pos[i], pos[j]])
79
+ }
80
+ }
81
+
82
+ return coords
83
+ }
BeadQR/miniprogram/utils/qrcore/alphanumeric-data.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const Mode = require('./mode')
2
+
3
+ /**
4
+ * Array of characters available in alphanumeric mode
5
+ *
6
+ * As per QR Code specification, to each character
7
+ * is assigned a value from 0 to 44 which in this case coincides
8
+ * with the array index
9
+ *
10
+ * @type {Array}
11
+ */
12
+ const ALPHA_NUM_CHARS = [
13
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
14
+ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
15
+ 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
16
+ ' ', '$', '%', '*', '+', '-', '.', '/', ':'
17
+ ]
18
+
19
+ function AlphanumericData (data) {
20
+ this.mode = Mode.ALPHANUMERIC
21
+ this.data = data
22
+ }
23
+
24
+ AlphanumericData.getBitsLength = function getBitsLength (length) {
25
+ return 11 * Math.floor(length / 2) + 6 * (length % 2)
26
+ }
27
+
28
+ AlphanumericData.prototype.getLength = function getLength () {
29
+ return this.data.length
30
+ }
31
+
32
+ AlphanumericData.prototype.getBitsLength = function getBitsLength () {
33
+ return AlphanumericData.getBitsLength(this.data.length)
34
+ }
35
+
36
+ AlphanumericData.prototype.write = function write (bitBuffer) {
37
+ let i
38
+
39
+ // Input data characters are divided into groups of two characters
40
+ // and encoded as 11-bit binary codes.
41
+ for (i = 0; i + 2 <= this.data.length; i += 2) {
42
+ // The character value of the first character is multiplied by 45
43
+ let value = ALPHA_NUM_CHARS.indexOf(this.data[i]) * 45
44
+
45
+ // The character value of the second digit is added to the product
46
+ value += ALPHA_NUM_CHARS.indexOf(this.data[i + 1])
47
+
48
+ // The sum is then stored as 11-bit binary number
49
+ bitBuffer.put(value, 11)
50
+ }
51
+
52
+ // If the number of input data characters is not a multiple of two,
53
+ // the character value of the final character is encoded as a 6-bit binary number.
54
+ if (this.data.length % 2) {
55
+ bitBuffer.put(ALPHA_NUM_CHARS.indexOf(this.data[i]), 6)
56
+ }
57
+ }
58
+
59
+ module.exports = AlphanumericData
BeadQR/miniprogram/utils/qrcore/bit-buffer.js ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function BitBuffer () {
2
+ this.buffer = []
3
+ this.length = 0
4
+ }
5
+
6
+ BitBuffer.prototype = {
7
+
8
+ get: function (index) {
9
+ const bufIndex = Math.floor(index / 8)
10
+ return ((this.buffer[bufIndex] >>> (7 - index % 8)) & 1) === 1
11
+ },
12
+
13
+ put: function (num, length) {
14
+ for (let i = 0; i < length; i++) {
15
+ this.putBit(((num >>> (length - i - 1)) & 1) === 1)
16
+ }
17
+ },
18
+
19
+ getLengthInBits: function () {
20
+ return this.length
21
+ },
22
+
23
+ putBit: function (bit) {
24
+ const bufIndex = Math.floor(this.length / 8)
25
+ if (this.buffer.length <= bufIndex) {
26
+ this.buffer.push(0)
27
+ }
28
+
29
+ if (bit) {
30
+ this.buffer[bufIndex] |= (0x80 >>> (this.length % 8))
31
+ }
32
+
33
+ this.length++
34
+ }
35
+ }
36
+
37
+ module.exports = BitBuffer
BeadQR/miniprogram/utils/qrcore/bit-matrix.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Helper class to handle QR Code symbol modules
3
+ *
4
+ * @param {Number} size Symbol size
5
+ */
6
+ function BitMatrix (size) {
7
+ if (!size || size < 1) {
8
+ throw new Error('BitMatrix size must be defined and greater than 0')
9
+ }
10
+
11
+ this.size = size
12
+ this.data = new Uint8Array(size * size)
13
+ this.reservedBit = new Uint8Array(size * size)
14
+ }
15
+
16
+ /**
17
+ * Set bit value at specified location
18
+ * If reserved flag is set, this bit will be ignored during masking process
19
+ *
20
+ * @param {Number} row
21
+ * @param {Number} col
22
+ * @param {Boolean} value
23
+ * @param {Boolean} reserved
24
+ */
25
+ BitMatrix.prototype.set = function (row, col, value, reserved) {
26
+ const index = row * this.size + col
27
+ this.data[index] = value
28
+ if (reserved) this.reservedBit[index] = true
29
+ }
30
+
31
+ /**
32
+ * Returns bit value at specified location
33
+ *
34
+ * @param {Number} row
35
+ * @param {Number} col
36
+ * @return {Boolean}
37
+ */
38
+ BitMatrix.prototype.get = function (row, col) {
39
+ return this.data[row * this.size + col]
40
+ }
41
+
42
+ /**
43
+ * Applies xor operator at specified location
44
+ * (used during masking process)
45
+ *
46
+ * @param {Number} row
47
+ * @param {Number} col
48
+ * @param {Boolean} value
49
+ */
50
+ BitMatrix.prototype.xor = function (row, col, value) {
51
+ this.data[row * this.size + col] ^= value
52
+ }
53
+
54
+ /**
55
+ * Check if bit at specified location is reserved
56
+ *
57
+ * @param {Number} row
58
+ * @param {Number} col
59
+ * @return {Boolean}
60
+ */
61
+ BitMatrix.prototype.isReserved = function (row, col) {
62
+ return this.reservedBit[row * this.size + col]
63
+ }
64
+
65
+ module.exports = BitMatrix
BeadQR/miniprogram/utils/qrcore/byte-data.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const Mode = require('./mode')
2
+
3
+ // 微信部分真机运行时没有 TextEncoder。二维码 BYTE 段只需要 UTF-8 字节,
4
+ // 在此实现最小兼容编码器,且与 TextEncoder 对无效代理对的处理一致。
5
+ function encodeUtf8 (value) {
6
+ const bytes = []
7
+ for (let index = 0; index < value.length; index += 1) {
8
+ let codePoint = value.charCodeAt(index)
9
+ if (codePoint >= 0xd800 && codePoint <= 0xdbff) {
10
+ const next = value.charCodeAt(index + 1)
11
+ if (next >= 0xdc00 && next <= 0xdfff) {
12
+ codePoint = 0x10000 + ((codePoint - 0xd800) << 10) + (next - 0xdc00)
13
+ index += 1
14
+ } else {
15
+ codePoint = 0xfffd
16
+ }
17
+ } else if (codePoint >= 0xdc00 && codePoint <= 0xdfff) {
18
+ codePoint = 0xfffd
19
+ }
20
+
21
+ if (codePoint <= 0x7f) {
22
+ bytes.push(codePoint)
23
+ } else if (codePoint <= 0x7ff) {
24
+ bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f))
25
+ } else if (codePoint <= 0xffff) {
26
+ bytes.push(0xe0 | (codePoint >> 12), 0x80 | ((codePoint >> 6) & 0x3f), 0x80 | (codePoint & 0x3f))
27
+ } else {
28
+ bytes.push(0xf0 | (codePoint >> 18), 0x80 | ((codePoint >> 12) & 0x3f), 0x80 | ((codePoint >> 6) & 0x3f), 0x80 | (codePoint & 0x3f))
29
+ }
30
+ }
31
+ return new Uint8Array(bytes)
32
+ }
33
+
34
+ function ByteData (data) {
35
+ this.mode = Mode.BYTE
36
+ if (typeof (data) === 'string') {
37
+ this.data = encodeUtf8(data)
38
+ } else {
39
+ this.data = new Uint8Array(data)
40
+ }
41
+ }
42
+
43
+ ByteData.getBitsLength = function getBitsLength (length) {
44
+ return length * 8
45
+ }
46
+
47
+ ByteData.prototype.getLength = function getLength () {
48
+ return this.data.length
49
+ }
50
+
51
+ ByteData.prototype.getBitsLength = function getBitsLength () {
52
+ return ByteData.getBitsLength(this.data.length)
53
+ }
54
+
55
+ ByteData.prototype.write = function (bitBuffer) {
56
+ for (let i = 0, l = this.data.length; i < l; i++) {
57
+ bitBuffer.put(this.data[i], 8)
58
+ }
59
+ }
60
+
61
+ module.exports = ByteData
BeadQR/miniprogram/utils/qrcore/dijkstrajs.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ /******************************************************************************
4
+ * Created 2008-08-19.
5
+ *
6
+ * Dijkstra path-finding functions. Adapted from the Dijkstar Python project.
7
+ *
8
+ * Copyright (C) 2008
9
+ * Wyatt Baldwin <self@wyattbaldwin.com>
10
+ * All rights reserved
11
+ *
12
+ * Licensed under the MIT license.
13
+ *
14
+ * http://www.opensource.org/licenses/mit-license.php
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ * THE SOFTWARE.
23
+ *****************************************************************************/
24
+ var dijkstra = {
25
+ single_source_shortest_paths: function(graph, s, d) {
26
+ // Predecessor map for each node that has been encountered.
27
+ // node ID => predecessor node ID
28
+ var predecessors = {};
29
+
30
+ // Costs of shortest paths from s to all nodes encountered.
31
+ // node ID => cost
32
+ var costs = {};
33
+ costs[s] = 0;
34
+
35
+ // Costs of shortest paths from s to all nodes encountered; differs from
36
+ // `costs` in that it provides easy access to the node that currently has
37
+ // the known shortest path from s.
38
+ // XXX: Do we actually need both `costs` and `open`?
39
+ var open = dijkstra.PriorityQueue.make();
40
+ open.push(s, 0);
41
+
42
+ var closest,
43
+ u, v,
44
+ cost_of_s_to_u,
45
+ adjacent_nodes,
46
+ cost_of_e,
47
+ cost_of_s_to_u_plus_cost_of_e,
48
+ cost_of_s_to_v,
49
+ first_visit;
50
+ while (!open.empty()) {
51
+ // In the nodes remaining in graph that have a known cost from s,
52
+ // find the node, u, that currently has the shortest path from s.
53
+ closest = open.pop();
54
+ u = closest.value;
55
+ cost_of_s_to_u = closest.cost;
56
+
57
+ // Get nodes adjacent to u...
58
+ adjacent_nodes = graph[u] || {};
59
+
60
+ // ...and explore the edges that connect u to those nodes, updating
61
+ // the cost of the shortest paths to any or all of those nodes as
62
+ // necessary. v is the node across the current edge from u.
63
+ for (v in adjacent_nodes) {
64
+ if (adjacent_nodes.hasOwnProperty(v)) {
65
+ // Get the cost of the edge running from u to v.
66
+ cost_of_e = adjacent_nodes[v];
67
+
68
+ // Cost of s to u plus the cost of u to v across e--this is *a*
69
+ // cost from s to v that may or may not be less than the current
70
+ // known cost to v.
71
+ cost_of_s_to_u_plus_cost_of_e = cost_of_s_to_u + cost_of_e;
72
+
73
+ // If we haven't visited v yet OR if the current known cost from s to
74
+ // v is greater than the new cost we just found (cost of s to u plus
75
+ // cost of u to v across e), update v's cost in the cost list and
76
+ // update v's predecessor in the predecessor list (it's now u).
77
+ cost_of_s_to_v = costs[v];
78
+ first_visit = (typeof costs[v] === 'undefined');
79
+ if (first_visit || cost_of_s_to_v > cost_of_s_to_u_plus_cost_of_e) {
80
+ costs[v] = cost_of_s_to_u_plus_cost_of_e;
81
+ open.push(v, cost_of_s_to_u_plus_cost_of_e);
82
+ predecessors[v] = u;
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ if (typeof d !== 'undefined' && typeof costs[d] === 'undefined') {
89
+ var msg = ['Could not find a path from ', s, ' to ', d, '.'].join('');
90
+ throw new Error(msg);
91
+ }
92
+
93
+ return predecessors;
94
+ },
95
+
96
+ extract_shortest_path_from_predecessor_list: function(predecessors, d) {
97
+ var nodes = [];
98
+ var u = d;
99
+ var predecessor;
100
+ while (u) {
101
+ nodes.push(u);
102
+ predecessor = predecessors[u];
103
+ u = predecessors[u];
104
+ }
105
+ nodes.reverse();
106
+ return nodes;
107
+ },
108
+
109
+ find_path: function(graph, s, d) {
110
+ var predecessors = dijkstra.single_source_shortest_paths(graph, s, d);
111
+ return dijkstra.extract_shortest_path_from_predecessor_list(
112
+ predecessors, d);
113
+ },
114
+
115
+ /**
116
+ * A very naive priority queue implementation.
117
+ */
118
+ PriorityQueue: {
119
+ make: function (opts) {
120
+ var T = dijkstra.PriorityQueue,
121
+ t = {},
122
+ key;
123
+ opts = opts || {};
124
+ for (key in T) {
125
+ if (T.hasOwnProperty(key)) {
126
+ t[key] = T[key];
127
+ }
128
+ }
129
+ t.queue = [];
130
+ t.sorter = opts.sorter || T.default_sorter;
131
+ return t;
132
+ },
133
+
134
+ default_sorter: function (a, b) {
135
+ return a.cost - b.cost;
136
+ },
137
+
138
+ /**
139
+ * Add a new item to the queue and ensure the highest priority element
140
+ * is at the front of the queue.
141
+ */
142
+ push: function (value, cost) {
143
+ var item = {value: value, cost: cost};
144
+ this.queue.push(item);
145
+ this.queue.sort(this.sorter);
146
+ },
147
+
148
+ /**
149
+ * Return the highest priority element in the queue.
150
+ */
151
+ pop: function () {
152
+ return this.queue.shift();
153
+ },
154
+
155
+ empty: function () {
156
+ return this.queue.length === 0;
157
+ }
158
+ }
159
+ };
160
+
161
+
162
+ // node.js module exports
163
+ if (typeof module !== 'undefined') {
164
+ module.exports = dijkstra;
165
+ }
BeadQR/miniprogram/utils/qrcore/error-correction-code.js ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const ECLevel = require('./error-correction-level')
2
+
3
+ const EC_BLOCKS_TABLE = [
4
+ // L M Q H
5
+ 1, 1, 1, 1,
6
+ 1, 1, 1, 1,
7
+ 1, 1, 2, 2,
8
+ 1, 2, 2, 4,
9
+ 1, 2, 4, 4,
10
+ 2, 4, 4, 4,
11
+ 2, 4, 6, 5,
12
+ 2, 4, 6, 6,
13
+ 2, 5, 8, 8,
14
+ 4, 5, 8, 8,
15
+ 4, 5, 8, 11,
16
+ 4, 8, 10, 11,
17
+ 4, 9, 12, 16,
18
+ 4, 9, 16, 16,
19
+ 6, 10, 12, 18,
20
+ 6, 10, 17, 16,
21
+ 6, 11, 16, 19,
22
+ 6, 13, 18, 21,
23
+ 7, 14, 21, 25,
24
+ 8, 16, 20, 25,
25
+ 8, 17, 23, 25,
26
+ 9, 17, 23, 34,
27
+ 9, 18, 25, 30,
28
+ 10, 20, 27, 32,
29
+ 12, 21, 29, 35,
30
+ 12, 23, 34, 37,
31
+ 12, 25, 34, 40,
32
+ 13, 26, 35, 42,
33
+ 14, 28, 38, 45,
34
+ 15, 29, 40, 48,
35
+ 16, 31, 43, 51,
36
+ 17, 33, 45, 54,
37
+ 18, 35, 48, 57,
38
+ 19, 37, 51, 60,
39
+ 19, 38, 53, 63,
40
+ 20, 40, 56, 66,
41
+ 21, 43, 59, 70,
42
+ 22, 45, 62, 74,
43
+ 24, 47, 65, 77,
44
+ 25, 49, 68, 81
45
+ ]
46
+
47
+ const EC_CODEWORDS_TABLE = [
48
+ // L M Q H
49
+ 7, 10, 13, 17,
50
+ 10, 16, 22, 28,
51
+ 15, 26, 36, 44,
52
+ 20, 36, 52, 64,
53
+ 26, 48, 72, 88,
54
+ 36, 64, 96, 112,
55
+ 40, 72, 108, 130,
56
+ 48, 88, 132, 156,
57
+ 60, 110, 160, 192,
58
+ 72, 130, 192, 224,
59
+ 80, 150, 224, 264,
60
+ 96, 176, 260, 308,
61
+ 104, 198, 288, 352,
62
+ 120, 216, 320, 384,
63
+ 132, 240, 360, 432,
64
+ 144, 280, 408, 480,
65
+ 168, 308, 448, 532,
66
+ 180, 338, 504, 588,
67
+ 196, 364, 546, 650,
68
+ 224, 416, 600, 700,
69
+ 224, 442, 644, 750,
70
+ 252, 476, 690, 816,
71
+ 270, 504, 750, 900,
72
+ 300, 560, 810, 960,
73
+ 312, 588, 870, 1050,
74
+ 336, 644, 952, 1110,
75
+ 360, 700, 1020, 1200,
76
+ 390, 728, 1050, 1260,
77
+ 420, 784, 1140, 1350,
78
+ 450, 812, 1200, 1440,
79
+ 480, 868, 1290, 1530,
80
+ 510, 924, 1350, 1620,
81
+ 540, 980, 1440, 1710,
82
+ 570, 1036, 1530, 1800,
83
+ 570, 1064, 1590, 1890,
84
+ 600, 1120, 1680, 1980,
85
+ 630, 1204, 1770, 2100,
86
+ 660, 1260, 1860, 2220,
87
+ 720, 1316, 1950, 2310,
88
+ 750, 1372, 2040, 2430
89
+ ]
90
+
91
+ /**
92
+ * Returns the number of error correction block that the QR Code should contain
93
+ * for the specified version and error correction level.
94
+ *
95
+ * @param {Number} version QR Code version
96
+ * @param {Number} errorCorrectionLevel Error correction level
97
+ * @return {Number} Number of error correction blocks
98
+ */
99
+ exports.getBlocksCount = function getBlocksCount (version, errorCorrectionLevel) {
100
+ switch (errorCorrectionLevel) {
101
+ case ECLevel.L:
102
+ return EC_BLOCKS_TABLE[(version - 1) * 4 + 0]
103
+ case ECLevel.M:
104
+ return EC_BLOCKS_TABLE[(version - 1) * 4 + 1]
105
+ case ECLevel.Q:
106
+ return EC_BLOCKS_TABLE[(version - 1) * 4 + 2]
107
+ case ECLevel.H:
108
+ return EC_BLOCKS_TABLE[(version - 1) * 4 + 3]
109
+ default:
110
+ return undefined
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Returns the number of error correction codewords to use for the specified
116
+ * version and error correction level.
117
+ *
118
+ * @param {Number} version QR Code version
119
+ * @param {Number} errorCorrectionLevel Error correction level
120
+ * @return {Number} Number of error correction codewords
121
+ */
122
+ exports.getTotalCodewordsCount = function getTotalCodewordsCount (version, errorCorrectionLevel) {
123
+ switch (errorCorrectionLevel) {
124
+ case ECLevel.L:
125
+ return EC_CODEWORDS_TABLE[(version - 1) * 4 + 0]
126
+ case ECLevel.M:
127
+ return EC_CODEWORDS_TABLE[(version - 1) * 4 + 1]
128
+ case ECLevel.Q:
129
+ return EC_CODEWORDS_TABLE[(version - 1) * 4 + 2]
130
+ case ECLevel.H:
131
+ return EC_CODEWORDS_TABLE[(version - 1) * 4 + 3]
132
+ default:
133
+ return undefined
134
+ }
135
+ }
BeadQR/miniprogram/utils/qrcore/error-correction-level.js ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ exports.L = { bit: 1 }
2
+ exports.M = { bit: 0 }
3
+ exports.Q = { bit: 3 }
4
+ exports.H = { bit: 2 }
5
+
6
+ function fromString (string) {
7
+ if (typeof string !== 'string') {
8
+ throw new Error('Param is not a string')
9
+ }
10
+
11
+ const lcStr = string.toLowerCase()
12
+
13
+ switch (lcStr) {
14
+ case 'l':
15
+ case 'low':
16
+ return exports.L
17
+
18
+ case 'm':
19
+ case 'medium':
20
+ return exports.M
21
+
22
+ case 'q':
23
+ case 'quartile':
24
+ return exports.Q
25
+
26
+ case 'h':
27
+ case 'high':
28
+ return exports.H
29
+
30
+ default:
31
+ throw new Error('Unknown EC Level: ' + string)
32
+ }
33
+ }
34
+
35
+ exports.isValid = function isValid (level) {
36
+ return level && typeof level.bit !== 'undefined' &&
37
+ level.bit >= 0 && level.bit < 4
38
+ }
39
+
40
+ exports.from = function from (value, defaultValue) {
41
+ if (exports.isValid(value)) {
42
+ return value
43
+ }
44
+
45
+ try {
46
+ return fromString(value)
47
+ } catch (e) {
48
+ return defaultValue
49
+ }
50
+ }
BeadQR/miniprogram/utils/qrcore/finder-pattern.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const getSymbolSize = require('./utils').getSymbolSize
2
+ const FINDER_PATTERN_SIZE = 7
3
+
4
+ /**
5
+ * Returns an array containing the positions of each finder pattern.
6
+ * Each array's element represent the top-left point of the pattern as (x, y) coordinates
7
+ *
8
+ * @param {Number} version QR Code version
9
+ * @return {Array} Array of coordinates
10
+ */
11
+ exports.getPositions = function getPositions (version) {
12
+ const size = getSymbolSize(version)
13
+
14
+ return [
15
+ // top-left
16
+ [0, 0],
17
+ // top-right
18
+ [size - FINDER_PATTERN_SIZE, 0],
19
+ // bottom-left
20
+ [0, size - FINDER_PATTERN_SIZE]
21
+ ]
22
+ }
BeadQR/miniprogram/utils/qrcore/format-info.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const Utils = require('./utils')
2
+
3
+ const G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0)
4
+ const G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1)
5
+ const G15_BCH = Utils.getBCHDigit(G15)
6
+
7
+ /**
8
+ * Returns format information with relative error correction bits
9
+ *
10
+ * The format information is a 15-bit sequence containing 5 data bits,
11
+ * with 10 error correction bits calculated using the (15, 5) BCH code.
12
+ *
13
+ * @param {Number} errorCorrectionLevel Error correction level
14
+ * @param {Number} mask Mask pattern
15
+ * @return {Number} Encoded format information bits
16
+ */
17
+ exports.getEncodedBits = function getEncodedBits (errorCorrectionLevel, mask) {
18
+ const data = ((errorCorrectionLevel.bit << 3) | mask)
19
+ let d = data << 10
20
+
21
+ while (Utils.getBCHDigit(d) - G15_BCH >= 0) {
22
+ d ^= (G15 << (Utils.getBCHDigit(d) - G15_BCH))
23
+ }
24
+
25
+ // xor final data with mask pattern in order to ensure that
26
+ // no combination of Error Correction Level and data mask pattern
27
+ // will result in an all-zero data string
28
+ return ((data << 10) | d) ^ G15_MASK
29
+ }
BeadQR/miniprogram/utils/qrcore/galois-field.js ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const EXP_TABLE = new Uint8Array(512)
2
+ const LOG_TABLE = new Uint8Array(256)
3
+ /**
4
+ * Precompute the log and anti-log tables for faster computation later
5
+ *
6
+ * For each possible value in the galois field 2^8, we will pre-compute
7
+ * the logarithm and anti-logarithm (exponential) of this value
8
+ *
9
+ * ref {@link https://en.wikiversity.org/wiki/Reed%E2%80%93Solomon_codes_for_coders#Introduction_to_mathematical_fields}
10
+ */
11
+ ;(function initTables () {
12
+ let x = 1
13
+ for (let i = 0; i < 255; i++) {
14
+ EXP_TABLE[i] = x
15
+ LOG_TABLE[x] = i
16
+
17
+ x <<= 1 // multiply by 2
18
+
19
+ // The QR code specification says to use byte-wise modulo 100011101 arithmetic.
20
+ // This means that when a number is 256 or larger, it should be XORed with 0x11D.
21
+ if (x & 0x100) { // similar to x >= 256, but a lot faster (because 0x100 == 256)
22
+ x ^= 0x11D
23
+ }
24
+ }
25
+
26
+ // Optimization: double the size of the anti-log table so that we don't need to mod 255 to
27
+ // stay inside the bounds (because we will mainly use this table for the multiplication of
28
+ // two GF numbers, no more).
29
+ // @see {@link mul}
30
+ for (let i = 255; i < 512; i++) {
31
+ EXP_TABLE[i] = EXP_TABLE[i - 255]
32
+ }
33
+ }())
34
+
35
+ /**
36
+ * Returns log value of n inside Galois Field
37
+ *
38
+ * @param {Number} n
39
+ * @return {Number}
40
+ */
41
+ exports.log = function log (n) {
42
+ if (n < 1) throw new Error('log(' + n + ')')
43
+ return LOG_TABLE[n]
44
+ }
45
+
46
+ /**
47
+ * Returns anti-log value of n inside Galois Field
48
+ *
49
+ * @param {Number} n
50
+ * @return {Number}
51
+ */
52
+ exports.exp = function exp (n) {
53
+ return EXP_TABLE[n]
54
+ }
55
+
56
+ /**
57
+ * Multiplies two number inside Galois Field
58
+ *
59
+ * @param {Number} x
60
+ * @param {Number} y
61
+ * @return {Number}
62
+ */
63
+ exports.mul = function mul (x, y) {
64
+ if (x === 0 || y === 0) return 0
65
+
66
+ // should be EXP_TABLE[(LOG_TABLE[x] + LOG_TABLE[y]) % 255] if EXP_TABLE wasn't oversized
67
+ // @see {@link initTables}
68
+ return EXP_TABLE[LOG_TABLE[x] + LOG_TABLE[y]]
69
+ }
BeadQR/miniprogram/utils/qrcore/kanji-data.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const Mode = require('./mode')
2
+ const Utils = require('./utils')
3
+
4
+ function KanjiData (data) {
5
+ this.mode = Mode.KANJI
6
+ this.data = data
7
+ }
8
+
9
+ KanjiData.getBitsLength = function getBitsLength (length) {
10
+ return length * 13
11
+ }
12
+
13
+ KanjiData.prototype.getLength = function getLength () {
14
+ return this.data.length
15
+ }
16
+
17
+ KanjiData.prototype.getBitsLength = function getBitsLength () {
18
+ return KanjiData.getBitsLength(this.data.length)
19
+ }
20
+
21
+ KanjiData.prototype.write = function (bitBuffer) {
22
+ let i
23
+
24
+ // In the Shift JIS system, Kanji characters are represented by a two byte combination.
25
+ // These byte values are shifted from the JIS X 0208 values.
26
+ // JIS X 0208 gives details of the shift coded representation.
27
+ for (i = 0; i < this.data.length; i++) {
28
+ let value = Utils.toSJIS(this.data[i])
29
+
30
+ // For characters with Shift JIS values from 0x8140 to 0x9FFC:
31
+ if (value >= 0x8140 && value <= 0x9FFC) {
32
+ // Subtract 0x8140 from Shift JIS value
33
+ value -= 0x8140
34
+
35
+ // For characters with Shift JIS values from 0xE040 to 0xEBBF
36
+ } else if (value >= 0xE040 && value <= 0xEBBF) {
37
+ // Subtract 0xC140 from Shift JIS value
38
+ value -= 0xC140
39
+ } else {
40
+ throw new Error(
41
+ 'Invalid SJIS character: ' + this.data[i] + '\n' +
42
+ 'Make sure your charset is UTF-8')
43
+ }
44
+
45
+ // Multiply most significant byte of result by 0xC0
46
+ // and add least significant byte to product
47
+ value = (((value >>> 8) & 0xff) * 0xC0) + (value & 0xff)
48
+
49
+ // Convert result to a 13-bit binary string
50
+ bitBuffer.put(value, 13)
51
+ }
52
+ }
53
+
54
+ module.exports = KanjiData
BeadQR/miniprogram/utils/qrcore/mask-pattern.js ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Data mask pattern reference
3
+ * @type {Object}
4
+ */
5
+ exports.Patterns = {
6
+ PATTERN000: 0,
7
+ PATTERN001: 1,
8
+ PATTERN010: 2,
9
+ PATTERN011: 3,
10
+ PATTERN100: 4,
11
+ PATTERN101: 5,
12
+ PATTERN110: 6,
13
+ PATTERN111: 7
14
+ }
15
+
16
+ /**
17
+ * Weighted penalty scores for the undesirable features
18
+ * @type {Object}
19
+ */
20
+ const PenaltyScores = {
21
+ N1: 3,
22
+ N2: 3,
23
+ N3: 40,
24
+ N4: 10
25
+ }
26
+
27
+ /**
28
+ * Check if mask pattern value is valid
29
+ *
30
+ * @param {Number} mask Mask pattern
31
+ * @return {Boolean} true if valid, false otherwise
32
+ */
33
+ exports.isValid = function isValid (mask) {
34
+ return mask != null && mask !== '' && !isNaN(mask) && mask >= 0 && mask <= 7
35
+ }
36
+
37
+ /**
38
+ * Returns mask pattern from a value.
39
+ * If value is not valid, returns undefined
40
+ *
41
+ * @param {Number|String} value Mask pattern value
42
+ * @return {Number} Valid mask pattern or undefined
43
+ */
44
+ exports.from = function from (value) {
45
+ return exports.isValid(value) ? parseInt(value, 10) : undefined
46
+ }
47
+
48
+ /**
49
+ * Find adjacent modules in row/column with the same color
50
+ * and assign a penalty value.
51
+ *
52
+ * Points: N1 + i
53
+ * i is the amount by which the number of adjacent modules of the same color exceeds 5
54
+ */
55
+ exports.getPenaltyN1 = function getPenaltyN1 (data) {
56
+ const size = data.size
57
+ let points = 0
58
+ let sameCountCol = 0
59
+ let sameCountRow = 0
60
+ let lastCol = null
61
+ let lastRow = null
62
+
63
+ for (let row = 0; row < size; row++) {
64
+ sameCountCol = sameCountRow = 0
65
+ lastCol = lastRow = null
66
+
67
+ for (let col = 0; col < size; col++) {
68
+ let module = data.get(row, col)
69
+ if (module === lastCol) {
70
+ sameCountCol++
71
+ } else {
72
+ if (sameCountCol >= 5) points += PenaltyScores.N1 + (sameCountCol - 5)
73
+ lastCol = module
74
+ sameCountCol = 1
75
+ }
76
+
77
+ module = data.get(col, row)
78
+ if (module === lastRow) {
79
+ sameCountRow++
80
+ } else {
81
+ if (sameCountRow >= 5) points += PenaltyScores.N1 + (sameCountRow - 5)
82
+ lastRow = module
83
+ sameCountRow = 1
84
+ }
85
+ }
86
+
87
+ if (sameCountCol >= 5) points += PenaltyScores.N1 + (sameCountCol - 5)
88
+ if (sameCountRow >= 5) points += PenaltyScores.N1 + (sameCountRow - 5)
89
+ }
90
+
91
+ return points
92
+ }
93
+
94
+ /**
95
+ * Find 2x2 blocks with the same color and assign a penalty value
96
+ *
97
+ * Points: N2 * (m - 1) * (n - 1)
98
+ */
99
+ exports.getPenaltyN2 = function getPenaltyN2 (data) {
100
+ const size = data.size
101
+ let points = 0
102
+
103
+ for (let row = 0; row < size - 1; row++) {
104
+ for (let col = 0; col < size - 1; col++) {
105
+ const last = data.get(row, col) +
106
+ data.get(row, col + 1) +
107
+ data.get(row + 1, col) +
108
+ data.get(row + 1, col + 1)
109
+
110
+ if (last === 4 || last === 0) points++
111
+ }
112
+ }
113
+
114
+ return points * PenaltyScores.N2
115
+ }
116
+
117
+ /**
118
+ * Find 1:1:3:1:1 ratio (dark:light:dark:light:dark) pattern in row/column,
119
+ * preceded or followed by light area 4 modules wide
120
+ *
121
+ * Points: N3 * number of pattern found
122
+ */
123
+ exports.getPenaltyN3 = function getPenaltyN3 (data) {
124
+ const size = data.size
125
+ let points = 0
126
+ let bitsCol = 0
127
+ let bitsRow = 0
128
+
129
+ for (let row = 0; row < size; row++) {
130
+ bitsCol = bitsRow = 0
131
+ for (let col = 0; col < size; col++) {
132
+ bitsCol = ((bitsCol << 1) & 0x7FF) | data.get(row, col)
133
+ if (col >= 10 && (bitsCol === 0x5D0 || bitsCol === 0x05D)) points++
134
+
135
+ bitsRow = ((bitsRow << 1) & 0x7FF) | data.get(col, row)
136
+ if (col >= 10 && (bitsRow === 0x5D0 || bitsRow === 0x05D)) points++
137
+ }
138
+ }
139
+
140
+ return points * PenaltyScores.N3
141
+ }
142
+
143
+ /**
144
+ * Calculate proportion of dark modules in entire symbol
145
+ *
146
+ * Points: N4 * k
147
+ *
148
+ * k is the rating of the deviation of the proportion of dark modules
149
+ * in the symbol from 50% in steps of 5%
150
+ */
151
+ exports.getPenaltyN4 = function getPenaltyN4 (data) {
152
+ let darkCount = 0
153
+ const modulesCount = data.data.length
154
+
155
+ for (let i = 0; i < modulesCount; i++) darkCount += data.data[i]
156
+
157
+ const k = Math.abs(Math.ceil((darkCount * 100 / modulesCount) / 5) - 10)
158
+
159
+ return k * PenaltyScores.N4
160
+ }
161
+
162
+ /**
163
+ * Return mask value at given position
164
+ *
165
+ * @param {Number} maskPattern Pattern reference value
166
+ * @param {Number} i Row
167
+ * @param {Number} j Column
168
+ * @return {Boolean} Mask value
169
+ */
170
+ function getMaskAt (maskPattern, i, j) {
171
+ switch (maskPattern) {
172
+ case exports.Patterns.PATTERN000: return (i + j) % 2 === 0
173
+ case exports.Patterns.PATTERN001: return i % 2 === 0
174
+ case exports.Patterns.PATTERN010: return j % 3 === 0
175
+ case exports.Patterns.PATTERN011: return (i + j) % 3 === 0
176
+ case exports.Patterns.PATTERN100: return (Math.floor(i / 2) + Math.floor(j / 3)) % 2 === 0
177
+ case exports.Patterns.PATTERN101: return (i * j) % 2 + (i * j) % 3 === 0
178
+ case exports.Patterns.PATTERN110: return ((i * j) % 2 + (i * j) % 3) % 2 === 0
179
+ case exports.Patterns.PATTERN111: return ((i * j) % 3 + (i + j) % 2) % 2 === 0
180
+
181
+ default: throw new Error('bad maskPattern:' + maskPattern)
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Apply a mask pattern to a BitMatrix
187
+ *
188
+ * @param {Number} pattern Pattern reference number
189
+ * @param {BitMatrix} data BitMatrix data
190
+ */
191
+ exports.applyMask = function applyMask (pattern, data) {
192
+ const size = data.size
193
+
194
+ for (let col = 0; col < size; col++) {
195
+ for (let row = 0; row < size; row++) {
196
+ if (data.isReserved(row, col)) continue
197
+ data.xor(row, col, getMaskAt(pattern, row, col))
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Returns the best mask pattern for data
204
+ *
205
+ * @param {BitMatrix} data
206
+ * @return {Number} Mask pattern reference number
207
+ */
208
+ exports.getBestMask = function getBestMask (data, setupFormatFunc) {
209
+ const numPatterns = Object.keys(exports.Patterns).length
210
+ let bestPattern = 0
211
+ let lowerPenalty = Infinity
212
+
213
+ for (let p = 0; p < numPatterns; p++) {
214
+ setupFormatFunc(p)
215
+ exports.applyMask(p, data)
216
+
217
+ // Calculate penalty
218
+ const penalty =
219
+ exports.getPenaltyN1(data) +
220
+ exports.getPenaltyN2(data) +
221
+ exports.getPenaltyN3(data) +
222
+ exports.getPenaltyN4(data)
223
+
224
+ // Undo previously applied mask
225
+ exports.applyMask(p, data)
226
+
227
+ if (penalty < lowerPenalty) {
228
+ lowerPenalty = penalty
229
+ bestPattern = p
230
+ }
231
+ }
232
+
233
+ return bestPattern
234
+ }
BeadQR/miniprogram/utils/qrcore/mode.js ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const VersionCheck = require('./version-check')
2
+ const Regex = require('./regex')
3
+
4
+ /**
5
+ * Numeric mode encodes data from the decimal digit set (0 - 9)
6
+ * (byte values 30HEX to 39HEX).
7
+ * Normally, 3 data characters are represented by 10 bits.
8
+ *
9
+ * @type {Object}
10
+ */
11
+ exports.NUMERIC = {
12
+ id: 'Numeric',
13
+ bit: 1 << 0,
14
+ ccBits: [10, 12, 14]
15
+ }
16
+
17
+ /**
18
+ * Alphanumeric mode encodes data from a set of 45 characters,
19
+ * i.e. 10 numeric digits (0 - 9),
20
+ * 26 alphabetic characters (A - Z),
21
+ * and 9 symbols (SP, $, %, *, +, -, ., /, :).
22
+ * Normally, two input characters are represented by 11 bits.
23
+ *
24
+ * @type {Object}
25
+ */
26
+ exports.ALPHANUMERIC = {
27
+ id: 'Alphanumeric',
28
+ bit: 1 << 1,
29
+ ccBits: [9, 11, 13]
30
+ }
31
+
32
+ /**
33
+ * In byte mode, data is encoded at 8 bits per character.
34
+ *
35
+ * @type {Object}
36
+ */
37
+ exports.BYTE = {
38
+ id: 'Byte',
39
+ bit: 1 << 2,
40
+ ccBits: [8, 16, 16]
41
+ }
42
+
43
+ /**
44
+ * The Kanji mode efficiently encodes Kanji characters in accordance with
45
+ * the Shift JIS system based on JIS X 0208.
46
+ * The Shift JIS values are shifted from the JIS X 0208 values.
47
+ * JIS X 0208 gives details of the shift coded representation.
48
+ * Each two-byte character value is compacted to a 13-bit binary codeword.
49
+ *
50
+ * @type {Object}
51
+ */
52
+ exports.KANJI = {
53
+ id: 'Kanji',
54
+ bit: 1 << 3,
55
+ ccBits: [8, 10, 12]
56
+ }
57
+
58
+ /**
59
+ * Mixed mode will contain a sequences of data in a combination of any of
60
+ * the modes described above
61
+ *
62
+ * @type {Object}
63
+ */
64
+ exports.MIXED = {
65
+ bit: -1
66
+ }
67
+
68
+ /**
69
+ * Returns the number of bits needed to store the data length
70
+ * according to QR Code specifications.
71
+ *
72
+ * @param {Mode} mode Data mode
73
+ * @param {Number} version QR Code version
74
+ * @return {Number} Number of bits
75
+ */
76
+ exports.getCharCountIndicator = function getCharCountIndicator (mode, version) {
77
+ if (!mode.ccBits) throw new Error('Invalid mode: ' + mode)
78
+
79
+ if (!VersionCheck.isValid(version)) {
80
+ throw new Error('Invalid version: ' + version)
81
+ }
82
+
83
+ if (version >= 1 && version < 10) return mode.ccBits[0]
84
+ else if (version < 27) return mode.ccBits[1]
85
+ return mode.ccBits[2]
86
+ }
87
+
88
+ /**
89
+ * Returns the most efficient mode to store the specified data
90
+ *
91
+ * @param {String} dataStr Input data string
92
+ * @return {Mode} Best mode
93
+ */
94
+ exports.getBestModeForData = function getBestModeForData (dataStr) {
95
+ if (Regex.testNumeric(dataStr)) return exports.NUMERIC
96
+ else if (Regex.testAlphanumeric(dataStr)) return exports.ALPHANUMERIC
97
+ else if (Regex.testKanji(dataStr)) return exports.KANJI
98
+ else return exports.BYTE
99
+ }
100
+
101
+ /**
102
+ * Return mode name as string
103
+ *
104
+ * @param {Mode} mode Mode object
105
+ * @returns {String} Mode name
106
+ */
107
+ exports.toString = function toString (mode) {
108
+ if (mode && mode.id) return mode.id
109
+ throw new Error('Invalid mode')
110
+ }
111
+
112
+ /**
113
+ * Check if input param is a valid mode object
114
+ *
115
+ * @param {Mode} mode Mode object
116
+ * @returns {Boolean} True if valid mode, false otherwise
117
+ */
118
+ exports.isValid = function isValid (mode) {
119
+ return mode && mode.bit && mode.ccBits
120
+ }
121
+
122
+ /**
123
+ * Get mode object from its name
124
+ *
125
+ * @param {String} string Mode name
126
+ * @returns {Mode} Mode object
127
+ */
128
+ function fromString (string) {
129
+ if (typeof string !== 'string') {
130
+ throw new Error('Param is not a string')
131
+ }
132
+
133
+ const lcStr = string.toLowerCase()
134
+
135
+ switch (lcStr) {
136
+ case 'numeric':
137
+ return exports.NUMERIC
138
+ case 'alphanumeric':
139
+ return exports.ALPHANUMERIC
140
+ case 'kanji':
141
+ return exports.KANJI
142
+ case 'byte':
143
+ return exports.BYTE
144
+ default:
145
+ throw new Error('Unknown mode: ' + string)
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Returns mode from a value.
151
+ * If value is not a valid mode, returns defaultValue
152
+ *
153
+ * @param {Mode|String} value Encoding mode
154
+ * @param {Mode} defaultValue Fallback value
155
+ * @return {Mode} Encoding mode
156
+ */
157
+ exports.from = function from (value, defaultValue) {
158
+ if (exports.isValid(value)) {
159
+ return value
160
+ }
161
+
162
+ try {
163
+ return fromString(value)
164
+ } catch (e) {
165
+ return defaultValue
166
+ }
167
+ }