diff --git a/components.d.ts b/components.d.ts
index 0dea9b2..37b874e 100644
--- a/components.d.ts
+++ b/components.d.ts
@@ -7,10 +7,19 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
- AppFooter: typeof import('./src/components/AppFooter.vue')['default']
- AppLogos: typeof import('./src/components/AppLogos.vue')['default']
- InputEntry: typeof import('./src/components/InputEntry.vue')['default']
+ Confetti: typeof import('./src/components/Confetti.vue')['default']
+ CountUp: typeof import('./src/components/CountUp.vue')['default']
+ EmptyState: typeof import('./src/components/EmptyState.vue')['default']
+ PaneLibrary: typeof import('./src/components/home/PaneLibrary.vue')['default']
+ PaneMine: typeof import('./src/components/home/PaneMine.vue')['default']
+ PaneWorkbench: typeof import('./src/components/home/PaneWorkbench.vue')['default']
+ Sticker: typeof import('./src/components/Sticker.vue')['default']
+ TabBar: typeof import('./src/components/TabBar.vue')['default']
+ TabIcon: typeof import('./src/components/TabIcon.vue')['default']
WdButton: typeof import('wot-design-uni/components/wd-button/wd-button.vue')['default']
+ WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
+ WdInput: typeof import('wot-design-uni/components/wd-input/wd-input.vue')['default']
WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default']
+ WdTextarea: typeof import('wot-design-uni/components/wd-textarea/wd-textarea.vue')['default']
}
}
diff --git a/eslint.config.js b/eslint.config.js
index 9bdf2e1..bb2a420 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,3 +1,8 @@
import uniHelper from '@uni-helper/eslint-config'
-export default uniHelper()
+export default uniHelper({
+ ignores: [
+ // QR 编码核心:vendored 第三方实现(qrcode-terminal MIT),不参与 lint
+ 'src/utils/vendor/**',
+ ],
+})
diff --git a/package.json b/package.json
index cb215e0..3564f36 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,8 @@
"@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-xhs": "3.0.0-4070620250821001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4070620250821001",
+ "heic2any": "^0.0.4",
+ "onnxruntime-web": "^1.29.0",
"pinia": "2.2.4",
"vue": "3.4.21",
"vue-i18n": "9.6.2",
@@ -88,4 +90,4 @@
"vite": "5.2.8",
"vue-tsc": "^3.0.7"
}
-}
\ No newline at end of file
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ca19c69..a10091a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -56,6 +56,12 @@ importers:
'@dcloudio/uni-quickapp-webview':
specifier: 3.0.0-4070620250821001
version: 3.0.0-4070620250821001(postcss@8.5.6)(rollup@4.52.2)(vue@3.4.21(typescript@5.9.2))
+ heic2any:
+ specifier: ^0.0.4
+ version: 0.0.4
+ onnxruntime-web:
+ specifier: ^1.29.0
+ version: 1.29.0
pinia:
specifier: 2.2.4
version: 2.2.4(typescript@5.9.2)(vue@3.4.21(typescript@5.9.2))
@@ -1451,6 +1457,33 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+ '@protobufjs/aspromise@1.1.2':
+ resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
+
+ '@protobufjs/base64@1.1.2':
+ resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
+
+ '@protobufjs/codegen@2.0.5':
+ resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
+
+ '@protobufjs/eventemitter@1.1.1':
+ resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
+
+ '@protobufjs/fetch@1.1.1':
+ resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
+
+ '@protobufjs/float@1.0.2':
+ resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
+
+ '@protobufjs/path@1.1.2':
+ resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
+
+ '@protobufjs/pool@1.1.0':
+ resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
+
+ '@protobufjs/utf8@1.1.2':
+ resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==}
+
'@rollup/pluginutils@5.3.0':
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
engines: {node: '>=14.0.0'}
@@ -1494,56 +1527,67 @@ packages:
resolution: {integrity: sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.2':
resolution: {integrity: sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.2':
resolution: {integrity: sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.2':
resolution: {integrity: sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.2':
resolution: {integrity: sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.2':
resolution: {integrity: sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.2':
resolution: {integrity: sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.2':
resolution: {integrity: sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.2':
resolution: {integrity: sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.2':
resolution: {integrity: sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.2':
resolution: {integrity: sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.2':
resolution: {integrity: sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q==}
@@ -2607,6 +2651,7 @@ packages:
eslint@9.36.0:
resolution: {integrity: sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -2738,6 +2783,9 @@ packages:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
+ flatbuffers@25.9.23:
+ resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
+
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
@@ -2827,7 +2875,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
- deprecated: Glob versions prior to v9 are no longer supported
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global@4.4.0:
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
@@ -2857,6 +2905,9 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
+ guid-typescript@1.0.9:
+ resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==}
+
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -2880,6 +2931,9 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
+ heic2any@0.0.4:
+ resolution: {integrity: sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==}
+
html-encoding-sniffer@2.0.1:
resolution: {integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==}
engines: {node: '>=10'}
@@ -3325,6 +3379,9 @@ packages:
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
+ long@5.3.2:
+ resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
+
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -3622,6 +3679,12 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
+ onnxruntime-common@1.29.0:
+ resolution: {integrity: sha512-/F63/e2VJoaVXGGNu6S5QH7jivBThGO95OzAVXXQ8hTta/b1QxI8udHa6cI3+3mAb5WWIIaMMwfZw01oivjJ1g==}
+
+ onnxruntime-web@1.29.0:
+ resolution: {integrity: sha512-LuQlpX6MFLJZu756erwUeb1mNfoJGbs1kzDwJGNlf5RvfYMdqhcY3vNpDPK40CUV2HoWTkIj+uS0o36GFHjeYw==}
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -3727,6 +3790,7 @@ packages:
phin@3.7.1:
resolution: {integrity: sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ==}
engines: {node: '>= 8'}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -3773,6 +3837,9 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
+ platform@1.3.6:
+ resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -3862,6 +3929,10 @@ packages:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
+ protobufjs@7.6.6:
+ resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==}
+ engines: {node: '>=12.0.0'}
+
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
@@ -4425,6 +4496,7 @@ packages:
vue-i18n@9.6.2:
resolution: {integrity: sha512-J43grTQjPR8LCUxvx3mkoM+11xhTnej1Al4lvJCEeKmQqf8eqbuYPQb54HXnEg/UzZyaxLBAwPAUTbrZ8V7hcg==}
engines: {node: '>= 16'}
+ deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html
peerDependencies:
vue: ^3.0.0
@@ -4471,6 +4543,7 @@ packages:
whatwg-encoding@1.0.5:
resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@2.3.0:
resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==}
@@ -6763,6 +6836,26 @@ snapshots:
'@pkgr/core@0.2.9': {}
+ '@protobufjs/aspromise@1.1.2': {}
+
+ '@protobufjs/base64@1.1.2': {}
+
+ '@protobufjs/codegen@2.0.5': {}
+
+ '@protobufjs/eventemitter@1.1.1': {}
+
+ '@protobufjs/fetch@1.1.1':
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+
+ '@protobufjs/float@1.0.2': {}
+
+ '@protobufjs/path@1.1.2': {}
+
+ '@protobufjs/pool@1.1.0': {}
+
+ '@protobufjs/utf8@1.1.2': {}
+
'@rollup/pluginutils@5.3.0(rollup@4.52.2)':
dependencies:
'@types/estree': 1.0.8
@@ -8211,6 +8304,8 @@ snapshots:
flatted: 3.3.3
keyv: 4.5.4
+ flatbuffers@25.9.23: {}
+
flatted@3.3.3: {}
follow-redirects@1.15.11: {}
@@ -8316,6 +8411,8 @@ snapshots:
graphemer@1.4.0: {}
+ guid-typescript@1.0.9: {}
+
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
@@ -8332,6 +8429,8 @@ snapshots:
he@1.2.0: {}
+ heic2any@0.0.4: {}
+
html-encoding-sniffer@2.0.1:
dependencies:
whatwg-encoding: 1.0.5
@@ -8993,6 +9092,8 @@ snapshots:
lodash@4.17.21: {}
+ long@5.3.2: {}
+
longest-streak@3.1.0: {}
lru-cache@5.1.1:
@@ -9442,6 +9543,17 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
+ onnxruntime-common@1.29.0: {}
+
+ onnxruntime-web@1.29.0:
+ dependencies:
+ flatbuffers: 25.9.23
+ guid-typescript: 1.0.9
+ long: 5.3.2
+ onnxruntime-common: 1.29.0
+ platform: 1.3.6
+ protobufjs: 7.6.6
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -9581,6 +9693,8 @@ snapshots:
exsolve: 1.0.7
pathe: 2.0.3
+ platform@1.3.6: {}
+
pluralize@8.0.0: {}
pngjs@3.4.0: {}
@@ -9669,6 +9783,20 @@ snapshots:
kleur: 3.0.3
sisteransi: 1.0.5
+ protobufjs@7.6.6:
+ dependencies:
+ '@protobufjs/aspromise': 1.1.2
+ '@protobufjs/base64': 1.1.2
+ '@protobufjs/codegen': 2.0.5
+ '@protobufjs/eventemitter': 1.1.1
+ '@protobufjs/fetch': 1.1.1
+ '@protobufjs/float': 1.0.2
+ '@protobufjs/path': 1.1.2
+ '@protobufjs/pool': 1.1.0
+ '@protobufjs/utf8': 1.1.2
+ '@types/node': 24.5.2
+ long: 5.3.2
+
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..e9a2e06
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,10 @@
+allowBuilds:
+ core-js: true
+ esbuild: true
+ protobufjs: true
+ vue-demi: true
+onlyBuiltDependencies:
+ - core-js
+ - esbuild
+ - protobufjs
+ - vue-demi
diff --git a/src/App.vue b/src/App.vue
index 297d679..c79c8fb 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,7 +1,255 @@
-
+
diff --git a/src/api/request.ts b/src/api/request.ts
new file mode 100644
index 0000000..2d3e1b0
--- /dev/null
+++ b/src/api/request.ts
@@ -0,0 +1,86 @@
+/** 统一响应约定:{code, message, result},code=0 成功,401 登录失效 */
+import { BASE_URL } from '@/config/env'
+
+export interface ApiBody {
+ code: number
+ message: string
+ result: T
+}
+
+const TOKEN_KEY = 'token'
+
+export function getToken(): string {
+ return (uni.getStorageSync(TOKEN_KEY) as string) || ''
+}
+
+export function setToken(token: string) {
+ uni.setStorageSync(TOKEN_KEY, token)
+}
+
+export function clearToken() {
+ uni.removeStorageSync(TOKEN_KEY)
+}
+
+/** H5:401 时引导去登录页(登录页自身不跳,避免循环) */
+export function gotoH5Login() {
+ // #ifdef H5
+ const pages = getCurrentPages()
+ const current = pages[pages.length - 1]
+ const route = (current && current.route) || ''
+ if (route.includes('account/login'))
+ return
+ const redirect = route ? `?redirect=/${route}` : ''
+ uni.redirectTo({ url: `/pages/account/login${redirect}` })
+ // #endif
+}
+
+function request(url: string, method: 'GET' | 'POST' | 'PUT', data?: unknown): Promise {
+ const header: Record = {}
+ const token = getToken()
+ if (token)
+ header.Authorization = `Bearer ${token}`
+
+ return new Promise((resolve, reject) => {
+ uni.request({
+ url: BASE_URL + url,
+ method,
+ data: data as Record | undefined,
+ header,
+ timeout: 15000,
+ success: (res) => {
+ const body = res.data as ApiBody
+ if (body && body.code === 0) {
+ resolve(body.result)
+ return
+ }
+ if (body && body.code === 401) {
+ clearToken()
+ gotoH5Login()
+ reject(new Error(body.message || 'unauthorized'))
+ return
+ }
+ // 4001 等业务错误码也走统一提示
+ uni.showToast({ title: (body && body.message) || '请求失败', icon: 'none' })
+ const err = new Error((body && body.message) || 'request failed')
+ ;(err as Error & { code?: number }).code = body && body.code
+ reject(err)
+ },
+ fail: (err) => {
+ uni.showToast({ title: '网络异常,请检查服务是否启动', icon: 'none' })
+ reject(new Error(err.errMsg || 'network error'))
+ },
+ })
+ })
+}
+
+export function get(url: string, params?: Record): Promise {
+ return request(url, 'GET', params)
+}
+
+export function post(url: string, data?: unknown): Promise {
+ return request(url, 'POST', data)
+}
+
+export function put(url: string, data?: unknown): Promise {
+ return request(url, 'PUT', data)
+}
diff --git a/src/api/user.ts b/src/api/user.ts
new file mode 100644
index 0000000..6c84a3c
--- /dev/null
+++ b/src/api/user.ts
@@ -0,0 +1,99 @@
+import { get, post, put } from '@/api/request'
+
+export interface UserInfo {
+ id: number
+ nickname: string
+ avatar_url: string
+}
+
+export interface LevelInfo {
+ level_key: string
+ name: string
+ modules: string[]
+}
+
+export interface LoginResult {
+ token: string
+ user: UserInfo
+ level: LevelInfo
+}
+
+export interface ModuleOut {
+ module_key: string
+ name: string
+ icon: string
+ locked: boolean
+ enabled_tools_count: number
+}
+
+export interface ToolOut {
+ tool_key: string
+ module_key: string
+ name: string
+ icon: string
+ description: string
+ is_hot: boolean
+ locked: boolean
+}
+
+export interface ToolsListResult {
+ modules: ModuleOut[]
+ list: ToolOut[]
+}
+
+export function apiWxLogin(code: string) {
+ return post('/user/wx-login', { code })
+}
+
+export function apiDevLogin() {
+ return post('/user/dev-login')
+}
+
+/** H5 账号注册(注册即登录) */
+export function apiRegister(username: string, password: string, nickname = '') {
+ return post('/user/register', { username, password, nickname })
+}
+
+/** H5 账号密码登录 */
+export function apiAccountLogin(username: string, password: string) {
+ return post('/user/login', { username, password })
+}
+
+/** 把 H5 独立账号合并进当前登录账号(工作台随迁) */
+export function apiBindAccount(username: string, password: string) {
+ return post<{ username: string }>('/user/bind-account', { username, password })
+}
+
+/** 更新个人信息(昵称 / 头像) */
+export function apiUpdateProfile(nickname: string, avatarUrl: string) {
+ return put<{ nickname: string, avatar_url: string }>('/user/profile', { nickname, avatar_url: avatarUrl })
+}
+
+export function apiGetLevel() {
+ return get('/user/level')
+}
+
+/** 当前登录用户信息(token 校验后恢复 user) */
+export function apiGetUserInfo() {
+ return get('/user/info')
+}
+
+export function apiToolsList() {
+ return get('/tools/list')
+}
+
+export function apiToolsUsed(toolKey: string) {
+ return post('/tools/used', { tool_key: toolKey })
+}
+
+export function apiWorkbenchGet() {
+ return get<{ tool_keys: string[] }>('/workbench/get')
+}
+
+export function apiWorkbenchSave(toolKeys: string[]) {
+ return post('/workbench/save', { tool_keys: toolKeys })
+}
+
+export function apiFeedbackSubmit(content: string, contact = '') {
+ return post('/feedback/submit', { content, contact })
+}
diff --git a/src/components/AppFooter.vue b/src/components/AppFooter.vue
deleted file mode 100644
index 61049b6..0000000
--- a/src/components/AppFooter.vue
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
-
-
-
-
-
diff --git a/src/components/AppLogos.vue b/src/components/AppLogos.vue
deleted file mode 100644
index 8779ca6..0000000
--- a/src/components/AppLogos.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
-
- uni-helper
-
-
-
- +
-
-
-
-
- Vite
-
-
-
-
-
-
diff --git a/src/components/Confetti.vue b/src/components/Confetti.vue
new file mode 100644
index 0000000..6b09adb
--- /dev/null
+++ b/src/components/Confetti.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/CountUp.vue b/src/components/CountUp.vue
new file mode 100644
index 0000000..4f0a72e
--- /dev/null
+++ b/src/components/CountUp.vue
@@ -0,0 +1,44 @@
+
+
+
+
+ {{ display }}
+
+
+
+
diff --git a/src/components/EmptyState.vue b/src/components/EmptyState.vue
new file mode 100644
index 0000000..ba30247
--- /dev/null
+++ b/src/components/EmptyState.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ sub }}
+
+
+ {{ actionText }}
+
+
+
+
+
+
diff --git a/src/components/InputEntry.vue b/src/components/InputEntry.vue
deleted file mode 100644
index 10bc891..0000000
--- a/src/components/InputEntry.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
-
-
- Hello
-
-
-
-
- Hello{{ ` ${name}` }} 👏
-
-
-
-
diff --git a/src/components/Sticker.vue b/src/components/Sticker.vue
new file mode 100644
index 0000000..a9984f1
--- /dev/null
+++ b/src/components/Sticker.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
diff --git a/src/components/TabBar.vue b/src/components/TabBar.vue
new file mode 100644
index 0000000..503d428
--- /dev/null
+++ b/src/components/TabBar.vue
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ sideTabs[0].title }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ sideTabs[1].title }}
+
+
+
+
+
+
+
+
+
+
+ {{ centerTab.title }}
+
+
+
+
+
+
diff --git a/src/components/TabIcon.vue b/src/components/TabIcon.vue
new file mode 100644
index 0000000..cda1106
--- /dev/null
+++ b/src/components/TabIcon.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/home/PaneLibrary.vue b/src/components/home/PaneLibrary.vue
new file mode 100644
index 0000000..3427b70
--- /dev/null
+++ b/src/components/home/PaneLibrary.vue
@@ -0,0 +1,365 @@
+
+
+
+
+
+
+
+
+ 工具库
+
+
+ {{ list.length }} 个工具
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 热门
+
+
+
+ {{ w }}
+
+
+
+
+
+
+
+
+
+
+ 未解锁
+
+
+
+
+
+ {{ t.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ m.name }}
+
+
+ {{ m.tools.length }} 个工具
+
+
+
+ 未解锁
+
+
+
+
+
+ 未解锁
+
+
+
+
+
+ {{ t.name }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/home/PaneMine.vue b/src/components/home/PaneMine.vue
new file mode 100644
index 0000000..6e0e606
--- /dev/null
+++ b/src/components/home/PaneMine.vue
@@ -0,0 +1,471 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayName }}
+
+
+
+ {{ levelName }}
+
+
+ {{ m }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ s.label }}
+
+
+
+
+
+
+
+
+
+
+
+ 我的工作台
+
+
+ {{ toolsStore.workbenchKeys.length }} 个工具
+
+
+
+
+
+
+
+
+ 意见反馈
+
+
+
+
+
+
+
+
+
+ 绑定 H5 账号
+
+
+ 合并两端数据
+
+
+
+
+
+
+
+
+
+ 版本
+
+
+ v0.1.0
+
+
+
+
+
+ 退出登录
+
+
+
+
+ 登录 / 注册
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 绑定 H5 账号
+
+
+ 合并后小程序与 H5 共用同一份数据,H5 端可用该账号密码登录
+
+
+
+
+
+
+
+
+ 绑定并合并数据
+
+
+
+
+
+
+
+
+
+ 意见反馈
+
+
+ 说说你想用的工具,或遇到的问题
+
+
+
+
+
+
+
+
+ 提交反馈
+
+
+
+
+
+
+
diff --git a/src/components/home/PaneWorkbench.vue b/src/components/home/PaneWorkbench.vue
new file mode 100644
index 0000000..44139f1
--- /dev/null
+++ b/src/components/home/PaneWorkbench.vue
@@ -0,0 +1,423 @@
+
+
+
+
+
+
+
+
+ {{ greeting }}
+
+
+ {{ userStore.level.name }}
+
+
+
+ 工具百宝箱
+
+
+
+
+ 搜索工具,如「压缩」「抠图」
+
+
+
+
+
+
+
+
+
+
+ 我的工作台
+
+
+ 编辑
+
+
+
+
+
+ 正在加载…
+
+
+
+
+
+
+
+
+
+ {{ workbench[0].name }}
+
+
+ {{ workbench[0].desc }}
+
+
+
+
+
+
+ 未解锁
+
+
+
+
+
+
+
+ 未解锁
+
+
+
+
+ {{ t.name }}
+
+
+
+
+
+
+
+
+ 未解锁
+
+
+
+
+
+ {{ t.name }}
+
+
+
+
+
+
+
+
+
+
+ 发现工具
+
+
+ 全部工具
+
+
+
+
+
+
+
+
+
+ {{ t.name }}
+
+
+ {{ t.description }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/config/env.ts b/src/config/env.ts
new file mode 100644
index 0000000..4edabb6
--- /dev/null
+++ b/src/config/env.ts
@@ -0,0 +1,10 @@
+/**
+ * 环境配置
+ * 微信开发者工具联调需勾选「不校验合法域名」;生产改为 https 域名(需在小程序后台配置 request 合法域名)
+ */
+let baseUrl = 'http://127.0.0.1:8000'
+// #ifdef H5
+baseUrl = '/api'
+// #endif
+
+export const BASE_URL = baseUrl
diff --git a/src/config/tools.ts b/src/config/tools.ts
new file mode 100644
index 0000000..2bc0ee9
--- /dev/null
+++ b/src/config/tools.ts
@@ -0,0 +1,128 @@
+/**
+ * 工具注册表(前端路由与展示的唯一来源)
+ *
+ * 新增工具 SOP(见 docs/design-levels.md §7):
+ * 1. 在 pages.json 注册工具页面
+ * 2. 在此登记:key 必须与后台工具目录 tool_key 一致
+ * 3. 后台「工具管理」新增目录记录并挂到模块
+ */
+export interface ToolDef {
+ key: string
+ name: string
+ icon: string // wot 图标名
+ desc: string
+ path: string
+ module: string
+ keywords: string[] // 工具库搜索关键词
+}
+
+export const TOOL_REGISTRY: ToolDef[] = [
+ {
+ key: 'img-compress',
+ name: '图片压缩',
+ icon: 'download',
+ desc: '批量压缩体积,画质几乎无损',
+ path: '/pages/tools/image/compress',
+ module: 'image',
+ keywords: ['压缩', '体积', '变小', 'compress'],
+ },
+ {
+ key: 'img-convert',
+ name: '格式转换',
+ icon: 'swap',
+ desc: 'PNG / JPG / WebP 互转',
+ path: '/pages/tools/image/convert',
+ module: 'image',
+ keywords: ['转换', 'png', 'jpg', 'jpeg', 'webp', 'convert'],
+ },
+ {
+ key: 'img-resize',
+ name: '修改尺寸',
+ icon: 'zoom-in',
+ desc: '按宽高、比例或最长边缩放',
+ path: '/pages/tools/image/resize',
+ module: 'image',
+ keywords: ['尺寸', '缩放', '大小', 'resize'],
+ },
+ {
+ key: 'img-crop',
+ name: '裁剪旋转',
+ icon: 'scan',
+ desc: '自由裁剪、旋转与镜像',
+ path: '/pages/tools/image/crop',
+ module: 'image',
+ keywords: ['裁剪', '旋转', '镜像', 'crop'],
+ },
+ {
+ key: 'img-watermark',
+ name: '文字水印',
+ icon: 'edit-outline',
+ desc: '平铺或居中的文字水印',
+ path: '/pages/tools/image/watermark',
+ module: 'image',
+ keywords: ['水印', '签名', 'watermark'],
+ },
+ {
+ key: 'img-matting',
+ name: '去除背景',
+ icon: 'image',
+ desc: '一键抠除纯色背景',
+ path: '/pages/tools/image/matting',
+ module: 'image',
+ keywords: ['抠图', '去背景', '透明', 'matting'],
+ },
+ {
+ key: 'img-avatarfx',
+ name: '头像特效',
+ icon: 'camera',
+ desc: '国庆红旗圈、节日边框等头像特效',
+ path: '/pages/tools/image/avatarfx',
+ module: 'image',
+ keywords: ['头像', '国旗', '红旗', '国庆', '边框', '特效', '节日'],
+ },
+ {
+ key: 'img-grid9',
+ name: '九宫格切图',
+ icon: 'dashboard',
+ desc: '照片切成 9 张发朋友圈',
+ path: '/pages/tools/image/grid9',
+ module: 'image',
+ keywords: ['九宫格', '切图', '朋友圈', '拼接', 'grid'],
+ },
+ {
+ key: 'img-collage',
+ name: '图片拼图',
+ icon: 'copy',
+ desc: '2-4 张照片拼成一张',
+ path: '/pages/tools/image/collage',
+ module: 'image',
+ keywords: ['拼图', '合并', '拼接', 'collage'],
+ },
+ {
+ key: 'img-qrcode',
+ name: '二维码生成',
+ icon: 'qrcode',
+ desc: '文本链接生成二维码图片',
+ path: '/pages/tools/image/qrcode',
+ module: 'image',
+ keywords: ['二维码', 'qr', '链接', '扫码'],
+ },
+ {
+ key: 'img-annotate',
+ name: '图片标注',
+ icon: 'edit',
+ desc: '涂鸦、文字、马赛克标注',
+ path: '/pages/tools/image/annotate',
+ module: 'image',
+ keywords: ['标注', '涂鸦', '马赛克', '箭头', '打码'],
+ },
+]
+
+export function searchTools(keyword: string): ToolDef[] {
+ const kw = keyword.trim().toLowerCase()
+ if (!kw)
+ return TOOL_REGISTRY
+ return TOOL_REGISTRY.filter(
+ t => t.name.toLowerCase().includes(kw) || t.desc.toLowerCase().includes(kw) || t.keywords.some(k => k.toLowerCase().includes(kw)),
+ )
+}
diff --git a/src/main.ts b/src/main.ts
index 4294d8e..34b158c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,5 +1,5 @@
-import { createSSRApp } from 'vue'
import * as Pinia from 'pinia'
+import { createSSRApp } from 'vue'
import App from './App.vue'
export function createApp() {
diff --git a/src/manifest.json b/src/manifest.json
index 3458c59..41d8e9d 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -49,7 +49,7 @@
},
"quickapp": {},
"mp-weixin": {
- "appid": "wx67e73fb3c9882dfc",
+ "appid": "wx8f3891eaad380949",
"setting": {
"urlCheck": false
},
diff --git a/src/pages.json b/src/pages.json
index 4a4de3e..cb446fe 100644
--- a/src/pages.json
+++ b/src/pages.json
@@ -1,26 +1,69 @@
{
"pages": [
{
- "path": "pages/index",
- "type": "home"
+ "path": "pages/index/index",
+ "type": "home",
+ "style": {
+ "enablePullDownRefresh": true,
+ "backgroundTextStyle": "dark",
+ "backgroundColor": "#FFF9F2"
+ }
},
{
- "path": "pages/mine/mine",
- "style": {
- "navigationBarTitleText": "我的",
- "enablePullDownRefresh": false
- }
+ "path": "pages/workbench/edit"
+ },
+ {
+ "path": "pages/mine/profile"
+ },
+ {
+ "path": "pages/tools/image/index"
+ },
+ {
+ "path": "pages/tools/image/compress"
+ },
+ {
+ "path": "pages/tools/image/convert"
+ },
+ {
+ "path": "pages/tools/image/resize"
+ },
+ {
+ "path": "pages/tools/image/crop"
+ },
+ {
+ "path": "pages/tools/image/watermark"
+ },
+ {
+ "path": "pages/tools/image/matting"
+ },
+ {
+ "path": "pages/tools/image/avatarfx"
+ },
+ {
+ "path": "pages/tools/image/grid9"
+ },
+ {
+ "path": "pages/tools/image/collage"
+ },
+ {
+ "path": "pages/tools/image/qrcode"
+ },
+ {
+ "path": "pages/tools/image/annotate"
+ },
+ // #ifdef H5
+ {
+ "path": "pages/account/login"
}
+ // #endif
],
"globalStyle": {
- "backgroundColor": "@bgColor",
- "backgroundColorBottom": "@bgColorBottom",
- "backgroundColorTop": "@bgColorTop",
- "backgroundTextStyle": "@bgTxtStyle",
- "navigationBarBackgroundColor": "#000000",
- "navigationBarTextStyle": "@navTxtStyle",
- "navigationBarTitleText": "Uni Creator",
- "navigationStyle": "custom"
- },
- "subPackages": []
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "工具百宝箱",
+ "navigationBarBackgroundColor": "#FFF9F2",
+ "navigationBarTextStyle": "black",
+ "backgroundColor": "#FFF9F2",
+ "backgroundColorTop": "#FFF9F2",
+ "backgroundColorBottom": "#FFF9F2"
+ }
}
diff --git a/src/pages/account/login.vue b/src/pages/account/login.vue
new file mode 100644
index 0000000..af6032b
--- /dev/null
+++ b/src/pages/account/login.vue
@@ -0,0 +1,297 @@
+
+
+
+
+
+
+
+
+ 工具百宝箱
+
+
+ {{ mode === 'login' ? '登录' : '注册' }}后同步你的工作台与工具数据
+
+
+
+
+
+
+
+
+
+ 登录
+
+
+ 注册
+
+
+
+
+
+ 用户名
+
+
+
+
+
+ 密码
+
+
+
+
+
+ 昵称(选填)
+
+
+
+
+
+ {{ errMsg }}
+
+
+
+ {{ submitting ? '处理中…' : (mode === 'login' ? '登 录' : '注册并登录') }}
+
+
+
+ 注册即创建独立 H5 账号;在微信小程序「我的」里绑定后可合并两端数据
+
+
+
+
+ 开发联调登录(dev-login)
+
+
+
+
+
+
+
+
diff --git a/src/pages/index.vue b/src/pages/index.vue
deleted file mode 100644
index 553811e..0000000
--- a/src/pages/index.vue
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/pages/index/index.vue b/src/pages/index/index.vue
new file mode 100644
index 0000000..9208f07
--- /dev/null
+++ b/src/pages/index/index.vue
@@ -0,0 +1,172 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/mine/mine.vue b/src/pages/mine/mine.vue
deleted file mode 100644
index 2c75593..0000000
--- a/src/pages/mine/mine.vue
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/src/pages/mine/profile.vue b/src/pages/mine/profile.vue
new file mode 100644
index 0000000..7e17ac5
--- /dev/null
+++ b/src/pages/mine/profile.vue
@@ -0,0 +1,253 @@
+
+
+
+
+
+
+
+
+
+ 个人信息
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 选择头像
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 昵称
+
+
+
+
+
+
+
+ 保存
+
+
+
+
+
+
diff --git a/src/pages/tools/image/annotate.vue b/src/pages/tools/image/annotate.vue
new file mode 100644
index 0000000..4d69c90
--- /dev/null
+++ b/src/pages/tools/image/annotate.vue
@@ -0,0 +1,577 @@
+
+
+
+
+
+
+
+
+
+ 图片标注
+
+
+
+
+
+
+
+ 选择一张照片开始标注
+
+
+
+
+
+
+
+ 画笔
+
+
+ 文字
+
+
+ 马赛克
+
+
+ 撤销
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 保存到相册
+
+
+ 换一张照片
+
+
+
+
+
+
+
diff --git a/src/pages/tools/image/avatarfx.vue b/src/pages/tools/image/avatarfx.vue
new file mode 100644
index 0000000..3e3ea50
--- /dev/null
+++ b/src/pages/tools/image/avatarfx.vue
@@ -0,0 +1,423 @@
+
+
+
+
+
+
+
+
+
+ 头像特效
+
+
+
+
+
+
+
+
+ 选择照片做头像
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ effect.badge?.text }}
+
+
+
+
+
+
+ 形状
+
+
+
+ 圆形
+
+
+ 方形
+
+
+
+
+
+
+
+ 照片缩放
+
+ (zoom = e.detail.value)"
+ />
+
+
+
+
+
+ 描边粗细
+
+ (ringScale = e.detail.value)"
+ />
+
+
+
+
+
+
+
+ {{ e.name }}
+
+
+
+
+
+
+
+ 生成并保存到相册
+
+
+ 换一张照片
+
+
+
+
+
+
+
diff --git a/src/pages/tools/image/collage.vue b/src/pages/tools/image/collage.vue
new file mode 100644
index 0000000..e3886f0
--- /dev/null
+++ b/src/pages/tools/image/collage.vue
@@ -0,0 +1,363 @@
+
+
+
+
+
+
+
+
+
+ 图片拼图
+
+
+
+
+
+
+
+ 选择 2-4 张照片(已选 {{ photos.length }} 张)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t.name }}
+
+
+
+
+
+
+ 底色
+
+
+
+
+
+
+
+
+
+ 间距
+
+ (gap = e.detail.value)"
+ />
+
+
+
+
+ 合成并保存到相册
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/tools/image/compress.vue b/src/pages/tools/image/compress.vue
new file mode 100644
index 0000000..64b24b8
--- /dev/null
+++ b/src/pages/tools/image/compress.vue
@@ -0,0 +1,313 @@
+
+
+
+
+
+
+
+
+
+ 图片压缩
+
+
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
+ 选择图片(可多选)
+
+
+ {{ processing ? `压缩中 ${doneCount}/${items.length}` : '开始压缩' }}
+
+
+
+
+
+
+
+
+ 原图 {{ formatSize(item.size) }}
+
+
+ → {{ formatSize(item.resultSize || 0) }}(-{{ Math.max(0, Math.round((1 - (item.resultSize || 0) / item.size) * 100)) }}%)
+
+
+ 压缩失败
+
+
+ 待处理
+
+
+
+ 保存
+
+
+
+ 保存全部到相册
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/tools/image/convert.vue b/src/pages/tools/image/convert.vue
new file mode 100644
index 0000000..15f3a39
--- /dev/null
+++ b/src/pages/tools/image/convert.vue
@@ -0,0 +1,241 @@
+
+
+
+
+
+
+
+
+
+ 格式转换
+
+
+
+
+
+
+ 选择图片
+
+
+
+
+
+ 点击重新选择
+
+
+
+
+
+ {{ f.label }}
+
+ 网页版可用
+
+
+
+
+
+ 开始转换
+
+
+
+
+
+ 保存{{ target.toUpperCase() }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/tools/image/crop.vue b/src/pages/tools/image/crop.vue
new file mode 100644
index 0000000..c2c2ee2
--- /dev/null
+++ b/src/pages/tools/image/crop.vue
@@ -0,0 +1,328 @@
+
+
+
+
+
+
+
+
+
+ 裁剪旋转
+
+
+
+
+
+
+ 选择图片
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ r.label }}
+
+
+
+
+
+
+
+ 旋转90°
+
+
+ 左右镜像
+
+
+ 上下镜像
+
+
+
+
+
+ 应用旋转/镜像
+
+
+ 确认裁剪
+
+
+ 保存结果
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/tools/image/grid9.vue b/src/pages/tools/image/grid9.vue
new file mode 100644
index 0000000..a6af999
--- /dev/null
+++ b/src/pages/tools/image/grid9.vue
@@ -0,0 +1,207 @@
+
+
+
+
+
+
+
+
+
+ 九宫格切图
+
+
+
+
+
+
+
+ 选择一张照片
+
+
+
+
+
+ 中心方裁后切成 9 张,保存后按 从左到右、从上到下 顺序发布即可
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 切成 9 张并保存
+
+
+ 换一张照片
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/tools/image/index.vue b/src/pages/tools/image/index.vue
new file mode 100644
index 0000000..1948418
--- /dev/null
+++ b/src/pages/tools/image/index.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+ 图片处理
+
+
+ 全程本机处理,图片不会上传
+
+
+
+
+
+
+
+
+
+
+ 未解锁
+
+
+
+
+
+ {{ t.name }}
+
+
+
+
+
+
+
+
diff --git a/src/pages/tools/image/matting.vue b/src/pages/tools/image/matting.vue
new file mode 100644
index 0000000..873e88e
--- /dev/null
+++ b/src/pages/tools/image/matting.vue
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+
+
+
+ 去除背景
+
+
+
+
+
+
+ 选择图片(纯色背景效果最佳)
+
+
+
+
+
+
+
+ {{ busy ? '处理中…' : '点击重新选择' }}
+
+
+
+
+
+
+ 容差 {{ tolerance }}
+
+
+
+
+ 拖动容差让背景去除更干净;边缘发虚请调小。确认后保存时会按原图尺寸重新处理。
+
+
+
+
+ 重新预览
+
+
+ 保存高清结果
+
+
+ 智能抠图(AI · 网页版)
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/tools/image/qrcode.vue b/src/pages/tools/image/qrcode.vue
new file mode 100644
index 0000000..89a4850
--- /dev/null
+++ b/src/pages/tools/image/qrcode.vue
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+
+
+
+ 二维码生成
+
+
+
+
+
+
+ 内容
+
+
+
+
+
+
+ 配色
+
+
+
+
+ {{ p.name }}
+
+
+
+
+
+
+ 生成二维码
+
+
+
+
+
+
+ 保存到相册
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/tools/image/resize.vue b/src/pages/tools/image/resize.vue
new file mode 100644
index 0000000..d73c0c9
--- /dev/null
+++ b/src/pages/tools/image/resize.vue
@@ -0,0 +1,229 @@
+
+
+
+
+
+
+
+
+
+ 修改尺寸
+
+
+
+
+
+
+ 选择图片
+
+
+
+
+
+ 点击重新选择
+
+
+
+
+
+ {{ m.label }}
+
+
+
+
+
+ 开始处理
+
+
+
+ 已输出 {{ result.width }} × {{ result.height }}
+
+ 保存
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/tools/image/watermark.vue b/src/pages/tools/image/watermark.vue
new file mode 100644
index 0000000..c1b79cb
--- /dev/null
+++ b/src/pages/tools/image/watermark.vue
@@ -0,0 +1,240 @@
+
+
+
+
+
+
+
+
+
+ 文字水印
+
+
+
+
+
+
+ 选择图片
+
+
+
+
+
+ 点击重新选择
+
+
+
+
+
+
+
+ 平铺
+
+
+ 居中
+
+
+
+
+ 透明度
+
+
+
+
+
+ 角度 {{ angle }}°
+
+
+
+
+
+
+ 生成水印
+
+
+ 保存
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/workbench/edit.vue b/src/pages/workbench/edit.vue
new file mode 100644
index 0000000..3f59469
--- /dev/null
+++ b/src/pages/workbench/edit.vue
@@ -0,0 +1,278 @@
+
+
+
+
+
+
+
+
+
+ 编辑工作台
+
+
+
+
+
+
+
+ 已固定({{ pinned.length }})
+
+
+
+ 工作台为空,从下方添加工具
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t.name }}
+
+
+ {{ t.desc }}
+
+
+
+
+
+
+
+
+
+
+ 可添加({{ available.length }})
+
+
+
+ 没有更多可添加的工具了
+
+
+
+
+
+
+
+
+ {{ t.name }}
+
+
+ {{ t.desc }}
+
+
+
+
+
+
+
+
+
+
+
+ 保存工作台
+
+
+
+
+
+
diff --git a/src/stores/counter.ts b/src/stores/counter.ts
deleted file mode 100644
index 1b6fe03..0000000
--- a/src/stores/counter.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { defineStore } from 'pinia'
-import { ref } from 'vue'
-
-export const useCounterStore = defineStore('counter', () => {
- const count = ref(0)
- function increment() {
- count.value++
- }
- return { count, increment }
-})
diff --git a/src/stores/tools.ts b/src/stores/tools.ts
new file mode 100644
index 0000000..bacef61
--- /dev/null
+++ b/src/stores/tools.ts
@@ -0,0 +1,51 @@
+import type { ToolsListResult } from '@/api/user'
+import type { ToolDef } from '@/config/tools'
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { apiToolsList, apiWorkbenchGet, apiWorkbenchSave } from '@/api/user'
+import { TOOL_REGISTRY } from '@/config/tools'
+
+export const useToolsStore = defineStore('tools', () => {
+ const modules = ref([])
+ const tools = ref([])
+ const workbenchKeys = ref([])
+ const loaded = ref(false)
+
+ async function fetchAll() {
+ const data = await apiToolsList()
+ modules.value = data.modules
+ tools.value = data.list
+ workbenchKeys.value = (await apiWorkbenchGet()).tool_keys ?? []
+ loaded.value = true
+ }
+
+ async function saveWorkbench(keys: string[]) {
+ await apiWorkbenchSave(keys)
+ workbenchKeys.value = [...keys]
+ }
+
+ /** 工作台工具(按保存顺序) */
+ function workbenchTools(): Array {
+ const byKey = new Map(tools.value.map(t => [t.tool_key, t]))
+ return workbenchKeys.value
+ .map((key) => {
+ const server = byKey.get(key)
+ const def = TOOL_REGISTRY.find(d => d.key === key)
+ if (!def || !server)
+ return null
+ return { ...def, locked: server.locked, serverName: server.name }
+ })
+ .filter((x): x is ToolDef & { locked: boolean, serverName: string } => x !== null)
+ }
+
+ /** 工具库(注册表 + 服务端锁定态合并) */
+ function libraryByModule(moduleKey: string): Array {
+ const byKey = new Map(tools.value.map(t => [t.tool_key, t]))
+ return TOOL_REGISTRY.filter(d => d.module === moduleKey).map(d => ({
+ ...d,
+ locked: byKey.get(d.key)?.locked ?? true,
+ }))
+ }
+
+ return { modules, tools, workbenchKeys, loaded, fetchAll, saveWorkbench, workbenchTools, libraryByModule }
+})
diff --git a/src/stores/user.ts b/src/stores/user.ts
new file mode 100644
index 0000000..174f22f
--- /dev/null
+++ b/src/stores/user.ts
@@ -0,0 +1,149 @@
+import type { LevelInfo, UserInfo } from '@/api/user'
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+import { clearToken, getToken, gotoH5Login, setToken } from '@/api/request'
+import {
+ apiAccountLogin,
+ apiDevLogin,
+ apiGetLevel,
+ apiGetUserInfo,
+ apiRegister,
+ apiUpdateProfile,
+ apiWxLogin,
+
+} from '@/api/user'
+
+const USER_KEY = 'user'
+
+export const useUserStore = defineStore('user', () => {
+ const user = ref(null)
+ const level = ref(null)
+ const logged = ref(false)
+
+ /** 已授权模块判定 */
+ const modules = computed(() => level.value?.modules ?? [])
+
+ function isModuleUnlocked(moduleKey: string): boolean {
+ return modules.value.includes(moduleKey)
+ }
+
+ function applyLogin(data: { token: string, user: UserInfo, level: LevelInfo }) {
+ setToken(data.token)
+ user.value = data.user
+ level.value = data.level
+ logged.value = true
+ // user 持久化:token 校验路径若拉取用户信息失败,可从本地兜底恢复
+ uni.setStorageSync(USER_KEY, JSON.stringify(data.user))
+ }
+
+ /** 从本地缓存恢复用户信息(/user/info 不可用时的兜底) */
+ function restoreUser(): UserInfo | null {
+ try {
+ return JSON.parse((uni.getStorageSync(USER_KEY) as string) || 'null') as UserInfo | null
+ }
+ catch {
+ return null
+ }
+ }
+
+ /** 微信静默登录(MP) */
+ async function loginWx(): Promise {
+ const code = await new Promise((resolve, reject) => {
+ uni.login({
+ provider: 'weixin',
+ success: res => resolve(res.code || ''),
+ fail: err => reject(new Error(err.errMsg || 'wx.login failed')),
+ })
+ })
+ if (!code)
+ throw new Error('wx.login 未返回 code')
+ applyLogin(await apiWxLogin(code))
+ }
+
+ /** H5 联调登录(后端需开启 debug) */
+ async function loginDev(): Promise {
+ applyLogin(await apiDevLogin())
+ }
+
+ /** H5 账号密码登录 */
+ async function loginAccount(username: string, password: string): Promise {
+ applyLogin(await apiAccountLogin(username, password))
+ }
+
+ /** H5 注册(注册即登录) */
+ async function registerAccount(username: string, password: string, nickname: string): Promise {
+ applyLogin(await apiRegister(username, password, nickname))
+ }
+
+ /** 进行中的登录 Promise:防止 onLaunch 与页面 onShow 并发触发多次登录 */
+ let loginPromise: Promise | null = null
+
+ /** 确保已登录:有 token 则校验一次;H5 无 token 引导去登录页,MP 走静默登录 */
+ async function ensureLogin(): Promise {
+ if (logged.value)
+ return
+ if (getToken()) {
+ try {
+ // 并行恢复等级与用户信息;用户信息拉取失败不阻塞登录态(走本地兜底)
+ const [lv, u] = await Promise.all([apiGetLevel(), apiGetUserInfo().catch(() => null)])
+ level.value = lv
+ user.value = u || restoreUser()
+ logged.value = true
+ return
+ }
+ catch {
+ clearToken()
+ }
+ }
+ if (!loginPromise) {
+ loginPromise = doLogin().finally(() => {
+ loginPromise = null
+ })
+ }
+ await loginPromise
+ }
+
+ async function doLogin(): Promise {
+ // #ifdef H5
+ // H5 无法静默登录:引导到登录/注册页(开发联调用登录页里的 dev-login)
+ gotoH5Login()
+ throw new Error('H5 需要账号登录')
+ // #endif
+ // #ifndef H5
+ await loginWx()
+ // #endif
+ }
+
+ /** 更新个人信息(昵称 / 头像),成功后同步本地 */
+ async function updateProfile(nickname: string, avatarUrl: string): Promise {
+ await apiUpdateProfile(nickname, avatarUrl)
+ if (user.value)
+ user.value = { ...user.value, nickname, avatar_url: avatarUrl }
+ }
+
+ function logout() {
+ clearToken()
+ uni.removeStorageSync(USER_KEY)
+ user.value = null
+ level.value = null
+ logged.value = false
+ // H5 退出后回登录页
+ gotoH5Login()
+ }
+
+ return {
+ user,
+ level,
+ logged,
+ modules,
+ isModuleUnlocked,
+ ensureLogin,
+ doLogin,
+ loginDev,
+ loginAccount,
+ registerAccount,
+ updateProfile,
+ loginWx,
+ logout,
+ }
+})
diff --git a/src/types/vendor.d.ts b/src/types/vendor.d.ts
new file mode 100644
index 0000000..ee240c7
--- /dev/null
+++ b/src/types/vendor.d.ts
@@ -0,0 +1,10 @@
+/** 内部 vendor JS 模块(无类型文件)的声明 */
+declare module '@/utils/vendor/qrcode-core.js' {
+ const QRCode: new (typeNumber: number, errorCorrectLevel: number) => {
+ addData: (data: string) => void
+ make: () => void
+ getModuleCount: () => number
+ isDark: (row: number, col: number) => boolean
+ }
+ export default QRCode
+}
diff --git a/src/uni.scss b/src/uni.scss
index 3c8e349..b3409a9 100644
--- a/src/uni.scss
+++ b/src/uni.scss
@@ -74,3 +74,23 @@ $uni-color-subtitle: #555; // 二级标题颜色
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-font-size-paragraph: 15px;
+
+/* ============ 「橘子汽水」设计 tokens(docs/design-language.md) ============ */
+$tb-primary: #ff6b35; // 主色珊瑚橙
+$tb-primary-light: #ff9f4d; // 渐变亮端
+$tb-primary-deep: #fb4e14; // 渐变深端
+$tb-bg: #fff9f2; // 页面底色(奶白)
+$tb-text: #3d2a1e; // 正文(暖炭)
+$tb-text-2: #9a7b65; // 次要文字(对比度 ≥ 4.5:1)
+$tb-border: #ffe3d3; // 描边
+$tb-card: #ffffff; // 卡片
+$tb-radius: 30rpx; // 卡片圆角
+$tb-shadow: 0 8rpx 24rpx rgba(107, 47, 15, 0.06); // 卡片软阴影
+$tb-shadow-float: 0 12rpx 40rpx rgba(107, 47, 15, 0.18); // 悬浮元素阴影
+$tb-spring: cubic-bezier(0.34, 1.56, 0.64, 1); // spring 动效曲线
+/* 撞色点缀 */
+$tb-mint: #3ecfb2;
+$tb-lemon: #ffc53d;
+$tb-sky: #4da8ff;
+$tb-pink: #ff7ba9;
+$tb-violet: #8b7cf6;
diff --git a/src/utils/avatar.ts b/src/utils/avatar.ts
new file mode 100644
index 0000000..63cd24e
--- /dev/null
+++ b/src/utils/avatar.ts
@@ -0,0 +1,15 @@
+/** 预置贴纸头像(docs/design-language.md):avatar_url 存 `avatar:key` 表示预置头像,其余按远端 URL 渲染 */
+export const AVATAR_PRESETS = [
+ { key: 'avatar:rocket', sticker: 'rocket', bg: '#FFE8DC', label: '火箭' },
+ { key: 'avatar:star', sticker: 'star', bg: '#FFF4D6', label: '星星' },
+ { key: 'avatar:cloud', sticker: 'cloud', bg: '#E3F2FF', label: '云朵' },
+ { key: 'avatar:rainbow', sticker: 'rainbow', bg: '#DFF7F0', label: '彩虹' },
+ { key: 'avatar:bolt', sticker: 'bolt', bg: '#FFE7F0', label: '闪电' },
+ { key: 'avatar:sparkle', sticker: 'sparkle', bg: '#EEE9FF', label: '闪光' },
+] as const
+
+export type AvatarPreset = (typeof AVATAR_PRESETS)[number]
+
+export function presetOf(avatarUrl: string): AvatarPreset | null {
+ return AVATAR_PRESETS.find(p => p.key === avatarUrl) || null
+}
diff --git a/src/utils/image/avatarfx.ts b/src/utils/image/avatarfx.ts
new file mode 100644
index 0000000..1000c46
--- /dev/null
+++ b/src/utils/image/avatarfx.ts
@@ -0,0 +1,275 @@
+/**
+ * 头像特效:定义 + Canvas 绘制
+ * 输出 720×720 头像 PNG(圆形 / 圆角方形两种形状:照片裁剪 + 彩色环 + 角标装饰 + 文字徽章)。
+ * 装饰全部用 Canvas 原语绘制(小程序 canvas 不能画 SVG),双端表现一致。
+ * 坐标约定:decor/badge 的 x/y 为百分比(0-100),CSS 实时预览与 Canvas 输出共用同一份定义。
+ */
+
+export type FxShape = 'circle' | 'square'
+
+export interface FxDecor {
+ kind: 'star5' | 'sparkle' | 'dot' | 'bolt'
+ x: number // % (中心)
+ y: number // %
+ size: number // % (半径基准)
+ color: string
+}
+
+export interface FxBadge {
+ text: string
+ bg: string
+ color: string
+ x: number // %
+ y: number // %
+}
+
+export interface AvatarEffect {
+ key: string
+ name: string
+ /** 环:由外到内的多层同宽环 */
+ rings: string[]
+ ringWidth: number // px @基准尺寸
+ decors: FxDecor[]
+ badge?: FxBadge
+}
+
+/** 方形的圆角占比(相对边长) */
+export const FX_SQUARE_RADIUS = 0.12
+
+export const AVATAR_EFFECTS: AvatarEffect[] = [
+ {
+ key: 'guoqing',
+ name: '国庆·红旗',
+ rings: ['#DE2910', '#FFDE00'],
+ ringWidth: 26,
+ decors: [
+ { kind: 'star5', x: 20, y: 18, size: 8, color: '#FFDE00' },
+ { kind: 'star5', x: 31, y: 12, size: 5, color: '#FFDE00' },
+ { kind: 'star5', x: 13, y: 30, size: 5, color: '#FFDE00' },
+ { kind: 'star5', x: 24, y: 27, size: 4, color: '#FFDE00' },
+ { kind: 'star5', x: 32, y: 22, size: 4, color: '#FFDE00' },
+ ],
+ },
+ {
+ key: 'xinchun',
+ name: '新春·福运',
+ rings: ['#E60012'],
+ ringWidth: 30,
+ decors: [{ kind: 'star5', x: 20, y: 20, size: 6, color: '#FFDE00' }],
+ badge: { text: '福', bg: '#E60012', color: '#FFDE00', x: 79, y: 79 },
+ },
+ {
+ key: 'shengdan',
+ name: '圣诞·星语',
+ rings: ['#0E7A4A', '#FFFFFF'],
+ ringWidth: 26,
+ decors: [
+ { kind: 'star5', x: 79, y: 17, size: 8, color: '#FFDE00' },
+ { kind: 'dot', x: 22, y: 74, size: 2.5, color: '#FFFFFF' },
+ { kind: 'dot', x: 70, y: 68, size: 2, color: '#FFFFFF' },
+ { kind: 'dot', x: 34, y: 34, size: 2, color: '#FFFFFF' },
+ ],
+ },
+ {
+ key: 'wansheng',
+ name: '万圣·夜',
+ rings: ['#FF7A00'],
+ ringWidth: 26,
+ decors: [{ kind: 'bolt', x: 76, y: 18, size: 9, color: '#FFC53D' }],
+ },
+ {
+ key: 'xingguang',
+ name: '星光·紫夜',
+ rings: ['#8B7CF6'],
+ ringWidth: 24,
+ decors: [
+ { kind: 'sparkle', x: 18, y: 20, size: 7, color: '#FFC53D' },
+ { kind: 'sparkle', x: 82, y: 26, size: 6, color: '#3ECFB2' },
+ { kind: 'sparkle', x: 74, y: 78, size: 5, color: '#FF7BA9' },
+ ],
+ },
+ {
+ key: 'caihong',
+ name: '彩虹·光环',
+ rings: ['#FF6B35', '#FFC53D', '#3ECFB2'],
+ ringWidth: 12,
+ decors: [],
+ },
+ {
+ key: 'yuanqi',
+ name: '元气·橙',
+ rings: ['#FF6B35'],
+ ringWidth: 28,
+ decors: [
+ { kind: 'star5', x: 80, y: 18, size: 7, color: '#FF6B35' },
+ { kind: 'dot', x: 16, y: 74, size: 3, color: '#FFC53D' },
+ ],
+ },
+ {
+ key: 'bohe',
+ name: '薄荷·清新',
+ rings: ['#3ECFB2', '#C8F5EA'],
+ ringWidth: 22,
+ decors: [
+ { kind: 'dot', x: 20, y: 22, size: 3, color: '#3ECFB2' },
+ { kind: 'dot', x: 78, y: 74, size: 3, color: '#3ECFB2' },
+ ],
+ },
+]
+
+// ===== Canvas 绘制 =====
+
+function roundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
+ const rr = Math.min(r, w / 2, h / 2)
+ ctx.beginPath()
+ ctx.moveTo(x + rr, y)
+ ctx.arcTo(x + w, y, x + w, y + h, rr)
+ ctx.arcTo(x + w, y + h, x, y + h, rr)
+ ctx.arcTo(x, y + h, x, y, rr)
+ ctx.arcTo(x, y, x + w, y, rr)
+ ctx.closePath()
+}
+
+function drawStar5(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string, rot = -Math.PI / 2) {
+ ctx.save()
+ ctx.beginPath()
+ for (let i = 0; i < 5; i++) {
+ const outerA = rot + (i * 2 * Math.PI) / 5
+ const innerA = outerA + Math.PI / 5
+ const ox = cx + Math.cos(outerA) * r
+ const oy = cy + Math.sin(outerA) * r
+ const ix = cx + Math.cos(innerA) * r * 0.42
+ const iy = cy + Math.sin(innerA) * r * 0.42
+ if (i === 0)
+ ctx.moveTo(ox, oy)
+ else ctx.lineTo(ox, oy)
+ ctx.lineTo(ix, iy)
+ }
+ ctx.closePath()
+ ctx.fillStyle = color
+ ctx.fill()
+ ctx.restore()
+}
+
+function drawSparkle(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string) {
+ ctx.save()
+ ctx.beginPath()
+ ctx.moveTo(cx, cy - r)
+ ctx.quadraticCurveTo(cx, cy, cx + r, cy)
+ ctx.quadraticCurveTo(cx, cy, cx, cy + r)
+ ctx.quadraticCurveTo(cx, cy, cx - r, cy)
+ ctx.quadraticCurveTo(cx, cy, cx, cy - r)
+ ctx.closePath()
+ ctx.fillStyle = color
+ ctx.fill()
+ ctx.restore()
+}
+
+function drawBolt(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string) {
+ ctx.save()
+ ctx.beginPath()
+ ctx.moveTo(cx + r * 0.15, cy - r)
+ ctx.lineTo(cx - r * 0.55, cy + r * 0.15)
+ ctx.lineTo(cx - r * 0.05, cy + r * 0.15)
+ ctx.lineTo(cx - r * 0.15, cy + r)
+ ctx.lineTo(cx + r * 0.55, cy - r * 0.15)
+ ctx.lineTo(cx + r * 0.05, cy - r * 0.15)
+ ctx.closePath()
+ ctx.fillStyle = color
+ ctx.fill()
+ ctx.restore()
+}
+
+function drawDot(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string) {
+ ctx.save()
+ ctx.beginPath()
+ ctx.arc(cx, cy, r, 0, Math.PI * 2)
+ ctx.fillStyle = color
+ ctx.fill()
+ ctx.restore()
+}
+
+function drawDecor(ctx: CanvasRenderingContext2D, size: number, d: FxDecor) {
+ const cx = (d.x / 100) * size
+ const cy = (d.y / 100) * size
+ const r = (d.size / 100) * size
+ if (d.kind === 'star5')
+ drawStar5(ctx, cx, cy, r, d.color)
+ else if (d.kind === 'sparkle')
+ drawSparkle(ctx, cx, cy, r, d.color)
+ else if (d.kind === 'bolt')
+ drawBolt(ctx, cx, cy, r, d.color)
+ else drawDot(ctx, cx, cy, r, d.color)
+}
+
+/** 绘制特效层(环 + 角标 + 徽章),支持形状与描边粗细缩放 */
+export function drawEffect(ctx: CanvasRenderingContext2D, size: number, fx: AvatarEffect, shape: FxShape = 'circle', ringScale = 1) {
+ const w = fx.ringWidth * ringScale
+ const c = size / 2
+ if (shape === 'circle') {
+ let r = c - w / 2
+ for (const color of fx.rings) {
+ ctx.save()
+ ctx.beginPath()
+ ctx.arc(c, c, r, 0, Math.PI * 2)
+ ctx.lineWidth = w
+ ctx.strokeStyle = color
+ ctx.stroke()
+ ctx.restore()
+ r -= w + 2
+ }
+ }
+ else {
+ const R = size * FX_SQUARE_RADIUS
+ let inset = w / 2
+ for (const color of fx.rings) {
+ ctx.save()
+ roundedRectPath(ctx, inset, inset, size - inset * 2, size - inset * 2, R + inset * 0.6)
+ ctx.lineWidth = w
+ ctx.strokeStyle = color
+ ctx.stroke()
+ ctx.restore()
+ inset += w + 2
+ }
+ }
+ for (const d of fx.decors) drawDecor(ctx, size, d)
+ if (fx.badge) {
+ const bx = (fx.badge.x / 100) * size
+ const by = (fx.badge.y / 100) * size
+ const br = size * 0.085
+ drawDot(ctx, bx, by, br, fx.badge.bg)
+ ctx.save()
+ ctx.beginPath()
+ ctx.arc(bx, by, br - size * 0.008, 0, Math.PI * 2)
+ ctx.strokeStyle = '#FFFFFF'
+ ctx.lineWidth = size * 0.01
+ ctx.stroke()
+ ctx.restore()
+ ctx.save()
+ ctx.fillStyle = fx.badge.color
+ ctx.font = `bold ${Math.round(br * 1.15)}px sans-serif`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(fx.badge.text, bx, by + br * 0.05)
+ ctx.restore()
+ }
+}
+
+/** 照片裁剪画到画布:圆形内切圆 / 圆角方形;zoom 以中心放大(1 = cover 铺满) */
+export function drawPhoto(ctx: CanvasRenderingContext2D, size: number, img: unknown, shape: FxShape = 'circle', zoom = 1) {
+ const src = img as CanvasImageSource & { width: number, height: number }
+ const R = shape === 'circle' ? size / 2 : size * FX_SQUARE_RADIUS
+ ctx.save()
+ ctx.beginPath()
+ if (shape === 'circle')
+ ctx.arc(size / 2, size / 2, R, 0, Math.PI * 2)
+ else roundedRectPath(ctx, 0, 0, size, size, R)
+ ctx.clip()
+ const base = Math.min(src.width, src.height)
+ const sw = base / zoom
+ const sh = base / zoom
+ const sx = (src.width - sw) / 2
+ const sy = (src.height - sh) / 2
+ ctx.drawImage(src, sx, sy, sw, sh, 0, 0, size, size)
+ ctx.restore()
+}
diff --git a/src/utils/image/collage.ts b/src/utils/image/collage.ts
new file mode 100644
index 0000000..f82ccfb
--- /dev/null
+++ b/src/utils/image/collage.ts
@@ -0,0 +1,99 @@
+/**
+ * 图片拼图:模板槽位定义 + Canvas 合成
+ * 槽位坐标为画布百分比;间距/圆角在绘制时按像素换算。
+ */
+
+export interface CollageSlot {
+ x: number // %
+ y: number // %
+ w: number // %
+ h: number // %
+}
+
+export interface CollageTemplate {
+ key: string
+ name: string
+ count: number
+ slots: CollageSlot[]
+}
+
+export const COLLAGE_TEMPLATES: CollageTemplate[] = [
+ { key: 'h2', name: '左右两张', count: 2, slots: [{ x: 0, y: 0, w: 50, h: 100 }, { x: 50, y: 0, w: 50, h: 100 }] },
+ { key: 'v2', name: '上下两张', count: 2, slots: [{ x: 0, y: 0, w: 100, h: 50 }, { x: 0, y: 50, w: 100, h: 50 }] },
+ {
+ key: 'l1r2',
+ name: '左大右二',
+ count: 3,
+ slots: [{ x: 0, y: 0, w: 58, h: 100 }, { x: 58, y: 0, w: 42, h: 50 }, { x: 58, y: 50, w: 42, h: 50 }],
+ },
+ { key: 'h3', name: '三竖条', count: 3, slots: [{ x: 0, y: 0, w: 33.34, h: 100 }, { x: 33.34, y: 0, w: 33.33, h: 100 }, { x: 66.67, y: 0, w: 33.33, h: 100 }] },
+ {
+ key: 'grid4',
+ name: '四宫格',
+ count: 4,
+ slots: [{ x: 0, y: 0, w: 50, h: 50 }, { x: 50, y: 0, w: 50, h: 50 }, { x: 0, y: 50, w: 50, h: 50 }, { x: 50, y: 50, w: 50, h: 50 }],
+ },
+ {
+ key: 't1b3',
+ name: '上大下三',
+ count: 4,
+ slots: [{ x: 0, y: 0, w: 100, h: 52 }, { x: 0, y: 52, w: 33.34, h: 48 }, { x: 33.34, y: 52, w: 33.33, h: 48 }, { x: 66.67, y: 52, w: 33.33, h: 48 }],
+ },
+]
+
+/** 单槽位圆角矩形 clip + cover 绘制 */
+function drawSlot(
+ ctx: CanvasRenderingContext2D,
+ slot: CollageSlot,
+ size: number,
+ img: unknown,
+ imgW: number,
+ imgH: number,
+ gap: number,
+ radius: number,
+) {
+ const sx = (slot.x / 100) * size + gap / 2
+ const sy = (slot.y / 100) * size + gap / 2
+ const sw = (slot.w / 100) * size - gap
+ const sh = (slot.h / 100) * size - gap
+ if (sw <= 0 || sh <= 0)
+ return
+ const scale = Math.max(sw / imgW, sh / imgH)
+ const dw = imgW * scale
+ const dh = imgH * scale
+ const dx = sx + (sw - dw) / 2
+ const dy = sy + (sh - dh) / 2
+ ctx.save()
+ const rr = Math.min(radius, sw / 2, sh / 2)
+ ctx.beginPath()
+ ctx.moveTo(sx + rr, sy)
+ ctx.arcTo(sx + sw, sy, sx + sw, sy + sh, rr)
+ ctx.arcTo(sx + sw, sy + sh, sx, sy + sh, rr)
+ ctx.arcTo(sx, sy + sh, sx, sy, rr)
+ ctx.arcTo(sx, sy, sx + sw, sy, rr)
+ ctx.closePath()
+ ctx.clip()
+ ctx.drawImage(img as CanvasImageSource, dx, dy, dw, dh)
+ ctx.restore()
+}
+
+/** 合成整张拼图 */
+export function drawCollage(
+ ctx: CanvasRenderingContext2D,
+ size: number,
+ tpl: CollageTemplate,
+ loaded: Array<{ img: unknown, width: number, height: number }>,
+ gap: number,
+ radius: number,
+ bg: string,
+) {
+ ctx.save()
+ ctx.fillStyle = bg
+ ctx.fillRect(0, 0, size, size)
+ ctx.restore()
+ loaded.forEach((item, i) => {
+ const slot = tpl.slots[i]
+ if (slot)
+ drawSlot(ctx, slot, size, item.img, item.width, item.height, gap, radius)
+ })
+}
diff --git a/src/utils/image/compress.ts b/src/utils/image/compress.ts
new file mode 100644
index 0000000..0fad3a3
--- /dev/null
+++ b/src/utils/image/compress.ts
@@ -0,0 +1,115 @@
+import type { CompressOptions } from './types'
+/**
+ * 图片压缩
+ * - MP:优先 wx.compressImage 原生压缩(快、省内存),不达标再叠 canvas 质量二分
+ * - H5:canvas 缩放 + JPEG 质量二分(±10% 命中目标体积)
+ */
+import { beginDraw, drawImageSource, exportFile, getFileSize, loadImage } from './core'
+
+function fitDims(w: number, h: number, maxEdge?: number) {
+ if (maxEdge && Math.max(w, h) > maxEdge) {
+ const s = maxEdge / Math.max(w, h)
+ return { w: Math.max(1, Math.round(w * s)), h: Math.max(1, Math.round(h * s)) }
+ }
+ return { w, h }
+}
+
+function getImageInfoMP(path: string): Promise<{ width: number, height: number }> {
+ return new Promise((resolve, reject) => {
+ wx.getImageInfo({
+ src: path,
+ success: r => resolve({ width: r.width, height: r.height }),
+ fail: e => reject(new Error(e.errMsg || '读取图片信息失败')),
+ })
+ })
+}
+
+function nativeCompressMP(path: string, quality: number, compressedWidth?: number): Promise {
+ return new Promise((resolve, reject) => {
+ const opt: Record = {
+ src: path,
+ quality: Math.round(quality * 100),
+ success: (res: { tempFilePath: string }) => resolve(res.tempFilePath),
+ fail: (e: { errMsg?: string }) => reject(new Error(e.errMsg || '压缩失败')),
+ }
+ if (compressedWidth)
+ opt.compressedWidth = Math.round(compressedWidth)
+ ;(wx.compressImage as (o: Record) => void)(opt)
+ })
+}
+
+/** canvas 压缩:目标体积存在时做质量二分,取满足体积的最高质量 */
+async function canvasCompress(
+ src: string,
+ opts: CompressOptions,
+): Promise<{ path: string, size: number }> {
+ const { img, width, height } = await loadImage(src)
+ const { w, h } = fitDims(width, height, opts.maxEdge)
+ // 按目标体积压缩时统一转 JPEG(PNG 无质量参数)
+ const type = opts.targetKB ? 'jpg' : 'png'
+
+ if (!opts.targetKB) {
+ const { ctx } = beginDraw(w, h, false)
+ drawImageSource(ctx, img, w, h)
+ const path = await exportFile(type, opts.quality ?? 0.8)
+ return { path, size: await getFileSize(path) }
+ }
+
+ const target = opts.targetKB * 1024
+ let lo = 0.3
+ let hi = Math.min(0.92, opts.quality ?? 0.85)
+ let best: { path: string, size: number } | null = null
+ for (let i = 0; i < 6 && hi - lo > 0.04; i++) {
+ const q = (lo + hi) / 2
+ const { ctx } = beginDraw(w, h, true)
+ drawImageSource(ctx, img, w, h)
+ const path = await exportFile('jpg', q)
+ const size = await getFileSize(path)
+ if (size <= target) {
+ best = { path, size } // 达标:尝试更高质量
+ lo = q
+ }
+ else {
+ hi = q
+ }
+ }
+ if (!best)
+ throw new Error('无法压缩到目标体积,请降低质量档位或缩小尺寸')
+ return best
+}
+
+export async function compressImage(
+ path: string,
+ size: number,
+ opts: CompressOptions,
+): Promise<{ path: string, size: number, ratio: number }> {
+ // #ifdef MP-WEIXIN
+ try {
+ const info = await getImageInfoMP(path)
+ const dims = fitDims(info.width, info.height, opts.maxEdge)
+ const scaleDown = dims.w < info.width
+ let q = Math.round((opts.quality ?? 0.8) * 100)
+ let cur = await nativeCompressMP(path, q / 100, scaleDown ? dims.w : undefined)
+ let curSize = await getFileSize(cur)
+ if (!opts.targetKB) {
+ return { path: cur, size: curSize, ratio: size ? curSize / size : 1 }
+ }
+ const target = opts.targetKB * 1024
+ while (curSize > target && q > 30) {
+ q -= 15
+ cur = await nativeCompressMP(path, q / 100, scaleDown ? dims.w : undefined)
+ curSize = await getFileSize(cur)
+ }
+ if (curSize > target) {
+ const r = await canvasCompress(path, opts)
+ return { path: r.path, size: r.size, ratio: size ? r.size / size : 1 }
+ }
+ return { path: cur, size: curSize, ratio: size ? curSize / size : 1 }
+ }
+ catch {
+ // 原生压缩不可用时回退通用 canvas 流程
+ }
+ // #endif
+ const r = await canvasCompress(path, opts)
+ return { path: r.path, size: r.size, ratio: size ? r.size / size : 1 }
+}
diff --git a/src/utils/image/convert.ts b/src/utils/image/convert.ts
new file mode 100644
index 0000000..03fea09
--- /dev/null
+++ b/src/utils/image/convert.ts
@@ -0,0 +1,54 @@
+/**
+ * 图片格式转换:解码 → canvas 重绘 → 导出
+ * - WebP 输出仅 H5(toBlob);小程序端 canvasToTempFilePath 仅 jpg/png
+ * - HEIC 输入仅 H5(heic2any 动态解码)
+ * - 转 JPEG 时白底合成透明区域
+ */
+import { beginDraw, drawImageSource, exportFile, loadImage } from './core'
+import { resultName } from './task'
+
+interface ConvertResult {
+ path: string
+ name: string
+}
+
+function convertMP(src: string, target: 'png' | 'jpg'): Promise {
+ return (async () => {
+ const { img, width, height } = await loadImage(src)
+ const { ctx } = beginDraw(width, height, target === 'jpg')
+ drawImageSource(ctx, img, width, height)
+ const path = await exportFile(target, 0.92)
+ return { path, name: resultName(src, 'convert', target) }
+ })()
+}
+
+async function convertH5(src: string, target: 'png' | 'jpg' | 'webp'): Promise {
+ let realSrc = src
+ if (/\.heic$/i.test(src)) {
+ const mod: any = await import('heic2any')
+ const heic2any = mod.default ?? mod
+ const blob = await (await fetch(src)).blob()
+ const out = await heic2any({ blob, toType: 'image/png' })
+ realSrc = URL.createObjectURL(out as Blob)
+ }
+ const { img, width, height } = await loadImage(realSrc)
+ const { ctx } = beginDraw(width, height, target === 'jpg')
+ drawImageSource(ctx, img, width, height)
+ const path = await exportFile(target, 0.92)
+ return { path, name: resultName(src, 'convert', target) }
+}
+
+export async function convertFormat(
+ src: string,
+ target: 'png' | 'jpg' | 'webp',
+): Promise {
+ // #ifdef MP-WEIXIN
+ if (target === 'webp') {
+ return Promise.reject(new Error('小程序端暂不支持导出 WebP,请在网页版使用'))
+ }
+ return convertMP(src, target)
+ // #endif
+ // #ifdef H5
+ return convertH5(src, target)
+ // #endif
+}
diff --git a/src/utils/image/core.ts b/src/utils/image/core.ts
new file mode 100644
index 0000000..a74d79a
--- /dev/null
+++ b/src/utils/image/core.ts
@@ -0,0 +1,303 @@
+import type { ExportType, LoadResult, PickedImage } from './types'
+/**
+ * 图片引擎平台隔离层
+ * - MP:使用页面内