初始化

This commit is contained in:
2026-09-17 14:39:15 +08:00
parent caac2530bb
commit 83a7ca7a1e
66 changed files with 10067 additions and 223 deletions

15
components.d.ts vendored
View File

@@ -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']
}
}

View File

@@ -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/**',
],
})

View File

@@ -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"
}
}
}

130
pnpm-lock.yaml generated
View File

@@ -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

10
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,10 @@
allowBuilds:
core-js: true
esbuild: true
protobufjs: true
vue-demi: true
onlyBuiltDependencies:
- core-js
- esbuild
- protobufjs
- vue-demi

View File

@@ -1,7 +1,255 @@
<script setup lang="ts">
import { onLaunch } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
onLaunch(() => {})
onLaunch(() => {
const userStore = useUserStore()
// 静默登录MP 走 wx.loginH5 走 dev-login后端需开 debug
userStore.ensureLogin().catch(() => {})
})
</script>
<style></style>
<style>
page {
/* ============ 「橘子汽水」设计 tokensdocs/design-language.md ============ */
--tb-primary: #ff6b35;
--tb-primary-deep: #fb4e14;
--tb-primary-light: #ff9f4d;
--tb-bg: #fff9f2;
--tb-card: #ffffff;
--tb-text: #3d2a1e;
--tb-text-2: #9a7b65;
--tb-border: #ffe3d3;
/* 撞色点缀 */
--tb-mint: #3ecfb2;
--tb-lemon: #ffc53d;
--tb-sky: #4da8ff;
--tb-pink: #ff7ba9;
--tb-violet: #8b7cf6;
/* 动效曲线:缓出 / 弹性 */
--tb-ease: cubic-bezier(0.22, 1, 0.36, 1);
--tb-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
/* wot 组件主题对齐(按钮、弹层等全局换肤) */
--wot-color-theme: #ff6b35;
--wot-button-primary-bg-color: #ff6b35;
--wot-button-medium-border-radius: 999rpx;
background-color: #fff9f2;
color: #3d2a1e;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'MiSans', 'HarmonyOS Sans',
'Helvetica Neue', sans-serif;
font-size: 28rpx;
-webkit-font-smoothing: antialiased;
}
/* 图片工具隐藏工作画布MP */
.hidden-canvas {
position: fixed;
left: -9999px;
top: -9999px;
width: 10px;
height: 10px;
}
/* 棋盘格透明底(抠图结果预览) */
.checker-bg {
background-image: linear-gradient(45deg, #f6e8dc 25%, transparent 25%),
linear-gradient(-45deg, #f6e8dc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #f6e8dc 75%),
linear-gradient(-45deg, transparent 75%, #f6e8dc 75%);
background-size: 24rpx 24rpx;
background-position: 0 0, 0 12rpx, 12rpx -12rpx, -12rpx 0;
}
/* ============ 按压反馈(弹簧回弹) ============ */
.press {
transition: transform 0.22s var(--tb-spring, cubic-bezier(0.34, 1.56, 0.64, 1)), opacity 0.15s ease;
}
.press-active {
transform: scale(0.965);
opacity: 0.92;
}
/* ============ 渐变头区(波浪底边,含状态栏 / 胶囊安全距离) ============ */
.tb-hero {
position: relative;
overflow: hidden;
/* 基础兜底:各页面用 heroPadTop() 内联覆盖MP 端避开胶囊按钮 */
padding: calc(var(--status-bar-height, 0px) + 26rpx) 32rpx 158rpx;
background:
radial-gradient(640rpx 420rpx at 88% -12%, rgba(255, 255, 255, 0.24), transparent 62%),
radial-gradient(520rpx 400rpx at -12% 112%, rgba(255, 197, 61, 0.45), transparent 62%),
linear-gradient(165deg, #ff9f4d 0%, #ff6b35 52%, #fb4e14 100%);
}
/* 波浪底边:奶白圆拱叠在橙色下缘 */
.tb-hero::after {
content: '';
position: absolute;
left: -8%;
right: -8%;
bottom: -2rpx;
height: 42rpx;
background: var(--tb-bg, #fff9f2);
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
}
/* 上浮内容区:负 margin 压住头区(靠 DOM 顺序绘制在上层,
不设 z-index避免层叠上下文困住内部的 fixed 弹层) */
.tb-body {
position: relative;
padding: 0 32rpx;
margin-top: -120rpx;
}
/* ============ 卡片 ============ */
.tb-card {
background: var(--tb-card, #ffffff);
border-radius: 36rpx;
border: 1rpx solid rgba(255, 195, 155, 0.5);
box-shadow: 0 3rpx 10rpx rgba(107, 47, 15, 0.04), 0 18rpx 44rpx rgba(107, 47, 15, 0.08);
}
/* 分区标题:橙色圆条 + 加粗 */
.tb-section-title {
display: flex;
align-items: center;
gap: 14rpx;
font-size: 32rpx;
font-weight: 800;
letter-spacing: 0.01em;
color: var(--tb-text, #3d2a1e);
}
.tb-section-title::before {
content: '';
width: 10rpx;
height: 30rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #ff9f4d, #ff6b35);
box-shadow: 0 4rpx 10rpx rgba(255, 107, 53, 0.35);
}
.tb-section-more {
display: flex;
align-items: center;
gap: 6rpx;
margin-left: auto;
font-size: 24rpx;
color: var(--tb-primary, #ff6b35);
}
/* ============ 搜索胶囊(头区悬浮 / 卡片内扁平两种形态) ============ */
.tb-search {
display: flex;
align-items: center;
gap: 16rpx;
background: rgba(255, 255, 255, 0.97);
border-radius: 999rpx;
padding: 22rpx 30rpx;
box-shadow: 0 14rpx 30rpx rgba(151, 60, 8, 0.22);
position: relative;
z-index: 1;
}
.tb-search--flat {
background: #fff9f2;
border: 2rpx solid #ffe3d3;
box-shadow: none;
padding: 16rpx 28rpx;
}
.tb-search__text {
font-size: 26rpx;
color: #b3907c;
}
.tb-search__input {
flex: 1;
height: 44rpx;
line-height: 44rpx;
font-size: 28rpx;
color: #3d2a1e;
}
/* ============ 工具宫格(扁平:无边框无底,靠图标块与字重支撑) ============ */
.tb-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 18rpx;
}
.tb-tile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 14rpx;
padding: 14rpx 8rpx 10rpx;
border-radius: 26rpx;
}
.tb-tile__icon {
width: 92rpx;
height: 92rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 16rpx rgba(107, 47, 15, 0.08);
}
.tb-tile__name {
font-size: 24rpx;
font-weight: 600;
color: var(--tb-text, #3d2a1e);
text-align: center;
line-height: 1.3;
}
.tb-tile--lock .tb-tile__name {
color: #bda794;
}
.tb-tile__corner {
position: absolute;
top: 0;
right: 0;
background: linear-gradient(135deg, #ffe9b8, #ffd77a);
color: #8a5a00;
font-size: 18rpx;
font-weight: 600;
padding: 6rpx 16rpx;
border-radius: 0 26rpx 0 20rpx;
box-shadow: 0 4rpx 10rpx rgba(180, 127, 0, 0.18);
}
/* ============ 空态 ============ */
.tb-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
border: 2rpx dashed #ffd4b8;
border-radius: 30rpx;
padding: 56rpx 0;
background: rgba(255, 255, 255, 0.7);
}
.tb-empty__title {
font-size: 30rpx;
font-weight: 700;
color: var(--tb-text, #3d2a1e);
}
.tb-empty__sub {
font-size: 24rpx;
color: var(--tb-text-2, #9a7b65);
}
/* ============ 动效:卡片上浮进场(随 pane 激活重放) ============ */
@keyframes tbRise {
from {
opacity: 0;
transform: translateY(26rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.pane--in .tb-rise {
animation: tbRise 0.5s var(--tb-ease, cubic-bezier(0.22, 1, 0.36, 1)) both;
}
/* ============ H5uni-input 有值时内部 input 高度塌陷的兜底 ============ */
/* #ifdef H5 */
uni-input .uni-input-wrapper {
min-height: 44rpx;
}
/* #endif */
</style>

86
src/api/request.ts Normal file
View File

@@ -0,0 +1,86 @@
/** 统一响应约定:{code, message, result}code=0 成功401 登录失效 */
import { BASE_URL } from '@/config/env'
export interface ApiBody<T> {
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)
}
/** H5401 时引导去登录页(登录页自身不跳,避免循环) */
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<T>(url: string, method: 'GET' | 'POST' | 'PUT', data?: unknown): Promise<T> {
const header: Record<string, string> = {}
const token = getToken()
if (token)
header.Authorization = `Bearer ${token}`
return new Promise<T>((resolve, reject) => {
uni.request({
url: BASE_URL + url,
method,
data: data as Record<string, unknown> | undefined,
header,
timeout: 15000,
success: (res) => {
const body = res.data as ApiBody<T>
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<T>(url: string, params?: Record<string, unknown>): Promise<T> {
return request<T>(url, 'GET', params)
}
export function post<T>(url: string, data?: unknown): Promise<T> {
return request<T>(url, 'POST', data)
}
export function put<T>(url: string, data?: unknown): Promise<T> {
return request<T>(url, 'PUT', data)
}

99
src/api/user.ts Normal file
View File

@@ -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<LoginResult>('/user/wx-login', { code })
}
export function apiDevLogin() {
return post<LoginResult>('/user/dev-login')
}
/** H5 账号注册(注册即登录) */
export function apiRegister(username: string, password: string, nickname = '') {
return post<LoginResult>('/user/register', { username, password, nickname })
}
/** H5 账号密码登录 */
export function apiAccountLogin(username: string, password: string) {
return post<LoginResult>('/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<LevelInfo>('/user/level')
}
/** 当前登录用户信息token 校验后恢复 user */
export function apiGetUserInfo() {
return get<UserInfo>('/user/info')
}
export function apiToolsList() {
return get<ToolsListResult>('/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 })
}

View File

@@ -1,34 +0,0 @@
<script setup lang="ts">
function handleClickGithub() {
if (window?.open) {
window.open('https://github.com/uni-helper/create-uni')
}
else {
uni.showToast({
icon: 'none',
title: '请使用浏览器打开',
})
}
}
</script>
<template>
<view class="footer" @click="handleClickGithub">
<image class="uni-helper-github__image" src="/static/github.svg" />
</view>
</template>
<style>
.footer{
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
color: #888;
}
.uni-helper-github__image {
display: inline-block;
height: 1em;
width: 1em;
}
</style>

View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
</script>
<template>
<view class="container">
<view class="uni-helper-logo">
<image class="uni-helper-logo__image" src="/static/logo.svg" />
<text class="uni-helper-logo__label green">
uni-helper
</text>
</view>
<text class="link-bar">
+
</text>
<view class="uni-helper-logo">
<image class="uni-helper-logo__image" src="/static/vite.png" />
<text class="uni-helper-logo__label purple">
Vite
</text>
</view>
</view>
</template>
<style scoped lang="scss">
.container {
display: inline-flex;
font-size: 1.5rem;
font-weight: 300;
&:hover {
.link-bar {
transform: rotate(135deg);
}
}
}
.uni-helper-logo {
display: flex;
flex-direction: column;
align-items: center;
.uni-helper-logo__image {
display: inline-block;
height: 4.5rem;
width: 4.5rem;
}
.uni-helper-logo__label {
margin-top: -0.5rem;
}
.green {
color: #22c55e;
};
.purple {
color: #a855f7;
}
}
.link-bar {
color: #9ca3af;
margin: auto 1em;
transition: all 500ms cubic-bezier(0.4, 0, 0.2, 1);
}
</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
/**
* 彩带庆祝:`burst` 数值每次自增触发一次彩带撒落1.6s 后自动收场
*/
const props = defineProps<{ burst: number }>()
const visible = ref(false)
let hideTimer: ReturnType<typeof setTimeout> | undefined
watch(
() => props.burst,
(n) => {
if (n <= 0)
return
visible.value = true
clearTimeout(hideTimer)
hideTimer = setTimeout(() => (visible.value = false), 1600)
},
)
const COLORS = ['#FF6B35', '#FFC53D', '#3ECFB2', '#FF7BA9', '#4DA8FF', '#8B7CF6']
const pieces = computed(() =>
Array.from({ length: 28 }, (_, i) => ({
id: i,
left: `${(i * 37) % 100}%`,
delay: `${(i % 7) * 90}ms`,
duration: `${1000 + ((i * 13) % 500)}ms`,
color: COLORS[i % COLORS.length],
round: i % 3 === 0,
})),
)
</script>
<template>
<view v-if="visible" class="confetti">
<view
v-for="p in pieces"
:key="p.id"
class="confetti__piece"
:class="{ 'confetti__piece--round': p.round }"
:style="{ left: p.left, background: p.color, animationDelay: p.delay, animationDuration: p.duration }"
/>
</view>
</template>
<style scoped>
.confetti {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60vh;
overflow: hidden;
pointer-events: none;
z-index: 20;
}
.confetti__piece {
position: absolute;
top: -30rpx;
width: 14rpx;
height: 26rpx;
border-radius: 4rpx;
animation: confettiFall linear both;
}
.confetti__piece--round {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
@keyframes confettiFall {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(60vh) rotate(540deg);
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
/** 数字滚动(我的页统计等):值变化时从当前值平滑滚到目标值 */
const props = withDefaults(defineProps<{
value?: number
duration?: number
}>(), { value: 0, duration: 600 })
const display = ref(props.value)
let timer: ReturnType<typeof setInterval> | undefined
watch(
() => props.value,
(target) => {
clearInterval(timer)
if (target === display.value)
return
const from = display.value
const start = Date.now()
timer = setInterval(() => {
const p = Math.min(1, (Date.now() - start) / props.duration)
// easeOutCubic
const eased = 1 - (1 - p) ** 3
display.value = Math.round(from + (target - from) * eased)
if (p >= 1)
clearInterval(timer)
}, 16)
},
{ immediate: true },
)
</script>
<template>
<text class="tb-count">
{{ display }}
</text>
</template>
<style scoped>
.tb-count {
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import Sticker from '@/components/Sticker.vue'
/**
* 空状态:贴纸插画组 + 文案 + 可选 CTAdocs/design-language.md
*/
withDefaults(defineProps<{
title?: string
sub?: string
actionText?: string
main?: 'rocket' | 'cloud' | 'star' | 'sparkle' | 'rainbow' | 'bolt'
}>(), { title: '这里还是空的', sub: '', actionText: '', main: 'rocket' })
const emit = defineEmits<{ action: [] }>()
</script>
<template>
<view class="empty">
<!-- 插画奶油圆盘 + 漂浮贴纸组 -->
<view class="empty__art">
<view class="empty__disc" />
<Sticker :type="main" :size="104" float />
<Sticker type="star" :size="44" float :delay="500" class="empty__s1" />
<Sticker type="sparkle" :size="34" float :delay="900" class="empty__s2" />
</view>
<text class="empty__title">
{{ title }}
</text>
<text v-if="sub" class="empty__sub">
{{ sub }}
</text>
<view v-if="actionText" class="empty__btn press" hover-class="press-active" @tap="emit('action')">
<text>{{ actionText }}</text>
<wd-icon name="arrow-right" size="26rpx" color="#ffffff" />
</view>
</view>
</template>
<style scoped>
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 32rpx 0 20rpx;
}
.empty__art {
position: relative;
width: 260rpx;
height: 210rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10rpx;
}
.empty__disc {
position: absolute;
width: 208rpx;
height: 208rpx;
border-radius: 50%;
background: radial-gradient(circle at 50% 38%, #fff3ea, #ffe8dc);
box-shadow: 0 16rpx 40rpx rgba(107, 47, 15, 0.08);
}
.empty__s1 {
position: absolute;
right: 18rpx;
top: 22rpx;
}
.empty__s2 {
position: absolute;
left: 22rpx;
bottom: 26rpx;
}
.empty__title {
font-size: 30rpx;
font-weight: 800;
color: #3d2a1e;
}
.empty__sub {
margin-top: 10rpx;
font-size: 24rpx;
color: #9a7b65;
}
.empty__btn {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 28rpx;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
color: #ffffff;
font-size: 27rpx;
font-weight: 600;
padding: 18rpx 46rpx;
border-radius: 999rpx;
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
}
</style>

View File

@@ -1,36 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
const name = ref('')
const popupShow = ref(false)
function handleClick() {
popupShow.value = true
}
</script>
<template>
<view class="input-box">
<input
v-model="name"
placeholder="What's your name?"
>
</view>
<view>
<wd-button :disabled="!name" @click="handleClick">
Hello
</wd-button>
</view>
<wd-popup v-model="popupShow" custom-style="padding: 30px 40px;">
Hello{{ ` ${name}` }} 👏
</wd-popup>
</template>
<style scoped lang="scss">
.input-box {
margin: 1rem;
padding: 0.5rem;
border-bottom: 1px solid gray;
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
/**
* 手绘描边风贴纸docs/design-language.md §5
* MP 端 <image> 不支持 SVG因此用 background-image + data-uri 渲染
*/
const props = withDefaults(defineProps<{
/** 贴纸类型key 需存在于 SVGS传未知 key 时不渲染) */
type?: string
/** 尺寸rpx */
size?: number
/** 漂浮动效 */
float?: boolean
/** 动效延迟ms用于错峰 */
delay?: number
}>(), { type: 'star', size: 80, float: false, delay: 0 })
const STROKED = `stroke='#3D2A1E' stroke-width='3' stroke-linejoin='round' stroke-linecap='round'`
const SVGS: Record<string, string> = {
star: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path d='M32 6l7.6 15.9 17.4 2.3-12.8 12.1 3.3 17.2L32 45l-15.5 7.5 3.3-17.2L7 24.2l17.4-2.3z' fill='#FFC53D' ${STROKED}/></svg>`,
cloud: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path d='M17 47h30a9 9 0 0 0 2.6-17.6A13 13 0 0 0 25 23.4 11 11 0 0 0 17 47z' fill='#FFFFFF' ${STROKED}/><circle cx='47' cy='18' r='4' fill='#FFC53D' ${STROKED}/></svg>`,
rocket: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path d='M32 4c9 7 13 17 13 27l-5 9H24l-5-9C19 21 23 11 32 4z' fill='#FF6B35' ${STROKED}/><circle cx='32' cy='24' r='6' fill='#DFF7F0' ${STROKED}/><path d='M24 38l-9 12 12-5z' fill='#FFC53D' ${STROKED}/><path d='M40 38l9 12-12-5z' fill='#FFC53D' ${STROKED}/><path d='M28 51h8l-4 9z' fill='#FF9F4D' ${STROKED}/></svg>`,
rainbow: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path d='M9 45a23 23 0 0 1 46 0' fill='none' stroke='#FF6B35' stroke-width='7' stroke-linecap='round'/><path d='M17 45a15 15 0 0 1 30 0' fill='none' stroke='#FFC53D' stroke-width='7' stroke-linecap='round'/><path d='M25 45a7 7 0 0 1 14 0' fill='none' stroke='#3ECFB2' stroke-width='7' stroke-linecap='round'/></svg>`,
bolt: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path d='M36 4L14 36h12l-4 24 24-34H32l4-22z' fill='#FFC53D' ${STROKED}/></svg>`,
sparkle: `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><path d='M32 5c2.4 14.6 8.6 22.6 24 27-15.4 4.4-21.6 12.4-24 27-2.4-14.6-8.6-22.6-24-27 15.4-4.4 21.6-12.4 24-27z' fill='#FF7BA9' ${STROKED}/></svg>`,
}
const bgImage = computed(
() => `url("data:image/svg+xml,${encodeURIComponent(SVGS[props.type])}")`,
)
const style = computed(() => ({
width: `${props.size}rpx`,
height: `${props.size}rpx`,
backgroundImage: bgImage.value,
animationDelay: `${props.delay}ms`,
}))
</script>
<template>
<view class="tb-sticker" :class="{ 'tb-sticker--float': float }" :style="style" />
</template>
<style scoped>
.tb-sticker {
background-repeat: no-repeat;
background-size: 100% 100%;
pointer-events: none;
}
.tb-sticker--float {
animation: tbStickerFloat 3.2s ease-in-out infinite;
}
@keyframes tbStickerFloat {
0%,
100% {
transform: translateY(0) rotate(-4deg);
}
50% {
transform: translateY(-10rpx) rotate(5deg);
}
}
</style>

193
src/components/TabBar.vue Normal file
View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed } from 'vue'
import TabIcon from '@/components/TabIcon.vue'
export interface TabBarTab {
key: string
title: string
icon: string
}
const props = defineProps<{ active: string, tabs: TabBarTab[] }>()
const emit = defineEmits<{ change: [key: string] }>()
/** 三 tab 布局:中间项(工具库)做凸起大按钮,其余分列两侧 */
const centerTab = computed(() => (props.tabs.length === 3 ? props.tabs[1] : null))
const sideTabs = computed(() => (props.tabs.length === 3 ? [props.tabs[0], props.tabs[2]] : props.tabs))
function onTap(key: string) {
if (key === props.active)
return
// #ifdef MP-WEIXIN
uni.vibrateShort({ type: 'light' })
// #endif
emit('change', key)
}
</script>
<template>
<view class="tb-tabbar">
<view class="tb-tabbar__dock">
<!-- 1 tab -->
<view
v-if="sideTabs[0]"
class="tb-tabbar__item press"
hover-class="press-active"
@tap="onTap(sideTabs[0].key)"
>
<view class="tb-tabbar__icon" :class="{ 'tb-tabbar__icon--on': sideTabs[0].key === active }">
<TabIcon :name="sideTabs[0].icon" :active="sideTabs[0].key === active" />
</view>
<text class="tb-tabbar__label" :class="{ 'tb-tabbar__label--on': sideTabs[0].key === active }">
{{ sideTabs[0].title }}
</text>
</view>
<view class="tb-tabbar__spacer" />
<!-- 3 tab -->
<view
v-if="sideTabs[1]"
class="tb-tabbar__item press"
hover-class="press-active"
@tap="onTap(sideTabs[1].key)"
>
<view class="tb-tabbar__icon" :class="{ 'tb-tabbar__icon--on': sideTabs[1].key === active }">
<TabIcon :name="sideTabs[1].icon" :active="sideTabs[1].key === active" />
</view>
<text class="tb-tabbar__label" :class="{ 'tb-tabbar__label--on': sideTabs[1].key === active }">
{{ sideTabs[1].title }}
</text>
</view>
</view>
<!-- 中间凸起大按钮嵌在 dock 的凹槽里 -->
<view
v-if="centerTab"
class="tb-tabbar__center press"
:class="{ 'tb-tabbar__center--on': active === centerTab.key }"
hover-class="press-active"
@tap="onTap(centerTab.key)"
>
<view class="tb-tabbar__center-btn">
<TabIcon
:name="centerTab.icon"
:active="active === centerTab.key"
base-color="#FFFFFF"
active-color="#FFFFFF"
/>
</view>
<text class="tb-tabbar__center-label" :class="{ 'tb-tabbar__center-label--on': active === centerTab.key }">
{{ centerTab.title }}
</text>
</view>
</view>
</template>
<style scoped>
.tb-tabbar {
position: fixed;
left: 28rpx;
right: 28rpx;
/* 底部安全距离Home Indicator 之上悬浮 */
bottom: calc(22rpx + env(safe-area-inset-bottom, 0px));
/* 低于 wot 弹层z-index 10避免弹层被 TabBar 压住 */
z-index: 9;
}
.tb-tabbar__dock {
position: relative;
display: flex;
align-items: center;
padding: 14rpx 28rpx;
background: rgba(255, 255, 255, 0.96);
border-radius: 999rpx;
box-shadow: 0 18rpx 50rpx rgba(107, 47, 15, 0.18), 0 2rpx 8rpx rgba(107, 47, 15, 0.06);
/* 凹槽:在 dock 顶部中央「咬」出一个圆弧缺口,中心键从中凸起
mask 同时裁掉阴影,缺口轮廓会带出阴影形状;不支持 mask 的环境退化为普通胶囊) */
-webkit-mask: radial-gradient(circle 80rpx at 50% 0, transparent 96%, #000 100%);
mask: radial-gradient(circle 80rpx at 50% 0, transparent 96%, #000 100%);
}
.tb-tabbar__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2rpx;
padding: 8rpx 0 6rpx;
}
.tb-tabbar__icon {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 56rpx;
}
/* 每次激活:图标整体轻微弹跳(描边动画在 TabIcon 内部) */
.tb-tabbar__icon--on {
animation: tbIconPop 0.4s var(--tb-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
}
.tb-tabbar__label {
font-size: 20rpx;
color: #b3907c;
transition: color 0.25s ease;
}
.tb-tabbar__label--on {
color: #ff6b35;
font-weight: 700;
}
/* 给中心键留出的空位 */
.tb-tabbar__spacer {
width: 168rpx;
flex-shrink: 0;
}
.tb-tabbar__center {
position: absolute;
left: 50%;
top: 0;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
}
.tb-tabbar__center-btn {
width: 104rpx;
height: 104rpx;
margin-top: -46rpx;
border-radius: 50%;
background: linear-gradient(160deg, #ff9f4d 0%, #ff6b35 55%, #fb4e14 100%);
border: 6rpx solid #fff9f2;
box-shadow: 0 14rpx 30rpx rgba(251, 78, 20, 0.38);
display: flex;
align-items: center;
justify-content: center;
transition: box-shadow 0.3s ease;
}
/* 激活时光环呼吸;白色描边在 TabIcon 内部以擦除动画重画 */
.tb-tabbar__center--on .tb-tabbar__center-btn {
animation: tbIconPop 0.4s var(--tb-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
box-shadow: 0 0 0 10rpx rgba(255, 107, 53, 0.16), 0 14rpx 30rpx rgba(251, 78, 20, 0.38);
}
.tb-tabbar__center-label {
margin-top: 4rpx;
font-size: 20rpx;
color: #b3907c;
transition: color 0.25s ease;
}
.tb-tabbar__center-label--on {
color: #ff6b35;
font-weight: 700;
}
@keyframes tbIconPop {
0% {
transform: scale(0.88);
}
55% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}
</style>

121
src/components/TabIcon.vue Normal file
View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed } from 'vue'
/**
* TabBar 描边图标:手绘描边风 SVG与贴纸同语言
* 激活时逐笔画「描」出图标:每个笔画是一层独立的单笔 SVG用 CSS mask 沿笔画方向
* 从起点扫到终点(纯 CSS 动画,微信 webview / H5 均生效),笔画间错峰起笔。
*/
const props = withDefaults(defineProps<{
name?: string
active?: boolean
baseColor?: string
activeColor?: string
}>(), { name: 'home', active: false, baseColor: '#B3907C', activeColor: '#FF6B35' })
/** 笔画拆解axis 决定扫入方向(沿笔画书写方向) */
interface Stroke { d: string, axis: 'x' | 'y' }
const STROKES: Record<string, Stroke[]> = {
home: [
{ d: 'M11 31 32 12l21 19', axis: 'x' },
{ d: 'M18 27v23h30V27', axis: 'y' },
],
app: [
{ d: 'M11 11h18v18H11z', axis: 'x' },
{ d: 'M35 11h18v18H35z', axis: 'x' },
{ d: 'M11 35h18v18H11z', axis: 'x' },
{ d: 'M35 35h18v18H35z', axis: 'x' },
],
user: [
{ d: 'M32 8a11 11 0 1 1 0 22 11 11 0 0 1 0-22z', axis: 'y' },
{ d: 'M10 56c2.5-13 10.5-19 22-19s19.5 6 22 19', axis: 'x' },
],
}
const strokes = computed(() => STROKES[props.name] || STROKES.home)
function uri(d: string, stroke: string): string {
const body = `<path d='${d}' fill='none' stroke='${stroke}' stroke-width='7.5' stroke-linecap='round' stroke-linejoin='round'/>`
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>${body}</svg>`
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
}
const baseBg = computed(() => {
const all = strokes.value.map(s => `<path d='${s.d}' fill='none' stroke='${props.baseColor}' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/>`).join('')
return `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>${all}</svg>`)}")`
})
function activeLayerStyle(s: Stroke, i: number) {
return {
backgroundImage: uri(s.d, props.activeColor),
animationDelay: `${i * 0.16}s`,
}
}
</script>
<template>
<view class="tab-icon" :style="{ backgroundImage: baseBg }">
<template v-if="active">
<view
v-for="(s, i) in strokes"
:key="i"
class="tab-icon__stroke"
:class="s.axis === 'x' ? 'tab-icon__stroke--x' : 'tab-icon__stroke--y'"
:style="activeLayerStyle(s, i)"
/>
</template>
</view>
</template>
<style scoped>
.tab-icon {
position: relative;
width: 48rpx;
height: 48rpx;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.tab-icon__stroke {
position: absolute;
inset: 0;
background-repeat: no-repeat;
background-size: 100% 100%;
filter: drop-shadow(0 2rpx 6rpx rgba(251, 78, 20, 0.45));
}
/* 横向笔画:从左往右扫出 */
.tab-icon__stroke--x {
-webkit-mask-image: linear-gradient(90deg, #000 96%, transparent 100%);
mask-image: linear-gradient(90deg, #000 96%, transparent 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
animation: tbDrawX 0.45s cubic-bezier(0.5, 0, 0.3, 1) both;
}
/* 纵向笔画:从上往下扫出 */
.tab-icon__stroke--y {
-webkit-mask-image: linear-gradient(180deg, #000 96%, transparent 100%);
mask-image: linear-gradient(180deg, #000 96%, transparent 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
animation: tbDrawY 0.45s cubic-bezier(0.5, 0, 0.3, 1) both;
}
@keyframes tbDrawX {
from {
-webkit-mask-size: 0% 100%;
mask-size: 0% 100%;
}
to {
-webkit-mask-size: 140% 100%;
mask-size: 140% 100%;
}
}
@keyframes tbDrawY {
from {
-webkit-mask-size: 100% 0%;
mask-size: 100% 0%;
}
to {
-webkit-mask-size: 100% 140%;
mask-size: 100% 140%;
}
}
</style>

View File

@@ -0,0 +1,365 @@
<script setup lang="ts">
import type { ToolDef } from '@/config/tools'
import { computed, ref } from 'vue'
import { apiToolsUsed } from '@/api/user'
import EmptyState from '@/components/EmptyState.vue'
import Sticker from '@/components/Sticker.vue'
import { searchTools, TOOL_REGISTRY } from '@/config/tools'
import { useToolsStore } from '@/stores/tools'
import { moduleColor, toolPalette } from '@/utils/palette'
import { heroPadTop } from '@/utils/safearea'
const toolsStore = useToolsStore()
/** 头区顶部留白MP 避开胶囊按钮 */
const heroPad = heroPadTop()
const keyword = ref('')
/** 热词:热推工具名,兜底常用词 */
const hotWords = computed(() => {
const names = toolsStore.tools.filter(t => t.is_hot).map(t => t.name)
return (names.length ? names : ['图片压缩', '去除背景', '格式转换', '修改尺寸']).slice(0, 6)
})
/** 模块楼层:每个模块一张彩色楼层卡 */
const moduleFloors = computed(() => {
const serverMap = new Map(toolsStore.tools.map(t => [t.tool_key, t]))
const modules = toolsStore.modules.length
? toolsStore.modules
: [{ module_key: 'image', name: '图片处理', locked: true }]
return modules
.map((m, i) => ({
...m,
tools: TOOL_REGISTRY.filter(d => d.module === m.module_key).map(d => ({
...d,
locked: serverMap.get(d.key)?.locked ?? true,
})),
color: moduleColor(m.module_key, i, m.locked),
}))
.filter(m => m.tools.length)
})
const FLOOR_STICKERS = ['rocket', 'rainbow', 'bolt', 'cloud', 'star', 'sparkle'] as const
/** 搜索结果(注册表 + 服务端锁定态合并) */
const list = computed(() => {
const serverMap = new Map(toolsStore.tools.map(t => [t.tool_key, t]))
return searchTools(keyword.value).map((d: ToolDef) => ({
...d,
locked: serverMap.get(d.key)?.locked ?? true,
}))
})
function lockedOf(key: string): boolean {
return toolsStore.tools.find(t => t.tool_key === key)?.locked ?? true
}
/** 热词直达:能精确匹配工具就直接打开,否则转为搜索词 */
function pickHotWord(word: string) {
const def = TOOL_REGISTRY.find(t => t.name === word || t.name.includes(word))
if (def) {
openTool(def.path, def.key, lockedOf(def.key))
}
else {
keyword.value = word
}
}
function openTool(path: string, toolKey: string, locked: boolean) {
if (locked) {
uni.showModal({
title: '功能未解锁',
content: '该功能属于更高等级模块,请联系管理员升级后使用。',
showCancel: false,
})
return
}
apiToolsUsed(toolKey).catch(() => {})
uni.navigateTo({ url: path })
}
</script>
<template>
<view>
<!-- 渐变头区 -->
<view class="tb-hero" :style="{ paddingTop: heroPad }">
<view class="hero__row">
<text class="hero__title">
工具库
</text>
<view class="hero__count">
{{ list.length }} 个工具
</view>
</view>
</view>
<!-- 上浮内容 -->
<view class="tb-body">
<!-- 大搜索框 + 热词 -->
<view class="tb-card searchcard tb-rise">
<view class="tb-search tb-search--flat">
<wd-icon name="search" size="34rpx" color="#B3907C" />
<input
v-model="keyword"
class="tb-search__input"
placeholder="搜索工具"
placeholder-style="color:#C9AE9C"
confirm-type="search"
>
<view v-if="keyword" class="searchcard__clear press" hover-class="press-active" @tap="keyword = ''">
<wd-icon name="close" size="24rpx" color="#B3907C" />
</view>
</view>
<view v-if="!keyword" class="hotwords">
<text class="hotwords__label">
热门
</text>
<scroll-view scroll-x class="hotwords__scroll" :show-scrollbar="false">
<view
v-for="w in hotWords"
:key="w"
class="hotwords__chip press"
hover-class="press-active"
@tap="pickHotWord(w)"
>
<text>{{ w }}</text>
</view>
</scroll-view>
</view>
</view>
<!-- 搜索结果宫格 -->
<template v-if="keyword">
<view v-if="list.length" class="tb-grid">
<view
v-for="(t, i) in list"
:key="t.key"
class="tb-tile tb-rise press"
:class="{ 'tb-tile--lock': t.locked }"
:style="{ animationDelay: `${Math.min(i, 11) * 40}ms` }"
hover-class="press-active"
@tap="openTool(t.path, t.key, t.locked)"
>
<view v-if="t.locked" class="tb-tile__corner">
未解锁
</view>
<view class="tb-tile__icon" :style="{ background: t.locked ? '#F3EEE9' : toolPalette(t.key).bg }">
<wd-icon :name="t.icon" size="46rpx" :color="t.locked ? '#BDA794' : toolPalette(t.key).fg" />
</view>
<text class="tb-tile__name">
{{ t.name }}
</text>
</view>
</view>
<EmptyState
v-else
title="没有找到匹配的工具"
sub="换个关键词试试"
main="cloud"
/>
</template>
<!-- 模块楼层卡 -->
<template v-else>
<view
v-for="(m, i) in moduleFloors"
:key="m.module_key"
class="floor tb-rise"
:style="{ background: m.color.soft, borderColor: m.color.bg, animationDelay: `${Math.min(i, 5) * 70}ms` }"
>
<view class="floor__head">
<Sticker :type="FLOOR_STICKERS[i % FLOOR_STICKERS.length]" :size="64" float :delay="i * 260" />
<view class="floor__titles">
<text class="floor__name" :style="{ color: m.locked ? '#BDA794' : m.color.deep }">
{{ m.name }}
</text>
<text class="floor__count">
{{ m.tools.length }} 个工具
</text>
</view>
<view v-if="m.locked" class="floor__lock">
未解锁
</view>
</view>
<view class="floor__grid">
<view
v-for="t in m.tools"
:key="t.key"
class="floor__tile press"
hover-class="press-active"
@tap="openTool(t.path, t.key, t.locked)"
>
<view v-if="t.locked" class="floor__tile-lock">
未解锁
</view>
<view class="floor__tile-icon" :style="{ background: t.locked ? '#F3EEE9' : toolPalette(t.key).bg }">
<wd-icon :name="t.icon" size="42rpx" :color="t.locked ? '#BDA794' : toolPalette(t.key).fg" />
</view>
<text class="floor__tile-name" :style="{ color: t.locked ? '#BDA794' : '#3D2A1E' }">
{{ t.name }}
</text>
</view>
</view>
</view>
</template>
<view class="foot" />
</view>
</view>
</template>
<style scoped>
.hero__row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 0 4rpx;
}
.hero__title {
font-size: 50rpx;
font-weight: 800;
letter-spacing: -0.02em;
color: #ffffff;
text-shadow: 0 6rpx 20rpx rgba(151, 60, 8, 0.3);
}
.hero__count {
background: rgba(255, 255, 255, 0.22);
border: 2rpx solid rgba(255, 255, 255, 0.45);
color: #ffffff;
font-size: 22rpx;
padding: 6rpx 22rpx;
border-radius: 999rpx;
}
.searchcard {
padding: 16rpx;
margin-bottom: 24rpx;
}
.searchcard__clear {
flex-shrink: 0;
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: #ffe3d3;
display: flex;
align-items: center;
justify-content: center;
}
/* 热词胶囊 */
.hotwords {
display: flex;
align-items: center;
gap: 14rpx;
padding: 16rpx 12rpx 8rpx;
}
.hotwords__label {
flex-shrink: 0;
font-size: 24rpx;
font-weight: 700;
color: #ff6b35;
}
.hotwords__scroll {
flex: 1;
white-space: nowrap;
}
.hotwords__chip {
display: inline-flex;
align-items: center;
background: #fff1e8;
border: 1rpx solid #ffd4b8;
color: #e8531a;
border-radius: 999rpx;
padding: 8rpx 24rpx;
margin-right: 14rpx;
font-size: 24rpx;
}
/* 模块楼层卡 */
.floor {
position: relative;
overflow: hidden;
border: 2rpx solid transparent;
border-radius: 32rpx;
padding: 26rpx 24rpx 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 12rpx 30rpx rgba(107, 47, 15, 0.08);
}
.floor__head {
display: flex;
align-items: center;
gap: 18rpx;
margin-bottom: 22rpx;
}
.floor__titles {
flex: 1;
min-width: 0;
}
.floor__name {
display: block;
font-size: 32rpx;
font-weight: 800;
}
.floor__count {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: #9a7b65;
}
.floor__lock {
background: #ffffff;
color: #bda794;
font-size: 22rpx;
padding: 8rpx 22rpx;
border-radius: 999rpx;
border: 2rpx dashed #e3d5c8;
}
.floor__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
}
.floor__tile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 22rpx 8rpx 20rpx;
box-shadow: 0 8rpx 20rpx rgba(107, 47, 15, 0.07);
}
.floor__tile-lock {
position: absolute;
top: 0;
right: 0;
background: #f3eee9;
color: #bda794;
font-size: 16rpx;
padding: 4rpx 12rpx;
border-radius: 0 24rpx 0 16rpx;
}
.floor__tile-icon {
width: 84rpx;
height: 84rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 2rpx 4rpx rgba(255, 255, 255, 0.9), inset 0 -8rpx 14rpx rgba(255, 255, 255, 0.85),
0 6rpx 14rpx rgba(107, 47, 15, 0.1);
}
.floor__tile-name {
font-size: 24rpx;
font-weight: 600;
max-width: 136rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.foot {
height: calc(220rpx + env(safe-area-inset-bottom, 0px));
}
</style>

View File

@@ -0,0 +1,471 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { apiBindAccount, apiFeedbackSubmit } from '@/api/user'
import Confetti from '@/components/Confetti.vue'
import CountUp from '@/components/CountUp.vue'
import Sticker from '@/components/Sticker.vue'
import { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
import { presetOf } from '@/utils/avatar'
import { heroPadTop } from '@/utils/safearea'
const toolsStore = useToolsStore()
const userStore = useUserStore()
/** 头区顶部留白MP 避开胶囊按钮 */
const heroPad = heroPadTop()
const levelName = computed(() => userStore.level?.name || '未授权')
const unlockedModules = computed(() =>
toolsStore.modules.filter(m => !m.locked).map(m => m.name),
)
const stats = computed(() => [
{ label: '工作台工具', value: toolsStore.workbenchKeys.length },
{ label: '可用工具', value: toolsStore.tools.filter(t => !t.locked).length },
{ label: '已解锁模块', value: toolsStore.modules.filter(m => !m.locked).length },
])
/** 菜单图标块的淡色轮换 */
const CELL_BGS = ['#FFE8DC', '#FFF4D6', '#DFF7F0', '#E3F2FF'] as const
const feedbackVisible = ref(false)
const feedbackContent = ref('')
const feedbackContact = ref('')
const submitting = ref(false)
/** 彩带触发计数 */
const burst = ref(0)
/** 绑定 H5 账号(微信端把独立 H5 账号合并进来) */
const bindVisible = ref(false)
const bindUsername = ref('')
const bindPassword = ref('')
const binding = ref(false)
async function submitFeedback() {
if (!feedbackContent.value.trim()) {
uni.showToast({ title: '请填写反馈内容', icon: 'none' })
return
}
submitting.value = true
try {
await apiFeedbackSubmit(feedbackContent.value.trim(), feedbackContact.value.trim())
uni.showToast({ title: '感谢反馈', icon: 'success' })
feedbackVisible.value = false
feedbackContent.value = ''
feedbackContact.value = ''
burst.value++
}
finally {
submitting.value = false
}
}
async function submitBind() {
const name = bindUsername.value.trim()
const pass = bindPassword.value
if (!name || !pass) {
uni.showToast({ title: '请填写 H5 账号的用户名和密码', icon: 'none' })
return
}
binding.value = true
try {
await apiBindAccount(name, pass)
uni.showToast({ title: '绑定成功,数据已合并', icon: 'success' })
bindVisible.value = false
bindUsername.value = ''
bindPassword.value = ''
burst.value++
await Promise.all([userStore.ensureLogin(), toolsStore.fetchAll()])
}
finally {
binding.value = false
}
}
function goLogin() {
uni.navigateTo({ url: '/pages/account/login' })
}
/** 已登录但 user 信息暂缺(如 /user/info 异常)时显示兜底昵称 */
const displayName = computed(() => userStore.user?.nickname || (userStore.logged ? '微信用户' : '未登录'))
/** 用户卡 → 个人信息编辑(未登录时引导登录) */
function goProfile() {
if (!userStore.logged) {
goLogin()
return
}
uni.navigateTo({ url: '/pages/mine/profile' })
}
/** 头像:预置贴纸 key 或远端 URL */
const avatarPreset = computed(() => presetOf(userStore.user?.avatar_url || ''))
const avatarBg = computed(() => avatarPreset.value?.bg || '#ffe8dc')
function goEditWorkbench() {
uni.navigateTo({ url: '/pages/workbench/edit' })
}
function logout() {
uni.showModal({
title: '退出登录',
content: '确定退出当前账号?',
success: (res) => {
if (res.confirm)
userStore.logout()
},
})
}
</script>
<template>
<view>
<!-- 渐变头区 + 用户卡 -->
<view class="tb-hero" :style="{ paddingTop: heroPad }">
<view class="user tb-rise press" hover-class="press-active" @tap="goProfile">
<Sticker type="sparkle" :size="60" float :delay="500" class="user__sticker" />
<view class="user__avatar" :style="{ background: avatarBg }">
<Sticker v-if="avatarPreset" :type="avatarPreset.sticker" :size="64" />
<image v-else-if="userStore.user?.avatar_url" :src="userStore.user.avatar_url" class="user__img" />
<wd-icon v-else name="user" size="56rpx" color="#FF6B35" />
</view>
<view class="user__body">
<text class="user__name">
{{ displayName }}
</text>
<view class="user__tags">
<view class="user__level">
{{ levelName }}
</view>
<view v-for="m in unlockedModules" :key="m" class="user__module">
{{ m }}
</view>
</view>
</view>
<wd-icon v-if="userStore.logged" name="arrow-right" size="30rpx" color="#D9C3B2" class="user__go" />
</view>
</view>
<!-- 上浮内容 -->
<view class="tb-body">
<!-- 数据总览 -->
<view class="tb-card stats tb-rise">
<view v-for="(s, i) in stats" :key="s.label" class="stats__item" :class="{ 'stats__item--div': i > 0 }">
<CountUp :value="s.value" class="stats__value" />
<text class="stats__label">
{{ s.label }}
</text>
</view>
</view>
<!-- 菜单 -->
<view class="tb-card cells tb-rise" style="animation-delay: 60ms">
<view class="cell press" hover-class="press-active" @tap="goEditWorkbench">
<view class="cell__icon" :style="{ background: CELL_BGS[0] }">
<wd-icon name="edit-outline" size="34rpx" color="#E8531A" />
</view>
<text class="cell__label">
我的工作台
</text>
<text class="cell__value">
{{ toolsStore.workbenchKeys.length }} 个工具
</text>
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
</view>
<view class="cell press" hover-class="press-active" @tap="feedbackVisible = true">
<view class="cell__icon" :style="{ background: CELL_BGS[1] }">
<wd-icon name="chat" size="34rpx" color="#B57F00" />
</view>
<text class="cell__label">
意见反馈
</text>
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
</view>
<!-- #ifdef MP-WEIXIN -->
<view v-if="userStore.logged" class="cell press" hover-class="press-active" @tap="bindVisible = true">
<view class="cell__icon" :style="{ background: CELL_BGS[3] }">
<wd-icon name="link" size="34rpx" color="#1677D9" />
</view>
<text class="cell__label">
绑定 H5 账号
</text>
<text class="cell__value">
合并两端数据
</text>
<wd-icon name="arrow-right" size="28rpx" color="#D9C3B2" />
</view>
<!-- #endif -->
<view class="cell">
<view class="cell__icon" :style="{ background: CELL_BGS[2] }">
<wd-icon name="setting" size="34rpx" color="#0FA385" />
</view>
<text class="cell__label">
版本
</text>
<text class="cell__value">
v0.1.0
</text>
</view>
</view>
<view v-if="userStore.logged" class="logout press tb-rise" style="animation-delay: 100ms" hover-class="press-active" @tap="logout">
<text>退出登录</text>
</view>
<!-- H5 未登录引导去登录/注册 -->
<!-- #ifdef H5 -->
<view v-else class="login-cta press tb-rise" style="animation-delay: 100ms" hover-class="press-active" @tap="goLogin">
<text>登录 / 注册</text>
</view>
<!-- #endif -->
<view class="foot" />
</view>
<!-- 彩带庆祝 -->
<Confetti :burst="burst" />
<!-- 绑定 H5 账号弹层微信端合并账号 -->
<wd-popup v-model="bindVisible" position="bottom" custom-style="border-radius: 36rpx 36rpx 0 0; background: transparent;">
<view class="fb">
<view class="fb__grip" />
<text class="fb__title">
绑定 H5 账号
</text>
<text class="fb__sub">
合并后小程序与 H5 共用同一份数据H5 端可用该账号密码登录
</text>
<view class="fb__box">
<wd-input v-model="bindUsername" placeholder="H5 账号的用户名" no-border custom-class="fb__input" />
</view>
<view class="fb__box">
<wd-input v-model="bindPassword" password placeholder="H5 账号的密码" no-border custom-class="fb__input" />
</view>
<wd-button
type="primary"
block
:loading="binding"
custom-style="border-radius: 999rpx; height: 88rpx; font-size: 30rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);"
@click="submitBind"
>
绑定并合并数据
</wd-button>
</view>
</wd-popup>
<!-- 反馈弹层 -->
<wd-popup v-model="feedbackVisible" position="bottom" custom-style="border-radius: 36rpx 36rpx 0 0; background: transparent;">
<view class="fb">
<view class="fb__grip" />
<text class="fb__title">
意见反馈
</text>
<text class="fb__sub">
说说你想用的工具或遇到的问题
</text>
<view class="fb__box">
<wd-textarea
v-model="feedbackContent"
placeholder="写下你的想法…"
:maxlength="500"
show-word-limit
custom-class="fb__textarea"
/>
</view>
<view class="fb__box">
<wd-input v-model="feedbackContact" placeholder="联系方式(选填)" no-border custom-class="fb__input" />
</view>
<wd-button type="primary" block :loading="submitting" custom-style="border-radius: 999rpx; height: 88rpx; font-size: 30rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);" @click="submitFeedback">
提交反馈
</wd-button>
</view>
</wd-popup>
</view>
</template>
<style scoped>
/* 用户卡:白色磨砂玻璃压在橙色渐变上 */
.user {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 26rpx;
margin-top: 20rpx;
background: rgba(255, 255, 255, 0.96);
border: 2rpx solid rgba(255, 255, 255, 0.7);
border-radius: 36rpx;
padding: 32rpx 28rpx;
box-shadow: 0 16rpx 40rpx rgba(151, 60, 8, 0.28);
}
.user__sticker {
position: absolute;
right: 16rpx;
top: 12rpx;
opacity: 0.9;
}
.user__avatar {
flex-shrink: 0;
width: 112rpx;
height: 112rpx;
border-radius: 34rpx;
border: 3rpx solid #ffffff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow: 0 10rpx 22rpx rgba(107, 47, 15, 0.14);
}
.user__img {
width: 100%;
height: 100%;
}
.user__name {
display: block;
font-size: 34rpx;
font-weight: 800;
color: #3d2a1e;
}
.user__tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 14rpx;
}
.user__level {
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
color: #fff;
font-size: 22rpx;
font-weight: 600;
padding: 6rpx 22rpx;
border-radius: 999rpx;
box-shadow: 0 6rpx 14rpx rgba(251, 78, 20, 0.3);
}
.user__module {
background: #fff1e8;
border: 2rpx solid #ffd4b8;
color: #e8531a;
font-size: 22rpx;
padding: 6rpx 20rpx;
border-radius: 999rpx;
}
/* 数据总览 */
.stats {
display: flex;
padding: 30rpx 0;
margin-bottom: 28rpx;
}
.stats__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.stats__item--div {
border-left: 2rpx solid #ffe9dd;
}
.stats__value {
font-size: 40rpx;
font-weight: 800;
color: #ff6b35;
}
.stats__label {
font-size: 22rpx;
color: #9a7b65;
}
/* 菜单 */
.cells {
padding: 8rpx 0;
}
.cell {
display: flex;
align-items: center;
gap: 22rpx;
padding: 26rpx 28rpx;
min-height: 44px;
box-sizing: border-box;
}
.cell__icon {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.cell__label {
flex: 1;
font-size: 28rpx;
font-weight: 500;
color: #3d2a1e;
}
.cell__value {
font-size: 24rpx;
color: #9a7b65;
margin-right: 8rpx;
}
.logout {
margin-top: 40rpx;
text-align: center;
color: #e5484d;
background: #ffffff;
border: 2rpx solid #ffd6d6;
border-radius: 999rpx;
padding: 24rpx 0;
font-size: 28rpx;
font-weight: 600;
box-shadow: 0 10rpx 24rpx rgba(229, 72, 77, 0.08);
}
.login-cta {
margin-top: 40rpx;
text-align: center;
color: #ffffff;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
border-radius: 999rpx;
padding: 24rpx 0;
font-size: 28rpx;
font-weight: 700;
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
}
/* 反馈弹层 */
.fb {
background: #ffffff;
border-radius: 36rpx 36rpx 0 0;
padding: 20rpx 32rpx calc(36rpx + env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
gap: 20rpx;
}
.fb__grip {
align-self: center;
width: 72rpx;
height: 8rpx;
border-radius: 999rpx;
background: #ffe3d3;
margin-bottom: 4rpx;
}
.fb__title {
font-size: 34rpx;
font-weight: 800;
color: #3d2a1e;
}
.fb__sub {
margin-top: -12rpx;
font-size: 24rpx;
color: #9a7b65;
}
.fb__box {
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 24rpx;
padding: 6rpx 20rpx;
}
.foot {
height: calc(200rpx + env(safe-area-inset-bottom, 0px));
}
</style>

View File

@@ -0,0 +1,423 @@
<script setup lang="ts">
import { computed } from 'vue'
import { apiToolsUsed } from '@/api/user'
import EmptyState from '@/components/EmptyState.vue'
import Sticker from '@/components/Sticker.vue'
import { TOOL_REGISTRY } from '@/config/tools'
import { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
import { toolPalette } from '@/utils/palette'
import { heroPadTop } from '@/utils/safearea'
const props = defineProps<{ ready: boolean }>()
const emit = defineEmits<{ library: [] }>()
const toolsStore = useToolsStore()
const userStore = useUserStore()
/** 头区顶部留白MP 避开胶囊按钮 */
const heroPad = heroPadTop()
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6)
return '夜深了'
if (h < 12)
return '早上好'
if (h < 14)
return '中午好'
if (h < 18)
return '下午好'
return '晚上好'
})
const workbench = computed(() => toolsStore.workbenchTools())
/** 热门推荐:热推工具中不在工作台的 */
const hotTools = computed(() =>
toolsStore.tools.filter(t => t.is_hot && !toolsStore.workbenchKeys.includes(t.tool_key)).slice(0, 6),
)
function toolPath(key: string): string {
return TOOL_REGISTRY.find(t => t.key === key)?.path || ''
}
function openTool(path: string, toolKey: string, locked: boolean) {
if (locked) {
uni.showModal({
title: '功能未解锁',
content: '该功能属于更高等级模块,请联系管理员升级后使用。',
showCancel: false,
})
return
}
// 使用统计fire-and-forget
apiToolsUsed(toolKey).catch(() => {})
uni.navigateTo({ url: path })
}
function goEdit() {
uni.navigateTo({ url: '/pages/workbench/edit' })
}
const SUB_STICKERS = ['cloud', 'rainbow', 'bolt'] as const
</script>
<template>
<view>
<!-- 渐变头区橙色波浪 -->
<view class="tb-hero" :style="{ paddingTop: heroPad }">
<view class="hero__meta">
<text class="hero__greet">
{{ greeting }}
</text>
<view v-if="userStore.level" class="hero__level">
{{ userStore.level.name }}
</view>
</view>
<text class="hero__title">
工具百宝箱
</text>
<view class="tb-search press" hover-class="press-active" @tap="emit('library')">
<wd-icon name="search" size="34rpx" color="#B3907C" />
<text class="tb-search__text">
搜索工具压缩抠图
</text>
</view>
</view>
<!-- 上浮内容 -->
<view class="tb-body">
<!-- 我的工作台 -->
<view class="tb-card sec tb-rise">
<view class="sec__head">
<text class="tb-section-title">
我的工作台
</text>
<view class="tb-section-more press" hover-class="press-active" @tap="goEdit">
<text>编辑</text>
<wd-icon name="edit-outline" size="26rpx" color="#FF6B35" />
</view>
</view>
<view v-if="!props.ready" class="sec__hint">
正在加载
</view>
<EmptyState
v-else-if="!workbench.length"
title="工作台还是空的"
sub="把常用的工具放进来,用的时候一步直达"
action-text="去添加工具"
main="rocket"
@action="goEdit"
/>
<template v-else>
<!-- 1 通栏主卡最常用 -->
<view
v-if="workbench[0]"
class="wb-lead tb-rise press"
hover-class="press-active"
@tap="openTool(workbench[0].path, workbench[0].key, workbench[0].locked)"
>
<Sticker type="star" :size="64" float :delay="400" class="wb-lead__sticker" />
<wd-icon
:name="workbench[0].icon"
size="60rpx"
:color="workbench[0].locked ? 'rgba(255,255,255,0.6)' : '#FFFFFF'"
class="wb-lead__icon"
/>
<view class="wb-lead__body">
<text class="wb-lead__name">
{{ workbench[0].name }}
</text>
<text class="wb-lead__desc">
{{ workbench[0].desc }}
</text>
</view>
<view class="wb-lead__go">
<wd-icon name="arrow-right" size="28rpx" color="#FFFFFF" />
</view>
<view v-if="workbench[0].locked" class="tb-tile__corner">
未解锁
</view>
</view>
<!-- 2 两张副卡底色与图标同源保证成套 -->
<view v-if="workbench.length > 1" class="wb-sub-row">
<view
v-for="(t, i) in workbench.slice(1, 3)"
:key="t.key"
class="wb-sub tb-rise press"
:style="{ background: t.locked ? '#F3EEE9' : toolPalette(t.key).bg, animationDelay: `${(i + 1) * 60}ms` }"
hover-class="press-active"
@tap="openTool(t.path, t.key, t.locked)"
>
<view v-if="t.locked" class="tb-tile__corner">
未解锁
</view>
<Sticker :type="SUB_STICKERS[i % SUB_STICKERS.length]" :size="56" float :delay="600 + i * 300" class="wb-sub__sticker" />
<wd-icon
:name="t.icon"
size="46rpx"
:color="t.locked ? '#BDA794' : toolPalette(t.key).fg"
class="wb-sub__icon"
/>
<text class="wb-sub__name" :style="{ color: t.locked ? '#BDA794' : '#3D2A1E' }">
{{ t.name }}
</text>
</view>
</view>
<!-- 其余固定工具宫格 -->
<view v-if="workbench.length > 3" class="wb-grid">
<view
v-for="(t, i) in workbench.slice(3)"
:key="t.key"
class="tb-tile tb-rise press"
:class="{ 'tb-tile--lock': t.locked }"
:style="{ animationDelay: `${Math.min(i + 3, 8) * 45}ms` }"
hover-class="press-active"
@tap="openTool(t.path, t.key, t.locked)"
>
<view v-if="t.locked" class="tb-tile__corner">
未解锁
</view>
<view class="tb-tile__icon" :style="{ background: t.locked ? '#F3EEE9' : toolPalette(t.key).bg }">
<wd-icon :name="t.icon" size="46rpx" :color="t.locked ? '#BDA794' : toolPalette(t.key).fg" />
</view>
<text class="tb-tile__name">
{{ t.name }}
</text>
</view>
</view>
</template>
</view>
<!-- 发现工具 -->
<view v-if="hotTools.length" class="tb-card sec sec--gap tb-rise" style="animation-delay: 80ms">
<view class="sec__head">
<text class="tb-section-title">
发现工具
</text>
<view class="tb-section-more press" hover-class="press-active" @tap="emit('library')">
<text>全部工具</text>
<wd-icon name="arrow-right" size="26rpx" color="#FF6B35" />
</view>
</view>
<view class="hots">
<view
v-for="(t, i) in hotTools"
:key="t.tool_key"
class="hot tb-rise press"
:style="{ background: toolPalette(t.tool_key).bg, animationDelay: `${Math.min(i, 6) * 45 + 120}ms` }"
hover-class="press-active"
@tap="openTool(toolPath(t.tool_key), t.tool_key, t.locked)"
>
<view class="hot__badge">
<wd-icon :name="t.icon" size="30rpx" :color="t.locked ? '#BDA794' : toolPalette(t.tool_key).fg" />
</view>
<text class="hot__name">
{{ t.name }}
</text>
<text class="hot__desc">
{{ t.description }}
</text>
</view>
</view>
</view>
<view class="foot" />
</view>
</view>
</template>
<style scoped>
.hero__meta {
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 4rpx;
}
.hero__greet {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.08em;
}
.hero__level {
background: rgba(255, 255, 255, 0.22);
border: 2rpx solid rgba(255, 255, 255, 0.45);
color: #ffffff;
font-size: 20rpx;
padding: 4rpx 18rpx;
border-radius: 999rpx;
}
.hero__title {
display: block;
margin-top: 12rpx;
font-size: 54rpx;
font-weight: 800;
letter-spacing: -0.02em;
color: #ffffff;
text-shadow: 0 6rpx 20rpx rgba(151, 60, 8, 0.3);
}
.tb-search {
margin-top: 30rpx;
}
.sec {
padding: 30rpx 28rpx 32rpx;
}
.sec--gap {
margin-top: 28rpx;
}
.sec__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 26rpx;
}
.sec__hint {
text-align: center;
color: #9a7b65;
font-size: 26rpx;
padding: 50rpx 0;
}
/* 通栏主卡:橙渐变 */
.wb-lead {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 24rpx;
background: linear-gradient(135deg, #ff9f4d 0%, #ff6b35 55%, #fb4e14 100%);
border-radius: 30rpx;
padding: 30rpx 28rpx;
box-shadow: 0 14rpx 32rpx rgba(251, 78, 20, 0.32);
}
.wb-lead__sticker {
position: absolute;
right: 14rpx;
top: 10rpx;
opacity: 0.9;
}
.wb-lead__icon {
flex-shrink: 0;
filter: drop-shadow(0 6rpx 12rpx rgba(151, 60, 8, 0.25));
}
.wb-lead__body {
min-width: 0;
flex: 1;
}
.wb-lead__name {
display: block;
font-size: 34rpx;
font-weight: 800;
color: #ffffff;
}
.wb-lead__desc {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.88);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wb-lead__go {
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.24);
display: flex;
align-items: center;
justify-content: center;
}
/* 副卡:淡色轮换 + 贴纸角标 */
.wb-sub-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
margin-top: 20rpx;
}
.wb-sub {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16rpx;
border-radius: 30rpx;
padding: 26rpx 24rpx 24rpx;
box-shadow: 0 10rpx 24rpx rgba(107, 47, 15, 0.08);
}
.wb-sub__sticker {
position: absolute;
right: 10rpx;
top: 10rpx;
opacity: 0.85;
}
.wb-sub__icon {
filter: drop-shadow(0 4rpx 8rpx rgba(107, 47, 15, 0.12));
}
.wb-sub__name {
font-size: 28rpx;
font-weight: 700;
}
/* 其余固定工具宫格 */
.wb-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 18rpx;
margin-top: 22rpx;
}
/* 发现工具横滑卡 */
.hots {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.hot {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: 30rpx;
padding: 24rpx;
box-shadow: 0 10rpx 24rpx rgba(107, 47, 15, 0.08);
}
.hot__badge {
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
box-shadow: 0 6rpx 12rpx rgba(107, 47, 15, 0.1);
}
.hot__name {
font-size: 28rpx;
font-weight: 800;
color: #3d2a1e;
}
.hot__desc {
margin-top: 6rpx;
font-size: 22rpx;
color: #6b4f3a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* 底部留白:避开悬浮 TabBar 与 Home Indicator */
.foot {
height: calc(220rpx + env(safe-area-inset-bottom, 0px));
}
</style>

10
src/config/env.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* 环境配置
* 微信开发者工具联调需勾选「不校验合法域名」;生产改为 https 域名(需在小程序后台配置 request 合法域名)
*/
let baseUrl = 'http://127.0.0.1:8000'
// #ifdef H5
baseUrl = '/api'
// #endif
export const BASE_URL = baseUrl

128
src/config/tools.ts Normal file
View File

@@ -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)),
)
}

View File

@@ -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() {

View File

@@ -49,7 +49,7 @@
},
"quickapp": {},
"mp-weixin": {
"appid": "wx67e73fb3c9882dfc",
"appid": "wx8f3891eaad380949",
"setting": {
"urlCheck": false
},

View File

@@ -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"
}
}

297
src/pages/account/login.vue Normal file
View File

@@ -0,0 +1,297 @@
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
import Sticker from '@/components/Sticker.vue'
import { useUserStore } from '@/stores/user'
import { heroPadTop } from '@/utils/safearea'
const userStore = useUserStore()
const heroPad = heroPadTop()
/** login | register */
const mode = ref<'login' | 'register'>('login')
const username = ref('')
const password = ref('')
const nickname = ref('')
const submitting = ref(false)
const errMsg = ref('')
/** 登录成功后的回跳地址 */
const redirectUrl = ref('/')
onLoad((query) => {
const r = (query as { redirect?: string } | undefined)?.redirect
if (r)
redirectUrl.value = r
})
const isDev = import.meta.env.DEV
function switchMode(m: 'login' | 'register') {
mode.value = m
errMsg.value = ''
}
function backHome() {
uni.reLaunch({ url: redirectUrl.value })
}
async function submit() {
const name = username.value.trim()
const pass = password.value
if (!name || !pass) {
errMsg.value = '请填写用户名和密码'
return
}
if (mode.value === 'register' && (name.length < 3 || pass.length < 6)) {
errMsg.value = '用户名至少 3 位,密码至少 6 位'
return
}
submitting.value = true
errMsg.value = ''
try {
if (mode.value === 'login')
await userStore.loginAccount(name, pass)
else
await userStore.registerAccount(name, pass, nickname.value.trim())
uni.showToast({ title: mode.value === 'login' ? '欢迎回来' : '注册成功', icon: 'success' })
setTimeout(() => backHome(), 400)
}
catch (e) {
errMsg.value = (e as Error).message || '操作失败'
}
finally {
submitting.value = false
}
}
async function devLogin() {
submitting.value = true
try {
await userStore.loginDev()
backHome()
}
finally {
submitting.value = false
}
}
</script>
<template>
<view class="page">
<view class="hero" :style="{ paddingTop: heroPad }">
<view class="hero__brand">
<Sticker type="rocket" :size="96" float class="hero__sticker" />
<text class="hero__title">
工具百宝箱
</text>
<text class="hero__sub">
{{ mode === 'login' ? '登录' : '注册' }}后同步你的工作台与工具数据
</text>
</view>
</view>
<view class="body">
<view class="card">
<!-- 登录/注册切换 -->
<view class="tabs">
<view class="tab press" :class="{ 'tab--on': mode === 'login' }" hover-class="press-active" @tap="switchMode('login')">
登录
</view>
<view class="tab press" :class="{ 'tab--on': mode === 'register' }" hover-class="press-active" @tap="switchMode('register')">
注册
</view>
</view>
<view class="field">
<text class="field__label">
用户名
</text>
<input
v-model="username"
class="field__input"
placeholder="3-32 位用户名"
placeholder-style="color:#C9AE9C"
>
</view>
<view class="field">
<text class="field__label">
密码
</text>
<input
v-model="password"
class="field__input"
password
placeholder="至少 6 位密码"
placeholder-style="color:#C9AE9C"
>
</view>
<view v-if="mode === 'register'" class="field">
<text class="field__label">
昵称选填
</text>
<input
v-model="nickname"
class="field__input"
placeholder="不填则使用用户名"
placeholder-style="color:#C9AE9C"
>
</view>
<text v-if="errMsg" class="error">
{{ errMsg }}
</text>
<view class="submit press" hover-class="press-active" @tap="submit">
<text>{{ submitting ? '处理中…' : (mode === 'login' ? '登 录' : '注册并登录') }}</text>
</view>
<text class="tip">
注册即创建独立 H5 账号在微信小程序我的里绑定后可合并两端数据
</text>
<!-- #ifdef H5 -->
<view v-if="isDev" class="dev press" hover-class="press-active" @tap="devLogin">
开发联调登录dev-login
</view>
<!-- #endif -->
</view>
</view>
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
background: #fff9f2;
}
.hero {
position: relative;
overflow: hidden;
padding: calc(var(--status-bar-height, 0px) + 40rpx) 48rpx 120rpx;
background:
radial-gradient(640rpx 420rpx at 88% -12%, rgba(255, 255, 255, 0.24), transparent 62%),
linear-gradient(165deg, #ff9f4d 0%, #ff6b35 52%, #fb4e14 100%);
}
.hero::after {
content: '';
position: absolute;
left: -8%;
right: -8%;
bottom: -2rpx;
height: 42rpx;
background: #fff9f2;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
}
.hero__brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
position: relative;
z-index: 1;
}
.hero__sticker {
margin-bottom: 6rpx;
}
.hero__title {
font-size: 48rpx;
font-weight: 800;
color: #ffffff;
letter-spacing: -0.02em;
text-shadow: 0 6rpx 20rpx rgba(151, 60, 8, 0.3);
}
.hero__sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.85);
}
.body {
padding: 0 40rpx;
margin-top: -70rpx;
position: relative;
z-index: 1;
}
.card {
background: #ffffff;
border-radius: 36rpx;
border: 1rpx solid rgba(255, 195, 155, 0.5);
box-shadow: 0 18rpx 44rpx rgba(107, 47, 15, 0.1);
padding: 32rpx 32rpx 40rpx;
}
.tabs {
display: flex;
background: #fff3ec;
border-radius: 999rpx;
padding: 6rpx;
margin-bottom: 32rpx;
}
.tab {
flex: 1;
text-align: center;
font-size: 28rpx;
color: #9a7b65;
padding: 14rpx 0;
border-radius: 999rpx;
transition: all 0.25s ease;
}
.tab--on {
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
color: #ffffff;
font-weight: 700;
box-shadow: 0 8rpx 18rpx rgba(251, 78, 20, 0.3);
}
.field {
margin-bottom: 24rpx;
}
.field__label {
display: block;
font-size: 24rpx;
color: #9a7b65;
margin-bottom: 10rpx;
}
.field__input {
width: 100%;
box-sizing: border-box;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 20rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
color: #3d2a1e;
}
.error {
display: block;
color: #e5484d;
font-size: 24rpx;
margin: 4rpx 0 8rpx;
}
.submit {
margin-top: 12rpx;
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
color: #ffffff;
text-align: center;
font-size: 30rpx;
font-weight: 700;
padding: 24rpx 0;
border-radius: 999rpx;
box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);
}
.tip {
display: block;
margin-top: 24rpx;
font-size: 22rpx;
color: #b3907c;
line-height: 1.6;
text-align: center;
}
.dev {
margin-top: 20rpx;
text-align: center;
font-size: 24rpx;
color: #ff6b35;
border-top: 2rpx dashed #ffe3d3;
padding-top: 20rpx;
}
</style>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import AppFooter from '@/components/AppFooter.vue'
import AppLogos from '@/components/AppLogos.vue'
import InputEntry from '@/components/InputEntry.vue'
</script>
<template>
<view class="root-container">
<AppLogos />
<InputEntry />
<AppFooter />
</view>
</template>
<style scoped>
.root-container {
padding: 5rem 2.5rem;
text-align: center;
}
</style>

172
src/pages/index/index.vue Normal file
View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { ref } from 'vue'
import PaneLibrary from '@/components/home/PaneLibrary.vue'
import PaneMine from '@/components/home/PaneMine.vue'
import PaneWorkbench from '@/components/home/PaneWorkbench.vue'
import TabBar from '@/components/TabBar.vue'
import { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
type TabKey = 'workbench' | 'library' | 'mine'
const TABS: Array<{ key: TabKey, title: string, icon: string }> = [
{ key: 'workbench', title: '工作台', icon: 'home' },
{ key: 'library', title: '工具库', icon: 'app' },
{ key: 'mine', title: '我的', icon: 'user' },
]
const toolsStore = useToolsStore()
const userStore = useUserStore()
const active = ref<TabKey>('workbench')
/** 退场中的 tab动画期间保持渲染 */
const leaving = ref<TabKey | null>(null)
/** 切换方向1 向右进、-1 向左进,决定进场滑块方向 */
const direction = ref<1 | -1>(1)
/** 首屏数据是否就绪(失败也置 true展示空态而非永久 loading */
const ready = ref(false)
let leaveTimer: ReturnType<typeof setTimeout> | undefined
function switchTab(key: string) {
const next = key as TabKey
if (next === active.value)
return
const from = TABS.findIndex(t => t.key === active.value)
const to = TABS.findIndex(t => t.key === next)
direction.value = to > from ? 1 : -1
leaving.value = active.value
active.value = next
clearTimeout(leaveTimer)
// 与 pane 退场动画时长保持一致
leaveTimer = setTimeout(() => (leaving.value = null), 320)
}
function paneClass(key: TabKey) {
return {
'pane--in': active.value === key,
'pane--out': leaving.value === key,
'pane--back': direction.value === -1,
}
}
async function loadData() {
try {
await userStore.ensureLogin()
await toolsStore.fetchAll()
}
catch {
/* 静默失败:页面展示空态 */
}
finally {
ready.value = true
}
}
onShow(() => {
loadData()
})
onPullDownRefresh(async () => {
await loadData()
uni.stopPullDownRefresh()
})
</script>
<template>
<view class="home">
<view class="home__stage">
<scroll-view
v-show="active === 'workbench' || leaving === 'workbench'"
scroll-y
class="pane"
:class="paneClass('workbench')"
:show-scrollbar="false"
>
<PaneWorkbench :ready="ready" @library="switchTab('library')" />
</scroll-view>
<scroll-view
v-show="active === 'library' || leaving === 'library'"
scroll-y
class="pane"
:class="paneClass('library')"
:show-scrollbar="false"
>
<PaneLibrary />
</scroll-view>
<scroll-view
v-show="active === 'mine' || leaving === 'mine'"
scroll-y
class="pane"
:class="paneClass('mine')"
:show-scrollbar="false"
>
<PaneMine />
</scroll-view>
</view>
<TabBar :tabs="TABS" :active="active" @change="switchTab" />
</view>
</template>
<style scoped>
.home {
height: 100vh;
overflow: hidden;
}
.home__stage {
position: relative;
height: 100%;
}
/* 三个 tab 视图绝对定位铺满,各自独立滚动位置 */
.pane {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* 先声明退场、后声明进场:同屏带两个类时进场动画优先 */
.pane--out {
animation: tbPaneOut 0.3s ease both;
}
/* fill 用 backwards动画结束后 transform 归位 none
避免永久保留层叠上下文/包含块,把 pane 内 fixed 弹层困住 */
.pane--in {
animation: tbPaneIn 0.36s var(--tb-ease, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
}
.pane--back.pane--in {
animation-name: tbPaneInBack;
}
@keyframes tbPaneIn {
from {
opacity: 0;
transform: translateX(48rpx);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes tbPaneInBack {
from {
opacity: 0;
transform: translateX(-48rpx);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes tbPaneOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style>

View File

@@ -1,14 +0,0 @@
<template>
<view>
</view>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
</style>

253
src/pages/mine/profile.vue Normal file
View File

@@ -0,0 +1,253 @@
<script setup lang="ts">
import { onShow } from '@dcloudio/uni-app'
import { ref, watch } from 'vue'
import Sticker from '@/components/Sticker.vue'
import { useUserStore } from '@/stores/user'
import { AVATAR_PRESETS, presetOf } from '@/utils/avatar'
import { navPadTop } from '@/utils/safearea'
const userStore = useUserStore()
/** 导航栏顶部留白MP 用真实状态栏高度 */
const navPad = navPadTop()
const nickname = ref('')
const avatarUrl = ref('')
const saving = ref(false)
function syncFromStore() {
const u = userStore.user
if (!u)
return
// 用户正在编辑时不覆盖输入
nickname.value = nickname.value || u.nickname || ''
avatarUrl.value = avatarUrl.value || u.avatar_url || AVATAR_PRESETS[0].key
}
onShow(syncFromStore)
// 登录态异步加载完成晚于 onShow 时兜底回填
watch(() => userStore.user, syncFromStore, { immediate: true })
function pickAvatar(key: string) {
avatarUrl.value = key
}
async function save() {
const name = nickname.value.trim()
if (!name) {
uni.showToast({ title: '昵称不能为空', icon: 'none' })
return
}
saving.value = true
try {
await userStore.updateProfile(name, avatarUrl.value)
uni.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => uni.navigateBack(), 500)
}
finally {
saving.value = false
}
}
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
个人信息
</text>
<view class="nav__slot" />
</view>
<!-- 头像预览 -->
<view class="preview">
<view class="preview__disc" :style="{ background: presetOf(avatarUrl)?.bg || '#FFE8DC' }">
<Sticker
v-if="presetOf(avatarUrl)"
:type="presetOf(avatarUrl)!.sticker"
:size="120"
/>
<image v-else-if="avatarUrl" :src="avatarUrl" class="preview__img" />
</view>
</view>
<!-- 头像选择 -->
<view class="card">
<text class="card__title">
选择头像
</text>
<view class="avatars">
<view
v-for="p in AVATAR_PRESETS"
:key="p.key"
class="avatar press"
:class="{ 'avatar--on': avatarUrl === p.key }"
:style="{ background: p.bg }"
hover-class="press-active"
@tap="pickAvatar(p.key)"
>
<Sticker :type="p.sticker" :size="64" />
<view v-if="avatarUrl === p.key" class="avatar__check">
<wd-icon name="check" size="22rpx" color="#ffffff" />
</view>
</view>
</view>
</view>
<!-- 昵称 -->
<view class="card">
<text class="card__title">
昵称
</text>
<input
v-model="nickname"
class="nickname"
placeholder="给自己起个名字"
placeholder-style="color:#C9AE9C"
:maxlength="32"
>
</view>
<!-- 底部悬浮保存 -->
<view class="save-bar">
<wd-button
type="primary"
block
:loading="saving"
custom-style="border-radius: 999rpx; height: 88rpx; font-size: 30rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);"
@click="save"
>
保存
</wd-button>
</view>
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(180rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 20rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
}
.nav__slot {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
}
.preview {
display: flex;
justify-content: center;
padding: 24rpx 0 36rpx;
}
.preview__disc {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 6rpx solid #ffffff;
box-shadow: 0 16rpx 40rpx rgba(107, 47, 15, 0.12);
overflow: hidden;
}
.preview__img {
width: 100%;
height: 100%;
}
.card {
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.5);
border-radius: 30rpx;
padding: 26rpx 28rpx;
margin-top: 24rpx;
box-shadow: 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
}
.card__title {
display: block;
font-size: 26rpx;
font-weight: 700;
color: #3d2a1e;
margin-bottom: 20rpx;
}
.avatars {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16rpx;
}
.avatar {
position: relative;
aspect-ratio: 1;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
border: 3rpx solid transparent;
}
.avatar--on {
border-color: #ff6b35;
box-shadow: 0 8rpx 18rpx rgba(251, 78, 20, 0.25);
}
.avatar__check {
position: absolute;
right: -6rpx;
top: -6rpx;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: #ff6b35;
display: flex;
align-items: center;
justify-content: center;
border: 3rpx solid #ffffff;
}
.nickname {
width: 100%;
box-sizing: border-box;
height: 88rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 20rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #3d2a1e;
}
.save-bar {
position: fixed;
left: 32rpx;
right: 32rpx;
bottom: calc(20rpx + env(safe-area-inset-bottom, 0px));
z-index: 8;
}
</style>

View File

@@ -0,0 +1,577 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { saveResult } from '@/utils/image/core'
import { resultName, runTask } from '@/utils/image/task'
import { navPadTop } from '@/utils/safearea'
type Mode = 'pen' | 'text' | 'mosaic'
interface Point { x: number, y: number }
interface PenOp { type: 'pen', points: Point[], color: string, width: number }
interface TextOp { type: 'text', x: number, y: number, text: string, color: string, size: number }
interface MosaicOp { type: 'mosaic', points: Point[], width: number }
type Op = PenOp | TextOp | MosaicOp
const COLORS = ['#E5484D', '#FF6B35', '#0D9488', '#4DA8FF', '#3D2A1E']
const WIDTHS = [
{ label: '细', value: 6 },
{ label: '中', value: 12 },
{ label: '粗', value: 20 },
]
const navPad = navPadTop()
const src = ref('')
const mode = ref<Mode>('pen')
const color = ref(COLORS[0])
const brushWidth = ref(12)
const saving = ref(false)
/** 画布 CSS 高度(选图后按比例确定) */
const canvasH = ref(280)
let canvasNode: any = null
let ctx: CanvasRenderingContext2D | null = null
let cw = 0
let ch = 0
let baseCanvas: any = null // 照片位图
let mosaicCanvas: any = null // 块化版底图(马赛克贴片源)
const ops = ref<Op[]>([])
let drawing = false
let currentPoints: Point[] = []
onReady(async () => {
// 页面就绪:若已有照片(场景保留)则初始化
if (src.value)
await runTask('准备画布…', prepare)
})
/** 初始化可见画布(双端) */
async function initCanvas(cssW: number, cssH: number) {
cw = cssW
ch = cssH
canvasH.value = cssH
// #ifdef MP-WEIXIN
await new Promise<void>((resolve) => {
uni.createSelectorQuery()
.select('#fxCanvas')
.fields({ node: true, size: true }, () => {})
.exec((res: any[]) => {
canvasNode = res?.[0]?.node
const dpr = uni.getWindowInfo().pixelRatio || 2
canvasNode.width = cw * dpr
canvasNode.height = ch * dpr
ctx = canvasNode.getContext('2d')
ctx?.scale(dpr, dpr)
resolve()
})
})
// #endif
// #ifdef H5
await new Promise<void>((resolve) => {
setTimeout(() => {
const el = document.querySelector('#fxCanvas canvas') as HTMLCanvasElement | null
if (el) {
const dpr = window.devicePixelRatio || 2
el.width = cw * dpr
el.height = ch * dpr
canvasNode = el
ctx = el.getContext('2d')
ctx?.scale(dpr, dpr)
}
resolve()
}, 80)
})
// #endif
}
/** 创建离屏画布(平台分函数:同一作用域不能出现同名声明) */
function createOffscreenMP(w: number, h: number): any {
return wx.createOffscreenCanvas({ type: '2d', width: w, height: h })
}
function createOffscreenH5(w: number, h: number): any {
const c = document.createElement('canvas')
c.width = w
c.height = h
return c
}
function createOffscreen(w: number, h: number): any {
// #ifdef MP-WEIXIN
return createOffscreenMP(w, h)
// #endif
// #ifdef H5
return createOffscreenH5(w, h)
// #endif
}
/** 加载图片元素(平台分函数) */
function loadImgElMP(srcPath: string, canvasNode: any): Promise<any> {
return new Promise((resolve, reject) => {
const img = canvasNode.createImage()
img.onload = () => resolve(img)
img.onerror = (e: unknown) => reject(new Error(`图片加载失败 ${String(e)}`))
img.src = srcPath
})
}
function loadImgElH5(srcPath: string): Promise<any> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('图片加载失败'))
img.src = srcPath
})
}
function loadImgEl(srcPath: string): Promise<any> {
// #ifdef MP-WEIXIN
return loadImgElMP(srcPath, canvasNode)
// #endif
// #ifdef H5
return loadImgElH5(srcPath)
// #endif
}
/** 选图后:初始化画布、底图与马赛克图层 */
async function prepare() {
const info = uni.getWindowInfo()
const cssW = info.windowWidth - 64 // 页面左右 padding 32×2
const cssH = Math.round(cssW * 0.75)
await initCanvas(cssW, cssH)
if (!ctx)
return
const img = await loadImgEl(src.value)
const iw = img.width as number
const ih = img.height as number
// 底图cover 填充
baseCanvas = createOffscreen(cw, ch)
const bctx = baseCanvas.getContext('2d')
const scale = Math.max(cw / iw, ch / ih)
const dw = iw * scale
const dh = ih * scale
bctx.drawImage(img, (cw - dw) / 2, (ch - dh) / 2, dw, dh)
// 马赛克图层:块化底图
mosaicCanvas = createOffscreen(cw, ch)
const mctx = mosaicCanvas.getContext('2d')
mctx.drawImage(baseCanvas, 0, 0, cw, ch)
const block = 10
for (let y = 0; y < ch; y += block) {
for (let x = 0; x < cw; x += block) {
const data = mctx.getImageData(x, y, Math.min(block, cw - x), Math.min(block, ch - y))
let r = 0
let g = 0
let b = 0
const n = data.data.length / 4
for (let i = 0; i < data.data.length; i += 4) {
r += data.data[i]
g += data.data[i + 1]
b += data.data[i + 2]
}
mctx.fillStyle = `rgb(${Math.round(r / n)},${Math.round(g / n)},${Math.round(b / n)})`
mctx.fillRect(x, y, block, block)
}
}
ops.value = []
render()
}
function render() {
const c = ctx
if (!c)
return
c.clearRect(0, 0, cw, ch)
c.drawImage(baseCanvas, 0, 0, cw, ch)
for (const op of ops.value) {
if (op.type === 'pen') {
c.save()
c.strokeStyle = op.color
c.lineWidth = op.width
c.lineCap = 'round'
c.lineJoin = 'round'
c.beginPath()
op.points.forEach((p, i) => (i === 0 ? c.moveTo(p.x, p.y) : c.lineTo(p.x, p.y)))
c.stroke()
c.restore()
}
else if (op.type === 'mosaic') {
for (const p of op.points) {
c.save()
c.beginPath()
c.arc(p.x, p.y, op.width / 2, 0, Math.PI * 2)
c.clip()
c.drawImage(mosaicCanvas, 0, 0, cw, ch)
c.restore()
}
}
else {
c.save()
c.font = `bold ${op.size}px sans-serif`
c.fillStyle = op.color
c.textAlign = 'center'
c.textBaseline = 'middle'
c.fillText(op.text, op.x, op.y)
c.restore()
}
}
}
function pointOf(e: any): Point {
// MPtouches[i].x/y 相对画布H5clientX/Y 减画布偏移
const t = e.touches?.[0] || e.changedTouches?.[0] || e
if (typeof t.x === 'number' && typeof t.y === 'number')
return { x: t.x, y: t.y }
const rect = (canvasNode as HTMLCanvasElement)?.getBoundingClientRect?.()
return { x: t.clientX - (rect?.left || 0), y: t.clientY - (rect?.top || 0) }
}
function onDown(e: any) {
if (!ctx || mode.value === 'text')
return
const p = pointOf(e)
drawing = true
currentPoints = [p]
if (mode.value === 'mosaic')
ops.value.push({ type: 'mosaic', points: currentPoints, width: brushWidth.value })
else ops.value.push({ type: 'pen', points: currentPoints, color: color.value, width: brushWidth.value })
render()
}
function onMove(e: any) {
const c = ctx
if (!drawing || !c)
return
const p = pointOf(e)
const last = currentPoints[currentPoints.length - 1]
if (last && Math.hypot(p.x - last.x, p.y - last.y) < 2)
return
currentPoints.push(p)
// 增量绘制(实时反馈)
const op = ops.value[ops.value.length - 1]
if (op.type === 'pen') {
c.strokeStyle = op.color
c.lineWidth = op.width
c.lineCap = 'round'
c.lineJoin = 'round'
c.beginPath()
c.moveTo(last.x, last.y)
c.lineTo(p.x, p.y)
c.stroke()
}
else if (op.type === 'mosaic') {
c.save()
c.beginPath()
c.arc(p.x, p.y, op.width / 2, 0, Math.PI * 2)
c.clip()
c.drawImage(mosaicCanvas, 0, 0, cw, ch)
c.restore()
}
}
function onUp() {
drawing = false
// 收笔后整帧重绘,修正增量痕迹
render()
}
function onCanvasTap(e: any) {
if (mode.value !== 'text' || !ctx)
return
const p = pointOf(e)
uni.showModal({
title: '添加文字',
editable: true,
placeholderText: '输入要标注的文字',
success: (res) => {
if (res.confirm && res.content) {
ops.value.push({ type: 'text', x: p.x, y: p.y, text: res.content as string, color: color.value, size: 28 })
render()
}
},
})
}
function undo() {
ops.value.pop()
render()
}
function pick() {
uni.chooseImage({
count: 1,
sizeType: ['original'],
success: async (res) => {
src.value = res.tempFilePaths?.[0] || ''
if (src.value)
await runTask('准备画布…', prepare)
},
})
}
async function save() {
if (!canvasNode || saving.value)
return
saving.value = true
await runTask('保存中…', async () => {
// #ifdef MP-WEIXIN
const mpPath = await new Promise<string>((resolve, reject) => {
wx.canvasToTempFilePath({
canvas: canvasNode,
fileType: 'png',
success: r => resolve(r.tempFilePath),
fail: e => reject(new Error(e.errMsg || '导出失败')),
})
})
await saveResult(mpPath, resultName(src.value, 'annotate', 'png'))
return mpPath
// #endif
// #ifdef H5
const hPath = await new Promise<string>((resolve, reject) => {
(canvasNode as HTMLCanvasElement).toBlob((blob) => {
if (blob)
resolve(URL.createObjectURL(blob))
else reject(new Error('导出失败'))
}, 'image/png')
})
await saveResult(hPath, resultName(src.value, 'annotate', 'png'))
return hPath
// #endif
})
saving.value = false
}
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
图片标注
</text>
<view class="nav__slot" />
</view>
<view v-if="!src" class="pick press" hover-class="press-active" @tap="pick">
<wd-icon name="add" size="56rpx" color="#FF6B35" />
<text class="pick__text">
选择一张照片开始标注
</text>
</view>
<template v-else>
<!-- 工具条 -->
<view class="toolbar">
<view class="tool press" :class="{ 'tool--on': mode === 'pen' }" hover-class="press-active" @tap="mode = 'pen'">
画笔
</view>
<view class="tool press" :class="{ 'tool--on': mode === 'text' }" hover-class="press-active" @tap="mode = 'text'">
文字
</view>
<view class="tool press" :class="{ 'tool--on': mode === 'mosaic' }" hover-class="press-active" @tap="mode = 'mosaic'">
马赛克
</view>
<view class="tool tool--undo press" hover-class="press-active" @tap="undo">
撤销
</view>
</view>
<!-- 颜色 + 笔宽 -->
<view class="palette">
<view class="colors">
<view
v-for="c in COLORS"
:key="c"
class="color press"
:class="{ 'color--on': color === c }"
:style="{ background: c }"
hover-class="press-active"
@tap="color = c"
/>
</view>
<view class="widths">
<view
v-for="w in WIDTHS"
:key="w.value"
class="width press"
:class="{ 'width--on': brushWidth === w.value }"
hover-class="press-active"
@tap="brushWidth = w.value"
>
<view class="width__dot" :style="{ width: `${w.value + 4}rpx`, height: `${w.value + 4}rpx` }" />
</view>
</view>
</view>
<!-- 画布 -->
<view class="canvas-wrap">
<canvas
id="fxCanvas"
type="2d"
class="canvas"
:style="{ height: `${canvasH}px` }"
@touchstart="onDown"
@touchmove="onMove"
@touchend="onUp"
@mousedown="onDown"
@mousemove="onMove"
@mouseup="onUp"
@tap="onCanvasTap"
/>
</view>
<view class="actions">
<wd-button type="primary" block :loading="saving" @click="save">
保存到相册
</wd-button>
<wd-button block plain @click="pick">
换一张照片
</wd-button>
</view>
</template>
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 20rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
}
.nav__slot {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
}
.pick {
margin-top: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
border: 3rpx dashed #ffd4b8;
border-radius: 36rpx;
background: rgba(255, 255, 255, 0.7);
padding: 130rpx 0;
}
.pick__text {
font-size: 26rpx;
color: #9a7b65;
}
.toolbar {
display: flex;
gap: 14rpx;
margin-top: 20rpx;
}
.tool {
flex: 1;
text-align: center;
background: #ffffff;
border: 2rpx solid rgba(255, 195, 155, 0.5);
border-radius: 999rpx;
padding: 14rpx 0;
font-size: 25rpx;
color: #3d2a1e;
transition: all 0.2s ease;
}
.tool--on {
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
border-color: #ff6b35;
color: #ffffff;
font-weight: 700;
}
.tool--undo {
border-color: #ffe3d3;
color: #9a7b65;
}
.palette {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.5);
border-radius: 24rpx;
padding: 16rpx 24rpx;
margin-top: 18rpx;
}
.colors {
display: flex;
gap: 16rpx;
}
.color {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
border: 3rpx solid transparent;
}
.color--on {
border-color: #ffffff;
box-shadow: 0 0 0 3rpx #ff6b35;
}
.widths {
display: flex;
gap: 14rpx;
}
.width {
width: 56rpx;
height: 56rpx;
border-radius: 12rpx;
background: #fff9f2;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid transparent;
}
.width--on {
border-color: #ff6b35;
}
.width__dot {
border-radius: 50%;
background: #3d2a1e;
}
.canvas-wrap {
margin-top: 18rpx;
border-radius: 30rpx;
overflow: hidden;
border: 1rpx solid rgba(255, 195, 155, 0.5);
box-shadow: 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
}
.canvas {
display: block;
width: 100%;
}
.actions {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 28rpx;
}
</style>

View File

@@ -0,0 +1,423 @@
<script setup lang="ts">
import type { FxShape } from '@/utils/image/avatarfx'
import { onReady } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import Sticker from '@/components/Sticker.vue'
import { AVATAR_EFFECTS, drawEffect, drawPhoto } from '@/utils/image/avatarfx'
import { beginDraw, bindImageCanvas, chooseOneImage, exportFile, loadImage, saveResult } from '@/utils/image/core'
import { resultName, runTask } from '@/utils/image/task'
import { navPadTop } from '@/utils/safearea'
const SIZE = 720
/** 预览区边长rpx750 - 页面左右 padding 64 */
const PREVIEW_RPX = 686
/** 方形圆角占比(与绘制引擎一致) */
const SQUARE_RADIUS = 0.12
const navPad = navPadTop()
const src = ref('')
const effectKey = ref(AVATAR_EFFECTS[0].key)
const effect = computed(() => AVATAR_EFFECTS.find(e => e.key === effectKey.value) || AVATAR_EFFECTS[0])
const shape = ref<FxShape>('circle')
const zoom = ref(1)
const ringScale = ref(1)
const saving = ref(false)
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
async function pick() {
try {
const p = await chooseOneImage()
src.value = p.path
}
catch {
/* 用户取消 */
}
}
function switchEffect(key: string) {
effectKey.value = key
}
/** 环的 CSS 预览层:由外到内多层,宽度/内缩按坐标系等比换算成 rpx */
const ringStyles = computed(() => {
const scale = PREVIEW_RPX / SIZE
let inset = 0
const w = effect.value.ringWidth * ringScale.value
return effect.value.rings.map((color) => {
const radius = shape.value === 'circle'
? '50%'
: `${Math.max(8, SQUARE_RADIUS * PREVIEW_RPX - inset * scale).toFixed(0)}rpx`
const style = {
border: `${(w * scale).toFixed(1)}rpx solid ${color}`,
borderRadius: radius,
top: `${(inset * scale).toFixed(1)}rpx`,
left: `${(inset * scale).toFixed(1)}rpx`,
right: `${(inset * scale).toFixed(1)}rpx`,
bottom: `${(inset * scale).toFixed(1)}rpx`,
}
inset += w + 2
return style
})
})
const photoStyle = computed(() => ({
borderRadius: shape.value === 'circle' ? '50%' : `${(SQUARE_RADIUS * PREVIEW_RPX).toFixed(0)}rpx`,
transform: `scale(${zoom.value})`,
}))
/** 角标装饰的 CSS 预览层 */
const decorStyles = computed(() =>
effect.value.decors.map((d) => {
const sizeRpx = (d.size / 100) * PREVIEW_RPX * 2
return {
left: `${d.x}%`,
top: `${d.y}%`,
width: `${sizeRpx.toFixed(0)}rpx`,
height: `${sizeRpx.toFixed(0)}rpx`,
background: d.kind === 'dot' ? d.color : 'transparent',
sticker: d.kind === 'star5' ? 'star' : d.kind === 'bolt' ? 'bolt' : d.kind === 'sparkle' ? 'sparkle' : '',
}
}),
)
const badgeStyle = computed(() => {
const b = effect.value.badge
if (!b)
return null
const sizeRpx = (0.17 * PREVIEW_RPX).toFixed(0)
return {
left: `${b.x}%`,
top: `${b.y}%`,
width: `${sizeRpx}rpx`,
height: `${sizeRpx}rpx`,
background: b.bg,
color: b.color,
}
})
async function save() {
if (!src.value || saving.value)
return
saving.value = true
await runTask('生成头像中…', async () => {
const { img } = await loadImage(src.value)
const { ctx } = beginDraw(SIZE, SIZE)
drawPhoto(ctx, SIZE, img, shape.value, zoom.value)
drawEffect(ctx, SIZE, effect.value, shape.value, ringScale.value)
const path = await exportFile('png', 0.95)
await saveResult(path, resultName(src.value, 'avatarfx', 'png'))
return path
})
saving.value = false
}
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
头像特效
</text>
<view class="nav__slot" />
</view>
<!-- 选图 -->
<view v-if="!src" class="pick press" hover-class="press-active" @tap="pick">
<wd-icon name="add" size="56rpx" color="#FF6B35" />
<text class="pick__text">
选择照片做头像
</text>
</view>
<template v-else>
<!-- 实时预览照片 + CSS /角标/徽章 Canvas 输出同一定义 -->
<view class="preview">
<image :src="src" mode="aspectFill" class="preview__photo" :style="photoStyle" />
<view
v-for="(rs, i) in ringStyles"
:key="i"
class="preview__ring"
:style="rs"
/>
<view
v-for="(d, i) in decorStyles"
:key="`d${i}`"
class="preview__decor"
:style="{ left: d.left, top: d.top, width: d.width, height: d.height, background: d.background }"
>
<Sticker v-if="d.sticker" :type="d.sticker" :size="100" />
</view>
<view v-if="badgeStyle" class="preview__badge" :style="badgeStyle">
<text>{{ effect.badge?.text }}</text>
</view>
</view>
<!-- 形状 -->
<view class="row">
<text class="row__label">
形状
</text>
<view class="seg">
<view class="seg__item press" :class="{ 'seg__item--on': shape === 'circle' }" hover-class="press-active" @tap="shape = 'circle'">
圆形
</view>
<view class="seg__item press" :class="{ 'seg__item--on': shape === 'square' }" hover-class="press-active" @tap="shape = 'square'">
方形
</view>
</view>
</view>
<!-- 缩放 -->
<view class="row">
<text class="row__label">
照片缩放
</text>
<slider
class="row__slider"
:min="1"
:max="2"
:step="0.05"
:value="zoom"
active-color="#FF6B35"
:block-size="20"
@change="(e: any) => (zoom = e.detail.value)"
/>
</view>
<!-- 描边粗细 -->
<view class="row">
<text class="row__label">
描边粗细
</text>
<slider
class="row__slider"
:min="0.6"
:max="1.6"
:step="0.05"
:value="ringScale"
active-color="#FF6B35"
:block-size="20"
@change="(e: any) => (ringScale = e.detail.value)"
/>
</view>
<!-- 特效选择 -->
<scroll-view scroll-x class="effects" :show-scrollbar="false">
<view
v-for="e in AVATAR_EFFECTS"
:key="e.key"
class="fx press"
:class="{ 'fx--on': e.key === effectKey }"
hover-class="press-active"
@tap="switchEffect(e.key)"
>
<view
class="fx__ring"
:style="{ borderColor: e.rings[0], boxShadow: e.rings[1] ? `0 0 0 ${(e.ringWidth / SIZE * PREVIEW_RPX * 0.12).toFixed(1)}rpx ${e.rings[1]}` : 'none' }"
/>
<text class="fx__name">
{{ e.name }}
</text>
</view>
</scroll-view>
<!-- 操作 -->
<view class="actions">
<wd-button type="primary" block :loading="saving" @click="save">
生成并保存到相册
</wd-button>
<wd-button block plain @click="pick">
换一张照片
</wd-button>
</view>
</template>
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 20rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
}
.nav__slot {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
}
.pick {
margin-top: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
border: 3rpx dashed #ffd4b8;
border-radius: 36rpx;
background: rgba(255, 255, 255, 0.7);
padding: 130rpx 0;
}
.pick__text {
font-size: 26rpx;
color: #9a7b65;
}
/* 预览:正方形 + 照片 + 覆盖层 */
.preview {
position: relative;
width: 100%;
height: 0;
padding-bottom: 100%;
margin-top: 20rpx;
overflow: hidden;
}
.preview__photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: #f3ece4;
}
.preview__ring {
position: absolute;
pointer-events: none;
}
.preview__decor {
position: absolute;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
}
.preview__badge {
position: absolute;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 4rpx solid #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 44rpx;
font-weight: 800;
pointer-events: none;
box-shadow: 0 8rpx 18rpx rgba(107, 47, 15, 0.2);
}
/* 参数行 */
.row {
display: flex;
align-items: center;
gap: 20rpx;
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.5);
border-radius: 24rpx;
padding: 18rpx 24rpx;
margin-top: 20rpx;
}
.row__label {
flex-shrink: 0;
width: 140rpx;
font-size: 26rpx;
font-weight: 600;
color: #3d2a1e;
}
.row__slider {
flex: 1;
margin: 0;
}
.seg {
display: flex;
flex: 1;
background: #fff3ec;
border-radius: 999rpx;
padding: 6rpx;
}
.seg__item {
flex: 1;
text-align: center;
font-size: 24rpx;
color: #9a7b65;
padding: 10rpx 0;
border-radius: 999rpx;
}
.seg__item--on {
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
color: #ffffff;
font-weight: 700;
}
/* 特效选择 */
.effects {
display: flex;
white-space: nowrap;
margin-top: 24rpx;
}
.fx {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
background: #ffffff;
border: 2rpx solid transparent;
border-radius: 26rpx;
padding: 20rpx 24rpx;
margin-right: 16rpx;
box-shadow: 0 8rpx 20rpx rgba(107, 47, 15, 0.06);
}
.fx--on {
border-color: #ff6b35;
box-shadow: 0 10rpx 24rpx rgba(251, 78, 20, 0.18);
}
.fx__ring {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
border: 8rpx solid #ff6b35;
}
.fx__name {
font-size: 22rpx;
color: #3d2a1e;
white-space: nowrap;
}
.actions {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 36rpx;
}
</style>

View File

@@ -0,0 +1,363 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { COLLAGE_TEMPLATES, drawCollage } from '@/utils/image/collage'
import { beginDraw, bindImageCanvas, chooseImages, exportFile, loadImage, saveResult } from '@/utils/image/core'
import { runTask } from '@/utils/image/task'
import { navPadTop } from '@/utils/safearea'
const SIZE = 720
const navPad = navPadTop()
const photos = ref<string[]>([])
const tplKey = ref('h2')
const gap = ref(16)
const bg = ref('#FFF9F2')
const saving = ref(false)
const BGS = [
{ key: '#FFF9F2', name: '奶油' },
{ key: '#DFF7F0', name: '薄荷' },
{ key: '#FFF1E8', name: '暖沙' },
{ key: '#1A1A1A', name: '纯黑' },
]
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
/** 当前张数可用的模板 */
const availTemplates = computed(() => COLLAGE_TEMPLATES.filter(t => t.count === photos.value.length))
const tpl = computed(() => availTemplates.value.find(t => t.key === tplKey.value) || availTemplates.value[0])
/** 预览槽位(按百分比,间距换算为 % */
const previewSlots = computed(() => {
if (!tpl.value)
return []
const gapPct = (gap.value / SIZE) * 100
return tpl.value.slots.map(s => ({
left: `${s.x + gapPct / 2}%`,
top: `${s.y + gapPct / 2}%`,
width: `${s.w - gapPct}%`,
height: `${s.h - gapPct}%`,
}))
})
async function pick() {
try {
const picked = await chooseImages(4)
photos.value = picked.map(p => p.path)
tplKey.value = availTemplates.value[0]?.key || tplKey.value
}
catch {
/* 用户取消 */
}
}
function removePhoto(i: number) {
photos.value = photos.value.filter((_, idx) => idx !== i)
tplKey.value = availTemplates.value[0]?.key || tplKey.value
}
async function save() {
if (photos.value.length < 2 || !tpl.value || saving.value)
return
saving.value = true
await runTask('正在合成拼图…', async () => {
const loaded = []
for (const p of photos.value)
loaded.push(await loadImage(p))
const { ctx } = beginDraw(SIZE, SIZE)
drawCollage(ctx, SIZE, tpl.value, loaded, gap.value, 24, bg.value)
const path = await exportFile('png', 0.95)
await saveResult(path, 'collage.png')
return path
})
saving.value = false
}
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
图片拼图
</text>
<view class="nav__slot" />
</view>
<view v-if="photos.length < 2" class="pick press" hover-class="press-active" @tap="pick">
<wd-icon name="add" size="56rpx" color="#FF6B35" />
<text class="pick__text">
选择 2-4 张照片已选 {{ photos.length }}
</text>
</view>
<template v-else>
<!-- 照片列表 -->
<view class="thumbs">
<view v-for="(p, i) in photos" :key="p + i" class="thumb">
<image :src="p" mode="aspectFill" class="thumb__img" />
<view class="thumb__del press" hover-class="press-active" @tap="removePhoto(i)">
<wd-icon name="close" size="20rpx" color="#ffffff" />
</view>
</view>
<view v-if="photos.length < 4" class="thumb thumb--add press" hover-class="press-active" @tap="pick">
<wd-icon name="add" size="40rpx" color="#FF6B35" />
</view>
</view>
<!-- 预览 -->
<view class="preview" :style="{ background: bg }">
<image
v-for="(s, i) in previewSlots"
:key="i"
:src="photos[i]"
mode="aspectFill"
class="preview__slot"
:style="s"
/>
</view>
<!-- 模板 -->
<scroll-view scroll-x class="tpls" :show-scrollbar="false">
<view
v-for="t in availTemplates"
:key="t.key"
class="tpl press"
:class="{ 'tpl--on': t.key === tplKey }"
hover-class="press-active"
@tap="tplKey = t.key"
>
<text>{{ t.name }}</text>
</view>
</scroll-view>
<!-- 底色 -->
<view class="row">
<text class="row__label">
底色
</text>
<view class="bgs">
<view
v-for="b in BGS"
:key="b.key"
class="bg press"
:class="{ 'bg--on': bg === b.key }"
:style="{ background: b.key }"
hover-class="press-active"
@tap="bg = b.key"
/>
</view>
</view>
<!-- 间距 -->
<view class="row">
<text class="row__label">
间距
</text>
<slider
class="row__slider"
:min="0"
:max="48"
:step="4"
:value="gap"
active-color="#FF6B35"
:block-size="20"
@change="(e: any) => (gap = e.detail.value)"
/>
</view>
<view class="actions">
<wd-button type="primary" block :loading="saving" @click="save">
合成并保存到相册
</wd-button>
</view>
</template>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布页面内隐藏节点onReady 时由 bindImageCanvas 绑定 -->
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 20rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
}
.nav__slot {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
}
.pick {
margin-top: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
border: 3rpx dashed #ffd4b8;
border-radius: 36rpx;
background: rgba(255, 255, 255, 0.7);
padding: 130rpx 0;
}
.pick__text {
font-size: 26rpx;
color: #9a7b65;
}
.thumbs {
display: flex;
gap: 16rpx;
margin-top: 20rpx;
flex-wrap: wrap;
}
.thumb {
position: relative;
width: 140rpx;
height: 140rpx;
}
.thumb__img {
width: 100%;
height: 100%;
border-radius: 20rpx;
background: #f3ece4;
}
.thumb__del {
position: absolute;
right: -8rpx;
top: -8rpx;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
}
.thumb--add {
border: 3rpx dashed #ffd4b8;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.7);
}
.preview {
position: relative;
width: 100%;
height: 0;
padding-bottom: 100%;
margin-top: 24rpx;
border-radius: 30rpx;
overflow: hidden;
}
.preview__slot {
position: absolute;
border-radius: 24rpx;
background: #f3ece4;
}
.tpls {
display: flex;
white-space: nowrap;
margin-top: 24rpx;
}
.tpl {
display: inline-flex;
background: #ffffff;
border: 2rpx solid transparent;
border-radius: 999rpx;
padding: 14rpx 32rpx;
margin-right: 16rpx;
font-size: 24rpx;
color: #3d2a1e;
box-shadow: 0 8rpx 20rpx rgba(107, 47, 15, 0.06);
}
.tpl--on {
background: linear-gradient(140deg, #ff9f4d, #ff6b35);
color: #ffffff;
font-weight: 700;
box-shadow: 0 10rpx 22rpx rgba(251, 78, 20, 0.25);
}
.row {
display: flex;
align-items: center;
gap: 20rpx;
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.5);
border-radius: 24rpx;
padding: 18rpx 24rpx;
margin-top: 20rpx;
}
.row__label {
flex-shrink: 0;
width: 110rpx;
font-size: 26rpx;
font-weight: 600;
color: #3d2a1e;
}
.bgs {
display: flex;
gap: 18rpx;
flex: 1;
}
.bg {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 3rpx solid rgba(0, 0, 0, 0.08);
}
.bg--on {
border: 4rpx solid #ff6b35;
box-shadow: 0 4rpx 12rpx rgba(251, 78, 20, 0.3);
}
.row__slider {
flex: 1;
margin: 0;
}
.actions {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 36rpx;
}
</style>

View File

@@ -0,0 +1,313 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { compressImage } from '@/utils/image/compress'
import { bindImageCanvas, chooseImages } from '@/utils/image/core'
import { formatSize, resultName, runTask } from '@/utils/image/task'
import { navPadTop } from '@/utils/safearea'
interface Item {
src: string
size: number
status: 'wait' | 'done' | 'fail'
resultPath?: string
resultSize?: number
}
const items = ref<Item[]>([])
const qualityLevel = ref<'high' | 'balanced' | 'mini'>('balanced')
const targetKB = ref('')
const maxEdge = ref('')
const processing = ref(false)
const doneCount = ref(0)
const navPad = navPadTop()
function goBack() {
uni.navigateBack()
}
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
const qualityMap = { high: 0.85, balanced: 0.7, mini: 0.5 } as const
async function pick() {
try {
const picked = await chooseImages(9)
items.value = picked.map(p => ({ src: p.path, size: p.size, status: 'wait' }))
doneCount.value = 0
}
catch {
/* 用户取消 */
}
}
async function process() {
if (!items.value.length) {
uni.showToast({ title: '请先选择图片', icon: 'none' })
return
}
processing.value = true
doneCount.value = 0
const opts = {
quality: qualityMap[qualityLevel.value],
targetKB: targetKB.value ? Number(targetKB.value) : undefined,
maxEdge: maxEdge.value ? Number(maxEdge.value) : undefined,
}
for (const item of items.value) {
const r = await runTask(`压缩中 ${doneCount.value + 1}/${items.value.length}`, () =>
compressImage(item.src, item.size, opts))
if (r) {
item.resultPath = r.path
item.resultSize = r.size
item.status = 'done'
}
else {
item.status = 'fail'
}
doneCount.value++
items.value = [...items.value]
}
processing.value = false
}
async function saveItem(item: Item) {
if (!item.resultPath)
return
const ext = item.resultPath.split('.').pop() || 'jpg'
await runTask('保存中', async () => {
const { saveResult } = await import('@/utils/image/core')
await saveResult(item.resultPath!, resultName(item.src, 'compress', ext))
})
}
async function saveAll() {
for (const item of items.value) {
if (item.status === 'done')
await saveItem(item)
}
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
图片压缩
</text>
</view>
<view class="card params">
<view class="quality">
<view
v-for="(label, key) in { high: '高质量', balanced: '均衡', mini: '极致体积' }"
:key="key"
class="q press"
:class="{ 'q--on': qualityLevel === key }"
hover-class="press-active"
@tap="qualityLevel = key as 'high' | 'balanced' | 'mini'"
>
<text>{{ label }}</text>
</view>
</view>
<view class="extras">
<view class="extra">
<text class="extra__label">
目标体积 KB
</text>
<input v-model="targetKB" class="extra__input" type="number" placeholder="如 200">
</view>
<view class="extra">
<text class="extra__label">
最长边 px
</text>
<input v-model="maxEdge" class="extra__input" type="number" placeholder="如 1920">
</view>
</view>
</view>
<view class="actions">
<wd-button type="primary" block @click="pick">
选择图片可多选
</wd-button>
<wd-button block :loading="processing" :disabled="!items.length" @click="process">
{{ processing ? `压缩中 ${doneCount}/${items.length}` : '开始压缩' }}
</wd-button>
</view>
<view v-if="items.length" class="list">
<view v-for="(item, i) in items" :key="i" class="item">
<image :src="item.resultPath || item.src" mode="aspectFill" class="item__img" />
<view class="item__body">
<text class="item__size">
原图 {{ formatSize(item.size) }}
</text>
<text v-if="item.status === 'done'" class="item__result">
{{ formatSize(item.resultSize || 0) }}-{{ Math.max(0, Math.round((1 - (item.resultSize || 0) / item.size) * 100)) }}%
</text>
<text v-else-if="item.status === 'fail'" class="item__fail">
压缩失败
</text>
<text v-else class="item__wait">
待处理
</text>
</view>
<wd-button v-if="item.status === 'done'" size="small" plain @click="saveItem(item)">
保存
</wd-button>
</view>
<wd-button v-if="doneCount && doneCount === items.length" type="primary" block @click="saveAll">
保存全部到相册
</wd-button>
</view>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布页面内隐藏节点onReady 时由 bindImageCanvas 绑定 -->
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.nav {
display: flex;
align-items: center;
padding: 0 0 16rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
margin-right: 64rpx;
}
.card {
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 32rpx;
padding: 28rpx;
box-shadow: 0 8rpx 24rpx rgba(107, 47, 15, 0.06);
}
.quality {
display: flex;
gap: 16rpx;
}
.q {
flex: 1;
text-align: center;
padding: 20rpx 0;
border-radius: 20rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
color: #3d2a1e;
font-size: 26rpx;
}
.q--on {
background: #ff6b35;
border-color: #ff6b35;
color: #ffffff;
}
.extras {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
.extra {
flex: 1;
display: flex;
align-items: center;
gap: 12rpx;
background: #fff9f2;
border-radius: 20rpx;
padding: 16rpx 20rpx;
}
.extra__label {
font-size: 22rpx;
color: #9a7b65;
flex-shrink: 0;
}
.extra__input {
font-size: 26rpx;
color: #3d2a1e;
width: 100%;
}
.actions {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 28rpx;
}
.list {
margin-top: 28rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.item {
display: flex;
align-items: center;
gap: 20rpx;
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 28rpx;
padding: 20rpx;
}
.item__img {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
background: #fff9f2;
}
.item__body {
flex: 1;
min-width: 0;
}
.item__size {
display: block;
font-size: 24rpx;
color: #9a7b65;
}
.item__result {
display: block;
margin-top: 6rpx;
font-size: 28rpx;
font-weight: 700;
color: #ff6b35;
}
.item__fail {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
color: #dc2626;
}
.item__wait {
display: block;
margin-top: 6rpx;
font-size: 24rpx;
color: #bda794;
}
</style>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { convertFormat } from '@/utils/image/convert'
import { bindImageCanvas, chooseOneImage, saveResult } from '@/utils/image/core'
import { runTask } from '@/utils/image/task'
import { navPadTop } from '@/utils/safearea'
const src = ref('')
const target = ref<'png' | 'jpg' | 'webp'>('png')
const result = ref<{ path: string, name: string } | null>(null)
const navPad = navPadTop()
function goBack() {
uni.navigateBack()
}
const isH5 = ref(false)
// #ifdef H5
isH5.value = true
// #endif
const mpOnly = computed(() => !isH5.value)
const formats = computed(() => [
{ key: 'png' as const, label: 'PNG', enabled: true },
{ key: 'jpg' as const, label: 'JPG', enabled: true },
{ key: 'webp' as const, label: 'WebP', enabled: !mpOnly.value },
])
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
async function pick() {
try {
const p = await chooseOneImage()
src.value = p.path
result.value = null
}
catch {
/* 取消 */
}
}
async function run() {
if (!src.value) {
uni.showToast({ title: '请先选择图片', icon: 'none' })
return
}
const r = await runTask('转换中', () => convertFormat(src.value, target.value))
if (r)
result.value = r
}
async function save() {
if (!result.value)
return
await saveResult(result.value.path, result.value.name)
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
格式转换
</text>
</view>
<view v-if="!src" class="pick press" hover-class="press-active" @tap="pick">
<wd-icon name="add" size="56rpx" color="#ff6b35" />
<text class="pick__text">
选择图片
</text>
</view>
<view v-else class="preview" @tap="pick">
<image :src="src" mode="aspectFit" class="preview__img" />
<text class="preview__tip">
点击重新选择
</text>
</view>
<view class="formats">
<view
v-for="f in formats"
:key="f.key"
class="f press"
:class="{ 'f--on': target === f.key, 'f--off': !f.enabled }"
hover-class="press-active"
@tap="f.enabled && (target = f.key)"
>
<text>{{ f.label }}</text>
<text v-if="!f.enabled" class="f__note">
网页版可用
</text>
</view>
</view>
<wd-button type="primary" block @click="run">
开始转换
</wd-button>
<view v-if="result" class="result checker-bg">
<image :src="result.path" mode="aspectFit" class="result__img" />
<wd-button type="primary" block @click="save">
保存{{ target.toUpperCase() }}
</wd-button>
</view>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布页面内隐藏节点onReady 时由 bindImageCanvas 绑定 -->
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 28rpx;
}
.nav {
display: flex;
align-items: center;
padding: 0 0 16rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
margin-right: 64rpx;
}
.pick {
background: #ffffff;
border: 2rpx dashed #ffd4b8;
border-radius: 32rpx;
padding: 100rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.pick__text {
font-size: 26rpx;
color: #9a7b65;
}
.preview {
position: relative;
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 32rpx;
padding: 20rpx;
}
.preview__img {
width: 100%;
height: 480rpx;
border-radius: 24rpx;
}
.preview__tip {
position: absolute;
right: 32rpx;
bottom: 32rpx;
font-size: 22rpx;
color: #9a7b65;
background: rgba(240, 253, 250, 0.9);
border-radius: 999rpx;
padding: 8rpx 20rpx;
}
.formats {
display: flex;
gap: 16rpx;
}
.f {
flex: 1;
text-align: center;
padding: 24rpx 0;
border-radius: 24rpx;
background: #ffffff;
border: 2rpx solid #ffe3d3;
color: #3d2a1e;
font-size: 28rpx;
font-weight: 700;
min-height: 44px;
box-sizing: border-box;
}
.f--on {
background: #ff6b35;
border-color: #ff6b35;
color: #ffffff;
}
.f--off {
opacity: 0.45;
}
.f__note {
display: block;
font-size: 20rpx;
font-weight: 400;
margin-top: 4rpx;
color: #9a7b65;
}
.f--on .f__note {
color: #ffe3d3;
}
.result {
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 32rpx;
padding: 20rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.result__img {
width: 100%;
height: 480rpx;
border-radius: 24rpx;
}
</style>

View File

@@ -0,0 +1,328 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { bindImageCanvas, chooseOneImage, loadImage, saveResult } from '@/utils/image/core'
import { runTask } from '@/utils/image/task'
import { cropImage, transformImage } from '@/utils/image/transform'
import { navPadTop } from '@/utils/safearea'
const src = ref('')
const result = ref<{ path: string, name: string } | null>(null)
const rotate = ref<0 | 90 | 180 | 270>(0)
const flipH = ref(false)
const flipV = ref(false)
const ratio = ref('free')
// 裁剪框显示状态
const display = ref({ w: 0, h: 0 }) // 预览显示尺寸px
const orig = ref({ w: 0, h: 0 }) // 原图尺寸
const box = ref({ w: 0, h: 0 }) // 裁剪框显示尺寸
const boxPos = ref({ x: 0, y: 0 })
const navPad = navPadTop()
function goBack() {
uni.navigateBack()
}
const ratios = [
{ key: 'free', label: '自由', v: 0 },
{ key: '1:1', label: '1:1', v: 1 },
{ key: '4:3', label: '4:3', v: 4 / 3 },
{ key: '3:4', label: '3:4', v: 3 / 4 },
{ key: '16:9', label: '16:9', v: 16 / 9 },
]
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
async function pick() {
try {
const p = await chooseOneImage()
src.value = p.path
result.value = null
rotate.value = 0
flipH.value = false
flipV.value = false
const info = await loadImage(p.path)
orig.value = { w: info.width, h: info.height }
layoutBox()
}
catch (e) {
uni.showToast({ title: (e as Error).message || '加载失败', icon: 'none' })
}
}
/** 预览容器宽 686rpx ≈ 343px按比例显示 */
function layoutBox() {
const W = 343
const s = W / orig.value.w
const H = orig.value.h * s
display.value = { w: W, h: H }
const r = ratios.find(x => x.key === ratio.value)?.v || 0
let bw = W * 0.8
let bh = r ? bw / r : H * 0.8
if (r && bh > H * 0.9) {
bh = H * 0.9
bw = bh * r
}
box.value = { w: Math.round(bw), h: Math.round(bh) }
boxPos.value = {
x: Math.round((W - bw) / 2),
y: Math.round((H - bh) / 2),
}
}
function pickRatio(key: string) {
ratio.value = key
layoutBox()
}
function onBoxMove(e: { detail: { x: number, y: number } }) {
boxPos.value = { x: e.detail.x, y: e.detail.y }
}
async function applyCrop() {
if (!src.value)
return
const s = orig.value.w / display.value.w
const rect = {
x: Math.max(0, Math.round(boxPos.value.x * s)),
y: Math.max(0, Math.round(boxPos.value.y * s)),
width: Math.round(box.value.w * s),
height: Math.round(box.value.h * s),
}
const r = await runTask('裁剪中', () => cropImage(src.value, rect))
if (r) {
result.value = r
src.value = r.path
const info = await loadImage(r.path)
orig.value = { w: info.width, h: info.height }
layoutBox()
}
}
async function applyTransform() {
if (!src.value)
return
if (rotate.value === 0 && !flipH.value && !flipV.value)
return
const r = await runTask('处理中', () =>
transformImage(src.value, { rotate: rotate.value, flipH: flipH.value, flipV: flipV.value }))
if (r) {
result.value = r
src.value = r.path
rotate.value = 0
flipH.value = false
flipV.value = false
const info = await loadImage(r.path)
orig.value = { w: info.width, h: info.height }
layoutBox()
}
}
async function save() {
if (!result.value)
return
await saveResult(result.value.path, result.value.name)
}
const hasImage = computed(() => !!src.value)
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
裁剪旋转
</text>
</view>
<view v-if="!hasImage" class="pick press" hover-class="press-active" @tap="pick">
<wd-icon name="add" size="56rpx" color="#ff6b35" />
<text class="pick__text">
选择图片
</text>
</view>
<template v-else>
<!-- 裁剪区 -->
<view class="stage">
<movable-area class="area" :style="{ width: `${display.w}px`, height: `${display.h}px` }">
<image :src="src" class="area__img" :style="{ width: `${display.w}px`, height: `${display.h}px` }" />
<movable-view
class="box"
direction="all"
:style="{ width: `${box.w}px`, height: `${box.h}px` }"
:x="boxPos.x"
:y="boxPos.y"
@change="onBoxMove"
/>
</movable-area>
</view>
<!-- 比例 -->
<scroll-view scroll-x class="ratios" :show-scrollbar="false">
<view
v-for="r in ratios"
:key="r.key"
class="r press"
:class="{ 'r--on': ratio === r.key }"
hover-class="press-active"
@tap="pickRatio(r.key)"
>
<text>{{ r.label }}</text>
</view>
</scroll-view>
<!-- 旋转/镜像 -->
<view class="ops">
<view class="op press" hover-class="press-active" @tap="rotate = ((rotate + 90) % 360) as 0 | 90 | 180 | 270">
<wd-icon name="arrow-right" size="32rpx" color="#ff6b35" />
<text>旋转90°</text>
</view>
<view class="op press" hover-class="press-active" @tap="flipH = !flipH">
<text>左右镜像</text>
</view>
<view class="op press" hover-class="press-active" @tap="flipV = !flipV">
<text>上下镜像</text>
</view>
</view>
<view class="actions">
<wd-button block :disabled="!rotate && !flipH && !flipV" @click="applyTransform">
应用旋转/镜像
</wd-button>
<wd-button type="primary" block @click="applyCrop">
确认裁剪
</wd-button>
<wd-button v-if="result" type="primary" block @click="save">
保存结果
</wd-button>
</view>
</template>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布页面内隐藏节点onReady 时由 bindImageCanvas 绑定 -->
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.nav {
display: flex;
align-items: center;
padding: 0 0 16rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
margin-right: 64rpx;
}
.pick {
background: #ffffff;
border: 2rpx dashed #ffd4b8;
border-radius: 32rpx;
padding: 100rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.pick__text {
font-size: 26rpx;
color: #9a7b65;
}
.stage {
display: flex;
justify-content: center;
}
.area {
position: relative;
overflow: hidden;
border-radius: 24rpx;
background: #ffffff;
border: 2rpx solid #ffe3d3;
}
.area__img {
display: block;
}
.box {
border: 4rpx solid #ff6b35;
border-radius: 8rpx;
background: rgba(251, 78, 20, 0.12);
box-shadow: 0 0 0 9999rpx rgba(107, 47, 15, 0.35);
}
.ratios {
display: flex;
white-space: nowrap;
}
.r {
display: inline-block;
padding: 14rpx 32rpx;
margin-right: 16rpx;
border-radius: 999rpx;
background: #ffffff;
border: 2rpx solid #ffe3d3;
font-size: 26rpx;
color: #3d2a1e;
}
.r--on {
background: #ff6b35;
border-color: #ff6b35;
color: #ffffff;
}
.ops {
display: flex;
gap: 16rpx;
}
.op {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 20rpx 0;
border-radius: 20rpx;
background: #ffffff;
border: 2rpx solid #ffe3d3;
font-size: 24rpx;
color: #3d2a1e;
min-height: 44px;
box-sizing: border-box;
}
.actions {
display: flex;
flex-direction: column;
gap: 16rpx;
}
</style>

View File

@@ -0,0 +1,207 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { beginDraw, bindImageCanvas, chooseOneImage, exportFile, loadImage, saveResult } from '@/utils/image/core'
import { runTask } from '@/utils/image/task'
import { navPadTop } from '@/utils/safearea'
const PIECE = 360
const navPad = navPadTop()
const src = ref('')
const saving = ref(false)
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
async function pick() {
try {
const p = await chooseOneImage()
src.value = p.path
}
catch {
/* 用户取消 */
}
}
async function save() {
if (!src.value || saving.value)
return
saving.value = true
await runTask('正在切成 9 张…', async () => {
const { img } = await loadImage(src.value)
const iw = (img as unknown as { width: number }).width
const ih = (img as unknown as { height: number }).height
// 中心方裁
const side = Math.min(iw, ih)
const sx = (iw - side) / 2
const sy = (ih - side) / 2
const piece = side / 3
// 从左到右、从上到下逐片导出并保存
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 3; col++) {
const { ctx } = beginDraw(PIECE, PIECE)
ctx.drawImage(img as unknown as CanvasImageSource, sx + col * piece, sy + row * piece, piece, piece, 0, 0, PIECE, PIECE)
const path = await exportFile('png', 0.95)
await saveResult(path, `grid9-${row + 1}-${col + 1}.png`)
}
}
uni.showToast({ title: '9 张已按顺序保存', icon: 'success' })
})
saving.value = false
}
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
九宫格切图
</text>
<view class="nav__slot" />
</view>
<view v-if="!src" class="pick press" hover-class="press-active" @tap="pick">
<wd-icon name="add" size="56rpx" color="#FF6B35" />
<text class="pick__text">
选择一张照片
</text>
</view>
<template v-else>
<view class="tip">
中心方裁后切成 9 保存后按 从左到右从上到下 顺序发布即可
</view>
<view class="preview">
<image :src="src" mode="aspectFill" class="preview__img" />
<view class="preview__lines">
<view class="preview__line preview__line--v" style="left: 33.33%" />
<view class="preview__line preview__line--v" style="left: 66.66%" />
<view class="preview__line preview__line--h" style="top: 33.33%" />
<view class="preview__line preview__line--h" style="top: 66.66%" />
</view>
</view>
<view class="actions">
<wd-button type="primary" block :loading="saving" @click="save">
切成 9 张并保存
</wd-button>
<wd-button block plain @click="pick">
换一张照片
</wd-button>
</view>
</template>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布页面内隐藏节点onReady 时由 bindImageCanvas 绑定 -->
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 20rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
}
.nav__slot {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
}
.pick {
margin-top: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
border: 3rpx dashed #ffd4b8;
border-radius: 36rpx;
background: rgba(255, 255, 255, 0.7);
padding: 130rpx 0;
}
.pick__text {
font-size: 26rpx;
color: #9a7b65;
}
.tip {
margin: 20rpx 4rpx 0;
font-size: 24rpx;
color: #9a7b65;
line-height: 1.6;
}
.preview {
position: relative;
width: 100%;
height: 0;
padding-bottom: 100%;
margin-top: 16rpx;
overflow: hidden;
border-radius: 30rpx;
}
.preview__img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.preview__line {
position: absolute;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 0 6rpx rgba(0, 0, 0, 0.25);
}
.preview__line--v {
top: 0;
bottom: 0;
width: 3rpx;
}
.preview__line--h {
left: 0;
right: 0;
height: 3rpx;
}
.actions {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-top: 36rpx;
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from 'vue'
import { TOOL_REGISTRY } from '@/config/tools'
import { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
import { toolPalette } from '@/utils/palette'
import { heroPadTop } from '@/utils/safearea'
const toolsStore = useToolsStore()
const userStore = useUserStore()
/** 头区顶部留白MP 避开胶囊按钮 */
const heroPad = heroPadTop()
function goBack() {
uni.navigateBack()
}
const tools = computed(() =>
TOOL_REGISTRY.filter(t => t.module === 'image').map(d => ({
...d,
locked: toolsStore.tools.find(t => t.tool_key === d.key)?.locked ?? true,
})),
)
function open(path: string, key: string, locked: boolean) {
if (locked) {
uni.showModal({ title: '功能未解锁', content: '该功能属于更高等级模块,请联系管理员升级。', showCancel: false })
return
}
uni.navigateTo({ url: path })
}
// 保证 stores 已初始化(从工作台直接进入时)
userStore.ensureLogin().catch(() => {})
toolsStore.fetchAll().catch(() => {})
</script>
<template>
<view class="page">
<!-- 渐变头区 -->
<view class="tb-hero" :style="{ paddingTop: heroPad }">
<view class="hero__row">
<wd-icon name="arrow-left" size="40rpx" color="#ffffff" class="hero__back press" hover-class="press-active" @tap="goBack" />
<view>
<text class="hero__title">
图片处理
</text>
<text class="hero__sub">
全程本机处理图片不会上传
</text>
</view>
</view>
</view>
<!-- 上浮内容 -->
<view class="tb-body">
<view class="tb-grid grid-3">
<view
v-for="t in tools"
:key="t.key"
class="tb-tile press"
:class="{ 'tb-tile--lock': t.locked }"
hover-class="press-active"
@tap="open(t.path, t.key, t.locked)"
>
<view v-if="t.locked" class="tb-tile__corner">
未解锁
</view>
<view class="tb-tile__icon" :style="{ background: t.locked ? '#F1F5F9' : toolPalette(t.key).bg }">
<wd-icon :name="t.icon" size="46rpx" :color="t.locked ? '#bda794' : toolPalette(t.key).fg" />
</view>
<text class="tb-tile__name">
{{ t.name }}
</text>
</view>
</view>
</view>
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom, 0px));
}
.hero__row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 8rpx 8rpx 0;
position: relative;
z-index: 1;
}
.hero__back {
flex-shrink: 0;
padding: 8rpx;
}
.hero__title {
display: block;
font-size: 44rpx;
font-weight: 800;
letter-spacing: -0.02em;
color: #ffffff;
}
.hero__sub {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.85);
}
/* 三列宫格 */
.grid-3 {
grid-template-columns: repeat(3, 1fr);
}
</style>

View File

@@ -0,0 +1,244 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { bindImageCanvas, chooseOneImage, saveResult } from '@/utils/image/core'
import { removeBackgroundSmart, removeBackgroundSolid } from '@/utils/image/matting'
import { resizeImage } from '@/utils/image/resize'
import { runTask } from '@/utils/image/task'
import { navPadTop } from '@/utils/safearea'
const src = ref('')
const tolerance = ref(25)
const result = ref<{ path: string, name: string } | null>(null)
const busy = ref(false)
const isH5 = ref(false)
// #ifdef H5
isH5.value = true
// #endif
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const navPad = navPadTop()
function goBack() {
uni.navigateBack()
}
function onToleranceSlider(e: { detail: { value: number } }) {
tolerance.value = e.detail.value
onToleranceChange()
}
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
async function pick() {
try {
const p = await chooseOneImage()
src.value = p.path
result.value = null
await preview()
}
catch {
/* 取消 */
}
}
/** 预览:先降采样到 ≤1024 再抠图,调参实时反馈 */
async function preview() {
if (!src.value || busy.value)
return
busy.value = true
try {
const small = await resizeImage(src.value, { mode: 'maxEdge', value: 1024 })
const r = await removeBackgroundSolid(small.path, tolerance.value)
result.value = r
}
catch (e) {
uni.showToast({ title: (e as Error).message || '处理失败', icon: 'none' })
}
finally {
busy.value = false
}
}
function onToleranceChange() {
if (!src.value)
return
if (debounceTimer)
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => preview(), 350)
}
async function save() {
if (!src.value)
return
const r = await runTask('全尺寸处理中', () => removeBackgroundSolid(src.value, tolerance.value))
if (r)
await saveResult(r.path, r.name)
}
async function saveSmart() {
const r = await runTask('AI 抠图中', () => removeBackgroundSmart(src.value))
if (r) {
result.value = r
await saveResult(r.path, r.name)
}
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
去除背景
</text>
</view>
<view v-if="!src" class="pick press" hover-class="press-active" @tap="pick">
<wd-icon name="add" size="56rpx" color="#ff6b35" />
<text class="pick__text">
选择图片纯色背景效果最佳
</text>
</view>
<template v-else>
<view class="preview checker-bg" @tap="pick">
<image :src="result?.path || src" mode="aspectFit" class="preview__img" />
<view class="preview__badge">
<text>{{ busy ? '处理中…' : '点击重新选择' }}</text>
</view>
</view>
<view class="card">
<view class="slider-row">
<text class="slider-label">
容差 {{ tolerance }}
</text>
<slider class="slider" :value="tolerance" :min="5" :max="100" active-color="#ff6b35" background-color="#ffe3d3" @change="onToleranceSlider" />
</view>
<text class="tip">
拖动容差让背景去除更干净边缘发虚请调小确认后保存时会按原图尺寸重新处理
</text>
</view>
<wd-button type="primary" block :loading="busy" @click="preview">
重新预览
</wd-button>
<wd-button type="primary" block @click="save">
保存高清结果
</wd-button>
<wd-button v-if="isH5" block plain @click="saveSmart">
智能抠图AI · 网页版
</wd-button>
</template>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布页面内隐藏节点onReady 时由 bindImageCanvas 绑定 -->
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 28rpx;
}
.nav {
display: flex;
align-items: center;
padding: 0 0 16rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
margin-right: 64rpx;
}
.pick {
background: #ffffff;
border: 2rpx dashed #ffd4b8;
border-radius: 32rpx;
padding: 100rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.pick__text {
font-size: 26rpx;
color: #9a7b65;
padding: 0 40rpx;
text-align: center;
}
.preview {
position: relative;
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 32rpx;
padding: 20rpx;
}
.preview__img {
width: 100%;
height: 520rpx;
border-radius: 24rpx;
}
.preview__badge {
position: absolute;
right: 32rpx;
bottom: 32rpx;
font-size: 22rpx;
color: #9a7b65;
background: rgba(255, 255, 255, 0.92);
border-radius: 999rpx;
padding: 8rpx 20rpx;
}
.card {
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 32rpx;
padding: 28rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.slider-row {
display: flex;
align-items: center;
gap: 24rpx;
}
.slider-label {
flex-shrink: 0;
width: 150rpx;
font-size: 24rpx;
color: #9a7b65;
}
.tip {
font-size: 22rpx;
color: #9a7b65;
line-height: 1.6;
}
</style>

View File

@@ -0,0 +1,246 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { beginDraw, bindImageCanvas, exportFile, saveResult } from '@/utils/image/core'
import { makeMatrix } from '@/utils/image/qrcode'
import { runTask } from '@/utils/image/task'
import { navPadTop } from '@/utils/safearea'
const SIZE = 720
const navPad = navPadTop()
const text = ref('')
const fg = ref('#3D2A1E')
const bg = ref('#FFFFFF')
const previewPath = ref('')
const generating = ref(false)
const PALETTES = [
{ fg: '#3D2A1E', bg: '#FFFFFF', name: '暖炭' },
{ fg: '#FF6B35', bg: '#FFF9F2', name: '元气' },
{ fg: '#0D9488', bg: '#F0FDFA', name: '薄荷' },
{ fg: '#111111', bg: '#FFFFFF', name: '经典' },
]
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
async function generate() {
const content = text.value.trim()
if (!content) {
uni.showToast({ title: '请输入文本或链接', icon: 'none' })
return
}
generating.value = true
const r = await runTask('生成中…', async () => {
const { count, isDark } = makeMatrix(content)
const quiet = 2 // 四周静区(模块数)
const total = count + quiet * 2
const cell = SIZE / total
const { ctx } = beginDraw(SIZE, SIZE)
ctx.save()
ctx.fillStyle = bg.value
ctx.fillRect(0, 0, SIZE, SIZE)
ctx.fillStyle = fg.value
for (let row = 0; row < count; row++) {
for (let col = 0; col < count; col++) {
if (isDark(row, col))
ctx.fillRect((col + quiet) * cell, (row + quiet) * cell, Math.ceil(cell), Math.ceil(cell))
}
}
ctx.restore()
return exportFile('png', 1)
})
if (r)
previewPath.value = r
generating.value = false
}
async function save() {
if (!previewPath.value)
return
await runTask('保存中…', async () => {
await saveResult(previewPath.value, 'qrcode.png')
return previewPath.value
})
}
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
二维码生成
</text>
<view class="nav__slot" />
</view>
<view class="card">
<text class="card__title">
内容
</text>
<textarea
v-model="text"
class="content"
placeholder="输入文本、网址、Wi-Fi 信息…"
placeholder-style="color:#C9AE9C"
:maxlength="500"
/>
</view>
<view class="card">
<text class="card__title">
配色
</text>
<view class="palettes">
<view
v-for="p in PALETTES"
:key="p.name"
class="palette press"
:class="{ 'palette--on': fg === p.fg && bg === p.bg }"
hover-class="press-active"
@tap="((fg = p.fg), (bg = p.bg))"
>
<view class="palette__dot" :style="{ background: p.fg }" />
<text>{{ p.name }}</text>
</view>
</view>
</view>
<view class="actions">
<wd-button type="primary" block :loading="generating" @click="generate">
生成二维码
</wd-button>
</view>
<view v-if="previewPath" class="result">
<image :src="previewPath" mode="aspectFit" class="result__img" />
<wd-button type="primary" block custom-style="border-radius: 999rpx;" @click="save">
保存到相册
</wd-button>
</view>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布页面内隐藏节点onReady 时由 bindImageCanvas 绑定 -->
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 20rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
}
.nav__slot {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
}
.card {
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.5);
border-radius: 30rpx;
padding: 26rpx 28rpx;
margin-top: 24rpx;
box-shadow: 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
}
.card__title {
display: block;
font-size: 26rpx;
font-weight: 700;
color: #3d2a1e;
margin-bottom: 18rpx;
}
.content {
width: 100%;
box-sizing: border-box;
height: 160rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 20rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
color: #3d2a1e;
}
.palettes {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
}
.palette {
display: flex;
align-items: center;
gap: 10rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
border-radius: 999rpx;
padding: 10rpx 24rpx;
font-size: 24rpx;
color: #3d2a1e;
}
.palette--on {
border-color: #ff6b35;
box-shadow: 0 6rpx 14rpx rgba(251, 78, 20, 0.2);
}
.palette__dot {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
}
.actions {
margin-top: 28rpx;
}
.result {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
margin-top: 32rpx;
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.5);
border-radius: 30rpx;
padding: 32rpx;
box-shadow: 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
}
.result__img {
width: 440rpx;
height: 440rpx;
border-radius: 20rpx;
}
</style>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
import type { ResizeMode } from '@/utils/image/types'
import { onReady } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { bindImageCanvas, chooseOneImage, saveResult } from '@/utils/image/core'
import { resizeImage } from '@/utils/image/resize'
import { runTask } from '@/utils/image/task'
import { navPadTop } from '@/utils/safearea'
const src = ref('')
const mode = ref<ResizeMode>('maxEdge')
const value = ref('')
const result = ref<{ path: string, name: string, width: number, height: number } | null>(null)
const navPad = navPadTop()
function goBack() {
uni.navigateBack()
}
const modes = [
{ key: 'maxEdge' as const, label: '最长边', hint: '如 1920' },
{ key: 'width' as const, label: '指定宽', hint: '如 1080' },
{ key: 'height' as const, label: '指定高', hint: '如 1920' },
{ key: 'percent' as const, label: '百分比', hint: '如 50%' },
]
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
async function pick() {
try {
const p = await chooseOneImage()
src.value = p.path
result.value = null
}
catch {
/* 取消 */
}
}
async function run() {
if (!src.value) {
uni.showToast({ title: '请先选择图片', icon: 'none' })
return
}
const v = Number(value.value)
if (!v || v <= 0) {
uni.showToast({ title: '请输入有效的数值', icon: 'none' })
return
}
const r = await runTask('处理中', () => resizeImage(src.value, { mode: mode.value, value: v }))
if (r)
result.value = r
}
async function save() {
if (!result.value)
return
await saveResult(result.value.path, result.value.name)
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
修改尺寸
</text>
</view>
<view v-if="!src" class="pick press" hover-class="press-active" @tap="pick">
<wd-icon name="add" size="56rpx" color="#ff6b35" />
<text class="pick__text">
选择图片
</text>
</view>
<view v-else class="preview" @tap="pick">
<image :src="result?.path || src" mode="aspectFit" class="preview__img" :class="{ 'checker-bg': !!result }" />
<text class="preview__tip">
点击重新选择
</text>
</view>
<view class="modes">
<view
v-for="m in modes"
:key="m.key"
class="m press"
:class="{ 'm--on': mode === m.key }"
hover-class="press-active"
@tap="mode = m.key"
>
<text>{{ m.label }}</text>
</view>
</view>
<input v-model="value" class="value" type="number" :placeholder="modes.find((m) => m.key === mode)?.hint">
<wd-button type="primary" block @click="run">
开始处理
</wd-button>
<view v-if="result" class="result-info">
<text>已输出 {{ result.width }} × {{ result.height }}</text>
<wd-button type="primary" block @click="save">
保存
</wd-button>
</view>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布页面内隐藏节点onReady 时由 bindImageCanvas 绑定 -->
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 28rpx;
}
.nav {
display: flex;
align-items: center;
padding: 0 0 16rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
margin-right: 64rpx;
}
.pick {
background: #ffffff;
border: 2rpx dashed #ffd4b8;
border-radius: 32rpx;
padding: 100rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.pick__text {
font-size: 26rpx;
color: #9a7b65;
}
.preview {
position: relative;
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 32rpx;
padding: 20rpx;
}
.preview__img {
width: 100%;
height: 480rpx;
border-radius: 24rpx;
}
.preview__tip {
position: absolute;
right: 32rpx;
bottom: 32rpx;
font-size: 22rpx;
color: #9a7b65;
background: rgba(240, 253, 250, 0.9);
border-radius: 999rpx;
padding: 8rpx 20rpx;
}
.modes {
display: flex;
gap: 16rpx;
}
.m {
flex: 1;
text-align: center;
padding: 22rpx 0;
border-radius: 20rpx;
background: #ffffff;
border: 2rpx solid #ffe3d3;
color: #3d2a1e;
font-size: 26rpx;
min-height: 44px;
box-sizing: border-box;
}
.m--on {
background: #ff6b35;
border-color: #ff6b35;
color: #ffffff;
}
.value {
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 24rpx;
padding: 24rpx 28rpx;
font-size: 30rpx;
color: #3d2a1e;
}
.result-info {
display: flex;
flex-direction: column;
gap: 20rpx;
font-size: 28rpx;
color: #ff6b35;
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,240 @@
<script setup lang="ts">
import { onReady } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { bindImageCanvas, chooseOneImage, saveResult } from '@/utils/image/core'
import { runTask } from '@/utils/image/task'
import { addWatermark } from '@/utils/image/watermark'
import { navPadTop } from '@/utils/safearea'
const src = ref('')
const result = ref<{ path: string, name: string } | null>(null)
const text = ref('')
const mode = ref<'tile' | 'center'>('tile')
const opacity = ref(35)
const angle = ref(-30)
const navPad = navPadTop()
function goBack() {
uni.navigateBack()
}
function onOpacityChange(e: { detail: { value: number } }) {
opacity.value = e.detail.value
}
function onAngleChange(e: { detail: { value: number } }) {
angle.value = e.detail.value
}
onReady(async () => {
// #ifdef MP-WEIXIN
await bindImageCanvas()
// #endif
})
async function pick() {
try {
const p = await chooseOneImage()
src.value = p.path
result.value = null
}
catch {
/* 取消 */
}
}
async function run() {
if (!src.value) {
uni.showToast({ title: '请先选择图片', icon: 'none' })
return
}
const r = await runTask('生成中', () =>
addWatermark(src.value, {
text: text.value,
mode: mode.value,
opacity: opacity.value / 100,
rotate: angle.value,
}))
if (r)
result.value = r
}
async function save() {
if (!result.value)
return
await saveResult(result.value.path, result.value.name)
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
文字水印
</text>
</view>
<view v-if="!src" class="pick press" hover-class="press-active" @tap="pick">
<wd-icon name="add" size="56rpx" color="#ff6b35" />
<text class="pick__text">
选择图片
</text>
</view>
<view v-else class="preview" @tap="pick">
<image :src="result?.path || src" mode="aspectFit" class="preview__img" />
<text class="preview__tip">
点击重新选择
</text>
</view>
<view class="card">
<wd-input v-model="text" placeholder="水印文字,如 @小工具" clearable />
<view class="modes">
<view class="m press" :class="{ 'm--on': mode === 'tile' }" hover-class="press-active" @tap="mode = 'tile'">
<text>平铺</text>
</view>
<view class="m press" :class="{ 'm--on': mode === 'center' }" hover-class="press-active" @tap="mode = 'center'">
<text>居中</text>
</view>
</view>
<view class="slider-row">
<text class="slider-label">
透明度
</text>
<slider class="slider" :value="opacity" :min="5" :max="100" active-color="#ff6b35" background-color="#ffe3d3" @change="onOpacityChange" />
</view>
<view v-if="mode === 'tile'" class="slider-row">
<text class="slider-label">
角度 {{ angle }}°
</text>
<slider class="slider" :value="angle" :min="-90" :max="90" :step="5" active-color="#ff6b35" background-color="#ffe3d3" @change="onAngleChange" />
</view>
</view>
<wd-button type="primary" block @click="run">
生成水印
</wd-button>
<wd-button v-if="result" type="primary" block @click="save">
保存
</wd-button>
<!-- #ifdef MP-WEIXIN -->
<!-- 图片引擎工作画布页面内隐藏节点onReady 时由 bindImageCanvas 绑定 -->
<canvas id="imgCanvas" type="2d" class="hidden-canvas" />
<!-- #endif -->
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 0 32rpx calc(60rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 28rpx;
}
.nav {
display: flex;
align-items: center;
padding: 0 0 16rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
flex: 1;
text-align: center;
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
margin-right: 64rpx;
}
.pick {
background: #ffffff;
border: 2rpx dashed #ffd4b8;
border-radius: 32rpx;
padding: 100rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
}
.pick__text {
font-size: 26rpx;
color: #9a7b65;
}
.preview {
position: relative;
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 32rpx;
padding: 20rpx;
}
.preview__img {
width: 100%;
height: 480rpx;
border-radius: 24rpx;
}
.preview__tip {
position: absolute;
right: 32rpx;
bottom: 32rpx;
font-size: 22rpx;
color: #9a7b65;
background: rgba(240, 253, 250, 0.9);
border-radius: 999rpx;
padding: 8rpx 20rpx;
}
.card {
background: #ffffff;
border: 2rpx solid #ffe3d3;
border-radius: 32rpx;
padding: 28rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.modes {
display: flex;
gap: 16rpx;
}
.m {
flex: 1;
text-align: center;
padding: 20rpx 0;
border-radius: 20rpx;
background: #fff9f2;
border: 2rpx solid #ffe3d3;
font-size: 26rpx;
color: #3d2a1e;
min-height: 44px;
box-sizing: border-box;
}
.m--on {
background: #ff6b35;
border-color: #ff6b35;
color: #ffffff;
}
.slider-row {
display: flex;
align-items: center;
gap: 24rpx;
}
.slider-label {
flex-shrink: 0;
width: 140rpx;
font-size: 24rpx;
color: #9a7b65;
}
</style>

View File

@@ -0,0 +1,278 @@
<script setup lang="ts">
import { onShow } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { useToolsStore } from '@/stores/tools'
import { useUserStore } from '@/stores/user'
import { navPadTop } from '@/utils/safearea'
const toolsStore = useToolsStore()
const userStore = useUserStore()
const keys = ref<string[]>([])
const saving = ref(false)
/** 导航栏顶部留白MP 用真实状态栏高度 */
const navPad = navPadTop()
function goBack() {
uni.navigateBack()
}
onShow(async () => {
await userStore.ensureLogin()
await toolsStore.fetchAll()
keys.value = [...toolsStore.workbenchKeys]
})
const pinned = computed(() =>
keys.value
.map((key) => {
const server = toolsStore.tools.find(t => t.tool_key === key)
const def = toolsStore.libraryByModule(server?.module_key || '').find(d => d.key === key)
return def ? { ...def, locked: server?.locked ?? true } : null
})
.filter((x): x is NonNullable<typeof x> => x !== null),
)
const available = computed(() =>
toolsStore.tools
.filter(t => !t.locked && !keys.value.includes(t.tool_key))
.map(t => ({ key: t.tool_key, name: t.name, icon: t.icon, desc: t.description })),
)
function add(key: string) {
if (!keys.value.includes(key))
keys.value.push(key)
}
function remove(key: string) {
keys.value = keys.value.filter(k => k !== key)
}
function move(index: number, delta: -1 | 1) {
const target = index + delta
if (target < 0 || target >= keys.value.length)
return
const next = [...keys.value]
;[next[index], next[target]] = [next[target], next[index]]
keys.value = next
}
async function save() {
if (!keys.value.length) {
uni.showToast({ title: '至少保留一个工具', icon: 'none' })
return
}
saving.value = true
try {
await toolsStore.saveWorkbench(keys.value)
uni.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => uni.navigateBack(), 500)
}
finally {
saving.value = false
}
}
</script>
<template>
<view class="page">
<view class="nav" :style="{ paddingTop: navPad }">
<view class="nav__back press" hover-class="press-active" @tap="goBack">
<wd-icon name="arrow-left" size="36rpx" color="#3d2a1e" />
</view>
<text class="nav__title">
编辑工作台
</text>
<!-- 与返回键等宽的占位保证标题居中 -->
<view class="nav__slot" />
</view>
<view class="section">
<text class="tb-section-title">
已固定{{ pinned.length }}
</text>
<view v-if="!pinned.length" class="tb-empty">
<text class="tb-empty__sub">
工作台为空从下方添加工具
</text>
</view>
<view v-for="(t, i) in pinned" :key="t.key" class="row">
<view class="row__ops">
<view class="op press" hover-class="press-active" @tap="move(i, -1)">
<wd-icon name="arrow-up" size="26rpx" color="#9a7b65" />
</view>
<view class="op press" hover-class="press-active" @tap="move(i, 1)">
<wd-icon name="arrow-down" size="26rpx" color="#9a7b65" />
</view>
</view>
<view class="row__icon">
<wd-icon :name="t.icon" size="36rpx" color="#ff6b35" />
</view>
<view class="row__body">
<text class="row__name">
{{ t.name }}
</text>
<text class="row__desc">
{{ t.desc }}
</text>
</view>
<view class="row__remove press" hover-class="press-active" @tap="remove(t.key)">
<wd-icon name="delete" size="32rpx" color="#DC2626" />
</view>
</view>
</view>
<view class="section">
<text class="tb-section-title">
可添加{{ available.length }}
</text>
<view v-if="!available.length" class="tb-empty">
<text class="tb-empty__sub">
没有更多可添加的工具了
</text>
</view>
<view v-for="t in available" :key="t.key" class="row">
<view class="row__icon">
<wd-icon :name="t.icon" size="36rpx" color="#ff6b35" />
</view>
<view class="row__body">
<text class="row__name">
{{ t.name }}
</text>
<text class="row__desc">
{{ t.desc }}
</text>
</view>
<view class="row__add press" hover-class="press-active" @tap="add(t.key)">
<wd-icon name="add" size="32rpx" color="#ff6b35" />
</view>
</view>
</view>
<!-- 底部悬浮保存栏 -->
<view class="save-bar">
<wd-button
type="primary"
block
:loading="saving"
custom-style="border-radius: 999rpx; height: 88rpx; font-size: 30rpx; box-shadow: 0 12rpx 28rpx rgba(251, 78, 20, 0.32);"
@click="save"
>
保存工作台
</wd-button>
</view>
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
/* 底部给悬浮保存栏留出空间 */
padding: 0 32rpx calc(180rpx + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 0 20rpx;
}
.nav__back {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #ffffff;
border: 1rpx solid #ffe9dd;
box-shadow: 0 6rpx 16rpx rgba(107, 47, 15, 0.08);
display: flex;
align-items: center;
justify-content: center;
}
.nav__title {
font-size: 34rpx;
font-weight: 700;
color: #3d2a1e;
}
.nav__slot {
flex-shrink: 0;
width: 64rpx;
height: 64rpx;
}
.section {
margin-top: 32rpx;
}
.section > .tb-section-title {
margin-bottom: 20rpx;
}
.row {
display: flex;
align-items: center;
gap: 20rpx;
background: #ffffff;
border: 1rpx solid rgba(255, 195, 155, 0.55);
border-radius: 30rpx;
padding: 22rpx 24rpx;
margin-top: 18rpx;
box-shadow: 0 10rpx 26rpx rgba(107, 47, 15, 0.06);
}
.row__ops {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.op {
width: 52rpx;
height: 52rpx;
border-radius: 16rpx;
background: #fff9f2;
display: flex;
align-items: center;
justify-content: center;
}
.row__icon {
flex-shrink: 0;
width: 72rpx;
height: 72rpx;
border-radius: 22rpx;
background: #fff9f2;
border: 1rpx solid #ffe3d3;
display: flex;
align-items: center;
justify-content: center;
}
.row__body {
flex: 1;
min-width: 0;
}
.row__name {
font-size: 30rpx;
font-weight: 700;
color: #3d2a1e;
}
.row__desc {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: #9a7b65;
}
.row__remove,
.row__add {
flex-shrink: 0;
width: 68rpx;
height: 68rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.row__add {
background: #ffe3d3;
}
/* 底部悬浮保存栏Home Indicator 之上) */
.save-bar {
position: fixed;
left: 32rpx;
right: 32rpx;
bottom: calc(20rpx + env(safe-area-inset-bottom, 0px));
z-index: 8; /* 低于 wot 弹层 */
}
</style>

View File

@@ -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 }
})

51
src/stores/tools.ts Normal file
View File

@@ -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<ToolsListResult['modules']>([])
const tools = ref<ToolsListResult['list']>([])
const workbenchKeys = ref<string[]>([])
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<ToolDef & { locked: boolean, serverName: string }> {
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<ToolDef & { locked: boolean }> {
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 }
})

149
src/stores/user.ts Normal file
View File

@@ -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<UserInfo | null>(null)
const level = ref<LevelInfo | null>(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<void> {
const code = await new Promise<string>((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<void> {
applyLogin(await apiDevLogin())
}
/** H5 账号密码登录 */
async function loginAccount(username: string, password: string): Promise<void> {
applyLogin(await apiAccountLogin(username, password))
}
/** H5 注册(注册即登录) */
async function registerAccount(username: string, password: string, nickname: string): Promise<void> {
applyLogin(await apiRegister(username, password, nickname))
}
/** 进行中的登录 Promise防止 onLaunch 与页面 onShow 并发触发多次登录 */
let loginPromise: Promise<void> | null = null
/** 确保已登录:有 token 则校验一次H5 无 token 引导去登录页MP 走静默登录 */
async function ensureLogin(): Promise<void> {
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<void> {
// #ifdef H5
// H5 无法静默登录:引导到登录/注册页(开发联调用登录页里的 dev-login
gotoH5Login()
throw new Error('H5 需要账号登录')
// #endif
// #ifndef H5
await loginWx()
// #endif
}
/** 更新个人信息(昵称 / 头像),成功后同步本地 */
async function updateProfile(nickname: string, avatarUrl: string): Promise<void> {
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,
}
})

10
src/types/vendor.d.ts vendored Normal file
View File

@@ -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
}

View File

@@ -74,3 +74,23 @@ $uni-color-subtitle: #555; // 二级标题颜色
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-font-size-paragraph: 15px;
/* ============ 「橘子汽水」设计 tokensdocs/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;

15
src/utils/avatar.ts Normal file
View File

@@ -0,0 +1,15 @@
/** 预置贴纸头像docs/design-language.mdavatar_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
}

275
src/utils/image/avatarfx.ts Normal file
View File

@@ -0,0 +1,275 @@
/**
* 头像特效:定义 + Canvas 绘制
* 输出 720×720 头像 PNG圆形 / 圆角方形两种形状:照片裁剪 + 彩色环 + 角标装饰 + 文字徽章)。
* 装饰全部用 Canvas 原语绘制(小程序 canvas 不能画 SVG双端表现一致。
* 坐标约定decor/badge 的 x/y 为百分比0-100CSS 实时预览与 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()
}

View File

@@ -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)
})
}

115
src/utils/image/compress.ts Normal file
View File

@@ -0,0 +1,115 @@
import type { CompressOptions } from './types'
/**
* 图片压缩
* - MP优先 wx.compressImage 原生压缩(快、省内存),不达标再叠 canvas 质量二分
* - H5canvas 缩放 + 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<string> {
return new Promise((resolve, reject) => {
const opt: Record<string, unknown> = {
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<string, unknown>) => 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)
// 按目标体积压缩时统一转 JPEGPNG 无质量参数)
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 }
}

View File

@@ -0,0 +1,54 @@
/**
* 图片格式转换:解码 → canvas 重绘 → 导出
* - WebP 输出仅 H5toBlob小程序端 canvasToTempFilePath 仅 jpg/png
* - HEIC 输入仅 H5heic2any 动态解码)
* - 转 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<ConvertResult> {
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<ConvertResult> {
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<ConvertResult> {
// #ifdef MP-WEIXIN
if (target === 'webp') {
return Promise.reject(new Error('小程序端暂不支持导出 WebP请在网页版使用'))
}
return convertMP(src, target)
// #endif
// #ifdef H5
return convertH5(src, target)
// #endif
}

303
src/utils/image/core.ts Normal file
View File

@@ -0,0 +1,303 @@
import type { ExportType, LoadResult, PickedImage } from './types'
/**
* 图片引擎平台隔离层
* - MP使用页面内 <canvas type="2d" id="imgCanvas"> 节点(页面 onReady 时调用 bindImageCanvas
* - H5动态创建离屏 canvas
* 平台差异通过「平台分函数 + 条件编译」隔离,避免同一作用域重复声明导致 type-check 失败。
*/
import { getCurrentInstance } from 'vue'
const MAX_EDGE_SAFE = 8000 // 超大图预检上限
/** 当前工作画布MP 为页面绑定的 canvas 节点H5 为最近一次创建的离屏 canvas */
let currentCanvasRef: any = null
/** MP 端绑定页面画布节点H5 端无需处理 */
async function bindImageCanvasMP(selector: string): Promise<unknown> {
const inst = getCurrentInstance()?.proxy
const node: any = await new Promise((resolve) => {
uni
.createSelectorQuery()
.in(inst)
.select(selector)
.fields({ node: true, size: true }, () => {})
.exec((res: any[]) => resolve(res?.[0]?.node || null))
})
if (!node)
throw new Error('未找到画布节点,请检查页面 <canvas id="imgCanvas">')
currentCanvasRef = node
return node
}
export async function bindImageCanvas(selector = '#imgCanvas'): Promise<unknown> {
// #ifdef MP-WEIXIN
return bindImageCanvasMP(selector)
// #endif
// #ifdef H5
void selector
return null
// #endif
}
export function assertImageSize(w: number, h: number) {
if (Math.max(w, h) > MAX_EDGE_SAFE) {
throw new Error('图片尺寸过大,请先缩小尺寸后再处理')
}
}
function loadImageMP(src: string): Promise<LoadResult> {
return new Promise((resolve, reject) => {
if (!currentCanvasRef)
return reject(new Error('画布未初始化'))
const img = currentCanvasRef.createImage()
img.onload = () => {
assertImageSize(img.width, img.height)
resolve({ img, width: img.width, height: img.height, src })
}
img.onerror = () => reject(new Error('图片加载失败'))
img.src = src
})
}
function loadImageH5(src: string): Promise<LoadResult> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
assertImageSize(img.naturalWidth, img.naturalHeight)
resolve({ img, width: img.naturalWidth, height: img.naturalHeight, src })
}
img.onerror = () => reject(new Error('图片加载失败'))
img.src = src
})
}
/** 加载图片MP 临时文件路径 / H5 blob URL 或普通 URL */
export async function loadImage(src: string): Promise<LoadResult> {
// #ifdef MP-WEIXIN
return loadImageMP(src)
// #endif
// #ifdef H5
return loadImageH5(src)
// #endif
}
interface WorkingCanvas {
canvas: any
ctx: CanvasRenderingContext2D
}
function createWorkingMP(w: number, h: number): WorkingCanvas {
if (!currentCanvasRef)
throw new Error('画布未初始化')
currentCanvasRef.width = w
currentCanvasRef.height = h
const ctx = currentCanvasRef.getContext('2d') as CanvasRenderingContext2D
ctx.imageSmoothingEnabled = true
if ('imageSmoothingQuality' in ctx)
(ctx as any).imageSmoothingQuality = 'high'
return { canvas: currentCanvasRef, ctx }
}
function createWorkingH5(w: number, h: number): WorkingCanvas {
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
currentCanvasRef = canvas // H5 端以「最近一次工作画布」为导出目标
return { canvas, ctx }
}
function createWorking(w: number, h: number): WorkingCanvas {
// #ifdef MP-WEIXIN
return createWorkingMP(w, h)
// #endif
// #ifdef H5
return createWorkingH5(w, h)
// #endif
}
/** 开始一次绘制清空画布并设为目标尺寸whiteBg 用于含透明图转 JPEG 的白底合成 */
export function beginDraw(w: number, h: number, whiteBg = false): WorkingCanvas {
const { canvas, ctx } = createWorking(w, h)
if (whiteBg) {
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, w, h)
}
return { canvas, ctx }
}
export function drawImageSource(ctx: CanvasRenderingContext2D, img: unknown, w: number, h: number) {
ctx.drawImage(img as CanvasImageSource, 0, 0, w, h)
}
export function getPixels(canvas: any): ImageData {
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
return ctx.getImageData(0, 0, canvas.width, canvas.height)
}
export function putPixels(canvas: any, data: ImageData) {
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
ctx.putImageData(data, 0, 0)
}
function exportFileMP(type: 'png' | 'jpg', quality: number): Promise<string> {
return new Promise((resolve, reject) => {
if (!currentCanvasRef)
return reject(new Error('画布未初始化'))
wx.canvasToTempFilePath({
canvas: currentCanvasRef,
fileType: type,
quality,
success: res => resolve(res.tempFilePath),
fail: err => reject(new Error(err.errMsg || '导出失败')),
})
})
}
function exportFileH5(type: ExportType, quality: number): Promise<string> {
const mime = type === 'jpg' ? 'image/jpeg' : type === 'webp' ? 'image/webp' : 'image/png'
const canvas = currentCanvasRef as HTMLCanvasElement | null
if (!canvas)
return Promise.reject(new Error('画布未初始化'))
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob)
return reject(new Error('导出失败'))
resolve(URL.createObjectURL(blob))
},
mime,
quality,
)
})
}
/** 导出为临时文件MP/ objectURLH5小程序端不支持 webp */
export async function exportFile(type: ExportType = 'png', quality = 0.92): Promise<string> {
// #ifdef MP-WEIXIN
if (type === 'webp')
throw new Error('小程序端暂不支持导出 WebP')
return exportFileMP(type, quality)
// #endif
// #ifdef H5
return exportFileH5(type, quality)
// #endif
}
export function currentCanvas(): unknown {
return currentCanvasRef
}
function getFileSizeMP(path: string): Promise<number> {
return new Promise((resolve, reject) => {
wx.getFileSystemManager().getFileInfo({
filePath: path,
success: r => resolve(r.size),
fail: e => reject(new Error(e.errMsg || '读取文件失败')),
})
})
}
async function getFileSizeH5(path: string): Promise<number> {
const blob = await (await fetch(path)).blob()
return blob.size
}
/** 文件大小(字节) */
export async function getFileSize(path: string): Promise<number> {
// #ifdef MP-WEIXIN
return getFileSizeMP(path)
// #endif
// #ifdef H5
return getFileSizeH5(path)
// #endif
}
function pickImages(count: number): Promise<PickedImage[]> {
return new Promise((resolve, reject) => {
uni.chooseImage({
count,
sizeType: ['original'],
success: (res) => {
const files = (res.tempFiles || []) as Array<{ path?: string, size?: number }>
const paths = res.tempFilePaths || []
const list = files
.map((f, i) => ({ path: f.path || paths[i] || '', size: f.size || 0 }))
.filter(x => x.path)
if (!list.length)
return reject(new Error('未选择图片'))
resolve(list)
},
fail: e => reject(new Error(e.errMsg || '取消选择')),
})
})
}
/** 选图(单张) */
export async function chooseOneImage(): Promise<PickedImage> {
const list = await pickImages(1)
return list[0]
}
/** 选图(多张,用于批量压缩) */
export function chooseImages(count = 9): Promise<PickedImage[]> {
return pickImages(count)
}
function saveResultMP(path: string): Promise<void> {
return new Promise((resolve) => {
uni.saveImageToPhotosAlbum({
filePath: path,
success: () => {
uni.showToast({ title: '已保存到相册', icon: 'success' })
resolve()
},
fail: (e) => {
const msg = e.errMsg || ''
if (msg.includes('auth') || msg.includes('deny')) {
uni.showModal({
title: '需要相册权限',
content: '请在设置中允许保存到相册',
confirmText: '去设置',
success: (r) => {
if (r.confirm)
uni.openSetting({})
resolve()
},
})
}
else {
uni.showToast({ title: '保存失败', icon: 'none' })
resolve()
}
},
})
})
}
function saveResultH5(path: string, fileName: string): void {
const a = document.createElement('a')
a.href = path
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
uni.showToast({ title: '已开始下载', icon: 'none' })
}
/** 保存结果MP 存相册含授权引导H5 触发下载 */
export async function saveResult(path: string, fileName: string): Promise<void> {
// #ifdef MP-WEIXIN
return saveResultMP(path)
// #endif
// #ifdef H5
return saveResultH5(path, fileName)
// #endif
}
/** 按扩展名推断导出格式 */
export function typeByExt(path: string): ExportType {
return /\.png$/i.test(path) ? 'png' : 'jpg'
}

