初始化
This commit is contained in:
15
components.d.ts
vendored
15
components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/**',
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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
130
pnpm-lock.yaml
generated
@@ -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
10
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
allowBuilds:
|
||||
core-js: true
|
||||
esbuild: true
|
||||
protobufjs: true
|
||||
vue-demi: true
|
||||
onlyBuiltDependencies:
|
||||
- core-js
|
||||
- esbuild
|
||||
- protobufjs
|
||||
- vue-demi
|
||||
252
src/App.vue
252
src/App.vue
@@ -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.login;H5 走 dev-login(后端需开 debug)
|
||||
userStore.ensureLogin().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
<style>
|
||||
page {
|
||||
/* ============ 「橘子汽水」设计 tokens(docs/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;
|
||||
}
|
||||
|
||||
/* ============ H5:uni-input 有值时内部 input 高度塌陷的兜底 ============ */
|
||||
/* #ifdef H5 */
|
||||
uni-input .uni-input-wrapper {
|
||||
min-height: 44rpx;
|
||||
}
|
||||
/* #endif */
|
||||
</style>
|
||||
|
||||
86
src/api/request.ts
Normal file
86
src/api/request.ts
Normal 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)
|
||||
}
|
||||
|
||||
/** H5:401 时引导去登录页(登录页自身不跳,避免循环) */
|
||||
export function gotoH5Login() {
|
||||
// #ifdef H5
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
const route = (current && current.route) || ''
|
||||
if (route.includes('account/login'))
|
||||
return
|
||||
const redirect = route ? `?redirect=/${route}` : ''
|
||||
uni.redirectTo({ url: `/pages/account/login${redirect}` })
|
||||
// #endif
|
||||
}
|
||||
|
||||
function request<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
99
src/api/user.ts
Normal 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 })
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
83
src/components/Confetti.vue
Normal file
83
src/components/Confetti.vue
Normal 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>
|
||||
44
src/components/CountUp.vue
Normal file
44
src/components/CountUp.vue
Normal 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>
|
||||
96
src/components/EmptyState.vue
Normal file
96
src/components/EmptyState.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import Sticker from '@/components/Sticker.vue'
|
||||
|
||||
/**
|
||||
* 空状态:贴纸插画组 + 文案 + 可选 CTA(docs/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>
|
||||
@@ -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>
|
||||
62
src/components/Sticker.vue
Normal file
62
src/components/Sticker.vue
Normal 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
193
src/components/TabBar.vue
Normal 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
121
src/components/TabIcon.vue
Normal 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>
|
||||
365
src/components/home/PaneLibrary.vue
Normal file
365
src/components/home/PaneLibrary.vue
Normal 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>
|
||||
471
src/components/home/PaneMine.vue
Normal file
471
src/components/home/PaneMine.vue
Normal 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>
|
||||
423
src/components/home/PaneWorkbench.vue
Normal file
423
src/components/home/PaneWorkbench.vue
Normal 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
10
src/config/env.ts
Normal 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
128
src/config/tools.ts
Normal 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)),
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "wx67e73fb3c9882dfc",
|
||||
"appid": "wx8f3891eaad380949",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
},
|
||||
|
||||
@@ -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
297
src/pages/account/login.vue
Normal 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>
|
||||
@@ -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
172
src/pages/index/index.vue
Normal 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>
|
||||
@@ -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
253
src/pages/mine/profile.vue
Normal 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>
|
||||
577
src/pages/tools/image/annotate.vue
Normal file
577
src/pages/tools/image/annotate.vue
Normal 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 {
|
||||
// MP:touches[i].x/y 相对画布;H5:clientX/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>
|
||||
423
src/pages/tools/image/avatarfx.vue
Normal file
423
src/pages/tools/image/avatarfx.vue
Normal 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
|
||||
/** 预览区边长(rpx):750 - 页面左右 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>
|
||||
363
src/pages/tools/image/collage.vue
Normal file
363
src/pages/tools/image/collage.vue
Normal 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>
|
||||
313
src/pages/tools/image/compress.vue
Normal file
313
src/pages/tools/image/compress.vue
Normal 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>
|
||||
241
src/pages/tools/image/convert.vue
Normal file
241
src/pages/tools/image/convert.vue
Normal 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>
|
||||
328
src/pages/tools/image/crop.vue
Normal file
328
src/pages/tools/image/crop.vue
Normal 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>
|
||||
207
src/pages/tools/image/grid9.vue
Normal file
207
src/pages/tools/image/grid9.vue
Normal 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>
|
||||
115
src/pages/tools/image/index.vue
Normal file
115
src/pages/tools/image/index.vue
Normal 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>
|
||||
244
src/pages/tools/image/matting.vue
Normal file
244
src/pages/tools/image/matting.vue
Normal 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>
|
||||
246
src/pages/tools/image/qrcode.vue
Normal file
246
src/pages/tools/image/qrcode.vue
Normal 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>
|
||||
229
src/pages/tools/image/resize.vue
Normal file
229
src/pages/tools/image/resize.vue
Normal 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>
|
||||
240
src/pages/tools/image/watermark.vue
Normal file
240
src/pages/tools/image/watermark.vue
Normal 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>
|
||||
278
src/pages/workbench/edit.vue
Normal file
278
src/pages/workbench/edit.vue
Normal 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>
|
||||
@@ -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
51
src/stores/tools.ts
Normal 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
149
src/stores/user.ts
Normal 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
10
src/types/vendor.d.ts
vendored
Normal 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
|
||||
}
|
||||
20
src/uni.scss
20
src/uni.scss
@@ -74,3 +74,23 @@ $uni-color-subtitle: #555; // 二级标题颜色
|
||||
$uni-font-size-subtitle: 18px;
|
||||
$uni-color-paragraph: #3f536e; // 文章段落颜色
|
||||
$uni-font-size-paragraph: 15px;
|
||||
|
||||
/* ============ 「橘子汽水」设计 tokens(docs/design-language.md) ============ */
|
||||
$tb-primary: #ff6b35; // 主色珊瑚橙
|
||||
$tb-primary-light: #ff9f4d; // 渐变亮端
|
||||
$tb-primary-deep: #fb4e14; // 渐变深端
|
||||
$tb-bg: #fff9f2; // 页面底色(奶白)
|
||||
$tb-text: #3d2a1e; // 正文(暖炭)
|
||||
$tb-text-2: #9a7b65; // 次要文字(对比度 ≥ 4.5:1)
|
||||
$tb-border: #ffe3d3; // 描边
|
||||
$tb-card: #ffffff; // 卡片
|
||||
$tb-radius: 30rpx; // 卡片圆角
|
||||
$tb-shadow: 0 8rpx 24rpx rgba(107, 47, 15, 0.06); // 卡片软阴影
|
||||
$tb-shadow-float: 0 12rpx 40rpx rgba(107, 47, 15, 0.18); // 悬浮元素阴影
|
||||
$tb-spring: cubic-bezier(0.34, 1.56, 0.64, 1); // spring 动效曲线
|
||||
/* 撞色点缀 */
|
||||
$tb-mint: #3ecfb2;
|
||||
$tb-lemon: #ffc53d;
|
||||
$tb-sky: #4da8ff;
|
||||
$tb-pink: #ff7ba9;
|
||||
$tb-violet: #8b7cf6;
|
||||
|
||||
15
src/utils/avatar.ts
Normal file
15
src/utils/avatar.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** 预置贴纸头像(docs/design-language.md):avatar_url 存 `avatar:key` 表示预置头像,其余按远端 URL 渲染 */
|
||||
export const AVATAR_PRESETS = [
|
||||
{ key: 'avatar:rocket', sticker: 'rocket', bg: '#FFE8DC', label: '火箭' },
|
||||
{ key: 'avatar:star', sticker: 'star', bg: '#FFF4D6', label: '星星' },
|
||||
{ key: 'avatar:cloud', sticker: 'cloud', bg: '#E3F2FF', label: '云朵' },
|
||||
{ key: 'avatar:rainbow', sticker: 'rainbow', bg: '#DFF7F0', label: '彩虹' },
|
||||
{ key: 'avatar:bolt', sticker: 'bolt', bg: '#FFE7F0', label: '闪电' },
|
||||
{ key: 'avatar:sparkle', sticker: 'sparkle', bg: '#EEE9FF', label: '闪光' },
|
||||
] as const
|
||||
|
||||
export type AvatarPreset = (typeof AVATAR_PRESETS)[number]
|
||||
|
||||
export function presetOf(avatarUrl: string): AvatarPreset | null {
|
||||
return AVATAR_PRESETS.find(p => p.key === avatarUrl) || null
|
||||
}
|
||||
275
src/utils/image/avatarfx.ts
Normal file
275
src/utils/image/avatarfx.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 头像特效:定义 + Canvas 绘制
|
||||
* 输出 720×720 头像 PNG(圆形 / 圆角方形两种形状:照片裁剪 + 彩色环 + 角标装饰 + 文字徽章)。
|
||||
* 装饰全部用 Canvas 原语绘制(小程序 canvas 不能画 SVG),双端表现一致。
|
||||
* 坐标约定:decor/badge 的 x/y 为百分比(0-100),CSS 实时预览与 Canvas 输出共用同一份定义。
|
||||
*/
|
||||
|
||||
export type FxShape = 'circle' | 'square'
|
||||
|
||||
export interface FxDecor {
|
||||
kind: 'star5' | 'sparkle' | 'dot' | 'bolt'
|
||||
x: number // % (中心)
|
||||
y: number // %
|
||||
size: number // % (半径基准)
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface FxBadge {
|
||||
text: string
|
||||
bg: string
|
||||
color: string
|
||||
x: number // %
|
||||
y: number // %
|
||||
}
|
||||
|
||||
export interface AvatarEffect {
|
||||
key: string
|
||||
name: string
|
||||
/** 环:由外到内的多层同宽环 */
|
||||
rings: string[]
|
||||
ringWidth: number // px @基准尺寸
|
||||
decors: FxDecor[]
|
||||
badge?: FxBadge
|
||||
}
|
||||
|
||||
/** 方形的圆角占比(相对边长) */
|
||||
export const FX_SQUARE_RADIUS = 0.12
|
||||
|
||||
export const AVATAR_EFFECTS: AvatarEffect[] = [
|
||||
{
|
||||
key: 'guoqing',
|
||||
name: '国庆·红旗',
|
||||
rings: ['#DE2910', '#FFDE00'],
|
||||
ringWidth: 26,
|
||||
decors: [
|
||||
{ kind: 'star5', x: 20, y: 18, size: 8, color: '#FFDE00' },
|
||||
{ kind: 'star5', x: 31, y: 12, size: 5, color: '#FFDE00' },
|
||||
{ kind: 'star5', x: 13, y: 30, size: 5, color: '#FFDE00' },
|
||||
{ kind: 'star5', x: 24, y: 27, size: 4, color: '#FFDE00' },
|
||||
{ kind: 'star5', x: 32, y: 22, size: 4, color: '#FFDE00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'xinchun',
|
||||
name: '新春·福运',
|
||||
rings: ['#E60012'],
|
||||
ringWidth: 30,
|
||||
decors: [{ kind: 'star5', x: 20, y: 20, size: 6, color: '#FFDE00' }],
|
||||
badge: { text: '福', bg: '#E60012', color: '#FFDE00', x: 79, y: 79 },
|
||||
},
|
||||
{
|
||||
key: 'shengdan',
|
||||
name: '圣诞·星语',
|
||||
rings: ['#0E7A4A', '#FFFFFF'],
|
||||
ringWidth: 26,
|
||||
decors: [
|
||||
{ kind: 'star5', x: 79, y: 17, size: 8, color: '#FFDE00' },
|
||||
{ kind: 'dot', x: 22, y: 74, size: 2.5, color: '#FFFFFF' },
|
||||
{ kind: 'dot', x: 70, y: 68, size: 2, color: '#FFFFFF' },
|
||||
{ kind: 'dot', x: 34, y: 34, size: 2, color: '#FFFFFF' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'wansheng',
|
||||
name: '万圣·夜',
|
||||
rings: ['#FF7A00'],
|
||||
ringWidth: 26,
|
||||
decors: [{ kind: 'bolt', x: 76, y: 18, size: 9, color: '#FFC53D' }],
|
||||
},
|
||||
{
|
||||
key: 'xingguang',
|
||||
name: '星光·紫夜',
|
||||
rings: ['#8B7CF6'],
|
||||
ringWidth: 24,
|
||||
decors: [
|
||||
{ kind: 'sparkle', x: 18, y: 20, size: 7, color: '#FFC53D' },
|
||||
{ kind: 'sparkle', x: 82, y: 26, size: 6, color: '#3ECFB2' },
|
||||
{ kind: 'sparkle', x: 74, y: 78, size: 5, color: '#FF7BA9' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'caihong',
|
||||
name: '彩虹·光环',
|
||||
rings: ['#FF6B35', '#FFC53D', '#3ECFB2'],
|
||||
ringWidth: 12,
|
||||
decors: [],
|
||||
},
|
||||
{
|
||||
key: 'yuanqi',
|
||||
name: '元气·橙',
|
||||
rings: ['#FF6B35'],
|
||||
ringWidth: 28,
|
||||
decors: [
|
||||
{ kind: 'star5', x: 80, y: 18, size: 7, color: '#FF6B35' },
|
||||
{ kind: 'dot', x: 16, y: 74, size: 3, color: '#FFC53D' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'bohe',
|
||||
name: '薄荷·清新',
|
||||
rings: ['#3ECFB2', '#C8F5EA'],
|
||||
ringWidth: 22,
|
||||
decors: [
|
||||
{ kind: 'dot', x: 20, y: 22, size: 3, color: '#3ECFB2' },
|
||||
{ kind: 'dot', x: 78, y: 74, size: 3, color: '#3ECFB2' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ===== Canvas 绘制 =====
|
||||
|
||||
function roundedRectPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||
const rr = Math.min(r, w / 2, h / 2)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + rr, y)
|
||||
ctx.arcTo(x + w, y, x + w, y + h, rr)
|
||||
ctx.arcTo(x + w, y + h, x, y + h, rr)
|
||||
ctx.arcTo(x, y + h, x, y, rr)
|
||||
ctx.arcTo(x, y, x + w, y, rr)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function drawStar5(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string, rot = -Math.PI / 2) {
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const outerA = rot + (i * 2 * Math.PI) / 5
|
||||
const innerA = outerA + Math.PI / 5
|
||||
const ox = cx + Math.cos(outerA) * r
|
||||
const oy = cy + Math.sin(outerA) * r
|
||||
const ix = cx + Math.cos(innerA) * r * 0.42
|
||||
const iy = cy + Math.sin(innerA) * r * 0.42
|
||||
if (i === 0)
|
||||
ctx.moveTo(ox, oy)
|
||||
else ctx.lineTo(ox, oy)
|
||||
ctx.lineTo(ix, iy)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawSparkle(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string) {
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx, cy - r)
|
||||
ctx.quadraticCurveTo(cx, cy, cx + r, cy)
|
||||
ctx.quadraticCurveTo(cx, cy, cx, cy + r)
|
||||
ctx.quadraticCurveTo(cx, cy, cx - r, cy)
|
||||
ctx.quadraticCurveTo(cx, cy, cx, cy - r)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawBolt(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string) {
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(cx + r * 0.15, cy - r)
|
||||
ctx.lineTo(cx - r * 0.55, cy + r * 0.15)
|
||||
ctx.lineTo(cx - r * 0.05, cy + r * 0.15)
|
||||
ctx.lineTo(cx - r * 0.15, cy + r)
|
||||
ctx.lineTo(cx + r * 0.55, cy - r * 0.15)
|
||||
ctx.lineTo(cx + r * 0.05, cy - r * 0.15)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawDot(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, color: string) {
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawDecor(ctx: CanvasRenderingContext2D, size: number, d: FxDecor) {
|
||||
const cx = (d.x / 100) * size
|
||||
const cy = (d.y / 100) * size
|
||||
const r = (d.size / 100) * size
|
||||
if (d.kind === 'star5')
|
||||
drawStar5(ctx, cx, cy, r, d.color)
|
||||
else if (d.kind === 'sparkle')
|
||||
drawSparkle(ctx, cx, cy, r, d.color)
|
||||
else if (d.kind === 'bolt')
|
||||
drawBolt(ctx, cx, cy, r, d.color)
|
||||
else drawDot(ctx, cx, cy, r, d.color)
|
||||
}
|
||||
|
||||
/** 绘制特效层(环 + 角标 + 徽章),支持形状与描边粗细缩放 */
|
||||
export function drawEffect(ctx: CanvasRenderingContext2D, size: number, fx: AvatarEffect, shape: FxShape = 'circle', ringScale = 1) {
|
||||
const w = fx.ringWidth * ringScale
|
||||
const c = size / 2
|
||||
if (shape === 'circle') {
|
||||
let r = c - w / 2
|
||||
for (const color of fx.rings) {
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.arc(c, c, r, 0, Math.PI * 2)
|
||||
ctx.lineWidth = w
|
||||
ctx.strokeStyle = color
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
r -= w + 2
|
||||
}
|
||||
}
|
||||
else {
|
||||
const R = size * FX_SQUARE_RADIUS
|
||||
let inset = w / 2
|
||||
for (const color of fx.rings) {
|
||||
ctx.save()
|
||||
roundedRectPath(ctx, inset, inset, size - inset * 2, size - inset * 2, R + inset * 0.6)
|
||||
ctx.lineWidth = w
|
||||
ctx.strokeStyle = color
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
inset += w + 2
|
||||
}
|
||||
}
|
||||
for (const d of fx.decors) drawDecor(ctx, size, d)
|
||||
if (fx.badge) {
|
||||
const bx = (fx.badge.x / 100) * size
|
||||
const by = (fx.badge.y / 100) * size
|
||||
const br = size * 0.085
|
||||
drawDot(ctx, bx, by, br, fx.badge.bg)
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.arc(bx, by, br - size * 0.008, 0, Math.PI * 2)
|
||||
ctx.strokeStyle = '#FFFFFF'
|
||||
ctx.lineWidth = size * 0.01
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
ctx.save()
|
||||
ctx.fillStyle = fx.badge.color
|
||||
ctx.font = `bold ${Math.round(br * 1.15)}px sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(fx.badge.text, bx, by + br * 0.05)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
/** 照片裁剪画到画布:圆形内切圆 / 圆角方形;zoom 以中心放大(1 = cover 铺满) */
|
||||
export function drawPhoto(ctx: CanvasRenderingContext2D, size: number, img: unknown, shape: FxShape = 'circle', zoom = 1) {
|
||||
const src = img as CanvasImageSource & { width: number, height: number }
|
||||
const R = shape === 'circle' ? size / 2 : size * FX_SQUARE_RADIUS
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
if (shape === 'circle')
|
||||
ctx.arc(size / 2, size / 2, R, 0, Math.PI * 2)
|
||||
else roundedRectPath(ctx, 0, 0, size, size, R)
|
||||
ctx.clip()
|
||||
const base = Math.min(src.width, src.height)
|
||||
const sw = base / zoom
|
||||
const sh = base / zoom
|
||||
const sx = (src.width - sw) / 2
|
||||
const sy = (src.height - sh) / 2
|
||||
ctx.drawImage(src, sx, sy, sw, sh, 0, 0, size, size)
|
||||
ctx.restore()
|
||||
}
|
||||
99
src/utils/image/collage.ts
Normal file
99
src/utils/image/collage.ts
Normal 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
115
src/utils/image/compress.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { CompressOptions } from './types'
|
||||
/**
|
||||
* 图片压缩
|
||||
* - MP:优先 wx.compressImage 原生压缩(快、省内存),不达标再叠 canvas 质量二分
|
||||
* - H5:canvas 缩放 + JPEG 质量二分(±10% 命中目标体积)
|
||||
*/
|
||||
import { beginDraw, drawImageSource, exportFile, getFileSize, loadImage } from './core'
|
||||
|
||||
function fitDims(w: number, h: number, maxEdge?: number) {
|
||||
if (maxEdge && Math.max(w, h) > maxEdge) {
|
||||
const s = maxEdge / Math.max(w, h)
|
||||
return { w: Math.max(1, Math.round(w * s)), h: Math.max(1, Math.round(h * s)) }
|
||||
}
|
||||
return { w, h }
|
||||
}
|
||||
|
||||
function getImageInfoMP(path: string): Promise<{ width: number, height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.getImageInfo({
|
||||
src: path,
|
||||
success: r => resolve({ width: r.width, height: r.height }),
|
||||
fail: e => reject(new Error(e.errMsg || '读取图片信息失败')),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function nativeCompressMP(path: string, quality: number, compressedWidth?: number): Promise<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)
|
||||
// 按目标体积压缩时统一转 JPEG(PNG 无质量参数)
|
||||
const type = opts.targetKB ? 'jpg' : 'png'
|
||||
|
||||
if (!opts.targetKB) {
|
||||
const { ctx } = beginDraw(w, h, false)
|
||||
drawImageSource(ctx, img, w, h)
|
||||
const path = await exportFile(type, opts.quality ?? 0.8)
|
||||
return { path, size: await getFileSize(path) }
|
||||
}
|
||||
|
||||
const target = opts.targetKB * 1024
|
||||
let lo = 0.3
|
||||
let hi = Math.min(0.92, opts.quality ?? 0.85)
|
||||
let best: { path: string, size: number } | null = null
|
||||
for (let i = 0; i < 6 && hi - lo > 0.04; i++) {
|
||||
const q = (lo + hi) / 2
|
||||
const { ctx } = beginDraw(w, h, true)
|
||||
drawImageSource(ctx, img, w, h)
|
||||
const path = await exportFile('jpg', q)
|
||||
const size = await getFileSize(path)
|
||||
if (size <= target) {
|
||||
best = { path, size } // 达标:尝试更高质量
|
||||
lo = q
|
||||
}
|
||||
else {
|
||||
hi = q
|
||||
}
|
||||
}
|
||||
if (!best)
|
||||
throw new Error('无法压缩到目标体积,请降低质量档位或缩小尺寸')
|
||||
return best
|
||||
}
|
||||
|
||||
export async function compressImage(
|
||||
path: string,
|
||||
size: number,
|
||||
opts: CompressOptions,
|
||||
): Promise<{ path: string, size: number, ratio: number }> {
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
const info = await getImageInfoMP(path)
|
||||
const dims = fitDims(info.width, info.height, opts.maxEdge)
|
||||
const scaleDown = dims.w < info.width
|
||||
let q = Math.round((opts.quality ?? 0.8) * 100)
|
||||
let cur = await nativeCompressMP(path, q / 100, scaleDown ? dims.w : undefined)
|
||||
let curSize = await getFileSize(cur)
|
||||
if (!opts.targetKB) {
|
||||
return { path: cur, size: curSize, ratio: size ? curSize / size : 1 }
|
||||
}
|
||||
const target = opts.targetKB * 1024
|
||||
while (curSize > target && q > 30) {
|
||||
q -= 15
|
||||
cur = await nativeCompressMP(path, q / 100, scaleDown ? dims.w : undefined)
|
||||
curSize = await getFileSize(cur)
|
||||
}
|
||||
if (curSize > target) {
|
||||
const r = await canvasCompress(path, opts)
|
||||
return { path: r.path, size: r.size, ratio: size ? r.size / size : 1 }
|
||||
}
|
||||
return { path: cur, size: curSize, ratio: size ? curSize / size : 1 }
|
||||
}
|
||||
catch {
|
||||
// 原生压缩不可用时回退通用 canvas 流程
|
||||
}
|
||||
// #endif
|
||||
const r = await canvasCompress(path, opts)
|
||||
return { path: r.path, size: r.size, ratio: size ? r.size / size : 1 }
|
||||
}
|
||||
54
src/utils/image/convert.ts
Normal file
54
src/utils/image/convert.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 图片格式转换:解码 → canvas 重绘 → 导出
|
||||
* - WebP 输出仅 H5(toBlob);小程序端 canvasToTempFilePath 仅 jpg/png
|
||||
* - HEIC 输入仅 H5(heic2any 动态解码)
|
||||
* - 转 JPEG 时白底合成透明区域
|
||||
*/
|
||||
import { beginDraw, drawImageSource, exportFile, loadImage } from './core'
|
||||
import { resultName } from './task'
|
||||
|
||||
interface ConvertResult {
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function convertMP(src: string, target: 'png' | 'jpg'): Promise<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
303
src/utils/image/core.ts
Normal 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)/ objectURL(H5);小程序端不支持 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
213
src/utils/image/matting.ts
Normal 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') }
|
||||
}
|
||||
|
||||
/** 智能模式(仅 H5):u2netp 推理 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
21
src/utils/image/qrcode.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 二维码矩阵生成:封装 vendor/qrcode-core(qrcode-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
52
src/utils/image/resize.ts
Normal 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
33
src/utils/image/task.ts
Normal 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}`
|
||||
}
|
||||
41
src/utils/image/transform.ts
Normal file
41
src/utils/image/transform.ts
Normal 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
59
src/utils/image/types.ts
Normal 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
|
||||
}
|
||||
56
src/utils/image/watermark.ts
Normal file
56
src/utils/image/watermark.ts
Normal 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
36
src/utils/palette.ts
Normal 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
36
src/utils/safearea.ts
Normal 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
1220
src/utils/vendor/qrcode-core.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user