feat: adopt naive ui and refine shell interactions
This commit is contained in:
Generated
+200
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@supabase/supabase-js": "^2.106.1",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^5.0.7"
|
||||
@@ -140,6 +141,24 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@css-render/plugin-bem": {
|
||||
"version": "0.15.14",
|
||||
"resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
|
||||
"integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"css-render": "~0.15.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@css-render/vue3-ssr": {
|
||||
"version": "0.15.14",
|
||||
"resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
|
||||
"integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz",
|
||||
@@ -174,6 +193,12 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/hash": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz",
|
||||
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -219,6 +244,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@juggle/resize-observer": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
@@ -915,6 +946,21 @@
|
||||
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.24",
|
||||
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz",
|
||||
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash-es": {
|
||||
"version": "4.17.12",
|
||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.4.tgz",
|
||||
@@ -1217,6 +1263,12 @@
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
|
||||
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz",
|
||||
@@ -1262,12 +1314,47 @@
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/css-render": {
|
||||
"version": "0.15.14",
|
||||
"resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz",
|
||||
"integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/hash": "~0.8.0",
|
||||
"csstype": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/css-render/node_modules/csstype": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz",
|
||||
"integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.2.1.tgz",
|
||||
"integrity": "sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns-tz": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
|
||||
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"date-fns": "^3.0.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1310,6 +1397,12 @@
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/evtd": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz",
|
||||
"integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz",
|
||||
@@ -1355,6 +1448,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
||||
@@ -1706,6 +1808,18 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1771,6 +1885,38 @@
|
||||
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/naive-ui": {
|
||||
"version": "2.44.1",
|
||||
"resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.44.1.tgz",
|
||||
"integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@css-render/plugin-bem": "^0.15.14",
|
||||
"@css-render/vue3-ssr": "^0.15.14",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"async-validator": "^4.2.5",
|
||||
"css-render": "^0.15.14",
|
||||
"csstype": "^3.1.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"evtd": "^0.2.4",
|
||||
"highlight.js": "^11.8.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"seemly": "^0.3.10",
|
||||
"treemate": "^0.3.11",
|
||||
"vdirs": "^0.1.8",
|
||||
"vooks": "^0.2.12",
|
||||
"vueuc": "^0.4.65"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz",
|
||||
@@ -1961,6 +2107,12 @@
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/seemly": {
|
||||
"version": "0.3.10",
|
||||
"resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz",
|
||||
"integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2028,6 +2180,12 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/treemate": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz",
|
||||
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -2091,6 +2249,18 @@
|
||||
"url": "https://github.com/sponsors/sxzz"
|
||||
}
|
||||
},
|
||||
"node_modules/vdirs": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz",
|
||||
"integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"evtd": "^0.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.13",
|
||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.13.tgz",
|
||||
@@ -2169,6 +2339,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vooks": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz",
|
||||
"integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"evtd": "^0.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
@@ -2292,6 +2474,24 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vueuc": {
|
||||
"version": "0.4.65",
|
||||
"resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.65.tgz",
|
||||
"integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@css-render/vue3-ssr": "^0.15.10",
|
||||
"@juggle/resize-observer": "^3.3.1",
|
||||
"css-render": "^0.15.10",
|
||||
"evtd": "^0.2.4",
|
||||
"seemly": "^0.3.6",
|
||||
"vdirs": "^0.1.4",
|
||||
"vooks": "^0.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@supabase/supabase-js": "^2.106.1",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^5.0.7"
|
||||
|
||||
+84
-6
@@ -1,12 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { NConfigProvider, NDialogProvider, NGlobalStyle, NMessageProvider, NNotificationProvider, type GlobalThemeOverrides } from 'naive-ui'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
|
||||
const themeOverrides: GlobalThemeOverrides = {
|
||||
common: {
|
||||
primaryColor: '#0f766e',
|
||||
primaryColorHover: '#115e59',
|
||||
primaryColorPressed: '#134e4a',
|
||||
primaryColorSuppl: '#0f766e',
|
||||
infoColor: '#0369a1',
|
||||
successColor: '#0f766e',
|
||||
warningColor: '#d97706',
|
||||
errorColor: '#dc2626',
|
||||
borderRadius: '0px',
|
||||
borderRadiusSmall: '0px',
|
||||
fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif',
|
||||
fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace',
|
||||
bodyColor: '#f3f7fb',
|
||||
cardColor: '#ffffff',
|
||||
modalColor: '#ffffff',
|
||||
popoverColor: '#ffffff',
|
||||
tableColor: '#ffffff',
|
||||
textColorBase: '#0f172a',
|
||||
textColor1: '#0f172a',
|
||||
textColor2: '#334155',
|
||||
textColor3: '#64748b',
|
||||
textColorDisabled: '#94a3b8',
|
||||
dividerColor: '#d9e3ee',
|
||||
borderColor: '#d9e3ee',
|
||||
inputColor: '#ffffff',
|
||||
},
|
||||
Button: {
|
||||
borderRadiusTiny: '0px',
|
||||
borderRadiusSmall: '0px',
|
||||
borderRadiusMedium: '0px',
|
||||
borderRadiusLarge: '0px',
|
||||
textColorPrimary: '#0f172a',
|
||||
textColorHoverPrimary: '#0f172a',
|
||||
},
|
||||
Card: {
|
||||
borderRadius: '0px',
|
||||
borderRadiusEmbedded: '0px',
|
||||
paddingMedium: '24px',
|
||||
},
|
||||
Input: {
|
||||
borderRadius: '0px',
|
||||
},
|
||||
Select: {
|
||||
peers: {
|
||||
InternalSelection: {
|
||||
borderRadius: '0px',
|
||||
},
|
||||
},
|
||||
},
|
||||
Tabs: {
|
||||
tabBorderRadius: '0px',
|
||||
},
|
||||
Tag: {
|
||||
borderRadius: '0px',
|
||||
},
|
||||
Alert: {
|
||||
borderRadius: '0px',
|
||||
},
|
||||
Modal: {
|
||||
borderRadius: '0px',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex flex-col">
|
||||
<AppHeader />
|
||||
<main class="flex-1">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
<NConfigProvider :theme-overrides="themeOverrides">
|
||||
<NGlobalStyle />
|
||||
<NDialogProvider>
|
||||
<NNotificationProvider>
|
||||
<NMessageProvider>
|
||||
<div class="min-h-screen flex flex-col bg-[radial-gradient(circle_at_top_left,_rgba(12,148,136,0.12),_transparent_28%),linear-gradient(180deg,#f8fbfd_0%,#eef4f8_100%)]">
|
||||
<AppHeader />
|
||||
<main class="relative flex-1">
|
||||
<div class="pointer-events-none absolute inset-0 bg-[linear-gradient(rgba(15,23,42,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(15,23,42,0.03)_1px,transparent_1px)] bg-[size:32px_32px] opacity-35"></div>
|
||||
<div class="relative">
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</NMessageProvider>
|
||||
</NNotificationProvider>
|
||||
</NDialogProvider>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
|
||||
@@ -1,85 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { NButton, NSpace, NTag } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const isMapRoute = computed(() => route.name === 'map')
|
||||
|
||||
const headerHidden = ref(false)
|
||||
const headerPinnedOpen = ref(false)
|
||||
|
||||
let lastScrollY = 0
|
||||
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
||||
|
||||
function showHeader(pin = false) {
|
||||
headerHidden.value = false
|
||||
headerPinnedOpen.value = pin
|
||||
}
|
||||
|
||||
function hideHeader() {
|
||||
if (window.scrollY <= 96) return
|
||||
if (headerPinnedOpen.value) return
|
||||
headerHidden.value = true
|
||||
}
|
||||
|
||||
function releasePinnedHeader() {
|
||||
headerPinnedOpen.value = false
|
||||
}
|
||||
|
||||
function forceHideHeader() {
|
||||
headerPinnedOpen.value = false
|
||||
headerHidden.value = true
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const currentScrollY = window.scrollY
|
||||
const delta = currentScrollY - lastScrollY
|
||||
|
||||
if (isMapRoute.value) {
|
||||
lastScrollY = currentScrollY
|
||||
return
|
||||
}
|
||||
|
||||
if (currentScrollY <= 64) {
|
||||
showHeader(false)
|
||||
lastScrollY = currentScrollY
|
||||
return
|
||||
}
|
||||
|
||||
if (delta > 12) {
|
||||
hideHeader()
|
||||
} else if (delta < -8) {
|
||||
showHeader(false)
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
lastScrollY = window.scrollY
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
||||
})
|
||||
|
||||
function syncHeaderForRoute() {
|
||||
headerPinnedOpen.value = false
|
||||
headerHidden.value = false
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
syncHeaderForRoute()
|
||||
})
|
||||
|
||||
watch(isMapRoute, () => {
|
||||
syncHeaderForRoute()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
syncHeaderForRoute()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="bg-white border-b border-gray-200 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<RouterLink to="/" class="flex items-center gap-2">
|
||||
<span class="text-2xl">☁️</span>
|
||||
<span class="text-xl font-bold text-gray-900">OpenCloud</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-6">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="route.name === 'map' ? 'text-sky-600' : 'text-gray-600 hover:text-gray-900'"
|
||||
>
|
||||
地图
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/encyclopedia"
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? 'text-sky-600' : 'text-gray-600 hover:text-gray-900'"
|
||||
>
|
||||
图鉴
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/gallery"
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="route.name === 'gallery' ? 'text-sky-600' : 'text-gray-600 hover:text-gray-900'"
|
||||
>
|
||||
画廊
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<RouterLink
|
||||
v-if="authStore.isLoggedIn"
|
||||
to="/upload"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 bg-sky-500 text-white text-sm font-medium rounded-lg hover:bg-sky-600 transition-colors"
|
||||
>
|
||||
<span>📷</span>
|
||||
<span>上传</span>
|
||||
<div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-16'">
|
||||
<header
|
||||
class="absolute inset-x-0 top-0 border-b border-slate-200/80 bg-white/88 backdrop-blur-xl transition-transform duration-300"
|
||||
:class="headerHidden ? '-translate-y-full' : 'translate-y-0'"
|
||||
@mouseenter="showHeader(true)"
|
||||
@mouseleave="releasePinnedHeader"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<RouterLink to="/" class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-xl shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]">
|
||||
☁️
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-500">Live Sky Atlas</div>
|
||||
<div class="text-lg font-bold text-slate-900">OpenCloud</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
<nav class="hidden md:flex items-center gap-2 border border-slate-200 bg-white px-2 py-2 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)]">
|
||||
<RouterLink
|
||||
to="/profile"
|
||||
class="text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
||||
to="/"
|
||||
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||
:class="route.name === 'map' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
|
||||
>
|
||||
{{ authStore.profile?.username }}
|
||||
</RouterLink>
|
||||
<button
|
||||
@click="authStore.logout()"
|
||||
class="text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
登录
|
||||
地图
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="inline-flex items-center px-4 py-2 bg-sky-500 text-white text-sm font-medium rounded-lg hover:bg-sky-600 transition-colors"
|
||||
to="/encyclopedia"
|
||||
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
|
||||
>
|
||||
注册
|
||||
图鉴
|
||||
</RouterLink>
|
||||
</template>
|
||||
<RouterLink
|
||||
to="/gallery"
|
||||
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||
:class="route.name === 'gallery' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'"
|
||||
>
|
||||
画廊
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<NSpace align="center" :size="12">
|
||||
<RouterLink
|
||||
v-if="authStore.isLoggedIn"
|
||||
to="/upload"
|
||||
class="no-underline"
|
||||
>
|
||||
<NButton type="primary" strong>
|
||||
上传
|
||||
</NButton>
|
||||
</RouterLink>
|
||||
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
<RouterLink
|
||||
to="/profile"
|
||||
class="no-underline"
|
||||
>
|
||||
<NTag type="success" bordered>
|
||||
{{ authStore.profile?.username }}
|
||||
</NTag>
|
||||
</RouterLink>
|
||||
<NButton
|
||||
quaternary
|
||||
@click="authStore.logout()"
|
||||
>
|
||||
登出
|
||||
</NButton>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="no-underline"
|
||||
>
|
||||
<NButton quaternary>登录</NButton>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="no-underline"
|
||||
>
|
||||
<NButton type="primary" strong>注册</NButton>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</NSpace>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
<button
|
||||
v-if="headerHidden"
|
||||
type="button"
|
||||
class="absolute left-1/2 top-3 flex -translate-x-1/2 items-center gap-2 border border-slate-300 bg-white/94 px-3 py-1.5 text-xs font-medium tracking-[0.16em] text-slate-700 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] backdrop-blur transition-colors hover:border-slate-900 hover:text-slate-900"
|
||||
@click="showHeader(true)"
|
||||
>
|
||||
<span>▼</span>
|
||||
<span>展开导航</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1 +1,26 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&family=Noto+Sans+SC:wght@400;500;700;900&display=swap');
|
||||
@import 'tailwindcss';
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #0f172a;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.02), rgba(15, 23, 42, 0)),
|
||||
#eef4f8;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
[class~='rounded'],
|
||||
[class*='rounded-'] {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -69,38 +70,40 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)] px-4">
|
||||
<div class="w-full max-w-sm text-center">
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<template v-if="state === 'success'">
|
||||
<span class="text-5xl block mb-4">✅</span>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">邮箱认证成功</h1>
|
||||
<p class="text-gray-500 mb-2">你的邮箱已确认,现在可以登录了!</p>
|
||||
<p class="text-sm text-gray-400 mb-6">{{ countdown }} 秒后自动跳转登录页面...</p>
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="inline-flex items-center px-6 py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 transition-colors"
|
||||
>
|
||||
立即登录
|
||||
</RouterLink>
|
||||
<NResult status="success" title="邮箱认证成功" description="你的邮箱已确认,现在可以登录了。">
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
|
||||
<RouterLink to="/login" class="no-underline">
|
||||
<NButton type="primary">立即登录</NButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'failed'">
|
||||
<span class="text-5xl block mb-4">❌</span>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">认证失败</h1>
|
||||
<p class="text-gray-500 mb-6">邮箱确认链接无效或已过期,请重新注册。</p>
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="inline-flex items-center px-6 py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 transition-colors"
|
||||
>
|
||||
重新注册
|
||||
</RouterLink>
|
||||
<NResult status="error" title="认证失败" description="邮箱确认链接无效或已过期,请重新注册。">
|
||||
<template #footer>
|
||||
<RouterLink to="/register" class="no-underline">
|
||||
<NButton type="primary">重新注册</NButton>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</NResult>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span class="text-5xl block mb-4 animate-pulse">⏳</span>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">正在验证...</h1>
|
||||
<p class="text-gray-500">请稍候</p>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<NSpin size="large" />
|
||||
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在验证...</h1>
|
||||
<p class="mt-2 text-slate-500">请稍候</p>
|
||||
</div>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@@ -28,56 +29,79 @@ async function handleLogin() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)] px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="text-center mb-8">
|
||||
<span class="text-5xl block mb-4">☁️</span>
|
||||
<h1 class="text-2xl font-bold text-gray-900">登录 OpenCloud</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">记录你眼中的每一朵云</p>
|
||||
</div>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
|
||||
<section class="border border-slate-200 bg-[linear-gradient(135deg,#f0fdfa_0%,#ffffff_48%,#eff6ff_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
|
||||
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
|
||||
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
|
||||
登录后继续记录
|
||||
<span class="block text-teal-700">你眼中的每一朵云</span>
|
||||
</h1>
|
||||
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
|
||||
</p>
|
||||
<div class="mt-8 flex gap-4">
|
||||
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Collection</div>
|
||||
<div class="mt-2 text-2xl font-bold text-slate-900">10</div>
|
||||
<div class="mt-1 text-sm text-slate-500">基础云属待收集</div>
|
||||
</div>
|
||||
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Mode</div>
|
||||
<div class="mt-2 text-2xl font-bold text-slate-900">Atlas</div>
|
||||
<div class="mt-1 text-sm text-slate-500">地图、图鉴、画廊一体</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="your@email.com"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<div class="mb-8">
|
||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Member Access</div>
|
||||
<h2 class="mt-3 text-3xl font-bold text-slate-900">登录 OpenCloud</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">输入邮箱和密码,继续你的天空档案。</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder="输入密码"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<NForm @submit.prevent="handleLogin">
|
||||
<NFormItem label="邮箱">
|
||||
<NInput
|
||||
v-model:value="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<div v-if="error" class="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-sm text-red-600">{{ error }}</p>
|
||||
</div>
|
||||
<NFormItem label="密码">
|
||||
<NInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
required
|
||||
show-password-on="click"
|
||||
autocomplete="current-password"
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
<NAlert v-if="error" type="error" class="mb-4">
|
||||
{{ error }}
|
||||
</NAlert>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-gray-500">
|
||||
没有账号?
|
||||
<RouterLink to="/register" class="text-sky-600 hover:text-sky-700 font-medium">去注册</RouterLink>
|
||||
</p>
|
||||
<NButton
|
||||
attr-type="submit"
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
:loading="loading"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</NButton>
|
||||
</NForm>
|
||||
|
||||
<p class="mt-6 text-sm text-slate-500">
|
||||
没有账号?
|
||||
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
|
||||
</p>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@@ -45,98 +46,110 @@ async function handleRegister() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)] px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div v-if="emailSent" class="text-center">
|
||||
<span class="text-5xl block mb-4">📧</span>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">确认你的邮箱</h1>
|
||||
<p class="text-gray-500 mb-6">
|
||||
我们已向 <strong class="text-gray-700">{{ email }}</strong> 发送了确认邮件,请查收并点击确认链接完成注册。
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||
<section class="border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f0fdfa_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
|
||||
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
||||
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
|
||||
加入天空探索者
|
||||
<span class="block text-sky-700">建立你的云图档案</span>
|
||||
</h1>
|
||||
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
|
||||
</p>
|
||||
<p class="text-sm text-gray-400 mb-6">没有收到?请检查垃圾邮件文件夹</p>
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="inline-flex items-center px-6 py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 transition-colors"
|
||||
</section>
|
||||
|
||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<NResult
|
||||
v-if="emailSent"
|
||||
status="success"
|
||||
title="确认你的邮箱"
|
||||
description="确认邮件已经发送,请查收并点击链接完成注册。"
|
||||
>
|
||||
去登录
|
||||
</RouterLink>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">
|
||||
目标邮箱:
|
||||
<span class="font-semibold text-slate-700">{{ email }}</span>
|
||||
</p>
|
||||
<RouterLink to="/login" class="no-underline">
|
||||
<NButton type="primary">去登录</NButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
|
||||
<template v-else>
|
||||
<div class="text-center mb-8">
|
||||
<span class="text-5xl block mb-4">☁️</span>
|
||||
<h1 class="text-2xl font-bold text-gray-900">注册 OpenCloud</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">加入天空探索者社区</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleRegister" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">用户名</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
placeholder="你的昵称"
|
||||
maxlength="20"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="mb-8">
|
||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Create Account</div>
|
||||
<h2 class="mt-3 text-3xl font-bold text-slate-900">注册 OpenCloud</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">加入社区后即可开始记录、收集与展示云图。</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="your@email.com"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<NForm @submit.prevent="handleRegister">
|
||||
<NFormItem label="用户名">
|
||||
<NInput
|
||||
v-model:value="username"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
placeholder="你的昵称"
|
||||
maxlength="20"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="至少8位"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<NFormItem label="邮箱">
|
||||
<NInput
|
||||
v-model:value="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">确认密码</label>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="再次输入密码"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<NFormItem label="密码">
|
||||
<NInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
required
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
placeholder="至少8位"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<div v-if="error" class="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-sm text-red-600">{{ error }}</p>
|
||||
</div>
|
||||
<NFormItem label="确认密码">
|
||||
<NInput
|
||||
v-model:value="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
placeholder="再次输入密码"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</button>
|
||||
</form>
|
||||
<NAlert v-if="error" type="error" class="mb-4">
|
||||
{{ error }}
|
||||
</NAlert>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-gray-500">
|
||||
已有账号?
|
||||
<RouterLink to="/login" class="text-sky-600 hover:text-sky-700 font-medium">去登录</RouterLink>
|
||||
</p>
|
||||
</template>
|
||||
<NButton
|
||||
attr-type="submit"
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
:loading="loading"
|
||||
>
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</NButton>
|
||||
</NForm>
|
||||
|
||||
<p class="mt-6 text-sm text-slate-500">
|
||||
已有账号?
|
||||
<RouterLink to="/login" class="font-semibold text-sky-700 hover:text-sky-800">去登录</RouterLink>
|
||||
</p>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -136,24 +137,33 @@ watch(() => route.params.id, loadPage)
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<div class="mb-6">
|
||||
<RouterLink to="/encyclopedia" class="text-sm font-medium text-sky-700 hover:text-sky-800">
|
||||
← 返回图鉴总览
|
||||
<RouterLink to="/encyclopedia">
|
||||
<NButton text type="primary">← 返回图鉴总览</NButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="space-y-6">
|
||||
<div class="h-80 animate-pulse rounded-[32px] bg-slate-200"></div>
|
||||
<NSkeleton class="h-80 w-full" />
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div v-for="n in 3" :key="n" class="h-28 animate-pulse rounded-3xl bg-slate-200"></div>
|
||||
<NCard v-for="n in 3" :key="n">
|
||||
<NSkeleton class="h-4 w-1/3" />
|
||||
<NSkeleton class="mt-4 h-7 w-1/2" />
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="rounded-[28px] border border-red-200 bg-red-50 p-6 text-red-700">
|
||||
<NAlert
|
||||
v-else-if="loadError"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
:bordered="false"
|
||||
title="详情加载失败"
|
||||
>
|
||||
{{ loadError }}
|
||||
</div>
|
||||
</NAlert>
|
||||
|
||||
<template v-else-if="cloudType">
|
||||
<section class="overflow-hidden rounded-[32px] border border-slate-200 bg-white shadow-sm">
|
||||
<section class="overflow-hidden border border-slate-200 bg-white shadow-sm">
|
||||
<div class="grid lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<div class="relative min-h-[360px] overflow-hidden">
|
||||
<img
|
||||
@@ -169,9 +179,9 @@ watch(() => route.params.id, loadPage)
|
||||
></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/78 via-slate-950/18 to-transparent"></div>
|
||||
<div class="absolute bottom-8 left-8 right-8 text-white">
|
||||
<span class="inline-flex rounded-full border px-3 py-1 text-xs font-medium backdrop-blur" :class="rarityMeta[cloudType.rarity].chip">
|
||||
<NTag :bordered="false" class="border border-white/20 bg-white/12 text-white backdrop-blur">
|
||||
{{ rarityMeta[cloudType.rarity].label }}
|
||||
</span>
|
||||
</NTag>
|
||||
<h1 class="mt-4 text-4xl font-bold">{{ cloudType.name }}</h1>
|
||||
<p class="mt-2 text-lg text-white/82">{{ cloudType.name_en }}</p>
|
||||
</div>
|
||||
@@ -185,7 +195,7 @@ watch(() => route.params.id, loadPage)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 rounded-[28px] border border-slate-200 bg-white p-5">
|
||||
<NCard class="mt-8 border border-slate-200">
|
||||
<p class="text-sm text-slate-500">你的收集状态</p>
|
||||
<template v-if="isUnlocked && collectionEntry">
|
||||
<p class="mt-2 text-2xl font-bold text-slate-900">已解锁</p>
|
||||
@@ -195,26 +205,26 @@ watch(() => route.params.id, loadPage)
|
||||
<p class="mt-2 text-2xl font-bold text-slate-900">尚未解锁</p>
|
||||
<p class="mt-2 text-sm text-slate-600">拍到并上传这种云朵后,这枚徽章就会被点亮。</p>
|
||||
</template>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<div class="rounded-[28px] border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<NCard class="border border-slate-200 shadow-sm">
|
||||
<p class="text-sm text-slate-500">稀有度</p>
|
||||
<p class="mt-2 text-2xl font-bold text-slate-900">{{ rarityMeta[cloudType.rarity].label }}</p>
|
||||
</div>
|
||||
<div class="rounded-[28px] border border-slate-200 bg-white p-5 shadow-sm">
|
||||
</NCard>
|
||||
<NCard class="border border-slate-200 shadow-sm">
|
||||
<p class="text-sm text-slate-500">公开云图</p>
|
||||
<p class="mt-2 text-2xl font-bold text-slate-900">{{ publicCount }}</p>
|
||||
</div>
|
||||
<div class="rounded-[28px] border border-slate-200 bg-white p-5 shadow-sm">
|
||||
</NCard>
|
||||
<NCard class="border border-slate-200 shadow-sm">
|
||||
<p class="text-sm text-slate-500">首次解锁</p>
|
||||
<p class="mt-2 text-xl font-bold text-slate-900">
|
||||
{{ collectionEntry ? formatDate(collectionEntry.unlocked_at) : '等待记录' }}
|
||||
</p>
|
||||
</div>
|
||||
</NCard>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
@@ -229,7 +239,7 @@ watch(() => route.params.id, loadPage)
|
||||
<article
|
||||
v-for="item in gallery"
|
||||
:key="item.id"
|
||||
class="overflow-hidden rounded-[28px] border border-slate-200 bg-white shadow-sm"
|
||||
class="overflow-hidden border border-slate-200 bg-white shadow-sm"
|
||||
>
|
||||
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-56 w-full object-cover" />
|
||||
<div class="p-5">
|
||||
@@ -245,9 +255,12 @@ watch(() => route.params.id, loadPage)
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-[28px] border border-dashed border-slate-300 bg-white p-8 text-center">
|
||||
<p class="text-lg font-semibold text-slate-900">还没有公开作品</p>
|
||||
<p class="mt-2 text-sm text-slate-500">等第一位观测者上传这类云图后,这里就会展示出来。</p>
|
||||
<div v-else class="border border-dashed border-slate-300 bg-white p-8">
|
||||
<NEmpty description="还没有公开作品">
|
||||
<template #extra>
|
||||
<p class="text-sm text-slate-500">等第一位观测者上传这类云图后,这里就会展示出来。</p>
|
||||
</template>
|
||||
</NEmpty>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { NAlert, NCard, NEmpty, NProgress, NSkeleton } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||
@@ -74,23 +75,26 @@ onMounted(async () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[28px] border border-white/80 bg-white/80 p-6 shadow-sm backdrop-blur">
|
||||
<NCard class="border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">当前进度</p>
|
||||
<p class="mt-1 text-3xl font-bold text-slate-900">{{ progressText }}</p>
|
||||
</div>
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-slate-900 text-xl font-semibold text-white">
|
||||
<div class="flex h-16 w-16 items-center justify-center border border-slate-900 bg-slate-900 text-xl font-semibold text-white">
|
||||
{{ authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0 }}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 h-3 overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
class="h-full rounded-full bg-[linear-gradient(90deg,#0ea5e9_0%,#f59e0b_100%)] transition-all duration-500"
|
||||
:style="{ width: `${authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<NProgress
|
||||
class="mt-5"
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:percentage="authStore.isLoggedIn ? encyclopediaStore.unlockPercent : 0"
|
||||
color="linear-gradient(90deg,#0ea5e9 0%,#f59e0b 100%)"
|
||||
rail-color="#dbe4ee"
|
||||
:height="12"
|
||||
/>
|
||||
|
||||
<p class="mt-4 text-sm text-slate-500">
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
@@ -100,27 +104,39 @@ onMounted(async () => {
|
||||
登录后可同步你的个人图鉴进度。
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<div v-if="encyclopediaStore.collectionError && authStore.isLoggedIn" class="mb-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
<NAlert
|
||||
v-if="encyclopediaStore.collectionError && authStore.isLoggedIn"
|
||||
class="mb-6"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
:bordered="false"
|
||||
title="图鉴收藏数据暂时不可用"
|
||||
>
|
||||
图鉴收藏数据暂时不可用:{{ encyclopediaStore.collectionError }}
|
||||
</div>
|
||||
</NAlert>
|
||||
|
||||
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div v-for="n in 6" :key="n" class="h-72 animate-pulse rounded-[28px] bg-slate-200"></div>
|
||||
<NCard v-for="n in 6" :key="n">
|
||||
<NSkeleton class="h-44 w-full" />
|
||||
<NSkeleton class="mt-5 h-5 w-2/5" />
|
||||
<NSkeleton class="mt-3 h-4 w-1/3" />
|
||||
<NSkeleton class="mt-6 h-4 w-full" :repeat="2" />
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<button
|
||||
v-for="cloudType in encyclopediaStore.cloudTypes"
|
||||
:key="cloudType.id"
|
||||
type="button"
|
||||
@click="openCard(cloudType)"
|
||||
class="group overflow-hidden rounded-[28px] border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
|
||||
class="group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
|
||||
:class="isUnlocked(cloudType.id) ? 'border-amber-300 shadow-amber-100/60' : 'border-slate-200 hover:border-slate-300'"
|
||||
>
|
||||
<div
|
||||
@@ -145,11 +161,11 @@ onMounted(async () => {
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent"></div>
|
||||
|
||||
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/22 backdrop-blur-[2px]">
|
||||
<div class="rounded-full border border-white/45 bg-white/15 px-4 py-2 text-sm font-medium text-white">🔒 尚未解锁</div>
|
||||
<div class="border border-white/45 bg-white/15 px-4 py-2 text-sm font-medium text-white">🔒 尚未解锁</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute left-4 top-4">
|
||||
<span class="inline-flex rounded-full border px-3 py-1 text-xs font-medium" :class="rarityMeta[cloudType.rarity].chip">
|
||||
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[cloudType.rarity].chip">
|
||||
{{ rarityMeta[cloudType.rarity].label }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -178,11 +194,15 @@ onMounted(async () => {
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="border border-dashed border-slate-300 bg-white px-6 py-12">
|
||||
<NEmpty description="暂时还没有图鉴数据" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="lockHint"
|
||||
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-full bg-slate-900 px-4 py-2 text-sm text-white shadow-lg"
|
||||
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 border border-slate-900 bg-slate-900 px-4 py-2 text-sm text-white shadow-lg"
|
||||
>
|
||||
{{ lockHint }}
|
||||
</div>
|
||||
|
||||
+108
-103
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NEmpty, NSkeleton, NTag } from 'naive-ui'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
@@ -197,129 +198,133 @@ onUnmounted(() => {
|
||||
<p class="text-sm font-medium uppercase tracking-[0.24em] text-sky-700">Community Gallery</p>
|
||||
<h1 class="mt-3 text-4xl font-bold text-slate-900">云图画廊</h1>
|
||||
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||
按上传时间倒序浏览社区云图。卡片采用 Instagram 风格的整齐宫格排布,悬停即可快速查看基本信息,点开可看大图和详细记录。
|
||||
按上传时间倒序浏览社区云图。瀑布流会尽量保留图片原始比例,悬停即可快速查看基本信息,点开可看大图和详细记录。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
|
||||
<section>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2">
|
||||
<button
|
||||
<section>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2">
|
||||
<NButton
|
||||
v-for="tab in filterTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
secondary
|
||||
strong
|
||||
@click="selectedTypeId = tab.id"
|
||||
class="shrink-0 rounded-full border px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="selectedTypeId === tab.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900'"
|
||||
class="shrink-0"
|
||||
:type="selectedTypeId === tab.id ? 'primary' : 'default'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</NButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="loadError" class="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ loadError }}
|
||||
</div>
|
||||
<NAlert v-if="loadError" class="mt-6" type="error" :show-icon="false" :bordered="false" title="画廊加载失败">
|
||||
{{ loadError }}
|
||||
</NAlert>
|
||||
|
||||
<section v-if="loading" class="mt-6 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||
<div v-for="n in 8" :key="n" class="aspect-square animate-pulse rounded-[26px] bg-slate-200"></div>
|
||||
</section>
|
||||
<section v-if="loading" class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]">
|
||||
<div
|
||||
v-for="n in 8"
|
||||
:key="n"
|
||||
class="mb-3 break-inside-avoid border border-slate-200 bg-white p-3"
|
||||
>
|
||||
<NSkeleton class="h-44 w-full" />
|
||||
<NSkeleton class="mt-4 h-4 w-3/5" />
|
||||
<NSkeleton class="mt-3 h-3 w-2/5" />
|
||||
<NSkeleton class="mt-2 h-3 w-4/5" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="galleryItems.length" class="mt-6 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
|
||||
<button
|
||||
v-for="cloud in galleryItems"
|
||||
:key="cloud.id"
|
||||
type="button"
|
||||
@click="openDetail(cloud)"
|
||||
class="group relative aspect-square overflow-hidden rounded-[26px] bg-slate-200 text-left shadow-sm"
|
||||
>
|
||||
<img
|
||||
:src="cloud.thumbnail_url || cloud.image_url"
|
||||
:alt="cloud.cloudTypeName"
|
||||
class="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]"
|
||||
/>
|
||||
<section v-else-if="galleryItems.length" class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]">
|
||||
<button
|
||||
v-for="cloud in galleryItems"
|
||||
:key="cloud.id"
|
||||
type="button"
|
||||
@click="openDetail(cloud)"
|
||||
class="group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<img
|
||||
:src="cloud.thumbnail_url || cloud.image_url"
|
||||
:alt="cloud.cloudTypeName"
|
||||
class="block h-auto w-full object-cover transition duration-500 group-hover:scale-[1.04]"
|
||||
/>
|
||||
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/82 via-slate-950/8 to-transparent opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"></div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-4 text-white opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||||
<div class="rounded-2xl bg-black/28 px-3 py-3 backdrop-blur-[2px]">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="truncate text-sm font-semibold">{{ cloud.cloudTypeName }}</p>
|
||||
<span class="shrink-0 rounded-full border border-white/20 bg-white/10 px-2 py-0.5 text-[11px]">
|
||||
{{ rarityMeta[cloud.cloudTypeRarity].label }}
|
||||
</span>
|
||||
<div class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/88 via-slate-950/45 to-transparent opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"></div>
|
||||
<div class="absolute inset-x-0 bottom-0 p-4 text-white opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="truncate text-sm font-semibold">{{ cloud.cloudTypeName }}</p>
|
||||
<NTag size="small" :bordered="false" class="shrink-0 bg-white/12 text-white backdrop-blur">
|
||||
{{ rarityMeta[cloud.cloudTypeRarity].label }}
|
||||
</NTag>
|
||||
</div>
|
||||
<p class="mt-2 truncate text-xs text-white/82">📷 {{ cloud.username }}</p>
|
||||
<p class="mt-1 truncate text-xs text-white/82">🕐 {{ formatUploadTime(cloud) }}</p>
|
||||
<p class="mt-1 truncate text-xs text-white/68">{{ cloud.location_name || '未填写位置' }}</p>
|
||||
</div>
|
||||
<p class="mt-2 truncate text-xs text-white/78">📷 {{ cloud.username }}</p>
|
||||
<p class="mt-1 truncate text-xs text-white/78">🕐 {{ formatUploadTime(cloud) }}</p>
|
||||
<p class="mt-1 truncate text-xs text-white/65">{{ cloud.location_name || '未填写位置' }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-else class="mt-6 border border-dashed border-slate-300 bg-white px-6 py-12">
|
||||
<NEmpty description="还没有符合条件的云图">
|
||||
<template #extra>
|
||||
<p class="text-sm text-slate-500">换个云型筛选试试,或者等社区上传更多作品。</p>
|
||||
</template>
|
||||
</NEmpty>
|
||||
</section>
|
||||
|
||||
<div ref="sentinel" class="h-10"></div>
|
||||
|
||||
<div v-if="loadingMore" class="flex justify-center py-4">
|
||||
<NButton secondary disabled>正在加载更多云图...</NButton>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasMore && galleryItems.length" class="flex justify-center py-4">
|
||||
<NButton secondary strong @click="loadMore">手动加载更多</NButton>
|
||||
</div>
|
||||
|
||||
<ImageDetailModal
|
||||
v-if="selectedCloud"
|
||||
:open="!!selectedCloud"
|
||||
:image-url="selectedCloud.image_url"
|
||||
:image-alt="selectedCloud.cloudTypeName"
|
||||
:title="selectedCloud.cloudTypeName"
|
||||
:subtitle="`上传者:${selectedCloud.username}`"
|
||||
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
|
||||
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
|
||||
@close="closeDetail"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">上传时间</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatUploadTime(selectedCloud) }}</p>
|
||||
</div>
|
||||
<div class="border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">拍摄时间</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatCapturedTime(selectedCloud) }}</p>
|
||||
</div>
|
||||
<div class="border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">位置</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
|
||||
</div>
|
||||
<div class="border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">模糊化经纬度</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度 {{ formatCoordinate(selectedCloud.longitude) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-else class="mt-6 rounded-[28px] border border-dashed border-slate-300 bg-white px-6 py-12 text-center">
|
||||
<p class="text-xl font-semibold text-slate-900">还没有符合条件的云图</p>
|
||||
<p class="mt-2 text-sm text-slate-500">换个云型筛选试试,或者等社区上传更多作品。</p>
|
||||
</section>
|
||||
|
||||
<div ref="sentinel" class="h-10"></div>
|
||||
|
||||
<div v-if="loadingMore" class="flex justify-center py-4">
|
||||
<div class="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm text-slate-500 shadow-sm">
|
||||
正在加载更多云图...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasMore && galleryItems.length" class="flex justify-center py-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="loadMore"
|
||||
class="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 hover:text-slate-900"
|
||||
>
|
||||
手动加载更多
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ImageDetailModal
|
||||
v-if="selectedCloud"
|
||||
:open="!!selectedCloud"
|
||||
:image-url="selectedCloud.image_url"
|
||||
:image-alt="selectedCloud.cloudTypeName"
|
||||
:title="selectedCloud.cloudTypeName"
|
||||
:subtitle="`上传者:${selectedCloud.username}`"
|
||||
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
|
||||
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
|
||||
@close="closeDetail"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl bg-slate-50 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">上传时间</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatUploadTime(selectedCloud) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">拍摄时间</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatCapturedTime(selectedCloud) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">位置</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">模糊化经纬度</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度 {{ formatCoordinate(selectedCloud.longitude) }}
|
||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">图片说明</p>
|
||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-[28px] border border-slate-200 bg-white p-5">
|
||||
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">图片说明</p>
|
||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
</ImageDetailModal>
|
||||
</ImageDetailModal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,6 +26,7 @@ const statusText = ref('加载中...')
|
||||
|
||||
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
|
||||
const MIN_MARKER_OPACITY = 0.3
|
||||
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
||||
|
||||
let AMapLib: typeof AMap | null = null
|
||||
let mapInst: AMap.Map | null = null
|
||||
@@ -135,6 +136,10 @@ function hideHoverCard() {
|
||||
hoverIW = null
|
||||
}
|
||||
|
||||
function hideHeader() {
|
||||
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
|
||||
}
|
||||
|
||||
async function loadClouds(): Promise<CloudMarkerData[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
@@ -261,6 +266,9 @@ onMounted(async () => {
|
||||
mapInst.addControl(new AMapLib.ToolBar({ position: 'LT' } as Record<string, unknown>))
|
||||
mapInst.addControl(new AMapLib.ControlBar({ position: { right: '10px', top: '80px' } } as Record<string, unknown>))
|
||||
mapInst.on('click', () => { previewCloud.value = null; hideHoverCard() })
|
||||
mapInst.on('zoomstart', hideHeader)
|
||||
mapInst.on('movestart', hideHeader)
|
||||
mapInst.on('dragstart', hideHeader)
|
||||
await refresh()
|
||||
startMarkerDecayTimer()
|
||||
} catch (e) {
|
||||
@@ -280,7 +288,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-[calc(100vh-4rem)]">
|
||||
<div class="relative h-[100dvh] min-h-screen">
|
||||
<div ref="mapEl" class="w-full h-full"></div>
|
||||
|
||||
<div class="absolute bottom-6 right-4 flex flex-col gap-2 z-10">
|
||||
|
||||
+187
-209
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { NAlert, NButton, NCard, NProgress, NResult, NTag } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -293,95 +294,84 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
|
||||
<div>
|
||||
<section class="border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]">
|
||||
<div class="max-w-6xl mx-auto px-4 py-10">
|
||||
<p class="text-sm font-medium uppercase tracking-[0.24em] text-sky-700">Cloud Upload</p>
|
||||
<h1 class="mt-3 text-4xl font-bold text-slate-900">上传云图</h1>
|
||||
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||
批量整理你的云图记录。类别和拍摄时间必填,经纬度既可以手动输入,也可以在迷你地图里点选回填。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
|
||||
|
||||
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="w-full max-w-5xl">
|
||||
<div class="text-center">
|
||||
<span class="text-5xl block mb-4">✅</span>
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-2">上传成功</h2>
|
||||
<p class="text-gray-500">
|
||||
<template v-if="unlockedBadges.length">
|
||||
新点亮了 {{ unlockedBadges.length }} 枚图鉴徽章。
|
||||
</template>
|
||||
<template v-else>
|
||||
这批云图已经进入你的收藏记录。
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<NResult status="success" title="上传成功" class="border border-slate-200 bg-white shadow-sm">
|
||||
<template #default>
|
||||
<p class="text-slate-500">
|
||||
<template v-if="unlockedBadges.length">
|
||||
新点亮了 {{ unlockedBadges.length }} 枚图鉴徽章。
|
||||
</template>
|
||||
<template v-else>
|
||||
这批云图已经进入你的收藏记录。
|
||||
</template>
|
||||
</p>
|
||||
</template>
|
||||
</NResult>
|
||||
|
||||
<div v-if="unlockedBadges.length" class="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<article
|
||||
<NCard
|
||||
v-for="badge in unlockedBadges"
|
||||
:key="badge.cloudTypeId"
|
||||
class="rounded-[28px] border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)] p-6 shadow-sm"
|
||||
class="border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)] shadow-sm"
|
||||
>
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-400 text-3xl text-white shadow-sm">
|
||||
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white shadow-sm">
|
||||
{{ badge.cloudName.slice(0, 1) }}
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-2xl font-bold text-gray-900">{{ badge.cloudName }}</h3>
|
||||
<span class="rounded-full border border-amber-200 bg-white px-3 py-1 text-xs font-medium text-amber-700">
|
||||
<NTag :bordered="false" type="warning">
|
||||
{{ rarityLabel(badge.rarity) }}
|
||||
</span>
|
||||
</NTag>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ badge.cloudNameEn }}</p>
|
||||
<p class="mt-4 text-sm text-gray-600">解锁时间:{{ formatUnlockedAt(badge.unlockedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button
|
||||
@click="saveBadge(badge)"
|
||||
class="flex-1 rounded-xl bg-slate-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-slate-800"
|
||||
>
|
||||
保存分享卡片
|
||||
</button>
|
||||
<button
|
||||
@click="router.push(`/encyclopedia/${badge.cloudTypeId}`)"
|
||||
class="flex-1 rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
<NButton secondary strong type="primary" class="flex-1" @click="saveBadge(badge)">保存分享卡片</NButton>
|
||||
<NButton secondary strong class="flex-1" @click="router.push(`/encyclopedia/${badge.cloudTypeId}`)">查看详情</NButton>
|
||||
</div>
|
||||
</article>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
@click="resetAfterSuccess"
|
||||
class="rounded-xl border border-gray-300 px-5 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
继续上传
|
||||
</button>
|
||||
<button
|
||||
@click="router.push('/encyclopedia')"
|
||||
class="rounded-xl bg-sky-500 px-5 py-2.5 text-sm font-medium text-white hover:bg-sky-600"
|
||||
>
|
||||
前往图鉴
|
||||
</button>
|
||||
<button
|
||||
@click="router.push('/profile')"
|
||||
class="rounded-xl border border-gray-300 px-5 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
返回个人主页
|
||||
</button>
|
||||
<NButton secondary strong @click="resetAfterSuccess">继续上传</NButton>
|
||||
<NButton secondary strong type="primary" @click="router.push('/encyclopedia')">前往图鉴</NButton>
|
||||
<NButton secondary strong @click="router.push('/profile')">返回个人主页</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">📷 上传云图</h1>
|
||||
|
||||
<div v-if="uploading" class="mb-6">
|
||||
<div v-if="uploading" class="mb-6 border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
|
||||
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 overflow-hidden">
|
||||
<div class="bg-sky-500 h-full rounded-full transition-all duration-300" :style="{ width: overallProgress + '%' }"></div>
|
||||
</div>
|
||||
<NProgress
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:percentage="overallProgress"
|
||||
color="#0ea5e9"
|
||||
rail-color="#dbe4ee"
|
||||
:height="10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -390,7 +380,7 @@ onUnmounted(() => {
|
||||
@dragleave.prevent="dragOver = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="fileInput?.click()"
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed rounded-2xl py-20 cursor-pointer transition-colors"
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed py-20 cursor-pointer transition-colors bg-white shadow-sm"
|
||||
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-gray-300 hover:border-sky-400 hover:bg-gray-50'"
|
||||
>
|
||||
<span class="text-5xl mb-4">☁️</span>
|
||||
@@ -401,7 +391,7 @@ onUnmounted(() => {
|
||||
<template v-else>
|
||||
<div class="flex gap-6">
|
||||
<div class="flex-shrink-0 w-[480px]">
|
||||
<div v-if="activeItem" class="bg-gray-900 rounded-xl overflow-hidden mb-3">
|
||||
<div v-if="activeItem" class="bg-gray-900 overflow-hidden mb-3 border border-slate-900">
|
||||
<img :src="activeItem.preview" alt="预览" class="w-full h-[360px] object-contain" />
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||
@@ -409,20 +399,20 @@ onUnmounted(() => {
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
@click="selectItem(item.id)"
|
||||
class="relative flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden cursor-pointer border-2 transition-colors"
|
||||
class="relative flex-shrink-0 w-16 h-16 overflow-hidden cursor-pointer border-2 transition-colors bg-white"
|
||||
:class="activeId === item.id ? 'border-sky-500' : 'border-transparent hover:border-gray-300'"
|
||||
>
|
||||
<img :src="item.preview" alt="" class="w-full h-full object-cover" />
|
||||
<div v-if="Object.keys(item.errors).length > 0" class="absolute top-0 right-0 w-2.5 h-2.5 bg-red-500 rounded-full"></div>
|
||||
<div v-if="Object.keys(item.errors).length > 0" class="absolute top-0 right-0 w-2.5 h-2.5 bg-red-500"></div>
|
||||
<button
|
||||
@click.stop="handleRemove(item.id)"
|
||||
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center"
|
||||
class="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center bg-red-500 text-xs text-white"
|
||||
style="opacity:0.8"
|
||||
>✕</button>
|
||||
</div>
|
||||
<div
|
||||
@click="fileInput?.click()"
|
||||
class="flex-shrink-0 w-16 h-16 rounded-lg border-2 border-dashed border-gray-300 flex items-center justify-center cursor-pointer hover:border-sky-400 hover:bg-gray-50 transition-colors"
|
||||
class="flex-shrink-0 w-16 h-16 border-2 border-dashed border-gray-300 flex items-center justify-center cursor-pointer bg-white hover:border-sky-400 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span class="text-gray-400 text-xl">+</span>
|
||||
</div>
|
||||
@@ -430,161 +420,154 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0" v-if="activeItem">
|
||||
<div class="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900">图片信息</h2>
|
||||
<span class="text-sm text-gray-400">{{ items.findIndex(i => i.id === activeId) + 1 }} / {{ items.length }}</span>
|
||||
</div>
|
||||
<NCard class="border border-gray-200 shadow-sm">
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900">图片信息</h2>
|
||||
<span class="text-sm text-gray-400">{{ items.findIndex(i => i.id === activeId) + 1 }} / {{ items.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 类别 * -->
|
||||
<div :id="`field-${activeItem.id}-cloudCategory`">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
类别 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="activeItem.cloudCategoryId ?? ''"
|
||||
@change="onCategoryChange"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors bg-white"
|
||||
>
|
||||
<option value="" disabled>请选择</option>
|
||||
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
|
||||
{{ ct.name }}({{ ct.name_en }})
|
||||
</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<div v-if="activeItem.cloudCategoryId === 'other'" class="mt-2">
|
||||
<!-- 类别 * -->
|
||||
<div :id="`field-${activeItem.id}-cloudCategory`">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
类别 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="activeItem.cloudCategoryId ?? ''"
|
||||
@change="onCategoryChange"
|
||||
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors bg-white"
|
||||
>
|
||||
<option value="" disabled>请选择</option>
|
||||
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
|
||||
{{ ct.name }}({{ ct.name_en }})
|
||||
</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<div v-if="activeItem.cloudCategoryId === 'other'" class="mt-2">
|
||||
<input
|
||||
v-model="activeItem.customCloudType"
|
||||
type="text"
|
||||
placeholder="输入云型名称"
|
||||
@input="activeItem.errors = {}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="activeItem.errors.cloudCategory" class="text-sm text-red-500 mt-1">{{ activeItem.errors.cloudCategory }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 拍摄时间 * -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
拍摄时间 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="activeItem.customCloudType"
|
||||
type="datetime-local"
|
||||
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
||||
@change="onCapturedAtChange"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
<p v-if="activeItem.errors.capturedAt" class="text-sm text-red-500 mt-1">{{ activeItem.errors.capturedAt }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 经纬度 -->
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between gap-3">
|
||||
<label class="block text-sm font-medium text-gray-700">经纬度</label>
|
||||
<button
|
||||
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
|
||||
type="button"
|
||||
@click="clearCoordinates"
|
||||
class="text-xs font-medium text-gray-400 transition-colors hover:text-gray-600"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||
<div>
|
||||
<input
|
||||
:value="activeItem.latitude !== null ? activeItem.latitude : ''"
|
||||
@input="onLatInput"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="纬度(如 39.90)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
<p v-if="activeItem.errors.latitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.latitude }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
:value="activeItem.longitude !== null ? activeItem.longitude : ''"
|
||||
@input="onLngInput"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="经度(如 116.40)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
<p v-if="activeItem.errors.longitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.longitude }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
@click="openMapPicker"
|
||||
title="地图选点"
|
||||
aria-label="地图选点"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
|
||||
>
|
||||
<span class="text-lg leading-none">🗺️</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">选填,可手动输入,或点击右侧小地图图标选点</p>
|
||||
</div>
|
||||
|
||||
<!-- 位置名称 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">位置名称</label>
|
||||
<input
|
||||
v-model="activeItem.locationName"
|
||||
type="text"
|
||||
placeholder="输入云型名称"
|
||||
@input="activeItem.errors = {}"
|
||||
placeholder="如:北京、成都"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="activeItem.errors.cloudCategory" class="text-sm text-red-500 mt-1">{{ activeItem.errors.cloudCategory }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 拍摄时间 * -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
拍摄时间 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
||||
@change="onCapturedAtChange"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
<p v-if="activeItem.errors.capturedAt" class="text-sm text-red-500 mt-1">{{ activeItem.errors.capturedAt }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 经纬度 -->
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between gap-3">
|
||||
<label class="block text-sm font-medium text-gray-700">经纬度</label>
|
||||
<button
|
||||
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
|
||||
type="button"
|
||||
@click="clearCoordinates"
|
||||
class="text-xs font-medium text-gray-400 transition-colors hover:text-gray-600"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<!-- 图片描述 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">图片描述</label>
|
||||
<textarea
|
||||
v-model="activeItem.description"
|
||||
rows="3"
|
||||
placeholder="描述一下这张图片..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||
<div>
|
||||
<input
|
||||
:value="activeItem.latitude !== null ? activeItem.latitude : ''"
|
||||
@input="onLatInput"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="纬度(如 39.90)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
<p v-if="activeItem.errors.latitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.latitude }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
:value="activeItem.longitude !== null ? activeItem.longitude : ''"
|
||||
@input="onLngInput"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="经度(如 116.40)"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
<p v-if="activeItem.errors.longitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.longitude }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
@click="openMapPicker"
|
||||
title="地图选点"
|
||||
aria-label="地图选点"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
|
||||
>
|
||||
<span class="text-lg leading-none">🗺️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 隐身模式 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="activeItem.isHidden" type="checkbox" :id="'isHidden-' + activeItem.id" class="w-4 h-4 text-sky-500 border-gray-300 rounded focus:ring-sky-500" />
|
||||
<label :for="'isHidden-' + activeItem.id" class="text-sm text-gray-600">隐身模式(地图不显示位置)</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">选填,可手动输入,或点击右侧小地图图标选点</p>
|
||||
</div>
|
||||
|
||||
<!-- 位置名称 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">位置名称</label>
|
||||
<input
|
||||
v-model="activeItem.locationName"
|
||||
type="text"
|
||||
placeholder="如:北京、成都"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 图片描述 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">图片描述</label>
|
||||
<textarea
|
||||
v-model="activeItem.description"
|
||||
rows="3"
|
||||
placeholder="描述一下这张图片..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 隐身模式 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="activeItem.isHidden" type="checkbox" :id="'isHidden-' + activeItem.id" class="w-4 h-4 text-sky-500 border-gray-300 rounded focus:ring-sky-500" />
|
||||
<label :for="'isHidden-' + activeItem.id" class="text-sm text-gray-600">隐身模式(地图不显示位置)</label>
|
||||
</div>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500">共 {{ items.length }} 张图片,类别和拍摄时间为必填项</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="clearAll()"
|
||||
:disabled="uploading"
|
||||
class="px-5 py-2.5 border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
@click="handleSubmit"
|
||||
:disabled="uploading"
|
||||
class="px-6 py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<NButton secondary strong @click="clearAll()" :disabled="uploading">清空</NButton>
|
||||
<NButton type="primary" secondary strong @click="handleSubmit" :disabled="uploading">
|
||||
{{ uploading ? '上传中...' : '提交上传' }}
|
||||
</button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-sm text-red-600">{{ errorMsg }}</p>
|
||||
</div>
|
||||
<NAlert v-if="errorMsg" class="mt-3" type="error" :show-icon="false" :bordered="false" title="上传失败">
|
||||
{{ errorMsg }}
|
||||
</NAlert>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
@@ -594,7 +577,7 @@ onUnmounted(() => {
|
||||
@click="closeMapPicker"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl overflow-hidden rounded-[28px] bg-white shadow-2xl"
|
||||
class="w-full max-w-3xl overflow-hidden bg-white shadow-2xl"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-start justify-between border-b border-gray-200 px-6 py-5">
|
||||
@@ -612,13 +595,13 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3 rounded-2xl bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3 border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<span>当前选择:</span>
|
||||
<span class="font-medium text-slate-900">纬度 {{ formatCoordinate(mapPickerLat) }}</span>
|
||||
<span class="font-medium text-slate-900">经度 {{ formatCoordinate(mapPickerLng) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="relative overflow-hidden rounded-3xl border border-gray-200 bg-slate-100">
|
||||
<div class="relative overflow-hidden border border-gray-200 bg-slate-100">
|
||||
<div ref="miniMapEl" class="h-[420px] w-full"></div>
|
||||
|
||||
<div
|
||||
@@ -640,21 +623,16 @@ onUnmounted(() => {
|
||||
<div class="flex items-center justify-between border-t border-gray-200 px-6 py-4">
|
||||
<p class="text-sm text-gray-500">建议点选大致拍摄位置,上传时会继续做模糊化处理。</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeMapPicker"
|
||||
class="rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<NButton secondary strong @click="closeMapPicker">取消</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
secondary
|
||||
strong
|
||||
:disabled="mapPickerLat === null || mapPickerLng === null"
|
||||
@click="confirmMapPicker"
|
||||
class="rounded-xl bg-sky-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-sky-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
使用这个位置
|
||||
</button>
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user