213
src/utils/image/matting.ts Normal file
View File

@@ -0,0 +1,213 @@
/**
* 去除背景
* - 纯色模式(双端):边缘采样定背景色 → 容差阈值 → 边缘 BFS flood fill 只清连通背景 → mask 羽化 → PNG
* - 智能模式H5 专属onnxruntime-web(wasm) + u2netp 模型推理出 alpha mask
*/
import { beginDraw, drawImageSource, exportFile, getPixels, loadImage, putPixels } from './core'
import { resultName } from './task'
/** 智能抠图模型u2netp onnx地址为空表示未配置UI 需引导 */
export const SMART_MODEL_URL = ''
type RGB = [number, number, number]
function rgbDist(r: number, g: number, b: number, c: RGB): number {
const dr = r - c[0]
const dg = g - c[1]
const db = b - c[2]
return Math.sqrt(dr * dr + dg * dg + db * db)
}
/** 采样四角 + 四边中点 16x16 块,按 32 级量化取最高频簇的均值 */
function sampleBackground(data: Uint8ClampedArray, w: number, h: number): RGB {
const blocks: Array<[number, number]> = [
[0, 0],
[Math.max(0, w - 16), 0],
[0, Math.max(0, h - 16)],
[Math.max(0, w - 16), Math.max(0, h - 16)],
[Math.max(0, Math.floor(w / 2) - 8), 0],
[Math.max(0, Math.floor(w / 2) - 8), Math.max(0, h - 16)],
[0, Math.max(0, Math.floor(h / 2) - 8)],
[Math.max(0, w - 16), Math.max(0, Math.floor(h / 2) - 8)],
]
const samples: RGB[] = []
for (const [bx, by] of blocks) {
let r = 0
let g = 0
let b = 0
let n = 0
for (let y = by; y < Math.min(by + 16, h); y++) {
for (let x = bx; x < Math.min(bx + 16, w); x++) {
const i = (y * w + x) * 4
r += data[i]
g += data[i + 1]
b += data[i + 2]
n++
}
}
if (n > 0)
samples.push([r / n, g / n, b / n])
}
const buckets = new Map<number, number>()
for (const [r, g, b] of samples) {
const key = ((r >> 5) << 10) | ((g >> 5) << 5) | (b >> 5)
buckets.set(key, (buckets.get(key) || 0) + 1)
}
let bestKey = -1
let bestN = -1
for (const [k, n] of buckets) {
if (n > bestN) {
bestN = n
bestKey = k
}
}
const hit = samples.filter(([r, g, b]) => (((r >> 5) << 10) | ((g >> 5) << 5) | (b >> 5)) === bestKey)
const avg = hit.reduce((acc, s) => [acc[0] + s[0], acc[1] + s[1], acc[2] + s[2]] as RGB, [0, 0, 0] as RGB)
return [avg[0] / hit.length, avg[1] / hit.length, avg[2] / hit.length]
}
/** 一维盒模糊(水平 + 垂直各一遍) */
function blurMask(mask: Float32Array, w: number, h: number, r: number): Float32Array {
const tmp = new Float32Array(mask.length)
const out = new Float32Array(mask.length)
const win = 2 * r + 1
for (let y = 0; y < h; y++) {
let sum = 0
for (let x = -r; x <= r; x++) sum += mask[y * w + Math.min(w - 1, Math.max(0, x))]
for (let x = 0; x < w; x++) {
tmp[y * w + x] = sum / win
sum -= mask[y * w + Math.min(w - 1, Math.max(0, x - r))]
sum += mask[y * w + Math.min(w - 1, Math.max(0, x + r + 1))]
}
}
for (let x = 0; x < w; x++) {
let sum = 0
for (let y = -r; y <= r; y++) sum += tmp[Math.min(h - 1, Math.max(0, y)) * w + x]
for (let y = 0; y < h; y++) {
out[y * w + x] = sum / win
sum -= tmp[Math.min(h - 1, Math.max(0, y - r)) * w + x]
sum += tmp[Math.min(h - 1, Math.max(0, y + r + 1)) * w + x]
}
}
return out
}
/**
* 纯色模式去背景(双端可用)
* @param src 图片路径
* @param tolerance 容差 0~100映射 RGB 距离 0~150
*/
export async function removeBackgroundSolid(src: string, tolerance = 25): Promise<{ path: string, name: string }> {
const { img, width, height } = await loadImage(src)
const { canvas, ctx } = beginDraw(width, height, false)
drawImageSource(ctx, img, width, height)
const image = getPixels(canvas)
const data = image.data
const w = width
const h = height
const bg = sampleBackground(data, w, h)
const tol = Math.max(8, tolerance * 1.5)
// 从四边出发的 BFS只清除与边缘连通的背景像素
const mask = new Float32Array(w * h)
const queue = new Int32Array(w * h)
let head = 0
let tail = 0
const pushIfBg = (x: number, y: number) => {
const idx = y * w + x
if (mask[idx])
return
const i = idx * 4
if (rgbDist(data[i], data[i + 1], data[i + 2], bg) <= tol) {
mask[idx] = 1
queue[tail++] = idx
}
}
for (let x = 0; x < w; x++) {
pushIfBg(x, 0)
pushIfBg(x, h - 1)
}
for (let y = 0; y < h; y++) {
pushIfBg(0, y)
pushIfBg(w - 1, y)
}
while (head < tail) {
const idx = queue[head++]
const x = idx % w
const y = (idx - x) / w
if (x > 0)
pushIfBg(x - 1, y)
if (x < w - 1)
pushIfBg(x + 1, y)
if (y > 0)
pushIfBg(x, y - 1)
if (y < h - 1)
pushIfBg(x, y + 1)
}
// 羽化边缘1px消除锯齿
const soft = blurMask(mask, w, h, 1)
for (let idx = 0; idx < w * h; idx++) {
if (soft[idx] > 0) {
data[idx * 4 + 3] = Math.round(255 * (1 - Math.min(1, soft[idx])))
}
}
putPixels(canvas, image)
const path = await exportFile('png', 1)
return { path, name: resultName(src, 'matting', 'png') }
}
/** 智能模式(仅 H5u2netp 推理 alpha mask */
export async function removeBackgroundSmart(src: string): Promise<{ path: string, name: string }> {
// #ifdef MP-WEIXIN
void src
throw new Error('智能抠图仅网页版支持,请使用纯色模式')
// #endif
// #ifdef H5
if (!SMART_MODEL_URL)
throw new Error('智能抠图模型未配置SMART_MODEL_URL请使用纯色模式')
const ort: any = await import('onnxruntime-web')
const resp = await fetch(SMART_MODEL_URL)
if (!resp.ok)
throw new Error('模型下载失败,请检查 SMART_MODEL_URL')
const buf = await resp.arrayBuffer()
const session = await ort.InferenceSession.create(buf, { executionProviders: ['wasm'] })
const S = 320
const { img, width, height } = await loadImage(src)
const { canvas, ctx } = beginDraw(S, S, false)
drawImageSource(ctx, img, S, S)
const px = getPixels(canvas).data
const input = new Float32Array(3 * S * S)
const mean = [0.485, 0.456, 0.406]
const std = [0.229, 0.224, 0.225]
for (let i = 0; i < S * S; i++) {
input[i] = (px[i * 4] / 255 - mean[0]) / std[0]
input[S * S + i] = (px[i * 4 + 1] / 255 - mean[1]) / std[1]
input[2 * S * S + i] = (px[i * 4 + 2] / 255 - mean[2]) / std[2]
}
const tensor = new ort.Tensor('float32', input, [1, 3, S, S])
const feeds: Record<string, unknown> = {}
feeds[session.inputNames[0]] = tensor
const out = await session.run(feeds)
const map = out[session.outputNames[0]].data as Float32Array
// mask 上采样到原图并应用 alpha
const { canvas: fc, ctx: fx } = beginDraw(width, height, false)
fx.drawImage(img as CanvasImageSource, 0, 0, width, height)
const full = getPixels(fc)
const fd = full.data
for (let y = 0; y < height; y++) {
const sy = Math.min(S - 1, Math.floor((y * S) / height))
for (let x = 0; x < width; x++) {
const sx = Math.min(S - 1, Math.floor((x * S) / width))
const m = map[sy * S + sx]
const i = (y * width + x) * 4
fd[i + 3] = Math.max(0, Math.min(255, Math.round(m * 255)))
}
}
putPixels(fc, full)
const path = await exportFile('png', 1)
return { path, name: resultName(src, 'matting-ai', 'png') }
// #endif
}

21
src/utils/image/qrcode.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* 二维码矩阵生成:封装 vendor/qrcode-coreqrcode-generator 实现)
* 类型声明见 types/vendor.d.ts
*/
import QRCode from '@/utils/vendor/qrcode-core.js'
export type QrEcc = 'L' | 'M' | 'Q' | 'H'
const ECC: Record<QrEcc, number> = { L: 1, M: 0, Q: 3, H: 2 }
export interface QrMatrix {
count: number
isDark: (row: number, col: number) => boolean
}
export function makeMatrix(text: string, ecc: QrEcc = 'M'): QrMatrix {
const q = new QRCode(-1, ECC[ecc])
q.addData(text)
q.make()
return { count: q.getModuleCount(), isDark: (r: number, c: number) => q.isDark(r, c) }
}

52
src/utils/image/resize.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { ResizeOptions } from './types'
/**
* 修改尺寸:等比缩放(指定宽 / 高 / 百分比 / 最长边)
* 小程序 canvas 支持从源图直接高质量重绘,无需两步缩放。
*/
import { beginDraw, drawImageSource, exportFile, loadImage, typeByExt } from './core'
import { resultName } from './task'
export async function resizeImage(
src: string,
opts: ResizeOptions,
): Promise<{ path: string, name: string, width: number, height: number }> {
const { img, width, height } = await loadImage(src)
let w = width
let h = height
const clamp = (v: number) => Math.max(1, Math.round(v))
switch (opts.mode) {
case 'width': {
const s = opts.value / width
w = opts.value
h = height * s
break
}
case 'height': {
const s = opts.value / height
h = opts.value
w = width * s
break
}
case 'percent': {
const s = opts.value / 100
w = width * s
h = height * s
break
}
case 'maxEdge': {
if (Math.max(width, height) > opts.value) {
const s = opts.value / Math.max(width, height)
w = width * s
h = height * s
}
break
}
}
w = clamp(w)
h = clamp(h)
const type = typeByExt(src)
const { ctx } = beginDraw(w, h, type === 'jpg')
drawImageSource(ctx, img, w, h)
const path = await exportFile(type, 0.92)
return { path, name: resultName(src, 'resize', type), width: w, height: h }
}

33
src/utils/image/task.ts Normal file
View File

@@ -0,0 +1,33 @@
/** 图片工具通用任务包装loading + 错误提示 */
export function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024)
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
if (bytes >= 1024)
return `${(bytes / 1024).toFixed(1)} KB`
return `${bytes} B`
}
/** 执行一个图片处理任务:自动 loading / 错误 toast返回 null 表示失败(页面保持原状态) */
export async function runTask<T>(title: string, fn: () => Promise<T>): Promise<T | null> {
uni.showLoading({ title, mask: true })
try {
const out = await fn()
return out
}
catch (e) {
const msg = (e as Error)?.message || '处理失败'
uni.showToast({ title: msg, icon: 'none' })
return null
}
finally {
uni.hideLoading()
}
}
/** 结果文件名:原名-功能.ext */
export function resultName(src: string, tag: string, ext: string): string {
const base = src.split('/').pop() || 'image'
const noExt = base.replace(/\.[^.]+$/, '')
return `${noExt}-${tag}.${ext}`
}

View File

@@ -0,0 +1,41 @@
import type { CropRect, TransformOptions } from './types'
/**
* 裁剪 / 旋转 / 翻转canvas 变换矩阵一次性重绘,全部基于源位图(非截屏,保画质)
*/
import { beginDraw, exportFile, loadImage, typeByExt } from './core'
import { resultName } from './task'
/** 裁剪rect 为源图像素坐标 */
export async function cropImage(
src: string,
rect: CropRect,
): Promise<{ path: string, name: string }> {
const { img } = await loadImage(src)
const type = typeByExt(src)
const { ctx } = beginDraw(rect.width, rect.height, type === 'jpg')
ctx.drawImage(img as CanvasImageSource, rect.x, rect.y, rect.width, rect.height, 0, 0, rect.width, rect.height)
const path = await exportFile(type, 0.92)
return { path, name: resultName(src, 'crop', type) }
}
/** 旋转90° 步进顺时针)+ 镜像 */
export async function transformImage(
src: string,
opts: TransformOptions,
): Promise<{ path: string, name: string }> {
const { img, width, height } = await loadImage(src)
const rotate = opts.rotate ?? 0
const swap = rotate === 90 || rotate === 270
const outW = swap ? height : width
const outH = swap ? width : height
const type = typeByExt(src)
const { ctx } = beginDraw(outW, outH, type === 'jpg')
ctx.save()
ctx.translate(outW / 2, outH / 2)
ctx.rotate((rotate * Math.PI) / 180)
ctx.scale(opts.flipH ? -1 : 1, opts.flipV ? -1 : 1)
ctx.drawImage(img as CanvasImageSource, -width / 2, -height / 2)
ctx.restore()
const path = await exportFile(type, 0.92)
return { path, name: resultName(src, 'transform', type) }
}

59
src/utils/image/types.ts Normal file
View File

@@ -0,0 +1,59 @@
/** 图片引擎公共类型 */
export interface LoadResult {
img: unknown // MP: canvas.createImage() 实例H5: HTMLImageElement
width: number
height: number
src: string
}
export interface PickedImage {
path: string
size: number
}
export type ExportType = 'png' | 'jpg' | 'webp'
export interface CompressOptions {
/** 目标体积KB与 quality 二选一优先 */
targetKB?: number
/** 基础质量 0~1 */
quality?: number
/** 最长边限制px */
maxEdge?: number
}
export type ResizeMode = 'width' | 'height' | 'percent' | 'maxEdge'
export interface ResizeOptions {
mode: ResizeMode
value: number
}
export interface CropRect {
x: number
y: number
width: number
height: number
}
export interface TransformOptions {
/** 90 度步进旋转(顺时针) */
rotate?: 0 | 90 | 180 | 270
flipH?: boolean
flipV?: boolean
}
export interface WatermarkOptions {
text: string
/** 字号px默认按图宽自适应 */
fontSize?: number
color?: string
/** 0~1 */
opacity?: number
/** 旋转角度(度,平铺模式) */
rotate?: number
mode?: 'tile' | 'center'
/** 平铺间距px */
gap?: number
}

View File

@@ -0,0 +1,56 @@
import type { WatermarkOptions } from './types'
/**
* 文字水印:平铺(循环 fillText带旋转/ 居中
*/
import { beginDraw, drawImageSource, exportFile, loadImage, typeByExt } from './core'
import { resultName } from './task'
export async function addWatermark(
src: string,
opts: WatermarkOptions,
): Promise<{ path: string, name: string }> {
if (!opts.text.trim())
throw new Error('请输入水印文字')
const { img, width, height } = await loadImage(src)
const type = typeByExt(src)
const { ctx } = beginDraw(width, height, type === 'jpg')
drawImageSource(ctx, img, width, height)
const fontSize = opts.fontSize ?? Math.max(14, Math.round(width * 0.04))
const color = opts.color ?? '#ffffff'
const opacity = opts.opacity ?? 0.35
const rotate = ((opts.rotate ?? -30) * Math.PI) / 180
const gap = opts.gap ?? Math.round(fontSize * 3)
ctx.save()
ctx.globalAlpha = opacity
ctx.fillStyle = color
ctx.font = `${fontSize}px sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
if ((opts.mode ?? 'tile') === 'center') {
ctx.translate(width / 2, height / 2)
ctx.rotate(rotate)
ctx.fillText(opts.text, 0, 0)
}
else {
const tw = ctx.measureText(opts.text).width
const stepX = tw + gap
const stepY = fontSize + gap
// 平铺范围放大覆盖旋转后的画布对角线
const R = Math.ceil(Math.sqrt(width * width + height * height))
ctx.translate(width / 2, height / 2)
ctx.rotate(rotate)
for (let y = -R; y <= R; y += stepY) {
for (let x = -R; x <= R; x += stepX) {
// 交错行错位,视觉更自然
ctx.fillText(opts.text, x + (Math.round(y / stepY) % 2 === 0 ? 0 : stepX / 2), y)
}
}
}
ctx.restore()
const path = await exportFile(type, 0.92)
return { path, name: resultName(src, 'watermark', type) }
}

36
src/utils/palette.ts Normal file
View File

@@ -0,0 +1,36 @@
/** 工具图标块的稳定配色:按 key 字符哈希取色,同一工具颜色恒定(暖色系,见 docs/design-language.md */
const PALETTES = [
{ bg: '#FFE8DC', fg: '#E8531A' },
{ bg: '#FFF4D6', fg: '#B57F00' },
{ bg: '#DFF7F0', fg: '#0FA385' },
{ bg: '#E3F2FF', fg: '#1677D9' },
{ bg: '#FFE7F0', fg: '#E0447C' },
{ bg: '#EEE9FF', fg: '#6C4FE0' },
] as const
export function toolPalette(key: string): { bg: string, fg: string } {
let hash = 0
for (const ch of key) hash += ch.charCodeAt(0)
return PALETTES[hash % PALETTES.length]
}
/** 模块楼层卡的专属撞色(按顺序循环),未解锁模块用灰调 */
const MODULE_COLORS = [
{ bg: '#FFE8DC', deep: '#FF6B35', soft: '#FFF3EC' },
{ bg: '#DFF7F0', deep: '#3ECFB2', soft: '#EFFBF8' },
{ bg: '#FFF4D6', deep: '#F2A900', soft: '#FFFAEC' },
{ bg: '#E3F2FF', deep: '#4DA8FF', soft: '#F0F8FF' },
{ bg: '#FFE7F0', deep: '#FF7BA9', soft: '#FFF3F7' },
{ bg: '#EEE9FF', deep: '#8B7CF6', soft: '#F6F3FF' },
] as const
export const MODULE_LOCKED_COLORS = { bg: '#F3EEE9', deep: '#BDA794', soft: '#F8F4F0' } as const
export interface ModuleColor { bg: string, deep: string, soft: string }
export function moduleColor(_moduleKey: string, index: number, locked = false): ModuleColor {
if (locked)
return MODULE_LOCKED_COLORS
// 按楼层顺序循环取色:第一个模块固定品牌橙
return MODULE_COLORS[index % MODULE_COLORS.length]
}

36
src/utils/safearea.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* 头区顶部留白:微信小程序的右上角胶囊按钮不在状态栏高度内,
* 必须用胶囊矩形计算内容起点;其它平台让出状态栏即可。
*/
export function heroPadTop(): string {
// #ifdef MP-WEIXIN
try {
const { statusBarHeight = 0 } = uni.getWindowInfo()
const capsule = uni.getMenuButtonBoundingClientRect()
if (capsule && capsule.bottom > statusBarHeight)
return `${capsule.bottom + 10}px`
}
catch {
/* 忽略:走兜底 */
}
// #endif
return 'calc(var(--status-bar-height, 0px) + 26rpx)'
}
/**
* 普通导航栏顶部留白。
* 注意uni 在 MP 端注入的 --status-bar-height 是 25px 固定值,真机(灵动岛等)会顶进状态栏,
* 因此 MP 端必须用 getWindowInfo 拿真实高度。
*/
export function navPadTop(): string {
// #ifdef MP-WEIXIN
try {
const { statusBarHeight = 0 } = uni.getWindowInfo()
return `${statusBarHeight + 8}px`
}
catch {
/* 忽略:走兜底 */
}
// #endif
return '16rpx'
}

1220
src/utils/vendor/qrcode-core.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,30 @@
{
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ESNext",
"jsx": "preserve",
"jsxImportSource": "vue",
"noImplicitThis": true,
"strict": true,
"verbatimModuleSyntax": true,
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"useDefineForClassFields": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"@/*": ["./src/*"]
},
"resolveJsonModule": true,
"types": [
"vite/client",
"@dcloudio/types",
"@mini-types/alipay",
"miniprogram-api-typings",
"wot-design-uni/global.d.ts",
"@uni-helper/uni-types"
]
],
"strict": true,
"noImplicitThis": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": [
"**/*.d.ts",
@@ -33,7 +32,7 @@
"src/**/*.tsx",
"src/**/*.jsx",
"src/**/*.vue"
],
],
"vueCompilerOptions": {
"plugins": ["@uni-helper/uni-types/volar-plugin"]
}

View File

@@ -1,25 +1,33 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import Uni from '@uni-helper/plugin-uni'
import Components from '@uni-helper/vite-plugin-uni-components'
import { WotResolver } from '@uni-helper/vite-plugin-uni-components/resolvers'
import Uni from '@uni-helper/plugin-uni'
import { defineConfig } from 'vite'
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
plugins: [
Components({
dts: true,
resolvers: [WotResolver()]
resolvers: [WotResolver()],
}),
// https://uni-helper.js.org/plugin-uni
Uni(),
Uni(),
],
server: {
proxy: {
// H5 联调:/api → 本地 GoFrame 服务
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''),
},
},
},
})