Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e49bd9b4 | ||
|
|
6445687d56 | ||
|
|
0fdd94317e | ||
|
|
015e0dd227 | ||
|
|
720ad29f06 | ||
|
|
cd70f89a28 |
+1
-1
@@ -1,3 +1,3 @@
|
||||
VITE_API_URL=http://localhost:8000/api/v1
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_AMAP_KEY=
|
||||
VITE_SITE_URL=http://localhost:5173
|
||||
|
||||
Generated
+359
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"better-auth": "^1.7.6",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
@@ -81,6 +82,135 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/core": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmmirror.com/@better-auth/core/-/core-1.7.6.tgz",
|
||||
"integrity": "sha512-yBDDO0J4VCHT1qEZtzj6aMexX4etyLesitoxehcWHzPrzYqrFHwe/hgskye+imlGhQVOysuljMg8M8BA6GrtIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.41.1",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"zod": "^4.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@better-auth/utils": "0.4.2",
|
||||
"@better-fetch/fetch": "1.3.2",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"better-call": "1.4.0",
|
||||
"jose": "^6.1.0",
|
||||
"kysely": "^0.28.5 || ^0.29.0",
|
||||
"nanostores": "^1.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/drizzle-adapter": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmmirror.com/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.6.tgz",
|
||||
"integrity": "sha512-ENlhwC7kkTjquuiel3Efggik0hC4vtD7ppJ8FhsiHhRJA8K6RT4hbhzkfm6keNvjdnAeWwCejm7FmMO4GoSGpA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@better-auth/core": "^1.7.6",
|
||||
"@better-auth/utils": "0.4.2",
|
||||
"drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"drizzle-orm": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/kysely-adapter": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmmirror.com/@better-auth/kysely-adapter/-/kysely-adapter-1.7.6.tgz",
|
||||
"integrity": "sha512-BV+DX2/z/6ozNC9DYSge78wjLerq6Bxd/lSPjzzuq9eEutkQDAJTuiaETzgGYqNV5LlEWi8TZFAQTEq/wavOEQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@better-auth/core": "^1.7.6",
|
||||
"@better-auth/utils": "0.4.2",
|
||||
"kysely": "^0.28.17 || ^0.29.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"kysely": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/memory-adapter": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmmirror.com/@better-auth/memory-adapter/-/memory-adapter-1.7.6.tgz",
|
||||
"integrity": "sha512-ng1xv8k2JsEIjnqzJzdoVOQrnoCLRuk4EiQL9yYAThyg6zYzV00LynlhH+O9OMUZ+yGVpGqGZmr5xcTiUSV2uA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@better-auth/core": "^1.7.6",
|
||||
"@better-auth/utils": "0.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/mongo-adapter": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmmirror.com/@better-auth/mongo-adapter/-/mongo-adapter-1.7.6.tgz",
|
||||
"integrity": "sha512-GvBwZli8i4XINMAj9tlW7eV9E8Dr+JWqavXjUs2o81GZt/Vh8wiX+31KK1jQ4iOuejfjpLoXcoFtlIPgf/pVog==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@better-auth/core": "^1.7.6",
|
||||
"@better-auth/utils": "0.4.2",
|
||||
"mongodb": "^6.0.0 || ^7.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"mongodb": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/prisma-adapter": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmmirror.com/@better-auth/prisma-adapter/-/prisma-adapter-1.7.6.tgz",
|
||||
"integrity": "sha512-31rz2OXIx2Q1L4Jhd5F71dafIWqder/Bo6vJ+eCgO5iZ0gvnv7fUmM95DvbVUoPC69XYpcIM/6PJmQ3M6RydWg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@better-auth/core": "^1.7.6",
|
||||
"@better-auth/utils": "0.4.2",
|
||||
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
||||
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@prisma/client": {
|
||||
"optional": true
|
||||
},
|
||||
"prisma": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/telemetry": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmmirror.com/@better-auth/telemetry/-/telemetry-1.7.6.tgz",
|
||||
"integrity": "sha512-xgRwPja2wTJtcu+OQZR2IiNpAmviddnW3n5Do5umXRugJFW3v9KlSmYjYm580mVsPnU4upvZkvSRl9v8DgSDtA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@better-auth/core": "^1.7.6",
|
||||
"@better-auth/utils": "0.4.2",
|
||||
"@better-fetch/fetch": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@better-auth/utils": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/@better-auth/utils/-/utils-0.4.2.tgz",
|
||||
"integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@better-fetch/fetch": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@better-fetch/fetch/-/fetch-1.3.2.tgz",
|
||||
"integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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",
|
||||
@@ -213,6 +343,39 @@
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@noble/ciphers/-/ciphers-2.4.0.tgz",
|
||||
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/semantic-conventions": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmmirror.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz",
|
||||
"integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
@@ -505,6 +668,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
@@ -1189,6 +1358,136 @@
|
||||
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-auth": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmmirror.com/better-auth/-/better-auth-1.7.6.tgz",
|
||||
"integrity": "sha512-WTMqOpmTTj+oBoX7zzPOzL85342Upyf+/v0IkH1kvGRA85lV4JGRFIYMIRKqvjZ6ShsuL5VX/c9C13jtoZXcKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@better-auth/core": "1.7.6",
|
||||
"@better-auth/drizzle-adapter": "1.7.6",
|
||||
"@better-auth/kysely-adapter": "1.7.6",
|
||||
"@better-auth/memory-adapter": "1.7.6",
|
||||
"@better-auth/mongo-adapter": "1.7.6",
|
||||
"@better-auth/prisma-adapter": "1.7.6",
|
||||
"@better-auth/telemetry": "1.7.6",
|
||||
"@better-auth/utils": "0.4.2",
|
||||
"@better-fetch/fetch": "1.3.2",
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"better-call": "1.4.0",
|
||||
"defu": "^6.1.4",
|
||||
"jose": "^6.2.3",
|
||||
"kysely": "^0.28.17 || ^0.29.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"zod": "^4.5.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@lynx-js/react": "*",
|
||||
"@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@tanstack/react-start": "^1.0.0",
|
||||
"@tanstack/solid-start": "^1.0.0",
|
||||
"drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1",
|
||||
"drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0",
|
||||
"mongodb": "^6.0.0 || ^7.0.0",
|
||||
"mysql2": "^3.0.0",
|
||||
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
||||
"pg": "^8.0.0",
|
||||
"prisma": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0",
|
||||
"solid-js": "^1.0.0",
|
||||
"svelte": "^4.0.0 || ^5.0.0",
|
||||
"vitest": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@lynx-js/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@prisma/client": {
|
||||
"optional": true
|
||||
},
|
||||
"@sveltejs/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"@tanstack/react-start": {
|
||||
"optional": true
|
||||
},
|
||||
"@tanstack/solid-start": {
|
||||
"optional": true
|
||||
},
|
||||
"drizzle-kit": {
|
||||
"optional": true
|
||||
},
|
||||
"drizzle-orm": {
|
||||
"optional": true
|
||||
},
|
||||
"mongodb": {
|
||||
"optional": true
|
||||
},
|
||||
"mysql2": {
|
||||
"optional": true
|
||||
},
|
||||
"next": {
|
||||
"optional": true
|
||||
},
|
||||
"pg": {
|
||||
"optional": true
|
||||
},
|
||||
"prisma": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vitest": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/better-call": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/better-call/-/better-call-1.4.0.tgz",
|
||||
"integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@better-auth/utils": "^0.5.0",
|
||||
"@better-fetch/fetch": "^1.3.1",
|
||||
"rou3": "^0.9.1",
|
||||
"set-cookie-parser": "^3.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zod": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/better-call/node_modules/@better-auth/utils": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/@better-auth/utils/-/utils-0.5.0.tgz",
|
||||
"integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz",
|
||||
@@ -1254,6 +1553,12 @@
|
||||
"date-fns": "^3.0.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.7.tgz",
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1379,6 +1684,24 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.12",
|
||||
"resolved": "https://registry.npmmirror.com/jose/-/jose-6.2.12.tgz",
|
||||
"integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/kysely": {
|
||||
"version": "0.29.6",
|
||||
"resolved": "https://registry.npmmirror.com/kysely/-/kysely-0.29.6.tgz",
|
||||
"integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -1736,6 +2059,21 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/nanostores": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmmirror.com/nanostores/-/nanostores-1.5.4.tgz",
|
||||
"integrity": "sha512-4UXY7OAJEK0Y6V3SudBU7cXjAzTDc189R78dDOh0Xbm19gpKYto6WizSg58LJf12lTsRxKSLGyE117OiLpM4DQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.0.0 || >=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
@@ -1857,12 +2195,24 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/rou3": {
|
||||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmmirror.com/rou3/-/rou3-0.9.2.tgz",
|
||||
"integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==",
|
||||
"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/set-cookie-parser": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
|
||||
"integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
|
||||
"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",
|
||||
@@ -2151,6 +2501,15 @@
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.11"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.6.5.tgz",
|
||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"better-auth": "^1.7.6",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NIcon } from 'naive-ui'
|
||||
import { Map as MapIcon, X } from '@vicons/tabler'
|
||||
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
export interface CloudEditFormValue {
|
||||
cloudCategoryId: number | 'other' | null
|
||||
customCloudType: string
|
||||
cloudCategoryId: number | null
|
||||
capturedAt: string
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
locationName: string
|
||||
description: string
|
||||
isPublic: boolean
|
||||
}
|
||||
@@ -35,16 +33,12 @@ const emit = defineEmits<{
|
||||
const form = ref<CloudEditFormValue>(createEmptyForm())
|
||||
const mapPickerOpen = ref(false)
|
||||
|
||||
const hasCoordinates = computed(() => form.value.latitude !== null || form.value.longitude !== null)
|
||||
|
||||
function createEmptyForm(): CloudEditFormValue {
|
||||
return {
|
||||
cloudCategoryId: null,
|
||||
customCloudType: '',
|
||||
capturedAt: '',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
locationName: '',
|
||||
description: '',
|
||||
isPublic: true,
|
||||
}
|
||||
@@ -56,9 +50,7 @@ function cloneForm(value: CloudEditFormValue | null) {
|
||||
|
||||
function onCategoryChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
if (value === 'other') {
|
||||
form.value.cloudCategoryId = 'other'
|
||||
} else if (!value) {
|
||||
if (!value) {
|
||||
form.value.cloudCategoryId = null
|
||||
} else {
|
||||
form.value.cloudCategoryId = Number(value)
|
||||
@@ -75,11 +67,6 @@ function onLngInput(event: Event) {
|
||||
form.value.longitude = Number.isNaN(value) ? null : value
|
||||
}
|
||||
|
||||
function clearCoordinates() {
|
||||
form.value.latitude = null
|
||||
form.value.longitude = null
|
||||
}
|
||||
|
||||
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
||||
form.value.latitude = payload.latitude
|
||||
form.value.longitude = payload.longitude
|
||||
@@ -154,21 +141,11 @@ watch(() => props.initialValue, value => {
|
||||
>
|
||||
<option value="" disabled>请选择</option>
|
||||
<option v-for="ct in cloudTypes" :key="ct.id" :value="ct.id">
|
||||
{{ ct.name }}({{ ct.name_en }})
|
||||
{{ ct.name }}{{ ct.genus ? '(' + ct.genus + ')' : '' }}
|
||||
</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="form.cloudCategoryId === 'other'">
|
||||
<label class="oc-field-label">自定义云型</label>
|
||||
<input
|
||||
v-model="form.customCloudType"
|
||||
type="text"
|
||||
class="oc-field-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="oc-field-label">拍摄时间</label>
|
||||
<input
|
||||
@@ -179,27 +156,7 @@ watch(() => props.initialValue, value => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="oc-field-label">位置名称</label>
|
||||
<input
|
||||
v-model="form.locationName"
|
||||
type="text"
|
||||
class="oc-field-control"
|
||||
placeholder="如:北京、成都"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between gap-3">
|
||||
<label class="oc-field-label mb-0">经纬度</label>
|
||||
<button
|
||||
v-if="hasCoordinates"
|
||||
type="button"
|
||||
class="oc-text-button oc-text-button--muted text-xs"
|
||||
@click="clearCoordinates"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<label class="oc-field-label">经纬度 <span class="oc-field-required">*</span></label>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||
<input
|
||||
:value="form.latitude !== null ? form.latitude : ''"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { NButton, NIcon, useMessage } from 'naive-ui'
|
||||
import { Heart } from '@vicons/tabler'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useLikesStore } from '@/stores/likes'
|
||||
|
||||
const props = defineProps<{ cloudId: string; count: number }>()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const likesStore = useLikesStore()
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const liked = computed(() => likesStore.isLiked(props.cloudId))
|
||||
const count = computed(() => likesStore.countFor(props.cloudId, props.count))
|
||||
|
||||
watch(() => [authStore.user?.id, props.cloudId], async ([userId]) => {
|
||||
error.value = ''
|
||||
if (!userId) return
|
||||
loading.value = true
|
||||
try {
|
||||
await likesStore.ensureLoaded()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '点赞状态加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function toggleLike() {
|
||||
error.value = ''
|
||||
try {
|
||||
await likesStore.toggle(props.cloudId, props.count)
|
||||
message.success(liked.value ? '已点赞' : '已取消点赞')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '点赞操作失败'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-5 flex flex-wrap items-center gap-3">
|
||||
<NButton
|
||||
v-if="authStore.isLoggedIn"
|
||||
type="default"
|
||||
:class="['oc-panel-button', liked ? 'oc-panel-button--teal' : 'oc-panel-button--neutral']"
|
||||
:loading="loading || likesStore.isPending(cloudId)"
|
||||
@click="toggleLike"
|
||||
>
|
||||
<template #icon><NIcon><Heart /></NIcon></template>
|
||||
{{ liked ? '取消点赞' : '点赞' }}
|
||||
</NButton>
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="{ path: '/login', query: { redirect: route.fullPath } }"
|
||||
class="oc-panel-button oc-panel-button--neutral no-underline"
|
||||
>登录后点赞</RouterLink>
|
||||
<span class="text-sm text-slate-600">{{ count }} 次点赞</span>
|
||||
<span v-if="error" class="text-sm text-rose-700">{{ error }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,108 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NCard } from 'naive-ui'
|
||||
|
||||
const props = defineProps<{
|
||||
unlockedCount: number
|
||||
totalCount: number
|
||||
isLoggedIn: boolean
|
||||
}>()
|
||||
|
||||
const displayedUnlockedCount = computed(() => {
|
||||
if (!props.isLoggedIn) return 0
|
||||
return Math.min(Math.max(props.unlockedCount, 0), props.totalCount)
|
||||
})
|
||||
const remainingCount = computed(() => Math.max(props.totalCount - displayedUnlockedCount.value, 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard class="oc-panel-card-soft border border-sky-100 bg-white/88 backdrop-blur" :bordered="false">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="oc-stat-label">Collection Status</p>
|
||||
<p class="mt-1 text-base font-semibold text-slate-900">图鉴收集进度</p>
|
||||
</div>
|
||||
<p class="shrink-0 font-mono tabular-nums text-slate-500">
|
||||
<span class="text-3xl font-bold text-sky-800">{{ displayedUnlockedCount }}</span>
|
||||
<span class="ml-1 text-sm">/ {{ totalCount }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-5 grid grid-cols-5 gap-2 sm:grid-cols-10"
|
||||
role="progressbar"
|
||||
aria-label="云属收集进度"
|
||||
aria-valuemin="0"
|
||||
:aria-valuemax="totalCount"
|
||||
:aria-valuenow="displayedUnlockedCount"
|
||||
:aria-valuetext="`已收录 ${displayedUnlockedCount} 种,共 ${totalCount} 种`"
|
||||
>
|
||||
<span
|
||||
v-for="index in totalCount"
|
||||
:key="index"
|
||||
aria-hidden="true"
|
||||
class="encyclopedia-progress-cell aspect-square border border-slate-300 bg-slate-50"
|
||||
:class="{ 'encyclopedia-progress-cell--active': index <= displayedUnlockedCount }"
|
||||
:style="{ animationDelay: `${(index - 1) * 90}ms` }"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 border-t border-slate-200 pt-3 text-sm text-slate-500">
|
||||
<template v-if="isLoggedIn">
|
||||
已收录 {{ displayedUnlockedCount }} 种云属,还差 {{ remainingCount }} 种。
|
||||
</template>
|
||||
<template v-else>
|
||||
登录后可同步你的个人图鉴进度。
|
||||
</template>
|
||||
</p>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.encyclopedia-progress-cell--active {
|
||||
background: rgb(224 242 254);
|
||||
border-color: rgb(125 211 252);
|
||||
box-shadow: 3px 3px 0 0 rgb(3 105 161 / 14%);
|
||||
animation:
|
||||
encyclopedia-cell-activate 1ms linear both,
|
||||
encyclopedia-cell-pulse 220ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes encyclopedia-cell-activate {
|
||||
from {
|
||||
background: rgb(248 250 252);
|
||||
border-color: rgb(203 213 225);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
to {
|
||||
background: rgb(224 242 254);
|
||||
border-color: rgb(125 211 252);
|
||||
box-shadow: 3px 3px 0 0 rgb(3 105 161 / 14%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes encyclopedia-cell-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
28% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
62% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.encyclopedia-progress-cell--active {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -49,10 +49,10 @@ const layout = computed(() => {
|
||||
? naturalWidth.value / naturalHeight.value
|
||||
: 4 / 3
|
||||
const maxModalWidth = viewportWidth.value > 0
|
||||
? Math.max(Math.min(viewportWidth.value * 0.92, viewportWidth.value - 32), 280)
|
||||
? Math.max(Math.min(viewportWidth.value * 0.95, viewportWidth.value - 32), 280)
|
||||
: 320
|
||||
const maxImageHeight = viewportHeight.value > 0
|
||||
? Math.max(viewportHeight.value * 0.86, 240)
|
||||
? Math.max(viewportHeight.value * 0.9, 240)
|
||||
: 360
|
||||
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
|
||||
|
||||
@@ -187,11 +187,11 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
type="button"
|
||||
@click="close"
|
||||
class="image-detail-close-button oc-overlay-button absolute right-4 top-4 z-30 h-10 w-10 [&_svg]:stroke-[2.5]"
|
||||
class="oc-overlay-button absolute right-3 top-3 z-30 h-9 w-9 opacity-0 drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)] transition-opacity duration-200 hover:opacity-100 focus-visible:opacity-100"
|
||||
aria-label="关闭大图"
|
||||
title="关闭大图"
|
||||
>
|
||||
<NIcon size="22">
|
||||
<NIcon size="20">
|
||||
<X />
|
||||
</NIcon>
|
||||
</button>
|
||||
@@ -301,13 +301,7 @@ onBeforeUnmount(() => {
|
||||
transform: translateY(10px) scale(0.97);
|
||||
}
|
||||
|
||||
.image-detail-shell.image-detail-shell {
|
||||
border-radius: 30px !important;
|
||||
}
|
||||
|
||||
.image-detail-close-button.image-detail-close-button,
|
||||
.image-detail-info-button.image-detail-info-button,
|
||||
.image-detail-panel-close.image-detail-panel-close {
|
||||
.image-detail-info-button.image-detail-info-button {
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -91,9 +91,7 @@ function handleRemove(id: string) {
|
||||
function onCategoryChange(event: Event) {
|
||||
if (!activeItem.value) return
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
if (value === 'other') {
|
||||
activeItem.value.cloudCategoryId = 'other'
|
||||
} else if (value === '') {
|
||||
if (value === '') {
|
||||
activeItem.value.cloudCategoryId = null
|
||||
} else {
|
||||
activeItem.value.cloudCategoryId = Number(value)
|
||||
@@ -102,6 +100,7 @@ function onCategoryChange(event: Event) {
|
||||
}
|
||||
|
||||
function formatDatetimeLocal(iso: string): string {
|
||||
if (!iso) return ''
|
||||
const date = new Date(iso)
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
@@ -110,8 +109,7 @@ function formatDatetimeLocal(iso: string): string {
|
||||
function onCapturedAtChange(event: Event) {
|
||||
if (!activeItem.value) return
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
if (!value) return
|
||||
activeItem.value.capturedAt = new Date(value).toISOString()
|
||||
activeItem.value.capturedAt = value ? new Date(value).toISOString() : ''
|
||||
delete activeItem.value.errors.capturedAt
|
||||
}
|
||||
|
||||
@@ -169,7 +167,7 @@ async function handleSubmit() {
|
||||
|
||||
const result = await uploadAll()
|
||||
if (!result.ok) {
|
||||
errorMsg.value = '上传失败,请稍后重试'
|
||||
errorMsg.value = result.errorMessage || '上传失败,请稍后重试'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -217,7 +215,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="max-h-[72vh] overflow-y-auto px-5 py-5">
|
||||
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileSelect" />
|
||||
<input ref="fileInput" type="file" accept="image/jpeg,image/png,image/webp" class="hidden" @change="handleFileSelect" />
|
||||
|
||||
<NAlert v-if="successMsg" type="success" :bordered="false" title="已提交审核" class="mb-4">
|
||||
图片审核通过后会出现在画廊和地图中。
|
||||
@@ -248,7 +246,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<NIcon size="42" class="text-sky-600"><CloudUpload /></NIcon>
|
||||
<p class="mt-3 text-base font-semibold text-slate-800">选择一张云图</p>
|
||||
<p class="oc-field-help">支持 JPG、PNG,可点击或拖拽上传。</p>
|
||||
<p class="oc-field-help">支持 JPEG、PNG、WebP,图片不超过 20MB。</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="activeItem">
|
||||
@@ -273,23 +271,15 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<option value="" disabled>请选择</option>
|
||||
<option v-for="cloudType in cloudsStore.cloudTypes" :key="cloudType.id" :value="cloudType.id">
|
||||
{{ cloudType.name }}({{ cloudType.name_en }})
|
||||
{{ cloudType.name }}{{ cloudType.genus ? '(' + cloudType.genus + ')' : '' }}
|
||||
</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
<input
|
||||
v-if="activeItem.cloudCategoryId === 'other'"
|
||||
v-model="activeItem.customCloudType"
|
||||
type="text"
|
||||
placeholder="输入云的类型"
|
||||
class="oc-field-control mt-2"
|
||||
@input="activeItem.errors = {}"
|
||||
/>
|
||||
<p v-if="activeItem.errors.cloudCategory" class="oc-field-error">{{ activeItem.errors.cloudCategory }}</p>
|
||||
<p v-if="activeItem.errors.file" class="oc-field-error">{{ activeItem.errors.file }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="oc-field-label">拍摄时间 <span class="oc-field-required">*</span></label>
|
||||
<label class="oc-field-label">拍摄时间</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
||||
@@ -300,7 +290,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="oc-field-label">经纬度</label>
|
||||
<label class="oc-field-label">经纬度 <span class="oc-field-required">*</span></label>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_40px] gap-2">
|
||||
<div>
|
||||
<input
|
||||
@@ -336,17 +326,7 @@ onBeforeUnmount(() => {
|
||||
</NIcon>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-400">选填,可手动输入,也可以点击右侧地图按钮选点。</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="oc-field-label">位置名称</label>
|
||||
<input
|
||||
v-model="activeItem.locationName"
|
||||
type="text"
|
||||
placeholder="如:北京、成都"
|
||||
class="oc-field-control"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-400">必填,可手动输入,也可以点击右侧地图按钮选点。</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -354,9 +334,11 @@ onBeforeUnmount(() => {
|
||||
<textarea
|
||||
v-model="activeItem.description"
|
||||
rows="3"
|
||||
maxlength="128"
|
||||
placeholder="描述一下这张图片..."
|
||||
class="oc-field-control resize-none"
|
||||
></textarea>
|
||||
<p v-if="activeItem.errors.description" class="oc-field-error">{{ activeItem.errors.description }}</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { NIcon, NSpace } from 'naive-ui'
|
||||
import { NIcon, NSpace, useMessage } from 'naive-ui'
|
||||
import { ChevronDown, Logout, Settings, Shield, User } from '@vicons/tabler'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
@@ -8,6 +8,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const isMapRoute = computed(() => route.name === 'map')
|
||||
|
||||
const headerHidden = ref(false)
|
||||
@@ -18,6 +19,9 @@ const inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800'
|
||||
|
||||
const displayUsername = computed(() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户')
|
||||
const userEmail = computed(() => authStore.user?.email || '未绑定邮箱')
|
||||
const avatarFailed = ref(false)
|
||||
|
||||
watch(() => authStore.user?.image, () => { avatarFailed.value = false })
|
||||
|
||||
let lastScrollY = 0
|
||||
let accountCloseTimer: number | null = null
|
||||
@@ -119,10 +123,14 @@ function closeAccountCard() {
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
closeAccountCard()
|
||||
if (route.meta.requiresAuth) {
|
||||
router.push({ name: 'map' })
|
||||
try {
|
||||
await authStore.logout()
|
||||
closeAccountCard()
|
||||
if (route.meta.requiresAuth) {
|
||||
void router.push({ name: 'map' })
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -198,7 +206,7 @@ async function handleLogout() {
|
||||
class="oc-nav-link"
|
||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
||||
>
|
||||
图鉴
|
||||
百科
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
@@ -232,9 +240,8 @@ async function handleLogout() {
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="accountCardOpen"
|
||||
>
|
||||
<NIcon size="16" class="shrink-0">
|
||||
<User />
|
||||
</NIcon>
|
||||
<img v-if="authStore.user?.image && !avatarFailed" :src="authStore.user.image" alt="" class="h-5 w-5 shrink-0 object-cover" @error="avatarFailed = true" />
|
||||
<NIcon v-else size="16" class="shrink-0"><User /></NIcon>
|
||||
<span class="truncate">@{{ displayUsername }}</span>
|
||||
</button>
|
||||
|
||||
@@ -247,7 +254,8 @@ async function handleLogout() {
|
||||
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800">
|
||||
{{ displayUsername.slice(0, 1).toUpperCase() }}
|
||||
<img v-if="authStore.user?.image && !avatarFailed" :src="authStore.user.image" :alt="displayUsername" class="h-full w-full object-cover" @error="avatarFailed = true" />
|
||||
<template v-else>{{ displayUsername.slice(0, 1).toUpperCase() }}</template>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold text-slate-950">@{{ displayUsername }}</p>
|
||||
@@ -362,7 +370,7 @@ async function handleLogout() {
|
||||
class="oc-nav-link shrink-0"
|
||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
||||
>
|
||||
图鉴
|
||||
百科
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -3,15 +3,12 @@ import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
import type { CloudCreateResponse } from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
export interface UploadItem {
|
||||
id: string
|
||||
file: File
|
||||
preview: string
|
||||
cloudCategoryId: number | 'other' | null
|
||||
customCloudType: string
|
||||
locationName: string
|
||||
cloudCategoryId: number | null
|
||||
description: string
|
||||
isHidden: boolean
|
||||
latitude: number | null
|
||||
@@ -20,17 +17,9 @@ export interface UploadItem {
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
export interface UnlockedBadge {
|
||||
cloudTypeId: number
|
||||
cloudName: string
|
||||
cloudNameEn: string
|
||||
rarity: CloudType['rarity']
|
||||
unlockedAt: string
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
ok: boolean
|
||||
unlockedBadges: UnlockedBadge[]
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
@@ -46,7 +35,7 @@ function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
|
||||
function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
const view = new DataView(buffer)
|
||||
if (view.getUint16(0, false) !== 0xffd8) return null
|
||||
if (view.byteLength < 4 || view.getUint16(0, false) !== 0xffd8) return null
|
||||
|
||||
let offset = 2
|
||||
while (offset < view.byteLength - 2) {
|
||||
@@ -89,8 +78,8 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function appendOptional(form: FormData, key: string, value: string | number | null) {
|
||||
if (value !== null && value !== '') form.append(key, String(value))
|
||||
function appendOptional(form: FormData, key: string, value: string) {
|
||||
if (value.trim()) form.append(key, value.trim())
|
||||
}
|
||||
|
||||
export function useUpload() {
|
||||
@@ -103,14 +92,13 @@ export function useUpload() {
|
||||
const totalItems = ref(0)
|
||||
|
||||
async function addFiles(files: File[]) {
|
||||
const imageFiles = files.filter(file => file.type.startsWith('image/'))
|
||||
const imageFiles = files.filter(file => ['image/jpeg', 'image/png', 'image/webp'].includes(file.type))
|
||||
for (const file of imageFiles) {
|
||||
let capturedAt = new Date().toISOString()
|
||||
let capturedAt = ''
|
||||
try {
|
||||
const exifDate = extractExifDate(await readFileAsArrayBuffer(file))
|
||||
if (exifDate) capturedAt = exifDate
|
||||
capturedAt = extractExifDate(await readFileAsArrayBuffer(file)) || ''
|
||||
} catch {
|
||||
// 无 EXIF 时使用当前时间。
|
||||
capturedAt = ''
|
||||
}
|
||||
|
||||
items.value.push({
|
||||
@@ -118,8 +106,6 @@ export function useUpload() {
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
cloudCategoryId: null,
|
||||
customCloudType: '',
|
||||
locationName: '',
|
||||
description: '',
|
||||
isHidden: false,
|
||||
latitude: null,
|
||||
@@ -145,15 +131,24 @@ export function useUpload() {
|
||||
function validateItem(item: UploadItem) {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别'
|
||||
if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) {
|
||||
errors.cloudCategory = '请输入自定义云型名称'
|
||||
if (item.file.size === 0) errors.file = '图片文件为空'
|
||||
if (item.file.size > 20 * 1024 * 1024) errors.file = '图片不能超过 20MB'
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(item.file.type)) {
|
||||
errors.file = '仅支持 JPEG、PNG、WebP 格式'
|
||||
}
|
||||
if (!item.capturedAt) errors.capturedAt = '请选择拍摄时间'
|
||||
|
||||
const hasLat = typeof item.latitude === 'number' && !isNaN(item.latitude)
|
||||
const hasLng = typeof item.longitude === 'number' && !isNaN(item.longitude)
|
||||
if (hasLat && !hasLng) errors.longitude = '经纬度必须同时填写'
|
||||
if (hasLng && !hasLat) errors.latitude = '经纬度必须同时填写'
|
||||
if (item.latitude === null || Number.isNaN(item.latitude)) {
|
||||
errors.latitude = '请填写纬度'
|
||||
} else if (item.latitude < -90 || item.latitude > 90) {
|
||||
errors.latitude = '纬度范围为 -90 到 90'
|
||||
}
|
||||
if (item.longitude === null || Number.isNaN(item.longitude)) {
|
||||
errors.longitude = '请填写经度'
|
||||
} else if (item.longitude < -180 || item.longitude > 180) {
|
||||
errors.longitude = '经度范围为 -180 到 180'
|
||||
}
|
||||
if (item.description.trim().length > 128) errors.description = '图片描述不能超过 128 个字符'
|
||||
|
||||
item.errors = errors
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
@@ -163,13 +158,12 @@ export function useUpload() {
|
||||
}
|
||||
|
||||
async function uploadAll(): Promise<UploadResult> {
|
||||
if (!validateAll() || !authStore.user) return { ok: false, unlockedBadges: [] }
|
||||
if (!validateAll() || !authStore.user) return { ok: false }
|
||||
|
||||
uploading.value = true
|
||||
totalItems.value = items.value.length
|
||||
currentItemIndex.value = 0
|
||||
overallProgress.value = 0
|
||||
const badges = new Map<number, UnlockedBadge>()
|
||||
|
||||
try {
|
||||
for (let index = 0; index < items.value.length; index++) {
|
||||
@@ -179,40 +173,28 @@ export function useUpload() {
|
||||
|
||||
const form = new FormData()
|
||||
form.append('image', item.file)
|
||||
if (item.cloudCategoryId === 'other') {
|
||||
form.append('custom_cloud_type', item.customCloudType.trim())
|
||||
} else {
|
||||
form.append('cloud_type_id', String(item.cloudCategoryId))
|
||||
}
|
||||
appendOptional(form, 'latitude', item.latitude)
|
||||
appendOptional(form, 'longitude', item.longitude)
|
||||
appendOptional(form, 'location_name', item.locationName.trim())
|
||||
appendOptional(form, 'description', item.description.trim())
|
||||
form.append('type_id', String(item.cloudCategoryId))
|
||||
form.append('latitude', String(item.latitude))
|
||||
form.append('longitude', String(item.longitude))
|
||||
appendOptional(form, 'description', item.description)
|
||||
appendOptional(form, 'captured_at', item.capturedAt)
|
||||
form.append('is_hidden', String(item.isHidden))
|
||||
|
||||
const result = await apiRequest<CloudCreateResponse>('/clouds', {
|
||||
await apiRequest<CloudCreateResponse>('/cloud', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
if (result.unlocked_badge) {
|
||||
const badge = result.unlocked_badge
|
||||
badges.set(badge.cloud_type_id, {
|
||||
cloudTypeId: badge.cloud_type_id,
|
||||
cloudName: badge.cloud_name,
|
||||
cloudNameEn: badge.cloud_name_en,
|
||||
rarity: badge.rarity,
|
||||
unlockedAt: badge.unlocked_at,
|
||||
})
|
||||
}
|
||||
overallProgress.value = Math.round((index + 1) / items.value.length * 100)
|
||||
}
|
||||
|
||||
clearAll()
|
||||
profileStore.invalidateUser(authStore.user.id)
|
||||
return { ok: true, unlockedBadges: [...badges.values()] }
|
||||
} catch {
|
||||
return { ok: false, unlockedBadges: [] }
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: error instanceof Error ? error.message : '上传失败,请稍后重试',
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
|
||||
+124
-64
@@ -1,14 +1,17 @@
|
||||
import type { AuthResponse } from '@/types/api'
|
||||
import type {
|
||||
ApiCloud,
|
||||
AuthUser,
|
||||
BackendCloudInfo,
|
||||
BackendCloudType,
|
||||
BackendUserProfile,
|
||||
} from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
const DEFAULT_API_URL = 'http://localhost:8000/api/v1'
|
||||
const DEFAULT_API_URL = 'http://localhost:3000'
|
||||
|
||||
export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
|
||||
export const AUTH_UPDATED_EVENT = 'opencloud:auth-updated'
|
||||
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
|
||||
|
||||
let accessToken: string | null = null
|
||||
let refreshPromise: Promise<AuthResponse> | null = null
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
detail: unknown
|
||||
@@ -24,7 +27,6 @@ export class ApiError extends Error {
|
||||
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: BodyInit | Record<string, unknown> | null
|
||||
auth?: boolean
|
||||
retry?: boolean
|
||||
}
|
||||
|
||||
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
|
||||
@@ -36,43 +38,71 @@ function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unk
|
||||
&& !(body instanceof ArrayBuffer)
|
||||
}
|
||||
|
||||
function detailToMessage(detail: unknown, fallback: string) {
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
const messages = detail
|
||||
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
|
||||
function validationMessage(value: unknown) {
|
||||
if (typeof value !== 'string') return ''
|
||||
|
||||
try {
|
||||
const issues = JSON.parse(value) as unknown
|
||||
if (!Array.isArray(issues)) return value
|
||||
const messages = issues
|
||||
.map(issue => issue && typeof issue === 'object' && 'message' in issue ? String(issue.message) : '')
|
||||
.filter(Boolean)
|
||||
if (messages.length) return messages.join(';')
|
||||
return messages.join(';') || value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(payload: unknown, fallback: string) {
|
||||
if (!payload || typeof payload !== 'object') return fallback
|
||||
|
||||
if ('detail' in payload) {
|
||||
const detail = payload.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
const messages = detail
|
||||
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
|
||||
.filter(Boolean)
|
||||
if (messages.length) return messages.join(';')
|
||||
}
|
||||
}
|
||||
|
||||
if ('error' in payload) {
|
||||
const error = payload.error
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
return validationMessage(error.message) || fallback
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function toApiError(response: Response) {
|
||||
let detail: unknown
|
||||
async function readPayload(response: Response) {
|
||||
const text = await response.text()
|
||||
if (!text) return undefined
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (!contentType.includes('json')) return text
|
||||
|
||||
try {
|
||||
const payload = await response.json() as { detail?: unknown }
|
||||
detail = payload.detail
|
||||
return JSON.parse(text) as unknown
|
||||
} catch {
|
||||
detail = undefined
|
||||
return text
|
||||
}
|
||||
|
||||
return new ApiError(
|
||||
detailToMessage(detail, `请求失败(${response.status})`),
|
||||
response.status,
|
||||
detail,
|
||||
)
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token
|
||||
}
|
||||
async function toApiError(response: Response) {
|
||||
const payload = await readPayload(response)
|
||||
const fallback = typeof payload === 'string' && payload.trim()
|
||||
? payload.trim()
|
||||
: `请求失败(${response.status})`
|
||||
|
||||
export function clearAccessToken() {
|
||||
accessToken = null
|
||||
return new ApiError(errorMessage(payload, fallback), response.status, payload)
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
const { body, auth = true, retry = true, ...requestOptions } = options
|
||||
const { body, auth = true, ...requestOptions } = options
|
||||
const headers = new Headers(requestOptions.headers)
|
||||
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
||||
|
||||
@@ -81,10 +111,6 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
requestBody = JSON.stringify(body)
|
||||
}
|
||||
|
||||
if (auth && accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
...requestOptions,
|
||||
body: requestBody,
|
||||
@@ -92,43 +118,77 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (response.status === 401 && auth && accessToken && retry) {
|
||||
try {
|
||||
const authResponse = await refreshSession()
|
||||
window.dispatchEvent(new CustomEvent<AuthResponse>(AUTH_UPDATED_EVENT, { detail: authResponse }))
|
||||
return request<T>(path, { ...options, retry: false })
|
||||
} catch {
|
||||
clearAccessToken()
|
||||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
||||
}
|
||||
if (response.status === 401 && auth && typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
||||
}
|
||||
|
||||
if (!response.ok) throw await toApiError(response)
|
||||
if (response.status === 204) return undefined as T
|
||||
|
||||
return response.json() as Promise<T>
|
||||
return await readPayload(response) as T
|
||||
}
|
||||
|
||||
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
return request<T>(path, options)
|
||||
}
|
||||
|
||||
export function refreshSession() {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
const response = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!response.ok) throw await toApiError(response)
|
||||
|
||||
const authResponse = await response.json() as AuthResponse
|
||||
setAccessToken(authResponse.access_token)
|
||||
return authResponse
|
||||
})().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return refreshPromise
|
||||
export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') {
|
||||
return `${API_URL}/image/${encodeURIComponent(id)}/${variant}`
|
||||
}
|
||||
|
||||
export function toAuthUser(profile: BackendUserProfile): AuthUser {
|
||||
return {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
username: profile.name,
|
||||
image: profile.image,
|
||||
role: profile.role,
|
||||
is_disabled: profile.is_disabled,
|
||||
cloud_count: profile.cloud_count,
|
||||
received_like_count: profile.received_like_count,
|
||||
created_at: profile.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function toCloudType(row: BackendCloudType): CloudType {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
genus: row.genus || '',
|
||||
icon_id: row.icon_id,
|
||||
// Compatibility value for the excluded legacy admin view. Migrated pages
|
||||
// display rarity_value directly because API.md defines no semantic mapping.
|
||||
rarity: 'common',
|
||||
rarity_value: row.rarity,
|
||||
description: row.description,
|
||||
created_at: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function toApiCloud(row: BackendCloudInfo): ApiCloud {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.owner.id,
|
||||
cloud_type_id: row.type.id,
|
||||
image_url: imageUrl(row.id, 'original'),
|
||||
thumbnail_url: imageUrl(row.id, 'preview'),
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
description: row.description,
|
||||
captured_at: row.captured_at,
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
created_at: row.uploaded_at,
|
||||
updated_at: row.updated_at,
|
||||
cloud_type: {
|
||||
id: row.type.id,
|
||||
name: row.type.name,
|
||||
rarity: 'common',
|
||||
rarity_value: row.type.rarity,
|
||||
},
|
||||
owner: { id: row.owner.id, username: row.owner.name },
|
||||
cloud_type_name: row.type.name,
|
||||
cloud_type_rarity: 'common',
|
||||
cloud_type_rarity_value: row.type.rarity,
|
||||
username: row.owner.name,
|
||||
received_like_count: row.received_like_count,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createAuthClient } from 'better-auth/client'
|
||||
import { API_URL } from '@/lib/api'
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: `${API_URL}/auth`,
|
||||
fetchOptions: { credentials: 'include' },
|
||||
})
|
||||
|
||||
interface AuthClientFailure {
|
||||
code?: string
|
||||
message?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export class AuthClientError extends Error {
|
||||
code?: string
|
||||
status?: number
|
||||
|
||||
constructor(failure: AuthClientFailure, fallback: string) {
|
||||
super(failure.code === 'EMAIL_NOT_VERIFIED'
|
||||
? '邮箱尚未验证,请先查收验证邮件。'
|
||||
: failure.status === 429
|
||||
? '操作过于频繁,请稍后重试。'
|
||||
: fallback)
|
||||
this.name = 'AuthClientError'
|
||||
this.code = failure.code
|
||||
this.status = failure.status
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAuthResult(result: { error?: AuthClientFailure | null }, fallback: string) {
|
||||
if (result.error) throw new AuthClientError(result.error, fallback)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
const PATCH_FLAG = '__openCloudWillReadFrequentlyPatched__'
|
||||
|
||||
type CanvasLikePrototype = {
|
||||
getContext?: (contextId: string, options?: unknown) => unknown
|
||||
[PATCH_FLAG]?: boolean
|
||||
}
|
||||
|
||||
function patch2DContextOptions(ctor: typeof HTMLCanvasElement | typeof OffscreenCanvas | undefined) {
|
||||
const prototype = ctor?.prototype as CanvasLikePrototype | undefined
|
||||
if (!prototype?.getContext || prototype[PATCH_FLAG]) return
|
||||
|
||||
const originalGetContext = prototype.getContext
|
||||
|
||||
prototype.getContext = function getContextWithReadHint(contextId: string, options?: unknown) {
|
||||
if (contextId === '2d') {
|
||||
const nextOptions =
|
||||
options && typeof options === 'object'
|
||||
? { ...options as Record<string, unknown>, willReadFrequently: true }
|
||||
: { willReadFrequently: true }
|
||||
|
||||
return originalGetContext.call(this, contextId, nextOptions)
|
||||
}
|
||||
|
||||
return originalGetContext.call(this, contextId, options)
|
||||
}
|
||||
|
||||
prototype[PATCH_FLAG] = true
|
||||
}
|
||||
|
||||
export function enableCanvasReadbackHint() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
patch2DContextOptions(window.HTMLCanvasElement)
|
||||
if (typeof window.OffscreenCanvas !== 'undefined') {
|
||||
patch2DContextOptions(window.OffscreenCanvas)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
export type BadgeRarity = 'common' | 'uncommon' | 'rare'
|
||||
|
||||
export interface CloudBadgeCardInput {
|
||||
cloudName: string
|
||||
cloudNameEn: string
|
||||
unlockedAt: string
|
||||
username: string
|
||||
rarity: BadgeRarity
|
||||
}
|
||||
|
||||
const rarityTheme = {
|
||||
common: {
|
||||
from: '#dbeafe',
|
||||
to: '#f8fafc',
|
||||
accent: '#0284c7',
|
||||
shadow: 'rgba(2, 132, 199, 0.18)',
|
||||
},
|
||||
uncommon: {
|
||||
from: '#fef3c7',
|
||||
to: '#fff7ed',
|
||||
accent: '#d97706',
|
||||
shadow: 'rgba(217, 119, 6, 0.22)',
|
||||
},
|
||||
rare: {
|
||||
from: '#fee2e2',
|
||||
to: '#fff7ed',
|
||||
accent: '#dc2626',
|
||||
shadow: 'rgba(220, 38, 38, 0.22)',
|
||||
},
|
||||
} satisfies Record<BadgeRarity, { from: string; to: string; accent: string; shadow: string }>
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function makeDownloadName(name: string) {
|
||||
return `opencloud-badge-${name}.png`
|
||||
}
|
||||
|
||||
export async function downloadCloudBadgeCard(input: CloudBadgeCardInput) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 1200
|
||||
canvas.height = 630
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const theme = rarityTheme[input.rarity]
|
||||
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
gradient.addColorStop(0, theme.from)
|
||||
gradient.addColorStop(1, theme.to)
|
||||
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.72)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(1010, 120, 140, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.45)'
|
||||
ctx.beginPath()
|
||||
ctx.arc(160, 520, 180, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#0f172a'
|
||||
ctx.font = '700 44px sans-serif'
|
||||
ctx.fillText('OpenCloud 云朵图鉴', 72, 88)
|
||||
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.62)'
|
||||
ctx.font = '400 24px sans-serif'
|
||||
ctx.fillText('新徽章已解锁', 72, 126)
|
||||
|
||||
ctx.shadowColor = theme.shadow
|
||||
ctx.shadowBlur = 28
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(72, 170, 1056, 380, 32)
|
||||
ctx.fill()
|
||||
ctx.shadowBlur = 0
|
||||
|
||||
ctx.fillStyle = theme.accent
|
||||
ctx.beginPath()
|
||||
ctx.arc(210, 360, 92, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = '700 82px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(input.cloudName.slice(0, 1), 210, 360)
|
||||
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
ctx.fillStyle = '#0f172a'
|
||||
ctx.font = '700 64px sans-serif'
|
||||
ctx.fillText(input.cloudName, 340, 300)
|
||||
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.62)'
|
||||
ctx.font = '400 30px sans-serif'
|
||||
ctx.fillText(input.cloudNameEn, 340, 346)
|
||||
|
||||
ctx.fillStyle = theme.accent
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(340, 382, 168, 48, 24)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = '600 24px sans-serif'
|
||||
ctx.fillText(input.rarity === 'common' ? '常见' : input.rarity === 'uncommon' ? '少见' : '罕见', 384, 414)
|
||||
|
||||
ctx.fillStyle = '#334155'
|
||||
ctx.font = '400 28px sans-serif'
|
||||
ctx.fillText(`解锁者:${input.username}`, 340, 476)
|
||||
ctx.fillText(`解锁日期:${formatDate(input.unlockedAt)}`, 340, 518)
|
||||
|
||||
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/png'))
|
||||
if (!blob) return
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = makeDownloadName(input.cloudNameEn.toLowerCase())
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
+10
-10
@@ -1,14 +1,14 @@
|
||||
export const CLOUD_TYPES = [
|
||||
{ id: 1, name: '积云', nameEn: 'Cumulus' },
|
||||
{ id: 2, name: '层云', nameEn: 'Stratus' },
|
||||
{ id: 3, name: '卷云', nameEn: 'Cirrus' },
|
||||
{ id: 4, name: '积雨云', nameEn: 'Cumulonimbus' },
|
||||
{ id: 5, name: '层积云', nameEn: 'Stratocumulus' },
|
||||
{ id: 6, name: '高积云', nameEn: 'Altocumulus' },
|
||||
{ id: 7, name: '高层云', nameEn: 'Altostratus' },
|
||||
{ id: 8, name: '雨层云', nameEn: 'Nimbostratus' },
|
||||
{ id: 9, name: '卷层云', nameEn: 'Cirrostratus' },
|
||||
{ id: 10, name: '卷积云', nameEn: 'Cirrocumulus' },
|
||||
{ id: 1, name: '积云' },
|
||||
{ id: 2, name: '层云' },
|
||||
{ id: 3, name: '卷云' },
|
||||
{ id: 4, name: '积雨云' },
|
||||
{ id: 5, name: '层积云' },
|
||||
{ id: 6, name: '高积云' },
|
||||
{ id: 7, name: '高层云' },
|
||||
{ id: 8, name: '雨层云' },
|
||||
{ id: 9, name: '卷层云' },
|
||||
{ id: 10, name: '卷积云' },
|
||||
] as const
|
||||
|
||||
export function getCloudTypeName(id: string | string[]) {
|
||||
|
||||
@@ -3,11 +3,8 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { enableCanvasReadbackHint } from './lib/canvas'
|
||||
import './style.css'
|
||||
|
||||
enableCanvasReadbackHint()
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
+13
-5
@@ -27,6 +27,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
meta: { title: '注册', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/resend-confirmation',
|
||||
name: 'resend-confirmation',
|
||||
component: () => import('@/views/auth/ResendConfirmationView.vue'),
|
||||
meta: { title: '重发验证邮件', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
@@ -34,13 +40,15 @@ const router = createRouter({
|
||||
meta: { title: '忘记密码', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/auth/confirm',
|
||||
path: '/verify-email',
|
||||
alias: '/auth/confirm',
|
||||
name: 'auth-confirm',
|
||||
component: () => import('@/views/auth/AuthConfirmView.vue'),
|
||||
meta: { title: '确认邮箱', noindex: true },
|
||||
},
|
||||
{
|
||||
path: '/auth/reset-password',
|
||||
path: '/reset-password',
|
||||
alias: '/auth/reset-password',
|
||||
name: 'auth-reset-password',
|
||||
component: () => import('@/views/auth/ResetPasswordView.vue'),
|
||||
meta: { title: '重置密码', noindex: true },
|
||||
@@ -57,8 +65,8 @@ const router = createRouter({
|
||||
component: () => import('@/views/encyclopedia/EncyclopediaView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '云朵图鉴',
|
||||
description: '浏览 10 种基础云属,了解云型特征,并通过拍摄云图点亮个人图鉴。',
|
||||
title: '云朵百科',
|
||||
description: '浏览基础云属,了解云型特征和社区公开观测记录。',
|
||||
noindex: true,
|
||||
},
|
||||
},
|
||||
@@ -109,7 +117,7 @@ const router = createRouter({
|
||||
component: () => import('@/views/profile/ProfileView.vue'),
|
||||
meta: {
|
||||
title: '用户云图档案',
|
||||
description: '查看用户公开分享的云图记录、图鉴进度和拍摄时间线。',
|
||||
description: '按用户名查看用户公开分享的云图记录和拍摄时间线。',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+75
-72
@@ -1,112 +1,111 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
AUTH_EXPIRED_EVENT,
|
||||
AUTH_UPDATED_EVENT,
|
||||
apiRequest,
|
||||
clearAccessToken,
|
||||
refreshSession,
|
||||
setAccessToken,
|
||||
} from '@/lib/api'
|
||||
import type { AuthResponse, AuthUser } from '@/types/api'
|
||||
import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api'
|
||||
import { assertAuthResult, authClient } from '@/lib/authClient'
|
||||
import type { BackendUserProfile } from '@/types/api'
|
||||
import type { Profile } from '@/types/database'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<AuthUser | null>(null)
|
||||
const user = ref<ReturnType<typeof toAuthUser> | null>(null)
|
||||
const profile = ref<Profile | null>(null)
|
||||
const loading = ref(true)
|
||||
let listenersAttached = false
|
||||
let listenerAttached = false
|
||||
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
const isAdmin = computed(() => profile.value?.role === 'admin')
|
||||
|
||||
function applyAuth(auth: AuthResponse) {
|
||||
setAccessToken(auth.access_token)
|
||||
user.value = auth.user
|
||||
function applyProfile(backendProfile: BackendUserProfile) {
|
||||
const normalized = toAuthUser(backendProfile)
|
||||
user.value = normalized
|
||||
profile.value = {
|
||||
id: auth.user.id,
|
||||
username: auth.user.username,
|
||||
avatar_url: auth.user.avatar_url,
|
||||
role: auth.user.role,
|
||||
is_disabled: auth.user.is_disabled,
|
||||
created_at: auth.user.created_at,
|
||||
id: normalized.id,
|
||||
username: normalized.username,
|
||||
image: normalized.image,
|
||||
role: normalized.role,
|
||||
is_disabled: normalized.is_disabled,
|
||||
created_at: normalized.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
clearAccessToken()
|
||||
user.value = null
|
||||
profile.value = null
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me')
|
||||
applyProfile(backendProfile)
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const auth = await apiRequest<AuthResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email, password },
|
||||
})
|
||||
applyAuth(auth)
|
||||
assertAuthResult(await authClient.signIn.email({ email, password }), '登录失败,请检查邮箱和密码。')
|
||||
const session = await authClient.getSession()
|
||||
assertAuthResult(session, '获取登录会话失败,请稍后重试。')
|
||||
if (!session.data) throw new Error('未能建立登录会话,请稍后重试。')
|
||||
await fetchMe()
|
||||
}
|
||||
|
||||
async function register(email: string, password: string, username: string) {
|
||||
await apiRequest('/auth/register', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email, password, username },
|
||||
})
|
||||
async function register(name: string, email: string, password: string) {
|
||||
assertAuthResult(await authClient.signUp.email({ name, email, password }), '注册失败,请稍后重试。')
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiRequest('/auth/logout', { method: 'POST', retry: false })
|
||||
} finally {
|
||||
clearAuth()
|
||||
}
|
||||
async function resendConfirmation(email: string) {
|
||||
assertAuthResult(await authClient.sendVerificationEmail({ email }), '验证邮件发送失败,请稍后重试。')
|
||||
}
|
||||
|
||||
async function updateUsername(username: string) {
|
||||
const updatedUser = await apiRequest<AuthUser>('/profiles/me', {
|
||||
method: 'PATCH',
|
||||
body: { username },
|
||||
})
|
||||
user.value = updatedUser
|
||||
profile.value = {
|
||||
id: updatedUser.id,
|
||||
username: updatedUser.username,
|
||||
avatar_url: updatedUser.avatar_url,
|
||||
role: updatedUser.role,
|
||||
is_disabled: updatedUser.is_disabled,
|
||||
created_at: updatedUser.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePassword(password: string) {
|
||||
const auth = await apiRequest<AuthResponse>('/auth/password', {
|
||||
method: 'PATCH',
|
||||
body: { password },
|
||||
})
|
||||
applyAuth(auth)
|
||||
async function confirmEmail(token: string) {
|
||||
assertAuthResult(await authClient.$fetch('/verify-email', {
|
||||
method: 'GET',
|
||||
query: { token },
|
||||
}), '确认链接无效或已过期。')
|
||||
}
|
||||
|
||||
async function sendPasswordReset(email: string) {
|
||||
await apiRequest('/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email },
|
||||
assertAuthResult(await authClient.requestPasswordReset({ email }), '重置邮件发送失败,请稍后重试。')
|
||||
}
|
||||
|
||||
async function resetPassword(token: string, newPassword: string) {
|
||||
assertAuthResult(await authClient.resetPassword({ token, newPassword }), '密码重置失败,请重新申请重置邮件。')
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const result = await authClient.signOut()
|
||||
if (result.error?.status === 401) {
|
||||
clearAuth()
|
||||
return
|
||||
}
|
||||
assertAuthResult(result, '退出登录失败,请稍后重试。')
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
async function updateUsername(username: string) {
|
||||
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me', {
|
||||
method: 'PATCH',
|
||||
body: { user_name: username },
|
||||
})
|
||||
applyProfile(backendProfile)
|
||||
}
|
||||
|
||||
async function updatePassword(currentPassword: string, newPassword: string) {
|
||||
assertAuthResult(await authClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions: true,
|
||||
}), '密码更新失败,请检查当前密码。')
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (!listenersAttached) {
|
||||
window.addEventListener(AUTH_UPDATED_EVENT, event => {
|
||||
applyAuth((event as CustomEvent<AuthResponse>).detail)
|
||||
})
|
||||
if (!listenerAttached) {
|
||||
window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
|
||||
listenersAttached = true
|
||||
listenerAttached = true
|
||||
}
|
||||
|
||||
try {
|
||||
applyAuth(await refreshSession())
|
||||
const session = await authClient.getSession()
|
||||
assertAuthResult(session, '获取登录会话失败,请稍后重试。')
|
||||
if (session.data) await fetchMe()
|
||||
else clearAuth()
|
||||
} catch {
|
||||
clearAuth()
|
||||
} finally {
|
||||
@@ -122,10 +121,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
isAdmin,
|
||||
login,
|
||||
register,
|
||||
resendConfirmation,
|
||||
confirmEmail,
|
||||
sendPasswordReset,
|
||||
resetPassword,
|
||||
logout,
|
||||
updateUsername,
|
||||
refreshProfile: fetchMe,
|
||||
updatePassword,
|
||||
sendPasswordReset,
|
||||
initialize,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { apiRequest, toCloudType } from '@/lib/api'
|
||||
import type { BackendCloudType } from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
export const useCloudsStore = defineStore('clouds', () => {
|
||||
@@ -11,11 +12,16 @@ export const useCloudsStore = defineStore('clouds', () => {
|
||||
if (cloudTypes.value.length > 0) return
|
||||
loading.value = true
|
||||
try {
|
||||
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
|
||||
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
|
||||
cloudTypes.value = rows.map(toCloudType)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { cloudTypes, loading, fetchCloudTypes }
|
||||
function setCloudTypes(types: CloudType[]) {
|
||||
cloudTypes.value = types
|
||||
}
|
||||
|
||||
return { cloudTypes, loading, fetchCloudTypes, setCloudTypes }
|
||||
})
|
||||
|
||||
+10
-79
@@ -1,104 +1,35 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ApiCollection } from '@/types/api'
|
||||
import type { CloudType, UserCollection } from '@/types/database'
|
||||
|
||||
export interface CollectionPreviewCloud {
|
||||
id: string
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
captured_at: string | null
|
||||
created_at: string
|
||||
location_name: string | null
|
||||
}
|
||||
|
||||
export interface CollectionEntry extends UserCollection {
|
||||
firstCloud: CollectionPreviewCloud | null
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '图鉴收藏加载失败'
|
||||
}
|
||||
import { apiRequest, toCloudType } from '@/lib/api'
|
||||
import type { BackendCloudType } from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const cloudTypes = ref<CloudType[]>([])
|
||||
const myCollection = ref<CollectionEntry[]>([])
|
||||
const loadingCloudTypes = ref(false)
|
||||
const loadingCollection = ref(false)
|
||||
const cloudTypesLoaded = ref(false)
|
||||
const collectionLoadedForUserId = ref<string | null>(null)
|
||||
const collectionError = ref('')
|
||||
|
||||
const collectionMap = computed(() => new Map(myCollection.value.map(item => [item.cloud_type_id, item])))
|
||||
const unlockedCount = computed(() => myCollection.value.length)
|
||||
const unlockProgress = computed(() => `${unlockedCount.value}/${cloudTypes.value.length || 10}`)
|
||||
const unlockPercent = computed(() => cloudTypes.value.length
|
||||
? Math.round(unlockedCount.value / cloudTypes.value.length * 100)
|
||||
: 0)
|
||||
|
||||
async function fetchCloudTypes(force = false) {
|
||||
if (cloudTypesLoaded.value && !force) return
|
||||
loadingCloudTypes.value = true
|
||||
try {
|
||||
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
|
||||
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
|
||||
cloudTypes.value = rows.map(toCloudType)
|
||||
cloudTypesLoaded.value = true
|
||||
} finally {
|
||||
loadingCloudTypes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMyCollection(force = false) {
|
||||
const userId = authStore.user?.id ?? null
|
||||
if (!userId) {
|
||||
myCollection.value = []
|
||||
collectionLoadedForUserId.value = null
|
||||
collectionError.value = ''
|
||||
return
|
||||
}
|
||||
if (!force && collectionLoadedForUserId.value === userId) return
|
||||
|
||||
loadingCollection.value = true
|
||||
collectionError.value = ''
|
||||
try {
|
||||
const rows = await apiRequest<ApiCollection[]>('/collections/me')
|
||||
myCollection.value = rows.map(({ first_cloud, cloud_type: _cloudType, ...collection }) => ({
|
||||
...collection,
|
||||
firstCloud: first_cloud,
|
||||
}))
|
||||
collectionLoadedForUserId.value = userId
|
||||
} catch (error) {
|
||||
myCollection.value = []
|
||||
collectionLoadedForUserId.value = userId
|
||||
collectionError.value = getErrorMessage(error)
|
||||
} finally {
|
||||
loadingCollection.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isUnlocked(cloudTypeId: number) {
|
||||
return collectionMap.value.has(cloudTypeId)
|
||||
}
|
||||
|
||||
function getCollectionEntry(cloudTypeId: number) {
|
||||
return collectionMap.value.get(cloudTypeId) ?? null
|
||||
function setCloudTypes(types: CloudType[]) {
|
||||
cloudTypes.value = types
|
||||
cloudTypesLoaded.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
cloudTypes,
|
||||
myCollection,
|
||||
loadingCloudTypes,
|
||||
loadingCollection,
|
||||
collectionError,
|
||||
unlockedCount,
|
||||
unlockProgress,
|
||||
unlockPercent,
|
||||
fetchCloudTypes,
|
||||
fetchMyCollection,
|
||||
isUnlocked,
|
||||
getCollectionEntry,
|
||||
setCloudTypes,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ActionResponse, BackendCloudInfo } from '@/types/api'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
export const useLikesStore = defineStore('likes', () => {
|
||||
const authStore = useAuthStore()
|
||||
const likedIds = ref<Set<string>>(new Set())
|
||||
const counts = ref<Record<string, number>>({})
|
||||
const pendingIds = ref<Set<string>>(new Set())
|
||||
const loadedForUser = ref<string | null>(null)
|
||||
let loadPromise: Promise<void> | null = null
|
||||
let loadingForUser: string | null = null
|
||||
|
||||
watch(() => authStore.user?.id, () => {
|
||||
likedIds.value = new Set()
|
||||
counts.value = {}
|
||||
pendingIds.value = new Set()
|
||||
loadedForUser.value = null
|
||||
loadPromise = null
|
||||
loadingForUser = null
|
||||
})
|
||||
|
||||
function isLiked(cloudId: string) {
|
||||
return likedIds.value.has(cloudId)
|
||||
}
|
||||
|
||||
function countFor(cloudId: string, initialCount: number) {
|
||||
return counts.value[cloudId] ?? initialCount
|
||||
}
|
||||
|
||||
function isPending(cloudId: string) {
|
||||
return pendingIds.value.has(cloudId)
|
||||
}
|
||||
|
||||
async function ensureLoaded() {
|
||||
const userId = authStore.user?.id
|
||||
if (!userId || loadedForUser.value === userId) return
|
||||
if (loadPromise && loadingForUser === userId) return loadPromise
|
||||
|
||||
loadingForUser = userId
|
||||
const request = (async () => {
|
||||
const nextIds = new Set<string>()
|
||||
for (let page = 1; ; page++) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
||||
const rows = await apiRequest<BackendCloudInfo[]>(`/profile/me/likes?${params}`)
|
||||
rows.forEach(row => nextIds.add(row.id))
|
||||
if (rows.length < PAGE_SIZE) break
|
||||
}
|
||||
if (authStore.user?.id === userId) {
|
||||
likedIds.value = nextIds
|
||||
loadedForUser.value = userId
|
||||
}
|
||||
})()
|
||||
loadPromise = request
|
||||
try {
|
||||
await request
|
||||
} finally {
|
||||
if (loadPromise === request) {
|
||||
loadPromise = null
|
||||
loadingForUser = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(cloudId: string, initialCount: number) {
|
||||
const userId = authStore.user?.id
|
||||
if (!userId) throw new Error('请先登录后点赞。')
|
||||
await ensureLoaded()
|
||||
if (authStore.user?.id !== userId) throw new Error('登录状态已改变,请重试。')
|
||||
if (isPending(cloudId)) return
|
||||
|
||||
const wasLiked = isLiked(cloudId)
|
||||
pendingIds.value = new Set(pendingIds.value).add(cloudId)
|
||||
try {
|
||||
await apiRequest<ActionResponse>(`/cloud/${cloudId}/like`, {
|
||||
method: wasLiked ? 'DELETE' : 'PUT',
|
||||
})
|
||||
if (authStore.user?.id !== userId) return
|
||||
|
||||
const nextIds = new Set(likedIds.value)
|
||||
if (wasLiked) nextIds.delete(cloudId)
|
||||
else nextIds.add(cloudId)
|
||||
likedIds.value = nextIds
|
||||
counts.value[cloudId] = Math.max(0, countFor(cloudId, initialCount) + (wasLiked ? -1 : 1))
|
||||
|
||||
try {
|
||||
const cloud = await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`, { auth: false })
|
||||
counts.value[cloudId] = cloud.received_like_count
|
||||
if (cloud.owner.id === authStore.user?.id) await authStore.refreshProfile()
|
||||
} catch {
|
||||
// The mutation succeeded; keep the local count if a refresh fails.
|
||||
}
|
||||
} finally {
|
||||
const nextPending = new Set(pendingIds.value)
|
||||
nextPending.delete(cloudId)
|
||||
pendingIds.value = nextPending
|
||||
}
|
||||
}
|
||||
|
||||
return { isLiked, countFor, isPending, ensureLoaded, toggle }
|
||||
})
|
||||
+109
-105
@@ -1,124 +1,128 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ApiCloud, ApiPage, LikeStats, PublicProfile } from '@/types/api'
|
||||
import type { CloudType, Profile } from '@/types/database'
|
||||
import type { ActionResponse, ApiCloud, BackendCloudInfo, BackendUserProfile } from '@/types/api'
|
||||
import type { Profile } from '@/types/database'
|
||||
|
||||
export interface ProfileCloudItem {
|
||||
id: string
|
||||
cloud_type_id: number | null
|
||||
custom_cloud_type: string | null
|
||||
cloud_type_id: number
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
location_name: string | null
|
||||
thumbnail_url: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
created_at: string
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
cloudTypeName: string
|
||||
cloudTypeRarity: CloudType['rarity']
|
||||
favoriteCount: number
|
||||
isFavorited: boolean
|
||||
cloudTypeRarity: number
|
||||
receivedLikeCount: number
|
||||
}
|
||||
|
||||
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
||||
return {
|
||||
id: row.id,
|
||||
cloud_type_id: row.cloud_type_id,
|
||||
custom_cloud_type: row.custom_cloud_type,
|
||||
cloud_type_id: row.cloud_type_id as number,
|
||||
image_url: row.image_url,
|
||||
thumbnail_url: row.thumbnail_url,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
location_name: row.location_name,
|
||||
thumbnail_url: row.thumbnail_url as string,
|
||||
latitude: row.latitude as number,
|
||||
longitude: row.longitude as number,
|
||||
description: row.description,
|
||||
captured_at: row.captured_at,
|
||||
created_at: row.created_at,
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
||||
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
||||
favoriteCount: row.favorite_count,
|
||||
isFavorited: row.is_favorited,
|
||||
}
|
||||
}
|
||||
|
||||
function toProfile(profile: PublicProfile): Profile {
|
||||
return {
|
||||
...profile,
|
||||
role: 'user',
|
||||
is_disabled: false,
|
||||
cloudTypeName: row.cloud_type_name || '未知',
|
||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
||||
receivedLikeCount: row.received_like_count,
|
||||
}
|
||||
}
|
||||
|
||||
export const useProfileStore = defineStore('profile-page', () => {
|
||||
const authStore = useAuthStore()
|
||||
const profilesById = ref<Record<string, Profile>>({})
|
||||
const profilesByKey = ref<Record<string, Profile>>({})
|
||||
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
||||
const likeStatsByUserId = ref<Record<string, LikeStats>>({})
|
||||
const loadingKeys = ref<Record<string, boolean>>({})
|
||||
const errorByKey = ref<Record<string, string>>({})
|
||||
|
||||
const makeKey = (userId: string, isOwnProfile: boolean) => `${userId}:${isOwnProfile ? 'own' : 'public'}`
|
||||
const makeKey = (identifier: string, isOwnProfile: boolean) => `${identifier}:${isOwnProfile ? 'own' : 'public'}`
|
||||
|
||||
function getProfile(userId: string | null) {
|
||||
return userId ? profilesById.value[userId] ?? null : null
|
||||
function getProfile(identifier: string | null) {
|
||||
return identifier ? profilesByKey.value[identifier] ?? null : null
|
||||
}
|
||||
|
||||
function getClouds(userId: string | null, isOwnProfile: boolean) {
|
||||
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
|
||||
function getClouds(identifier: string | null, isOwnProfile: boolean) {
|
||||
return identifier ? cloudsByKey.value[makeKey(identifier, isOwnProfile)] ?? [] : []
|
||||
}
|
||||
|
||||
function getLikeStats(userId: string | null) {
|
||||
return userId ? likeStatsByUserId.value[userId] ?? null : null
|
||||
function getError(identifier: string | null, isOwnProfile: boolean) {
|
||||
return identifier ? errorByKey.value[makeKey(identifier, isOwnProfile)] ?? '' : ''
|
||||
}
|
||||
|
||||
function getError(userId: string | null, isOwnProfile: boolean) {
|
||||
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
|
||||
function isLoaded(identifier: string | null, isOwnProfile: boolean) {
|
||||
return !!identifier && makeKey(identifier, isOwnProfile) in cloudsByKey.value
|
||||
}
|
||||
|
||||
function isLoaded(userId: string | null, isOwnProfile: boolean) {
|
||||
return !!userId && makeKey(userId, isOwnProfile) in cloudsByKey.value
|
||||
async function fetchAllPublicClouds(username: string) {
|
||||
const rows: BackendCloudInfo[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: '100',
|
||||
filter: 'owner',
|
||||
value: `@${username}`,
|
||||
})
|
||||
let current: BackendCloudInfo[]
|
||||
try {
|
||||
current = await apiRequest<BackendCloudInfo[]>(`/cloud?${params}`, { auth: false })
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return rows
|
||||
throw error
|
||||
}
|
||||
rows.push(...current)
|
||||
if (current.length < 100) return rows
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllClouds(userId: string, isOwnProfile: boolean) {
|
||||
const requestOptions = { auth: isOwnProfile }
|
||||
const first = await apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=1&page_size=100`, requestOptions)
|
||||
if (first.total_pages <= 1) return first.items
|
||||
|
||||
const remaining = await Promise.all(
|
||||
Array.from({ length: first.total_pages - 1 }, (_, index) =>
|
||||
apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=${index + 2}&page_size=100`, requestOptions),
|
||||
),
|
||||
)
|
||||
return [first, ...remaining].flatMap(page => page.items)
|
||||
}
|
||||
|
||||
async function fetchProfilePage(userId: string, isOwnProfile: boolean, force = false) {
|
||||
const key = makeKey(userId, isOwnProfile)
|
||||
if (!force && key in cloudsByKey.value && profilesById.value[userId]) return
|
||||
async function fetchProfilePage(identifier: string, isOwnProfile: boolean, force = false) {
|
||||
const key = makeKey(identifier, isOwnProfile)
|
||||
if (!force && key in cloudsByKey.value && profilesByKey.value[identifier]) return
|
||||
|
||||
loadingKeys.value[key] = true
|
||||
errorByKey.value[key] = ''
|
||||
try {
|
||||
const profilePromise = isOwnProfile && authStore.profile?.id === userId
|
||||
? Promise.resolve(authStore.profile)
|
||||
: apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
|
||||
const likeStatsPromise = isOwnProfile
|
||||
? apiRequest<LikeStats>('/profiles/me/like-stats')
|
||||
: Promise.resolve(null)
|
||||
const [profile, cloudRows, likeStats] = await Promise.all([
|
||||
profilePromise,
|
||||
fetchAllClouds(userId, isOwnProfile),
|
||||
likeStatsPromise,
|
||||
])
|
||||
profilesById.value[userId] = profile
|
||||
cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
|
||||
if (likeStats) likeStatsByUserId.value[userId] = likeStats
|
||||
const backendRows = isOwnProfile
|
||||
? await apiRequest<BackendCloudInfo[]>('/profile/me/clouds')
|
||||
: await fetchAllPublicClouds(identifier)
|
||||
const cloudRows = backendRows.map(toApiCloud).map(toProfileCloud)
|
||||
|
||||
if (isOwnProfile && authStore.profile) {
|
||||
profilesByKey.value[identifier] = authStore.profile
|
||||
} else if (identifier === authStore.profile?.username && authStore.profile) {
|
||||
profilesByKey.value[identifier] = authStore.profile
|
||||
} else {
|
||||
const owner = backendRows[0]?.owner
|
||||
let publicProfile: BackendUserProfile | null = null
|
||||
if (owner?.id && authStore.isLoggedIn) {
|
||||
try {
|
||||
publicProfile = await apiRequest<BackendUserProfile>(`/profile/${owner.id}`)
|
||||
} catch {
|
||||
// Public clouds can still be displayed if the profile lookup fails.
|
||||
}
|
||||
}
|
||||
profilesByKey.value[identifier] = {
|
||||
id: owner?.id || identifier,
|
||||
username: owner?.name || identifier,
|
||||
image: publicProfile?.image ?? null,
|
||||
role: 'user',
|
||||
is_disabled: false,
|
||||
created_at: '',
|
||||
}
|
||||
}
|
||||
cloudsByKey.value[key] = cloudRows
|
||||
} catch (error) {
|
||||
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
|
||||
cloudsByKey.value[key] = []
|
||||
@@ -127,13 +131,13 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function isLoading(userId: string | null, isOwnProfile: boolean) {
|
||||
return !!userId && !!loadingKeys.value[makeKey(userId, isOwnProfile)]
|
||||
function isLoading(identifier: string | null, isOwnProfile: boolean) {
|
||||
return !!identifier && !!loadingKeys.value[makeKey(identifier, isOwnProfile)]
|
||||
}
|
||||
|
||||
function patchCachedCloud(userId: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
|
||||
function patchCachedCloud(identifier: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
|
||||
for (const isOwnProfile of [true, false]) {
|
||||
const key = makeKey(userId, isOwnProfile)
|
||||
const key = makeKey(identifier, isOwnProfile)
|
||||
const current = cloudsByKey.value[key]
|
||||
if (!current) continue
|
||||
cloudsByKey.value[key] = current
|
||||
@@ -142,68 +146,68 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function removeCachedClouds(userId: string, cloudIds: string[]) {
|
||||
const idSet = new Set(cloudIds)
|
||||
function removeCachedClouds(identifier: string, cloudIds: string[]) {
|
||||
const ids = new Set(cloudIds)
|
||||
for (const isOwnProfile of [true, false]) {
|
||||
const key = makeKey(userId, isOwnProfile)
|
||||
const key = makeKey(identifier, isOwnProfile)
|
||||
const current = cloudsByKey.value[key]
|
||||
if (current) cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
|
||||
if (current) cloudsByKey.value[key] = current.filter(item => !ids.has(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateUser(userId: string) {
|
||||
delete cloudsByKey.value[makeKey(userId, true)]
|
||||
delete cloudsByKey.value[makeKey(userId, false)]
|
||||
function invalidateUser(identifier: string) {
|
||||
delete cloudsByKey.value[makeKey(identifier, true)]
|
||||
delete cloudsByKey.value[makeKey(identifier, false)]
|
||||
}
|
||||
|
||||
async function updateCloud(userId: string, cloudId: string, patch: {
|
||||
cloud_type_id: number | null
|
||||
custom_cloud_type: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
location_name: string | null
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
async function updateCloud(identifier: string, cloudId: string, patch: {
|
||||
type_id: number
|
||||
latitude: number
|
||||
longitude: number
|
||||
description?: string
|
||||
captured_at?: string
|
||||
is_hidden: boolean
|
||||
}) {
|
||||
const updated = toProfileCloud(await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
|
||||
await apiRequest<ActionResponse>(`/cloud/${cloudId}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
}))
|
||||
patchCachedCloud(userId, cloudId, updated)
|
||||
})
|
||||
const updated = toProfileCloud(toApiCloud(await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`)))
|
||||
patchCachedCloud(identifier, cloudId, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) {
|
||||
await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
|
||||
async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) {
|
||||
await apiRequest<ActionResponse>(`/cloud/${cloudId}`, {
|
||||
method: 'PATCH',
|
||||
body: { is_hidden: isHidden },
|
||||
})
|
||||
patchCachedCloud(userId, cloudId, { is_hidden: isHidden })
|
||||
patchCachedCloud(identifier, cloudId, { is_hidden: isHidden })
|
||||
}
|
||||
|
||||
async function deleteClouds(userId: string, cloudIds: string[]) {
|
||||
async function deleteClouds(identifier: string, cloudIds: string[]) {
|
||||
if (!cloudIds.length) return 0
|
||||
|
||||
if (cloudIds.length === 1) {
|
||||
await apiRequest(`/clouds/${cloudIds[0]}`, { method: 'DELETE' })
|
||||
await apiRequest<ActionResponse>(`/cloud/${cloudIds[0]}`, { method: 'DELETE' })
|
||||
} else {
|
||||
for (let offset = 0; offset < cloudIds.length; offset += 100) {
|
||||
await apiRequest('/clouds/batch-delete', {
|
||||
method: 'POST',
|
||||
body: { ids: cloudIds.slice(offset, offset + 100) },
|
||||
await apiRequest<ActionResponse>('/cloud', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
cloud_ids: cloudIds.slice(offset, offset + 100).map(cloudId => ({ cloud_id: cloudId })),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
removeCachedClouds(userId, cloudIds)
|
||||
removeCachedClouds(identifier, cloudIds)
|
||||
return cloudIds.length
|
||||
}
|
||||
|
||||
return {
|
||||
getProfile,
|
||||
getClouds,
|
||||
getLikeStats,
|
||||
getError,
|
||||
isLoaded,
|
||||
isLoading,
|
||||
|
||||
+64
-85
@@ -1,50 +1,80 @@
|
||||
import type { Cloud, CloudType, Profile, UserCollection } from '@/types/database'
|
||||
import type { Cloud, CloudType, Profile } from '@/types/database'
|
||||
|
||||
export interface BackendUserProfile {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
image: string | null
|
||||
cloud_count: number
|
||||
received_like_count: number
|
||||
last_online: string | null
|
||||
role: Profile['role']
|
||||
is_disabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BackendCloudType {
|
||||
id: number
|
||||
name: string
|
||||
genus: string | null
|
||||
icon_id: string | null
|
||||
rarity: number
|
||||
description: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BackendCloudOwner {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface BackendCloudInfo {
|
||||
id: string
|
||||
owner: BackendCloudOwner
|
||||
type: BackendCloudType
|
||||
latitude: number
|
||||
longitude: number
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
uploaded_at: string
|
||||
updated_at: string
|
||||
received_like_count: number
|
||||
status: Cloud['status']
|
||||
is_hidden: boolean
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
status_code: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CloudCreateResponse extends ActionResponse {
|
||||
cloud_id: string
|
||||
}
|
||||
|
||||
export interface CloudTypeCreateResponse extends ActionResponse {
|
||||
cloud_type_id: number
|
||||
}
|
||||
|
||||
// Internal view model. The backend profile is normalized here so existing
|
||||
// layout/admin consumers do not need to understand the wire representation.
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
email_verified: boolean
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
image: string | null
|
||||
role: Profile['role']
|
||||
is_disabled: boolean
|
||||
cloud_count: number
|
||||
public_cloud_count: number
|
||||
collection_count: number
|
||||
received_like_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string
|
||||
token_type: 'bearer'
|
||||
expires_in: number
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export interface PublicProfile {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ApiPage<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
export interface ApiCloudTypeSummary {
|
||||
id: number
|
||||
name: string
|
||||
name_en: string
|
||||
rarity: CloudType['rarity']
|
||||
rarity_value: number
|
||||
}
|
||||
|
||||
export interface ApiCloudOwner {
|
||||
@@ -58,58 +88,7 @@ export interface ApiCloud extends Cloud {
|
||||
owner: ApiCloudOwner | null
|
||||
cloud_type_name: string | null
|
||||
cloud_type_rarity: CloudType['rarity'] | null
|
||||
cloud_type_rarity_value: number | null
|
||||
username: string | null
|
||||
favorite_count: number
|
||||
is_favorited: boolean
|
||||
}
|
||||
|
||||
export interface LikeStatsPhoto {
|
||||
cloud_id: string
|
||||
image_url: string
|
||||
thumbnail_url: string
|
||||
favorite_count: number
|
||||
captured_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface LikeStats {
|
||||
user_id: string
|
||||
username: string
|
||||
total_likes: number
|
||||
photos: LikeStatsPhoto[]
|
||||
}
|
||||
|
||||
export interface ApiCollection extends UserCollection {
|
||||
cloud_type: ApiCloudTypeSummary
|
||||
first_cloud: Pick<
|
||||
ApiCloud,
|
||||
'id' | 'image_url' | 'thumbnail_url' | 'captured_at' | 'created_at' | 'location_name'
|
||||
> | null
|
||||
}
|
||||
|
||||
export interface UnlockedBadge {
|
||||
cloud_type_id: number
|
||||
cloud_name: string
|
||||
cloud_name_en: string
|
||||
rarity: CloudType['rarity']
|
||||
unlocked_at: string
|
||||
}
|
||||
|
||||
export interface CloudCreateResponse {
|
||||
cloud: ApiCloud
|
||||
unlocked_badge: UnlockedBadge | null
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
users: number
|
||||
images: number
|
||||
today_uploads: number
|
||||
pending: number
|
||||
approved: number
|
||||
rejected: number
|
||||
hidden: number
|
||||
}
|
||||
|
||||
export interface AdminUserItem {
|
||||
user: AuthUser
|
||||
received_like_count: number
|
||||
}
|
||||
|
||||
+3
-21
@@ -1,11 +1,11 @@
|
||||
export interface CloudType {
|
||||
id: number
|
||||
name: string
|
||||
name_en: string
|
||||
genus: string
|
||||
icon_id: string | null
|
||||
rarity: 'common' | 'uncommon' | 'rare'
|
||||
rarity_value: number
|
||||
description: string | null
|
||||
icon_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -13,12 +13,10 @@ export interface Cloud {
|
||||
id: string
|
||||
user_id: string
|
||||
cloud_type_id: number | null
|
||||
custom_cloud_type: string | null
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
location_name: string | null
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
@@ -29,24 +27,8 @@ export interface Cloud {
|
||||
export interface Profile {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
image: string | null
|
||||
role: 'user' | 'admin'
|
||||
is_disabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UserCollection {
|
||||
id: string
|
||||
user_id: string
|
||||
cloud_type_id: number
|
||||
first_cloud_id: string | null
|
||||
unlocked_at: string
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: string
|
||||
user_id: string
|
||||
cloud_id: string
|
||||
emoji: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
+576
-174
File diff suppressed because it is too large
Load Diff
@@ -1,89 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const state = ref<'loading' | 'success' | 'failed'>('loading')
|
||||
const error = ref('')
|
||||
const countdown = ref(5)
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
const confirmationToken = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
if (route.query.token !== undefined) {
|
||||
window.history.replaceState(window.history.state, '', window.location.pathname)
|
||||
window.history.replaceState(window.history.state, '', route.path)
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
countdownTimer = setInterval(() => {
|
||||
timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
router.push('/login')
|
||||
if (timer) clearInterval(timer)
|
||||
void router.push('/login')
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!token) {
|
||||
error.value = '确认链接缺少验证信息'
|
||||
state.value = 'failed'
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!confirmationToken) {
|
||||
state.value = 'failed'
|
||||
return
|
||||
}
|
||||
|
||||
await apiRequest('/auth/confirm-email', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { token: confirmationToken },
|
||||
})
|
||||
await authStore.confirmEmail(token)
|
||||
state.value = 'success'
|
||||
startCountdown()
|
||||
} catch {
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '确认链接无效或已过期'
|
||||
state.value = 'failed'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
})
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<NCard class="oc-panel-card-xl">
|
||||
<template v-if="state === 'success'">
|
||||
<NResult status="success" title="邮箱认证成功" description="你的邮箱已确认,现在可以登录了。">
|
||||
<NResult v-if="state === 'success'" 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" class="oc-primary-button oc-primary-button--teal">立即登录</NButton>
|
||||
</RouterLink>
|
||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动前往登录页面</p>
|
||||
<RouterLink to="/login" class="no-underline"><NButton type="default" class="oc-panel-button oc-panel-button--teal">立即登录</NButton></RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'failed'">
|
||||
<NResult status="error" title="认证失败" description="邮箱确认链接无效或已过期,请重新注册。">
|
||||
<NResult v-else-if="state === 'failed'" status="error" title="邮箱确认失败" :description="error">
|
||||
<template #footer>
|
||||
<RouterLink to="/register" class="no-underline">
|
||||
<NButton type="primary" class="oc-primary-button oc-primary-button--sky">重新注册</NButton>
|
||||
</RouterLink>
|
||||
<RouterLink to="/resend-confirmation" class="no-underline"><NButton type="default" class="oc-panel-button oc-panel-button--sky">重新发送验证邮件</NButton></RouterLink>
|
||||
</template>
|
||||
</NResult>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div v-else 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>
|
||||
<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>
|
||||
|
||||
@@ -6,123 +6,61 @@ import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
|
||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const cooldown = ref(0)
|
||||
let cooldownTimer: ReturnType<typeof setInterval> | null = null
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const resendButtonLabel = computed(() => {
|
||||
if (loading.value) return '发送中...'
|
||||
if (cooldown.value > 0) return `重新发送(${cooldown.value}s)`
|
||||
return '重新发送'
|
||||
})
|
||||
|
||||
function clearCooldownTimer() {
|
||||
if (!cooldownTimer) return
|
||||
clearInterval(cooldownTimer)
|
||||
cooldownTimer = null
|
||||
}
|
||||
const buttonLabel = computed(() => loading.value ? '发送中...' : cooldown.value > 0 ? `重新发送(${cooldown.value}s)` : '重新发送')
|
||||
|
||||
function startCooldown() {
|
||||
clearCooldownTimer()
|
||||
if (timer) clearInterval(timer)
|
||||
cooldown.value = 60
|
||||
cooldownTimer = setInterval(() => {
|
||||
timer = setInterval(() => {
|
||||
cooldown.value--
|
||||
if (cooldown.value <= 0) {
|
||||
cooldown.value = 0
|
||||
clearCooldownTimer()
|
||||
if (cooldown.value <= 0 && timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function handleSendResetEmail() {
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
|
||||
if (!email.value.trim()) {
|
||||
error.value = '请输入需要找回密码的邮箱。'
|
||||
const value = email.value.trim()
|
||||
if (!value || value.length > 64) {
|
||||
error.value = '请输入不超过 64 个字符的有效邮箱'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.sendPasswordReset(email.value.trim())
|
||||
await authStore.sendPasswordReset(value)
|
||||
email.value = value
|
||||
submitted.value = true
|
||||
startCooldown()
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试。'
|
||||
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resendResetEmail() {
|
||||
if (cooldown.value > 0 || loading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleSendResetEmail()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearCooldownTimer()
|
||||
})
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||
<section class="oc-panel-card-xl min-w-0 overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_52%,#f2faf6_100%)] p-6 sm:p-8">
|
||||
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Password Recovery</p>
|
||||
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
||||
重新拿回你的
|
||||
<span class="block text-sky-700">OpenCloud 账号</span>
|
||||
</h1>
|
||||
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||
输入注册邮箱后,我们会发送一封密码重置邮件。打开邮件中的链接,即可进入新的密码设置页面。
|
||||
</p>
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="oc-stat-label">Step 1</div>
|
||||
<div class="mt-2 text-lg font-bold text-slate-900">发送邮件</div>
|
||||
<div class="oc-field-help">使用注册邮箱请求重置链接</div>
|
||||
</div>
|
||||
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="oc-stat-label">Step 2</div>
|
||||
<div class="mt-2 text-lg font-bold text-slate-900">设置新密码</div>
|
||||
<div class="oc-field-help">在专用页面完成新密码更新</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NCard class="oc-panel-card-xl min-w-0">
|
||||
<NResult
|
||||
v-if="submitted"
|
||||
status="success"
|
||||
title="重置邮件已发送"
|
||||
description="如果该邮箱已注册,请查收邮件中的重置链接。"
|
||||
>
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<NCard class="oc-panel-card-xl">
|
||||
<NResult v-if="submitted" status="success" title="请求已受理" description="如果该邮箱符合条件,我们会发送密码重置邮件。">
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">
|
||||
目标邮箱:
|
||||
<span class="font-semibold text-slate-700">{{ email }}</span>
|
||||
</p>
|
||||
<p class="text-sm text-slate-500">目标邮箱:<span class="font-semibold text-slate-700">{{ email }}</span></p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<NButton
|
||||
type="primary"
|
||||
class="oc-primary-button oc-primary-button--teal"
|
||||
:disabled="cooldown > 0"
|
||||
:loading="loading"
|
||||
@click="resendResetEmail"
|
||||
>
|
||||
{{ resendButtonLabel }}
|
||||
</NButton>
|
||||
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline">
|
||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">返回登录</NButton>
|
||||
</RouterLink>
|
||||
<NButton type="default" class="oc-panel-button oc-panel-button--sky" :disabled="cooldown > 0" :loading="loading" @click="submit">{{ buttonLabel }}</NButton>
|
||||
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline"><NButton type="default" class="oc-panel-button oc-panel-button--neutral">返回登录</NButton></RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -130,48 +68,18 @@ onUnmounted(() => {
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-8">
|
||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Reset Request</div>
|
||||
<h2 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">输入你的注册邮箱,我们会发送一封密码重置邮件。</p>
|
||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">输入注册邮箱,我们会发送有效期为 1 小时的重置链接。</p>
|
||||
</div>
|
||||
|
||||
<NForm @submit.prevent="handleSendResetEmail">
|
||||
<NForm @submit.prevent="submit">
|
||||
<NFormItem label="邮箱">
|
||||
<NInput
|
||||
v-model:value="email"
|
||||
type="text"
|
||||
:input-props="{
|
||||
autocomplete: 'email',
|
||||
inputmode: 'email',
|
||||
name: 'email',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
<NInput v-model:value="email" maxlength="64" type="text" placeholder="your@email.com" :input-props="{ autocomplete: 'email', inputmode: 'email', name: 'email', required: true }" />
|
||||
</NFormItem>
|
||||
|
||||
<NAlert v-if="error" type="error" class="mb-4">
|
||||
{{ error }}
|
||||
</NAlert>
|
||||
|
||||
<NButton
|
||||
attr-type="submit"
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
class="oc-primary-button oc-primary-button--sky"
|
||||
:loading="loading"
|
||||
>
|
||||
{{ loading ? '发送中...' : '发送重置邮件' }}
|
||||
</NButton>
|
||||
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
||||
<NButton attr-type="submit" type="primary" block size="large" class="oc-primary-button oc-primary-button--sky" :loading="loading">{{ loading ? '发送中...' : '发送重置邮件' }}</NButton>
|
||||
</NForm>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
||||
<p>如果你已经记起密码,可以直接返回登录。</p>
|
||||
<RouterLink :to="{ path: '/login', query: { email } }" class="font-semibold text-sky-700 hover:text-sky-800">
|
||||
返回登录
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p class="mt-6 text-sm text-slate-500"><RouterLink :to="{ path: '/login', query: { email } }" class="font-semibold text-sky-700 hover:text-sky-800">返回登录</RouterLink></p>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
|
||||
import { AuthClientError } from '@/lib/authClient'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@@ -12,6 +13,7 @@ const email = ref(typeof route.query.email === 'string' ? route.query.email : ''
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const needsEmailConfirmation = ref(false)
|
||||
|
||||
function getSafeRedirect(value: unknown) {
|
||||
if (typeof value !== 'string' || !value.startsWith('/')) return '/'
|
||||
@@ -28,11 +30,13 @@ function getSafeRedirect(value: unknown) {
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
needsEmailConfirmation.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login(email.value, password.value)
|
||||
void router.push(getSafeRedirect(route.query.redirect))
|
||||
} catch (e: unknown) {
|
||||
needsEmailConfirmation.value = e instanceof AuthClientError && e.code === 'EMAIL_NOT_VERIFIED'
|
||||
error.value = e instanceof Error ? e.message : '登录失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -51,19 +55,19 @@ async function handleLogin() {
|
||||
<span class="block text-teal-700">你眼中的每一朵云</span>
|
||||
</h1>
|
||||
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
|
||||
在地图、百科和画廊之间浏览观测记录。上传新的云图后,审核通过的作品会进入公开画廊与地图。
|
||||
</p> -->
|
||||
</div>
|
||||
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
|
||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="oc-stat-label">Collection</div>
|
||||
<div class="oc-stat-value">10</div>
|
||||
<div class="oc-field-help">基础云属待收集</div>
|
||||
<div class="oc-field-help">基础云属资料</div>
|
||||
</div>
|
||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="oc-stat-label">Mode</div>
|
||||
<div class="oc-stat-value">Atlas</div>
|
||||
<div class="oc-field-help">地图、图鉴、画廊一体</div>
|
||||
<div class="oc-field-help">地图、百科、画廊一体</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -106,6 +110,14 @@ async function handleLogin() {
|
||||
|
||||
<NAlert v-if="error" type="error" class="mb-4">
|
||||
{{ error }}
|
||||
<div v-if="needsEmailConfirmation" class="mt-2">
|
||||
<RouterLink
|
||||
:to="{ path: '/resend-confirmation', query: { email } }"
|
||||
class="font-semibold text-rose-700 underline underline-offset-2"
|
||||
>
|
||||
重新发送验证邮件
|
||||
</RouterLink>
|
||||
</div>
|
||||
</NAlert>
|
||||
|
||||
<NButton
|
||||
@@ -121,15 +133,15 @@ async function handleLogin() {
|
||||
</NForm>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
||||
<p>
|
||||
没有账号?
|
||||
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
|
||||
</p>
|
||||
<span>
|
||||
还没有账号?
|
||||
<RouterLink to="/register" class="font-semibold text-sky-700 hover:text-sky-800">立即注册</RouterLink>
|
||||
</span>
|
||||
<RouterLink
|
||||
:to="email ? { path: '/forgot-password', query: { email } } : { path: '/forgot-password' }"
|
||||
class="font-semibold text-teal-700 transition-colors hover:text-teal-800"
|
||||
:to="{ path: '/forgot-password', query: { email } }"
|
||||
class="font-semibold text-teal-700 hover:text-teal-800"
|
||||
>
|
||||
忘记密码?
|
||||
忘记密码
|
||||
</RouterLink>
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
@@ -1,44 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||
import { AuthClientError } from '@/lib/authClient'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
|
||||
const email = ref('')
|
||||
const name = ref('')
|
||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const username = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const emailSent = ref(false)
|
||||
const registered = ref(false)
|
||||
const registerConflict = ref(false)
|
||||
|
||||
async function handleRegister() {
|
||||
error.value = ''
|
||||
registerConflict.value = false
|
||||
const trimmedName = name.value.trim()
|
||||
const trimmedEmail = email.value.trim()
|
||||
|
||||
if (!username.value.trim()) {
|
||||
if (!trimmedName) {
|
||||
error.value = '请输入用户名'
|
||||
return
|
||||
}
|
||||
if (username.value.length > 20) {
|
||||
error.value = '用户名不能超过20个字符'
|
||||
if (trimmedName.length > 16) {
|
||||
error.value = '用户名不能超过 16 个字符'
|
||||
return
|
||||
}
|
||||
if (!trimmedEmail || trimmedEmail.length > 64) {
|
||||
error.value = '请输入不超过 64 个字符的有效邮箱'
|
||||
return
|
||||
}
|
||||
if (password.value.length < 8) {
|
||||
error.value = '密码至少8位'
|
||||
error.value = '密码至少需要 8 位'
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = '两次密码不一致'
|
||||
error.value = '两次输入的密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.register(email.value, password.value, username.value.trim())
|
||||
emailSent.value = true
|
||||
await authStore.register(trimmedName, trimmedEmail, password.value)
|
||||
email.value = trimmedEmail
|
||||
registered.value = true
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
|
||||
if (e instanceof AuthClientError && (e.status === 409 || e.code === 'USER_ALREADY_EXISTS')) {
|
||||
registerConflict.value = true
|
||||
error.value = '邮箱或用户名已被占用;若邮箱尚未验证,可以尝试重发验证邮件。'
|
||||
} else {
|
||||
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -55,40 +72,39 @@ async function handleRegister() {
|
||||
加入天空探索者
|
||||
<span class="block text-sky-700">建立你的云图档案</span>
|
||||
</h1>
|
||||
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
|
||||
</p> -->
|
||||
</div>
|
||||
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
|
||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="oc-stat-label">Review</div>
|
||||
<div class="oc-stat-value">Queue</div>
|
||||
<div class="oc-field-help">上传后进入审核队列</div>
|
||||
<div class="oc-stat-label">Step 1</div>
|
||||
<div class="oc-stat-value">Join</div>
|
||||
<div class="oc-field-help">填写账号资料</div>
|
||||
</div>
|
||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="oc-stat-label">Profile</div>
|
||||
<div class="oc-stat-value">Journal</div>
|
||||
<div class="oc-field-help">自动生成个人天空日志</div>
|
||||
<div class="oc-stat-label">Step 2</div>
|
||||
<div class="oc-stat-value">Verify</div>
|
||||
<div class="oc-field-help">通过邮件确认邮箱</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
||||
<NResult
|
||||
v-if="emailSent"
|
||||
v-if="registered"
|
||||
status="success"
|
||||
title="确认你的邮箱"
|
||||
description="确认邮件已经发送,请查收并点击链接完成注册。"
|
||||
description="如该邮箱可以注册,请查收验证邮件并点击链接。验证后请重新登录。"
|
||||
>
|
||||
<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" class="oc-primary-button oc-primary-button--teal">去登录</NButton>
|
||||
</RouterLink>
|
||||
<p class="text-sm text-slate-500">目标邮箱:<span class="font-semibold text-slate-700">{{ email }}</span></p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="no-underline">
|
||||
<NButton type="default" class="oc-panel-button oc-panel-button--sky">重发验证邮件</NButton>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline">
|
||||
<NButton type="default" class="oc-panel-button oc-panel-button--teal">去登录</NButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
@@ -97,86 +113,36 @@ async function handleRegister() {
|
||||
<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>
|
||||
<p class="mt-2 text-sm text-slate-500">注册后,请先通过邮件确认邮箱再登录。</p>
|
||||
</div>
|
||||
|
||||
<NForm @submit.prevent="handleRegister">
|
||||
<NFormItem label="用户名">
|
||||
<NInput
|
||||
v-model:value="username"
|
||||
type="text"
|
||||
:input-props="{
|
||||
autocomplete: 'username',
|
||||
name: 'username',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="你的昵称"
|
||||
maxlength="20"
|
||||
/>
|
||||
<NInput v-model:value="name" maxlength="16" show-count placeholder="最多 16 个字符" :input-props="{ autocomplete: 'username', name: 'username', required: true }" />
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="邮箱">
|
||||
<NInput
|
||||
v-model:value="email"
|
||||
type="text"
|
||||
:input-props="{
|
||||
autocomplete: 'email',
|
||||
inputmode: 'email',
|
||||
name: 'email',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
<NInput v-model:value="email" maxlength="64" type="text" placeholder="your@email.com" :input-props="{ autocomplete: 'email', inputmode: 'email', name: 'email', required: true }" />
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="密码">
|
||||
<NInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="至少8位"
|
||||
/>
|
||||
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="至少 8 位" :input-props="{ autocomplete: 'new-password', name: 'new-password', required: true }" />
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="确认密码">
|
||||
<NInput
|
||||
v-model:value="confirmPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'confirm-new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="再次输入密码"
|
||||
/>
|
||||
<NInput v-model:value="confirmPassword" type="password" show-password-on="click" placeholder="再次输入密码" :input-props="{ autocomplete: 'new-password', name: 'confirm-new-password', required: true }" />
|
||||
</NFormItem>
|
||||
|
||||
<NAlert v-if="error" type="error" class="mb-4">
|
||||
{{ error }}
|
||||
<div v-if="registerConflict" class="mt-2">
|
||||
<RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="font-semibold text-rose-700 underline underline-offset-2">重发验证邮件</RouterLink>
|
||||
</div>
|
||||
</NAlert>
|
||||
|
||||
<NButton
|
||||
attr-type="submit"
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
class="oc-primary-button oc-primary-button--sky"
|
||||
:loading="loading"
|
||||
>
|
||||
<NButton attr-type="submit" type="primary" block size="large" class="oc-primary-button oc-primary-button--sky" :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>
|
||||
<p class="mt-6 text-sm text-slate-500">已有账号? <RouterLink :to="{ path: '/login', query: { email } }" class="font-semibold text-sky-700 hover:text-sky-800">去登录</RouterLink></p>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const cooldown = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const buttonLabel = computed(() => loading.value ? '发送中...' : cooldown.value > 0 ? `重新发送(${cooldown.value}s)` : '重新发送')
|
||||
|
||||
function startCooldown() {
|
||||
if (timer) clearInterval(timer)
|
||||
cooldown.value = 60
|
||||
timer = setInterval(() => {
|
||||
cooldown.value--
|
||||
if (cooldown.value <= 0 && timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
const value = email.value.trim()
|
||||
if (!value || value.length > 64) {
|
||||
error.value = '请输入不超过 64 个字符的有效邮箱'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.resendConfirmation(value)
|
||||
email.value = value
|
||||
submitted.value = true
|
||||
startCooldown()
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '验证邮件发送失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<NCard class="oc-panel-card-xl">
|
||||
<NResult v-if="submitted" status="success" title="请求已受理" description="如果该邮箱符合条件,我们会发送新的验证邮件。">
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">目标邮箱:<span class="font-semibold text-slate-700">{{ email }}</span></p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<NButton type="default" class="oc-panel-button oc-panel-button--sky" :disabled="cooldown > 0" :loading="loading" @click="submit">{{ buttonLabel }}</NButton>
|
||||
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline"><NButton type="default" class="oc-panel-button oc-panel-button--teal">返回登录</NButton></RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
<template v-else>
|
||||
<div class="mb-8">
|
||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Email Verification</div>
|
||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">重发验证邮件</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">输入注册邮箱。为保护账号,系统不会透露邮箱是否存在或已验证。</p>
|
||||
</div>
|
||||
<NForm @submit.prevent="submit">
|
||||
<NFormItem label="邮箱"><NInput v-model:value="email" maxlength="64" type="text" placeholder="your@email.com" :input-props="{ autocomplete: 'email', inputmode: 'email', name: 'email', required: true }" /></NFormItem>
|
||||
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
||||
<NButton attr-type="submit" type="primary" block size="large" class="oc-primary-button oc-primary-button--sky" :loading="loading">{{ loading ? '发送中...' : '发送验证邮件' }}</NButton>
|
||||
</NForm>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,206 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||
import { AuthClientError } from '@/lib/authClient'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const email = typeof route.query.email === 'string' ? route.query.email : ''
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const error = ref('')
|
||||
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
|
||||
const error = ref(!token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '')
|
||||
const invalid = ref(!token)
|
||||
const success = ref(false)
|
||||
const loading = ref(false)
|
||||
const countdown = ref(5)
|
||||
const initialResetToken = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
const resetToken = ref(initialResetToken)
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
if (route.query.token !== undefined) {
|
||||
window.history.replaceState(window.history.state, '', window.location.pathname)
|
||||
if (route.query.token !== undefined || route.query.email !== undefined) {
|
||||
window.history.replaceState(window.history.state, '', route.path)
|
||||
}
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
state.value === 'ready' && password.value.length >= 8 && password.value === confirmPassword.value,
|
||||
)
|
||||
|
||||
function showInvalidRecoveryLink() {
|
||||
error.value = '密码重置链接无效或已过期,请重新发送邮件。'
|
||||
state.value = 'invalid'
|
||||
}
|
||||
|
||||
function getResetErrorMessage(value: unknown) {
|
||||
const message = value instanceof Error ? value.message : ''
|
||||
if (message.includes('无效') || message.includes('过期') || message.includes('expired')) {
|
||||
return '密码重置链接无效或已过期,请重新发送邮件。'
|
||||
}
|
||||
|
||||
if (message.includes('8')) {
|
||||
return '新密码至少需要 8 位。'
|
||||
}
|
||||
|
||||
return message || '密码重置失败,请稍后重试。'
|
||||
}
|
||||
const canSubmit = computed(() => !invalid.value && password.value.length >= 8 && password.value === confirmPassword.value)
|
||||
|
||||
function startCountdown() {
|
||||
countdownTimer = setInterval(() => {
|
||||
timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
router.push('/login')
|
||||
if (timer) clearInterval(timer)
|
||||
void router.push({ path: '/login', query: { email } })
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function initializeRecoverySession() {
|
||||
if (!resetToken.value) {
|
||||
showInvalidRecoveryLink()
|
||||
return
|
||||
}
|
||||
state.value = 'ready'
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
|
||||
if (password.value.length < 8) {
|
||||
error.value = '新密码至少需要 8 位。'
|
||||
error.value = '新密码至少需要 8 位'
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = '两次输入的密码不一致。'
|
||||
error.value = '两次输入的密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await apiRequest('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { token: resetToken.value, password: password.value },
|
||||
})
|
||||
countdown.value = 5
|
||||
state.value = 'success'
|
||||
await authStore.resetPassword(token, password.value)
|
||||
success.value = true
|
||||
startCountdown()
|
||||
} catch (e) {
|
||||
error.value = getResetErrorMessage(e)
|
||||
if (error.value === '密码重置链接无效或已过期,请重新发送邮件。') {
|
||||
state.value = 'invalid'
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试'
|
||||
if (e instanceof AuthClientError && (e.status === 400 || e.code === 'INVALID_TOKEN')) invalid.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void initializeRecoverySession()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
})
|
||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<NCard class="oc-panel-card-xl">
|
||||
<NResult
|
||||
v-if="state === 'success'"
|
||||
status="success"
|
||||
title="密码已重置"
|
||||
description="你已使用新密码完成更新,现在可以重新登录。"
|
||||
>
|
||||
<NResult v-if="success" status="success" title="密码已重置" description="请使用新密码重新登录。">
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
|
||||
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">立即登录</NButton>
|
||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动前往登录页面</p>
|
||||
<NButton type="default" class="oc-panel-button oc-panel-button--teal" @click="router.push({ path: '/login', query: { email } })">立即登录</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
|
||||
<template v-else-if="state === 'checking'">
|
||||
<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>
|
||||
|
||||
<template v-else>
|
||||
<div class="mb-8">
|
||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
{{
|
||||
state === 'invalid'
|
||||
? '当前重置链接不可用,请重新发送密码重置邮件。'
|
||||
: '请输入新的登录密码,提交后当前重置链接会失效。'
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-slate-500">提交成功后,此重置链接和现有登录会话都会失效。</p>
|
||||
</div>
|
||||
|
||||
<NForm @submit.prevent="handleResetPassword">
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
class="sr-only"
|
||||
tabindex="-1"
|
||||
aria-label="账户用户名"
|
||||
/>
|
||||
<NFormItem label="新密码">
|
||||
<NInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="至少 8 位"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NFormItem label="确认新密码">
|
||||
<NInput
|
||||
v-model:value="confirmPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'confirm-new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
<NAlert v-if="error" type="error" class="mb-4">
|
||||
{{ error }}
|
||||
</NAlert>
|
||||
|
||||
<RouterLink
|
||||
v-if="state === 'invalid'"
|
||||
to="/forgot-password"
|
||||
class="mb-4 block text-sm font-semibold text-teal-700 hover:text-teal-800"
|
||||
>
|
||||
返回重新发送重置邮件
|
||||
</RouterLink>
|
||||
|
||||
<NButton
|
||||
attr-type="submit"
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
class="oc-primary-button oc-primary-button--teal"
|
||||
:disabled="!canSubmit"
|
||||
:loading="loading"
|
||||
>
|
||||
保存新密码
|
||||
</NButton>
|
||||
<NForm @submit.prevent="submit">
|
||||
<NFormItem label="新密码"><NInput v-model:value="password" type="password" show-password-on="click" placeholder="至少 8 位" :disabled="invalid" :input-props="{ autocomplete: 'new-password', name: 'new-password', required: true }" /></NFormItem>
|
||||
<NFormItem label="确认新密码"><NInput v-model:value="confirmPassword" type="password" show-password-on="click" placeholder="再次输入新密码" :disabled="invalid" :input-props="{ autocomplete: 'new-password', name: 'confirm-new-password', required: true }" /></NFormItem>
|
||||
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
||||
<RouterLink v-if="invalid" :to="{ path: '/forgot-password', query: { email } }" class="mb-4 block text-sm font-semibold text-teal-700 hover:text-teal-800">重新发送密码重置邮件</RouterLink>
|
||||
<NButton attr-type="submit" type="primary" block size="large" class="oc-primary-button oc-primary-button--teal" :disabled="!canSubmit" :loading="loading">保存新密码</NButton>
|
||||
</NForm>
|
||||
</template>
|
||||
</NCard>
|
||||
|
||||
@@ -1,126 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
|
||||
import { NAlert, NButton, NEmpty, NSkeleton } from 'naive-ui'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiRequest, imageUrl, toApiCloud } from '@/lib/api'
|
||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||
import type { ApiCloud, ApiPage } from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
interface CloudGalleryItem {
|
||||
id: string
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
location_name: string | null
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
created_at: string
|
||||
profiles: {
|
||||
username: string
|
||||
} | null
|
||||
}
|
||||
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const encyclopediaStore = useEncyclopediaStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const gallery = ref<CloudGalleryItem[]>([])
|
||||
const selectedGalleryItem = ref<CloudGalleryItem | null>(null)
|
||||
const publicCount = ref(0)
|
||||
|
||||
const rarityMeta = {
|
||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200', glow: 'from-sky-100 via-white to-cyan-50' },
|
||||
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200', glow: 'from-amber-100 via-white to-orange-50' },
|
||||
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200', glow: 'from-rose-100 via-white to-orange-50' },
|
||||
} satisfies Record<CloudType['rarity'], { label: string; chip: string; glow: string }>
|
||||
const gallery = ref<ApiCloud[]>([])
|
||||
const selectedItem = ref<ApiCloud | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const hasNextPage = ref(false)
|
||||
|
||||
const cloudTypeId = computed(() => Number(route.params.id))
|
||||
const cloudType = computed(() => encyclopediaStore.cloudTypes.find(item => item.id === cloudTypeId.value) ?? null)
|
||||
const collectionEntry = computed(() => {
|
||||
if (!authStore.isLoggedIn || !cloudType.value) return null
|
||||
return encyclopediaStore.getCollectionEntry(cloudType.value.id)
|
||||
})
|
||||
const isUnlocked = computed(() => !!collectionEntry.value)
|
||||
const heroImage = computed(() => collectionEntry.value?.firstCloud?.image_url || gallery.value[0]?.image_url || null)
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return '未知时间'
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatGalleryTime(item: CloudGalleryItem) {
|
||||
return formatDate(item.captured_at || item.created_at)
|
||||
}
|
||||
const heroImage = computed(() => cloudType.value?.icon_id ? imageUrl(cloudType.value.icon_id, 'original') : null)
|
||||
|
||||
function formatDateTime(iso: string | null) {
|
||||
if (!iso) return '未知时间'
|
||||
if (!iso) return '未记录拍摄时间'
|
||||
return new Date(iso).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatCoordinate(value: number | null) {
|
||||
return value === null ? '未记录' : value.toFixed(2)
|
||||
async function loadGallery(page: number) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
||||
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud/type/${cloudTypeId.value}?${params}`, { auth: false })
|
||||
gallery.value = rows.map(toApiCloud)
|
||||
currentPage.value = page
|
||||
hasNextPage.value = rows.length === PAGE_SIZE
|
||||
}
|
||||
|
||||
function openGalleryDetail(item: CloudGalleryItem) {
|
||||
selectedGalleryItem.value = item
|
||||
}
|
||||
|
||||
async function loadGallery(typeId: number) {
|
||||
const result = await apiRequest<ApiPage<ApiCloud>>(`/cloud-types/${typeId}/clouds?page=1&page_size=24`, { auth: false })
|
||||
gallery.value = result.items.map(row => ({
|
||||
id: row.id,
|
||||
image_url: row.image_url,
|
||||
thumbnail_url: row.thumbnail_url,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
location_name: row.location_name,
|
||||
description: row.description,
|
||||
captured_at: row.captured_at,
|
||||
created_at: row.created_at,
|
||||
profiles: row.username ? { username: row.username } : null,
|
||||
}))
|
||||
publicCount.value = result.total
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
if (!Number.isFinite(cloudTypeId.value) || cloudTypeId.value <= 0) {
|
||||
loadError.value = '云型不存在'
|
||||
async function loadPage(page = 1) {
|
||||
if (!Number.isInteger(cloudTypeId.value) || cloudTypeId.value <= 0) {
|
||||
loadError.value = '云类型不存在'
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
|
||||
selectedItem.value = null
|
||||
try {
|
||||
await encyclopediaStore.fetchCloudTypes()
|
||||
await encyclopediaStore.fetchMyCollection()
|
||||
|
||||
if (!cloudType.value) {
|
||||
loadError.value = '云型不存在'
|
||||
loadError.value = '云类型不存在'
|
||||
gallery.value = []
|
||||
publicCount.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
await loadGallery(cloudType.value.id)
|
||||
await loadGallery(page)
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '详情加载失败'
|
||||
} finally {
|
||||
@@ -128,191 +63,82 @@ async function loadPage() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPage)
|
||||
watch(() => route.params.id, loadPage)
|
||||
|
||||
watch(() => route.params.id, () => {
|
||||
selectedGalleryItem.value = null
|
||||
})
|
||||
onMounted(() => loadPage())
|
||||
watch(() => route.params.id, () => loadPage())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<div class="mb-6">
|
||||
<RouterLink to="/encyclopedia">
|
||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回图鉴总览</NButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="oc-container oc-container--content py-8">
|
||||
<RouterLink to="/encyclopedia">
|
||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回百科总览</NButton>
|
||||
</RouterLink>
|
||||
|
||||
<div v-if="loading" class="space-y-6">
|
||||
<div v-if="loading" class="mt-6 space-y-6">
|
||||
<NSkeleton class="h-80 w-full" />
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<NCard v-for="n in 3" :key="n" class="oc-panel-card border border-slate-200">
|
||||
<NSkeleton class="h-4 w-1/3" />
|
||||
<NSkeleton class="mt-4 h-7 w-1/2" />
|
||||
</NCard>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3"><NSkeleton v-for="n in 6" :key="n" class="h-56 w-full" /></div>
|
||||
</div>
|
||||
|
||||
<NAlert
|
||||
v-else-if="loadError"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
:bordered="false"
|
||||
title="详情加载失败"
|
||||
>
|
||||
<NAlert v-else-if="loadError" class="mt-6" type="error" :show-icon="false" :bordered="false" title="详情加载失败">
|
||||
{{ loadError }}
|
||||
</NAlert>
|
||||
|
||||
<template v-else-if="cloudType">
|
||||
<section class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
|
||||
<div class="grid lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<div class="relative min-h-[360px] overflow-hidden">
|
||||
<img
|
||||
v-if="heroImage"
|
||||
:src="heroImage"
|
||||
:alt="cloudType.name"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="absolute inset-0 bg-gradient-to-br"
|
||||
:class="rarityMeta[cloudType.rarity].glow"
|
||||
></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">
|
||||
<NTag :bordered="false" class="border border-white/20 bg-white/12 text-white backdrop-blur">
|
||||
{{ rarityMeta[cloudType.rarity].label }}
|
||||
</NTag>
|
||||
<section class="oc-panel-card mt-6 overflow-hidden border border-slate-200 bg-white">
|
||||
<div class="grid lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div class="relative min-h-80 bg-sky-50">
|
||||
<img v-if="heroImage" :src="heroImage" :alt="cloudType.name" class="absolute inset-0 h-full w-full object-cover" />
|
||||
<div v-else class="absolute inset-0 flex items-center justify-center pb-20 text-sm font-medium text-slate-500">无图片</div>
|
||||
<div v-if="heroImage" class="absolute inset-0 bg-gradient-to-t from-slate-950/75 via-slate-950/10 to-transparent"></div>
|
||||
<div class="absolute bottom-7 left-7 right-7" :class="heroImage ? 'text-white' : 'text-slate-900'">
|
||||
<span
|
||||
class="border px-2.5 py-1 text-xs"
|
||||
:class="heroImage ? 'border-white/35 bg-slate-950/25 backdrop-blur' : 'border-slate-200 bg-white'"
|
||||
>稀有度 {{ cloudType.rarity_value }}</span>
|
||||
<h1 class="mt-4 text-4xl font-bold">{{ cloudType.name }}</h1>
|
||||
<p class="mt-2 text-lg text-white/82">{{ cloudType.name_en }}</p>
|
||||
<p class="mt-2" :class="heroImage ? 'text-white/80' : 'text-slate-600'">{{ cloudType.genus || '未提供云属名称' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-between bg-slate-50 p-8">
|
||||
<div>
|
||||
<p class="text-sm font-medium uppercase tracking-[0.22em] text-slate-500">Meteorology Note</p>
|
||||
<p class="mt-5 text-base leading-8 text-slate-700">
|
||||
{{ cloudType.description || '这类云的图像、形成条件和观测要点会随着图鉴完善逐步补充。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="oc-inset-panel mt-8">
|
||||
<p class="text-sm text-slate-500">你的收集状态</p>
|
||||
<template v-if="isUnlocked && collectionEntry">
|
||||
<p class="oc-stat-value">已解锁</p>
|
||||
<p class="mt-2 text-sm text-slate-600">首次解锁于 {{ formatDate(collectionEntry.unlocked_at) }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="oc-stat-value">尚未解锁</p>
|
||||
<p class="mt-2 text-sm text-slate-600">拍到并上传这种云朵后,这枚徽章就会被点亮。</p>
|
||||
</template>
|
||||
</div>
|
||||
<div class="p-8">
|
||||
<p class="oc-section-label">Meteorology Note</p>
|
||||
<p class="mt-5 text-base leading-8 text-slate-700">{{ cloudType.description || '暂未提供识别说明。' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<NCard class="oc-panel-card border border-slate-200">
|
||||
<p class="text-sm text-slate-500">稀有度</p>
|
||||
<p class="oc-stat-value">{{ rarityMeta[cloudType.rarity].label }}</p>
|
||||
</NCard>
|
||||
<NCard class="oc-panel-card border border-slate-200">
|
||||
<p class="text-sm text-slate-500">公开云图</p>
|
||||
<p class="oc-stat-value">{{ publicCount }}</p>
|
||||
</NCard>
|
||||
<NCard class="oc-panel-card border border-slate-200">
|
||||
<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>
|
||||
</NCard>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<div class="mb-4 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-slate-900">该类型云图画廊</h2>
|
||||
<p class="oc-field-help">来自社区公开可见的已通过云图。</p>
|
||||
<div class="mb-5 flex items-end justify-between gap-4">
|
||||
<div><h2 class="text-2xl font-bold text-slate-900">公开观测</h2><p class="oc-field-help">第 {{ currentPage }} 页,每页最多 {{ PAGE_SIZE }} 张。</p></div>
|
||||
<div class="flex gap-3">
|
||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" :disabled="currentPage === 1" @click="loadPage(currentPage - 1)">上一页</NButton>
|
||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" :disabled="!hasNextPage" @click="loadPage(currentPage + 1)">下一页</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="gallery.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<button
|
||||
v-for="item in gallery"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left transition-transform duration-300 hover:-translate-y-1"
|
||||
@click="openGalleryDetail(item)"
|
||||
>
|
||||
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-56 w-full object-cover" />
|
||||
<div class="p-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<p class="text-sm font-medium text-slate-900">{{ item.profiles?.username || '匿名' }}</p>
|
||||
<p class="text-xs text-slate-400">{{ formatGalleryTime(item) }}</p>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ item.location_name || '未填写位置' }}</p>
|
||||
<p v-if="item.description" class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="gallery.length" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<button v-for="item in gallery" :key="item.id" type="button" class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left" @click="selectedItem = item">
|
||||
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-48 w-full object-cover" />
|
||||
<div class="p-4"><p class="font-medium text-slate-900">@{{ item.username }}</p><p class="mt-1 text-xs text-slate-500">{{ formatDateTime(item.captured_at || item.created_at) }}</p></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10"><NEmpty description="这一页暂无公开云图" /></div>
|
||||
</section>
|
||||
|
||||
<ImageDetailModal
|
||||
v-if="selectedGalleryItem"
|
||||
:open="!!selectedGalleryItem"
|
||||
:image-url="selectedGalleryItem.image_url"
|
||||
:thumbnail-url="selectedGalleryItem.thumbnail_url"
|
||||
:image-alt="cloudType.name"
|
||||
:title="cloudType.name"
|
||||
:subtitle="`上传者:${selectedGalleryItem.profiles?.username || '匿名'}`"
|
||||
:badge-label="rarityMeta[cloudType.rarity].label"
|
||||
:badge-class="rarityMeta[cloudType.rarity].chip"
|
||||
@close="selectedGalleryItem = null"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">上传时间</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedGalleryItem.created_at) }}</p>
|
||||
</div>
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">拍摄时间</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedGalleryItem.captured_at) }}</p>
|
||||
</div>
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">位置</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedGalleryItem.location_name || '未填写位置名称' }}</p>
|
||||
</div>
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">模糊化经纬度</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||
纬度 {{ formatCoordinate(selectedGalleryItem.latitude) }} / 经度 {{ formatCoordinate(selectedGalleryItem.longitude) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
||||
<p class="oc-section-label">图片说明</p>
|
||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||
{{ selectedGalleryItem.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
<MiniLocationMap
|
||||
:latitude="selectedGalleryItem.latitude"
|
||||
:longitude="selectedGalleryItem.longitude"
|
||||
:location-name="selectedGalleryItem.location_name"
|
||||
/>
|
||||
</ImageDetailModal>
|
||||
</template>
|
||||
|
||||
<ImageDetailModal
|
||||
v-if="selectedItem && cloudType"
|
||||
:open="true"
|
||||
:image-url="selectedItem.image_url"
|
||||
:thumbnail-url="selectedItem.thumbnail_url"
|
||||
:image-alt="cloudType.name"
|
||||
:title="cloudType.name"
|
||||
:subtitle="selectedItem.captured_at ? `拍摄于 ${formatDateTime(selectedItem.captured_at)}` : `上传于 ${formatDateTime(selectedItem.created_at)}`"
|
||||
:badge-label="`稀有度 ${cloudType.rarity_value}`"
|
||||
badge-class="border-slate-200 bg-slate-50 text-slate-600"
|
||||
@close="selectedItem = null"
|
||||
>
|
||||
<p class="text-sm leading-7 text-slate-700">{{ selectedItem.description || '上传者没有留下额外说明。' }}</p>
|
||||
<MiniLocationMap :latitude="selectedItem.latitude" :longitude="selectedItem.longitude" />
|
||||
<CloudLikeButton :cloud-id="selectedItem.id" :count="selectedItem.received_like_count" />
|
||||
</ImageDetailModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,109 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { NAlert, NEmpty, NIcon, NSkeleton } from 'naive-ui'
|
||||
import { onMounted } from 'vue'
|
||||
import { NEmpty, NSkeleton } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { imageUrl } from '@/lib/api'
|
||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||
import EncyclopediaProgressCard from '@/components/cloud/EncyclopediaProgressCard.vue'
|
||||
import { Lock } from '@vicons/tabler'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const encyclopediaStore = useEncyclopediaStore()
|
||||
|
||||
const lockHint = ref('')
|
||||
let lockHintTimer: number | null = null
|
||||
|
||||
const rarityMeta = {
|
||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200', glow: 'from-sky-100 to-white' },
|
||||
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200', glow: 'from-amber-100 to-white' },
|
||||
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200', glow: 'from-rose-100 to-white' },
|
||||
} satisfies Record<CloudType['rarity'], { label: string; chip: string; glow: string }>
|
||||
|
||||
function isUnlocked(cloudTypeId: number) {
|
||||
return authStore.isLoggedIn && encyclopediaStore.isUnlocked(cloudTypeId)
|
||||
function typeImageUrl(iconId: string | null) {
|
||||
return iconId ? imageUrl(iconId, 'preview') : ''
|
||||
}
|
||||
|
||||
function getCollectionEntry(cloudTypeId: number) {
|
||||
if (!authStore.isLoggedIn) return null
|
||||
return encyclopediaStore.getCollectionEntry(cloudTypeId)
|
||||
}
|
||||
|
||||
function formatUnlockedAt(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function showLockHint(name: string) {
|
||||
lockHint.value = `拍到 ${name} 后,这枚徽章就会亮起来。`
|
||||
if (lockHintTimer) window.clearTimeout(lockHintTimer)
|
||||
lockHintTimer = window.setTimeout(() => {
|
||||
lockHint.value = ''
|
||||
lockHintTimer = null
|
||||
}, 2200)
|
||||
}
|
||||
|
||||
function openCard(cloudType: CloudType) {
|
||||
if (isUnlocked(cloudType.id)) {
|
||||
router.push(`/encyclopedia/${cloudType.id}`)
|
||||
return
|
||||
}
|
||||
showLockHint(cloudType.name)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await encyclopediaStore.fetchCloudTypes()
|
||||
await encyclopediaStore.fetchMyCollection()
|
||||
})
|
||||
onMounted(() => encyclopediaStore.fetchCloudTypes())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="oc-page-hero">
|
||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
||||
<div class="oc-page-hero__split">
|
||||
<div class="min-w-0">
|
||||
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
|
||||
<h1 class="oc-page-title">云朵图鉴</h1>
|
||||
<p class="oc-page-description">
|
||||
收集 10 种基础云属。每拍到一种新的云型,图鉴里就会点亮一枚徽章,并记录你第一次遇见它的时间。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EncyclopediaProgressCard
|
||||
:unlocked-count="authStore.isLoggedIn ? encyclopediaStore.unlockedCount : 0"
|
||||
:total-count="encyclopediaStore.cloudTypes.length || 10"
|
||||
:is-logged-in="authStore.isLoggedIn"
|
||||
/>
|
||||
</div>
|
||||
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
|
||||
<h1 class="oc-page-title">云朵百科</h1>
|
||||
<p class="oc-page-description">
|
||||
浏览后端提供的云类型资料,了解云属名称、识别说明和社区公开观测记录。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oc-container oc-container--content oc-page-content">
|
||||
<NAlert
|
||||
v-if="encyclopediaStore.collectionError && authStore.isLoggedIn"
|
||||
class="mb-6"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
:bordered="false"
|
||||
title="图鉴收藏数据暂时不可用"
|
||||
>
|
||||
图鉴收藏数据暂时不可用:{{ encyclopediaStore.collectionError }}
|
||||
</NAlert>
|
||||
|
||||
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="n in 6" :key="n" class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
|
||||
<NSkeleton class="h-44 w-full border-b border-slate-200" />
|
||||
<div class="p-5">
|
||||
<NSkeleton class="h-3 w-1/3" />
|
||||
<NSkeleton class="mt-3 h-6 w-2/5" />
|
||||
<NSkeleton class="mt-2 h-3 w-1/2" />
|
||||
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
|
||||
<NSkeleton class="mt-5 h-10 w-full" />
|
||||
</div>
|
||||
<NSkeleton class="h-80 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,114 +39,54 @@ onMounted(async () => {
|
||||
v-for="cloudType in encyclopediaStore.cloudTypes"
|
||||
:key="cloudType.id"
|
||||
type="button"
|
||||
@click="openCard(cloudType)"
|
||||
class="encyclopedia-type-card oc-interactive-card oc-panel-card flex h-full flex-col overflow-hidden border bg-white text-left"
|
||||
:class="isUnlocked(cloudType.id) ? 'border-sky-200' : 'border-slate-200'"
|
||||
class="oc-interactive-card oc-panel-card relative isolate min-h-80 overflow-hidden border border-slate-200 bg-sky-50 text-left"
|
||||
@click="router.push(`/encyclopedia/${cloudType.id}`)"
|
||||
>
|
||||
<div class="relative h-44 shrink-0 overflow-hidden border-b border-slate-200 bg-sky-50">
|
||||
<img
|
||||
v-if="getCollectionEntry(cloudType.id)?.firstCloud?.image_url"
|
||||
:src="getCollectionEntry(cloudType.id)?.firstCloud?.thumbnail_url || getCollectionEntry(cloudType.id)?.firstCloud?.image_url || ''"
|
||||
:alt="cloudType.name"
|
||||
class="encyclopedia-type-card__image h-full w-full object-cover"
|
||||
:class="isUnlocked(cloudType.id) ? '' : 'grayscale contrast-75'"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center bg-gradient-to-br"
|
||||
:class="isUnlocked(cloudType.id) ? rarityMeta[cloudType.rarity].glow : 'from-slate-200 to-slate-50'"
|
||||
>
|
||||
<span class="font-mono text-5xl font-bold text-slate-500/75">{{ cloudType.name.slice(0, 1) }}</span>
|
||||
</div>
|
||||
<img
|
||||
v-if="cloudType.icon_id"
|
||||
:src="typeImageUrl(cloudType.icon_id)"
|
||||
:alt="`${cloudType.name}介绍图`"
|
||||
class="absolute inset-0 -z-20 h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-if="cloudType.icon_id"
|
||||
class="absolute inset-0 -z-10 bg-gradient-to-t from-slate-950/90 via-slate-950/55 to-slate-950/25"
|
||||
></div>
|
||||
<div v-else class="absolute inset-0 -z-10 flex items-center justify-center text-sm font-medium text-slate-500">无图片</div>
|
||||
|
||||
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/12">
|
||||
<span class="encyclopedia-lock-badge inline-flex items-center gap-2 border border-slate-300 bg-white/92 px-3 py-2 text-xs font-semibold text-slate-700 backdrop-blur">
|
||||
<NIcon size="15"><Lock /></NIcon>
|
||||
尚未收录
|
||||
<div class="flex min-h-80 flex-col p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="oc-section-label" :class="cloudType.icon_id ? 'text-white/70' : 'text-slate-500'">Cloud Genus</p>
|
||||
<h2 class="mt-2 text-xl font-bold" :class="cloudType.icon_id ? 'text-white' : 'text-slate-900'">{{ cloudType.name }}</h2>
|
||||
<p
|
||||
class="mt-1 font-mono text-xs uppercase tracking-[0.12em]"
|
||||
:class="cloudType.icon_id ? 'text-white/75' : 'text-slate-500'"
|
||||
>
|
||||
{{ cloudType.genus || '未提供云属名称' }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 border px-2.5 py-1 text-xs font-medium"
|
||||
:class="cloudType.icon_id ? 'border-white/35 bg-slate-950/25 text-white backdrop-blur' : 'border-slate-200 bg-white text-slate-600'"
|
||||
>
|
||||
稀有度 {{ cloudType.rarity_value }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute left-4 top-4">
|
||||
<span class="inline-flex border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em]" :class="rarityMeta[cloudType.rarity].chip">
|
||||
{{ rarityMeta[cloudType.rarity].label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col p-5">
|
||||
<p class="oc-section-label">Cloud Genus</p>
|
||||
<h2 class="mt-2 text-xl font-bold text-slate-900">{{ cloudType.name }}</h2>
|
||||
<p class="mt-1 font-mono text-xs uppercase tracking-[0.12em] text-slate-500">{{ cloudType.name_en }}</p>
|
||||
|
||||
<p class="mt-4 line-clamp-2 min-h-[3.25rem] text-sm leading-6 text-slate-600">
|
||||
{{ cloudType.description || '云层特征和识别要点会显示在这里。' }}
|
||||
<p class="mt-auto line-clamp-3 pt-16 text-sm leading-6" :class="cloudType.icon_id ? 'text-white/85' : 'text-slate-600'">
|
||||
{{ cloudType.description || '暂未提供识别说明。' }}
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between gap-3 border-t border-slate-200 pt-4 text-xs">
|
||||
<template v-if="isUnlocked(cloudType.id) && getCollectionEntry(cloudType.id)">
|
||||
<span class="font-semibold uppercase tracking-[0.12em] text-sky-800">已收录</span>
|
||||
<span class="text-right text-slate-500">首次记录 {{ formatUnlockedAt(getCollectionEntry(cloudType.id)!.unlocked_at) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="font-semibold uppercase tracking-[0.12em] text-slate-600">等待发现</span>
|
||||
<span class="text-right text-slate-400">拍到后点亮</span>
|
||||
</template>
|
||||
</div>
|
||||
<p
|
||||
class="mt-4 border-t pt-4 text-xs font-semibold"
|
||||
:class="cloudType.icon_id ? 'border-white/25 text-white' : 'border-slate-200 text-sky-700'"
|
||||
>查看类型资料与公开云图 →</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white px-6 py-12">
|
||||
<NEmpty description="暂时还没有图鉴数据" />
|
||||
<NEmpty description="暂时还没有云类型资料" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="lockHint"
|
||||
class="encyclopedia-lock-hint 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"
|
||||
>
|
||||
{{ lockHint }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.encyclopedia-lock-badge {
|
||||
box-shadow: var(--oc-shadow-xs);
|
||||
}
|
||||
|
||||
.encyclopedia-lock-hint {
|
||||
box-shadow: var(--oc-shadow-lg);
|
||||
}
|
||||
|
||||
.encyclopedia-type-card:hover {
|
||||
border-color: rgb(125 211 252);
|
||||
box-shadow: var(--oc-shadow-lg) !important;
|
||||
transform: translate(-1px, -1px);
|
||||
}
|
||||
|
||||
.encyclopedia-type-card:active {
|
||||
box-shadow: var(--oc-shadow-md) !important;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.encyclopedia-type-card__image {
|
||||
transition: transform 500ms ease;
|
||||
}
|
||||
|
||||
.encyclopedia-type-card:hover .encyclopedia-type-card__image {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.encyclopedia-type-card,
|
||||
.encyclopedia-type-card__image {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
|
||||
.encyclopedia-type-card:hover,
|
||||
.encyclopedia-type-card:hover .encyclopedia-type-card__image {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||
import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
|
||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
|
||||
import type { ApiCloud, ApiPage } from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
||||
|
||||
interface GalleryCloud {
|
||||
id: string
|
||||
user_id: string
|
||||
cloud_type_id: number | null
|
||||
custom_cloud_type: string | null
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
location_name: string | null
|
||||
description: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
@@ -29,8 +28,9 @@ interface GalleryCloud {
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
cloudTypeName: string
|
||||
cloudTypeRarity: CloudType['rarity']
|
||||
cloudTypeRarity: number
|
||||
username: string
|
||||
receivedLikeCount: number
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
@@ -47,8 +47,7 @@ const selectedTypeId = ref<number | 'all'>('all')
|
||||
const searchQuery = ref('')
|
||||
const selectedCloud = ref<GalleryCloud | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const totalCount = ref(0)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / PAGE_SIZE)))
|
||||
const hasNextPage = ref(false)
|
||||
const manageError = ref('')
|
||||
const editModalOpen = ref(false)
|
||||
const editSaving = ref(false)
|
||||
@@ -56,11 +55,7 @@ const editInitialValue = ref<CloudEditFormValue | null>(null)
|
||||
|
||||
let searchTimer: number | null = null
|
||||
|
||||
const rarityMeta = {
|
||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
|
||||
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
|
||||
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
||||
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
|
||||
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
|
||||
|
||||
function formatDateTime(iso: string | null) {
|
||||
if (!iso) return '未知时间'
|
||||
@@ -93,10 +88,8 @@ function toGalleryCloud(row: ApiCloud) {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
cloud_type_id: row.cloud_type_id,
|
||||
custom_cloud_type: row.custom_cloud_type,
|
||||
image_url: row.image_url,
|
||||
thumbnail_url: row.thumbnail_url,
|
||||
location_name: row.location_name,
|
||||
description: row.description,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
@@ -104,9 +97,10 @@ function toGalleryCloud(row: ApiCloud) {
|
||||
created_at: row.created_at,
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
||||
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
||||
cloudTypeName: row.cloud_type_name || '未知',
|
||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
||||
username: row.username || '匿名',
|
||||
receivedLikeCount: row.received_like_count,
|
||||
} satisfies GalleryCloud
|
||||
}
|
||||
|
||||
@@ -116,14 +110,28 @@ async function loadPage(page: number) {
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
||||
if (selectedTypeId.value !== 'all') params.set('type_id', String(selectedTypeId.value))
|
||||
if (normalizedSearch.value) params.set('search', normalizedSearch.value)
|
||||
if (normalizedSearch.value) {
|
||||
params.set('filter', isUserSearch.value ? 'owner' : 'type')
|
||||
params.set('value', normalizedSearch.value)
|
||||
} else if (selectedTypeId.value !== 'all') {
|
||||
const typeName = cloudsStore.cloudTypes.find(type => type.id === selectedTypeId.value)?.name
|
||||
if (typeName) {
|
||||
params.set('filter', 'type')
|
||||
params.set('value', typeName)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await apiRequest<ApiPage<ApiCloud>>(`/clouds?${params}`, { auth: false })
|
||||
totalCount.value = result.total
|
||||
galleryItems.value = result.items.map(toGalleryCloud)
|
||||
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud?${params}`, { auth: false })
|
||||
galleryItems.value = rows.map(toApiCloud).map(toGalleryCloud)
|
||||
hasNextPage.value = rows.length === PAGE_SIZE
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404 && isUserSearch.value) {
|
||||
galleryItems.value = []
|
||||
hasNextPage.value = false
|
||||
currentPage.value = page
|
||||
return
|
||||
}
|
||||
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -131,7 +139,7 @@ async function loadPage(page: number) {
|
||||
}
|
||||
|
||||
async function goToPage(page: number) {
|
||||
if (page < 1 || page > totalPages.value || page === currentPage.value) return
|
||||
if (page < 1 || page === currentPage.value || (page > currentPage.value && !hasNextPage.value)) return
|
||||
await loadPage(page)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
@@ -172,12 +180,10 @@ function openEditModal() {
|
||||
|
||||
manageError.value = ''
|
||||
editInitialValue.value = {
|
||||
cloudCategoryId: selectedCloud.value.cloud_type_id ?? (selectedCloud.value.custom_cloud_type ? 'other' : null),
|
||||
customCloudType: selectedCloud.value.custom_cloud_type || '',
|
||||
cloudCategoryId: selectedCloud.value.cloud_type_id,
|
||||
capturedAt: formatDatetimeLocal(selectedCloud.value.captured_at),
|
||||
latitude: selectedCloud.value.latitude,
|
||||
longitude: selectedCloud.value.longitude,
|
||||
locationName: selectedCloud.value.location_name || '',
|
||||
description: selectedCloud.value.description || '',
|
||||
isPublic: !selectedCloud.value.is_hidden,
|
||||
}
|
||||
@@ -204,18 +210,14 @@ async function submitEditModal(value: CloudEditFormValue) {
|
||||
manageError.value = '请选择云型类别。'
|
||||
return
|
||||
}
|
||||
if (value.cloudCategoryId === 'other' && !value.customCloudType.trim()) {
|
||||
manageError.value = '请输入自定义云型名称。'
|
||||
return
|
||||
}
|
||||
if (!value.capturedAt) {
|
||||
manageError.value = '请选择拍摄时间。'
|
||||
return
|
||||
}
|
||||
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
||||
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
||||
if (hasLat !== hasLng) {
|
||||
manageError.value = '经纬度必须同时填写。'
|
||||
if (!hasLat || !hasLng) {
|
||||
manageError.value = '经纬度为必填项。'
|
||||
return
|
||||
}
|
||||
if (value.description.trim().length > 128) {
|
||||
manageError.value = '图片描述不能超过 128 个字符。'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -225,13 +227,11 @@ async function submitEditModal(value: CloudEditFormValue) {
|
||||
|
||||
try {
|
||||
const updated = await profileStore.updateCloud(authStore.user.id, current.id, {
|
||||
cloud_type_id: value.cloudCategoryId === 'other' ? null : value.cloudCategoryId,
|
||||
custom_cloud_type: value.cloudCategoryId === 'other' ? value.customCloudType.trim() : null,
|
||||
latitude: hasLat ? blurCoordinate(value.latitude as number) : null,
|
||||
longitude: hasLng ? blurCoordinate(value.longitude as number) : null,
|
||||
location_name: value.locationName.trim() || null,
|
||||
description: value.description.trim() || null,
|
||||
captured_at: new Date(value.capturedAt).toISOString(),
|
||||
type_id: value.cloudCategoryId,
|
||||
latitude: blurCoordinate(value.latitude as number),
|
||||
longitude: blurCoordinate(value.longitude as number),
|
||||
description: value.description.trim(),
|
||||
...(value.capturedAt ? { captured_at: new Date(value.capturedAt).toISOString() } : {}),
|
||||
is_hidden: !value.isPublic,
|
||||
})
|
||||
|
||||
@@ -362,7 +362,7 @@ onUnmounted(() => {
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="min-w-0 flex-1 bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400"
|
||||
placeholder="搜索云型名称,或输入 @用户名 查看某个用户的图片"
|
||||
placeholder="输入完整云类型名称,或 @用户名进行精确搜索"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@@ -382,7 +382,7 @@ onUnmounted(() => {
|
||||
正在按上传者搜索:{{ normalizedSearch.slice(1) || '请输入用户名' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
正在匹配云型名称和自定义云型:{{ normalizedSearch }}
|
||||
正在精确匹配云型名称:{{ normalizedSearch }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
@@ -444,7 +444,7 @@ onUnmounted(() => {
|
||||
<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 }}
|
||||
稀有度 {{ cloud.cloudTypeRarity }}
|
||||
</NTag>
|
||||
</div>
|
||||
<p class="mt-2 flex items-center gap-1.5 truncate text-xs text-white/82">
|
||||
@@ -463,7 +463,7 @@ onUnmounted(() => {
|
||||
<NIcon size="14">
|
||||
<Location />
|
||||
</NIcon>
|
||||
<span class="truncate">{{ cloud.location_name || '未填写位置' }}</span>
|
||||
<span class="truncate">{{ formatCoordinate(cloud.latitude) }}, {{ formatCoordinate(cloud.longitude) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,7 +478,7 @@ onUnmounted(() => {
|
||||
</NEmpty>
|
||||
</section>
|
||||
|
||||
<div v-if="totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
|
||||
<div v-if="currentPage > 1 || hasNextPage" class="mt-8 flex items-center justify-center gap-2">
|
||||
<NButton
|
||||
secondary
|
||||
strong
|
||||
@@ -490,28 +490,13 @@ onUnmounted(() => {
|
||||
上一页
|
||||
</NButton>
|
||||
|
||||
<template v-for="page in totalPages" :key="page">
|
||||
<NButton
|
||||
v-if="page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2"
|
||||
secondary
|
||||
strong
|
||||
:class="page === currentPage ? 'oc-panel-button oc-panel-button--sky' : 'oc-panel-button oc-panel-button--neutral'"
|
||||
type="default"
|
||||
@click="goToPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</NButton>
|
||||
<span
|
||||
v-else-if="page === 2 || page === totalPages - 1"
|
||||
class="px-1 text-sm text-slate-400"
|
||||
>...</span>
|
||||
</template>
|
||||
<span class="border border-slate-200 bg-white px-4 py-2 text-sm text-slate-600">第 {{ currentPage }} 页</span>
|
||||
|
||||
<NButton
|
||||
secondary
|
||||
strong
|
||||
type="default"
|
||||
:disabled="currentPage >= totalPages"
|
||||
:disabled="!hasNextPage"
|
||||
class="oc-panel-button oc-panel-button--neutral"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
@@ -519,10 +504,6 @@ onUnmounted(() => {
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div v-if="totalCount > 0" class="mt-2 text-center text-xs text-slate-400">
|
||||
共 {{ totalCount }} 张照片,第 {{ currentPage }} / {{ totalPages }} 页
|
||||
</div>
|
||||
|
||||
<ImageDetailModal
|
||||
v-if="selectedCloud"
|
||||
:open="!!selectedCloud"
|
||||
@@ -531,8 +512,8 @@ onUnmounted(() => {
|
||||
:image-alt="selectedCloud.cloudTypeName"
|
||||
:title="selectedCloud.cloudTypeName"
|
||||
:subtitle="`上传者:${selectedCloud.username}`"
|
||||
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
|
||||
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
|
||||
:badge-label="`稀有度 ${selectedCloud.cloudTypeRarity}`"
|
||||
:badge-class="rarityChip"
|
||||
@close="closeDetail"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
@@ -546,7 +527,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">位置</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
|
||||
</div>
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">模糊化经纬度</p>
|
||||
@@ -556,17 +537,24 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
||||
<p class="oc-section-label">图片说明</p>
|
||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||
</p>
|
||||
</div>
|
||||
<MiniLocationMap
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'user-profile', params: { id: selectedCloud.username } }"
|
||||
class="oc-panel-button oc-panel-button--neutral mt-5 inline-flex"
|
||||
>
|
||||
查看 @{{ selectedCloud.username }} 的公开主页
|
||||
</RouterLink>
|
||||
<MiniLocationMap
|
||||
:latitude="selectedCloud.latitude"
|
||||
:longitude="selectedCloud.longitude"
|
||||
:location-name="selectedCloud.location_name"
|
||||
/>
|
||||
<CloudLikeButton :cloud-id="selectedCloud.id" :count="selectedCloud.receivedLikeCount" />
|
||||
|
||||
<template v-if="selectedCloudIsMine" #actions>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
|
||||
+22
-28
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { apiRequest, toApiCloud } from '@/lib/api'
|
||||
import { loadAMap } from '@/lib/amap'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ApiCloud } from '@/types/api'
|
||||
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import { Adjustments, Calendar, CloudUpload, Minus, Plus, Refresh, Map, Satellite, X } from '@vicons/tabler'
|
||||
|
||||
@@ -16,13 +17,13 @@ interface CloudMarkerData {
|
||||
longitude: number
|
||||
imageUrl: string
|
||||
thumbnailUrl: string | null
|
||||
locationName: string | null
|
||||
description: string | null
|
||||
cloudTypeName: string
|
||||
rarity: 'common' | 'uncommon' | 'rare'
|
||||
rarity: number
|
||||
username: string
|
||||
capturedAt: string
|
||||
createdAt: string
|
||||
receivedLikeCount: number
|
||||
}
|
||||
|
||||
const mapEl = ref<HTMLDivElement>()
|
||||
@@ -66,17 +67,8 @@ let zoomAnimationValue = currentZoom.value
|
||||
let zoomLastFrameTime = 0
|
||||
let zoomHoldStarted = false
|
||||
|
||||
const rarityColors: Record<string, string> = {
|
||||
common: '#cbd5e1',
|
||||
uncommon: '#f59e0b',
|
||||
rare: '#f43f5e',
|
||||
}
|
||||
|
||||
const rarityMeta = {
|
||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
|
||||
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
|
||||
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
||||
} as const
|
||||
const markerBorder = '#64748b'
|
||||
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
|
||||
|
||||
const selectedTimeLabel = computed(() => formatMinuteOfDay(selectedMinuteOfDay.value))
|
||||
const currentMinutePercent = computed(() => Math.min(100, Math.max(0, currentMinuteOfDay.value / 1439 * 100)))
|
||||
@@ -178,7 +170,7 @@ function getMarkerOpacity(cloud: CloudMarkerData) {
|
||||
}
|
||||
|
||||
function bubbleContent(cloud: CloudMarkerData): HTMLElement {
|
||||
const border = rarityColors[cloud.rarity] || rarityColors.common
|
||||
const border = markerBorder
|
||||
const bubbleImage = cloud.thumbnailUrl || cloud.imageUrl
|
||||
const opacity = getMarkerOpacity(cloud) ?? MIN_MARKER_OPACITY
|
||||
|
||||
@@ -199,9 +191,9 @@ function bubbleContent(cloud: CloudMarkerData): HTMLElement {
|
||||
}
|
||||
|
||||
function hoverCardContent(cloud: CloudMarkerData): HTMLElement {
|
||||
const border = rarityColors[cloud.rarity] || rarityColors.common
|
||||
const border = markerBorder
|
||||
const cardImage = cloud.thumbnailUrl || cloud.imageUrl
|
||||
const label = cloud.rarity === 'common' ? '常见' : cloud.rarity === 'uncommon' ? '少见' : '罕见'
|
||||
const label = `稀有度 ${cloud.rarity}`
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.style.cssText = 'background:#fff;border-radius:14px;box-shadow:0 10px 30px rgba(0,0,0,0.18);overflow:hidden;width:200px;font-family:system-ui,-apple-system,sans-serif'
|
||||
@@ -421,25 +413,25 @@ function toCloudMarker(row: ApiCloud): CloudMarkerData {
|
||||
longitude: row.longitude as number,
|
||||
imageUrl: row.image_url,
|
||||
thumbnailUrl: row.thumbnail_url,
|
||||
locationName: row.location_name,
|
||||
description: row.description,
|
||||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
||||
rarity: row.cloud_type_rarity || 'common',
|
||||
cloudTypeName: row.cloud_type_name || '未知',
|
||||
rarity: row.cloud_type_rarity_value ?? 0,
|
||||
username: row.username || '匿名',
|
||||
capturedAt: row.captured_at || row.created_at,
|
||||
createdAt: row.created_at,
|
||||
receivedLikeCount: row.received_like_count,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCloudsByRange(field: 'captured_at' | 'created_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
|
||||
async function fetchCloudsByRange(field: 'captured_at' | 'uploaded_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
|
||||
const params = new URLSearchParams({
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
time_field: field,
|
||||
})
|
||||
try {
|
||||
const rows = await apiRequest<ApiCloud[]>(`/clouds/map?${params}`, { auth: false })
|
||||
return rows.map(toCloudMarker)
|
||||
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud/map?${params}`, { auth: false })
|
||||
return rows.map(toApiCloud).map(toCloudMarker)
|
||||
} catch (error) {
|
||||
statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||
return []
|
||||
@@ -836,8 +828,8 @@ onUnmounted(() => {
|
||||
:image-alt="previewCloud.cloudTypeName"
|
||||
:title="previewCloud.cloudTypeName"
|
||||
:subtitle="`上传者:${previewCloud.username}`"
|
||||
:badge-label="rarityMeta[previewCloud.rarity].label"
|
||||
:badge-class="rarityMeta[previewCloud.rarity].chip"
|
||||
:badge-label="`稀有度 ${previewCloud.rarity}`"
|
||||
:badge-class="rarityChip"
|
||||
@close="previewCloud = null"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
@@ -851,7 +843,9 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">位置</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ previewCloud.locationName || '未填写位置名称' }}</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||
纬度 {{ formatCoordinate(previewCloud.latitude) }} / 经度 {{ formatCoordinate(previewCloud.longitude) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">模糊化经纬度</p>
|
||||
@@ -870,8 +864,8 @@ onUnmounted(() => {
|
||||
<MiniLocationMap
|
||||
:latitude="previewCloud.latitude"
|
||||
:longitude="previewCloud.longitude"
|
||||
:location-name="previewCloud.locationName"
|
||||
/>
|
||||
<CloudLikeButton :cloud-id="previewCloud.id" :count="previewCloud.receivedLikeCount" />
|
||||
</ImageDetailModal>
|
||||
|
||||
<QuickUploadModal
|
||||
|
||||
@@ -8,6 +8,7 @@ const authStore = useAuthStore()
|
||||
const message = useMessage()
|
||||
|
||||
const usernameDraft = ref('')
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmNewPassword = ref('')
|
||||
const error = ref('')
|
||||
@@ -29,8 +30,8 @@ async function saveUsername() {
|
||||
error.value = '昵称不能为空。'
|
||||
return
|
||||
}
|
||||
if (nextUsername.length < 2) {
|
||||
error.value = '昵称至少需要 2 个字符。'
|
||||
if (nextUsername.length > 16) {
|
||||
error.value = '昵称不能超过 16 个字符。'
|
||||
return
|
||||
}
|
||||
if (nextUsername === authStore.profile?.username) {
|
||||
@@ -62,13 +63,18 @@ async function savePassword() {
|
||||
error.value = '两次输入的新密码不一致。'
|
||||
return
|
||||
}
|
||||
if (!currentPassword.value) {
|
||||
error.value = '请输入当前密码。'
|
||||
return
|
||||
}
|
||||
|
||||
passwordSaving.value = true
|
||||
try {
|
||||
await authStore.updatePassword(newPassword.value)
|
||||
await authStore.updatePassword(currentPassword.value, newPassword.value)
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmNewPassword.value = ''
|
||||
success.value = '密码已更新。'
|
||||
success.value = '密码已更新。其他设备上的会话已退出。'
|
||||
message.success('密码已更新')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '密码更新失败'
|
||||
@@ -89,7 +95,7 @@ async function savePassword() {
|
||||
<p class="oc-page-eyebrow">Account Settings</p>
|
||||
<h1 class="oc-page-title">个人资料设置</h1>
|
||||
<p class="oc-page-description">
|
||||
修改公开昵称和登录密码。昵称会显示在画廊、图鉴和地图中,且不能与其他用户重复。
|
||||
修改公开昵称和登录密码。昵称会显示在画廊、百科和地图中,且不能与其他用户重复。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -123,7 +129,7 @@ async function savePassword() {
|
||||
<NInput
|
||||
v-model:value="usernameDraft"
|
||||
placeholder="输入新的昵称"
|
||||
maxlength="32"
|
||||
maxlength="16"
|
||||
show-count
|
||||
/>
|
||||
<NButton
|
||||
@@ -159,6 +165,17 @@ async function savePassword() {
|
||||
aria-label="账户邮箱"
|
||||
readonly
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="currentPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:input-props="{
|
||||
autocomplete: 'current-password',
|
||||
name: 'current-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="当前密码"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="newPassword"
|
||||
type="password"
|
||||
|
||||
@@ -4,13 +4,14 @@ import { RouterLink, useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
|
||||
import { Settings } from '@vicons/tabler'
|
||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||
import CloudLikeButton from '@/components/cloud/CloudLikeButton.vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
import { useProfileStore, type ProfileCloudItem } from '@/stores/profile'
|
||||
import type { CloudType, Profile } from '@/types/database'
|
||||
import type { Profile } from '@/types/database'
|
||||
|
||||
interface TimelineGroup {
|
||||
key: string
|
||||
@@ -34,11 +35,7 @@ const editSaving = ref(false)
|
||||
const manageError = ref('')
|
||||
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
||||
|
||||
const rarityMeta = {
|
||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
|
||||
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
|
||||
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
||||
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
|
||||
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
|
||||
|
||||
const statusMeta = {
|
||||
approved: { label: '已通过', chip: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
|
||||
@@ -46,35 +43,30 @@ const statusMeta = {
|
||||
rejected: { label: '未通过', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
||||
} satisfies Record<ProfileCloudItem['status'], { label: string; chip: string }>
|
||||
|
||||
const viewedUserId = computed(() => {
|
||||
const routeUserId = typeof route.params.id === 'string' ? route.params.id : null
|
||||
return routeUserId || authStore.user?.id || null
|
||||
const viewedIdentifier = computed(() => {
|
||||
const routeUsername = typeof route.params.id === 'string' ? route.params.id : null
|
||||
return routeUsername || authStore.user?.id || null
|
||||
})
|
||||
|
||||
const isOwnProfile = computed(() => {
|
||||
return !!viewedUserId.value && viewedUserId.value === authStore.user?.id
|
||||
})
|
||||
const isOwnProfile = computed(() => typeof route.params.id !== 'string')
|
||||
|
||||
const loading = computed(() => profileStore.isLoading(viewedUserId.value, isOwnProfile.value))
|
||||
const loading = computed(() => profileStore.isLoading(viewedIdentifier.value, isOwnProfile.value))
|
||||
const errorMsg = computed(() => {
|
||||
if (!viewedUserId.value) return '未找到要查看的用户。'
|
||||
return profileStore.getError(viewedUserId.value, isOwnProfile.value)
|
||||
if (!viewedIdentifier.value) return '未找到要查看的用户。'
|
||||
return profileStore.getError(viewedIdentifier.value, isOwnProfile.value)
|
||||
})
|
||||
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedUserId.value))
|
||||
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedUserId.value, isOwnProfile.value))
|
||||
const receivedLikes = computed(() => profileStore.getLikeStats(viewedUserId.value)?.total_likes || 0)
|
||||
const profileData = computed<Profile | null>(() => isOwnProfile.value
|
||||
? authStore.profile
|
||||
: profileStore.getProfile(viewedIdentifier.value))
|
||||
const profileImageFailed = ref(false)
|
||||
watch(() => profileData.value?.image, () => { profileImageFailed.value = false })
|
||||
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedIdentifier.value, isOwnProfile.value))
|
||||
const receivedLikes = computed(() => isOwnProfile.value ? authStore.user?.received_like_count || 0 : 0)
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
|
||||
})
|
||||
|
||||
// const profileSubtitle = computed(() => {
|
||||
// if (isOwnProfile.value) {
|
||||
// return '回看你的云图记录、收集进度和拍摄节奏。'
|
||||
// }
|
||||
// return '公开展示的云图记录会按时间顺序陈列在这里。'
|
||||
// })
|
||||
|
||||
const totalShots = computed(() => clouds.value.length)
|
||||
|
||||
const approvedShots = computed(() => {
|
||||
@@ -224,7 +216,9 @@ function formatCoordinate(value: number | null) {
|
||||
}
|
||||
|
||||
function formatCloudTime(item: ProfileCloudItem) {
|
||||
return formatDateTime(item.captured_at || item.created_at)
|
||||
return item.captured_at
|
||||
? `拍摄于 ${formatDateTime(item.captured_at)}`
|
||||
: `上传于 ${formatDateTime(item.created_at)}`
|
||||
}
|
||||
|
||||
function getProfileInitial(profile: Profile | null) {
|
||||
@@ -274,12 +268,10 @@ function formatDatetimeLocal(iso: string | null) {
|
||||
function openEditModal(cloud: ProfileCloudItem) {
|
||||
manageError.value = ''
|
||||
editInitialValue.value = {
|
||||
cloudCategoryId: cloud.cloud_type_id ?? (cloud.custom_cloud_type ? 'other' : null),
|
||||
customCloudType: cloud.custom_cloud_type || '',
|
||||
cloudCategoryId: cloud.cloud_type_id,
|
||||
capturedAt: formatDatetimeLocal(cloud.captured_at),
|
||||
latitude: cloud.latitude,
|
||||
longitude: cloud.longitude,
|
||||
locationName: cloud.location_name || '',
|
||||
description: cloud.description || '',
|
||||
isPublic: !cloud.is_hidden,
|
||||
}
|
||||
@@ -297,24 +289,20 @@ function blurCoordinate(value: number): number {
|
||||
}
|
||||
|
||||
async function submitEditModal(value: CloudEditFormValue) {
|
||||
if (!selectedCloud.value || !viewedUserId.value) return
|
||||
if (!selectedCloud.value || !viewedIdentifier.value) return
|
||||
|
||||
if (!value.cloudCategoryId) {
|
||||
manageError.value = '请选择云型类别。'
|
||||
return
|
||||
}
|
||||
if (value.cloudCategoryId === 'other' && !value.customCloudType.trim()) {
|
||||
manageError.value = '请输入自定义云型名称。'
|
||||
return
|
||||
}
|
||||
if (!value.capturedAt) {
|
||||
manageError.value = '请选择拍摄时间。'
|
||||
return
|
||||
}
|
||||
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
||||
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
||||
if (hasLat !== hasLng) {
|
||||
manageError.value = '经纬度必须同时填写。'
|
||||
if (!hasLat || !hasLng) {
|
||||
manageError.value = '经纬度为必填项。'
|
||||
return
|
||||
}
|
||||
if (value.description.trim().length > 128) {
|
||||
manageError.value = '图片描述不能超过 128 个字符。'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -322,14 +310,12 @@ async function submitEditModal(value: CloudEditFormValue) {
|
||||
manageError.value = ''
|
||||
|
||||
try {
|
||||
const updated = await profileStore.updateCloud(viewedUserId.value, selectedCloud.value.id, {
|
||||
cloud_type_id: value.cloudCategoryId === 'other' ? null : value.cloudCategoryId,
|
||||
custom_cloud_type: value.cloudCategoryId === 'other' ? value.customCloudType.trim() : null,
|
||||
latitude: hasLat ? blurCoordinate(value.latitude as number) : null,
|
||||
longitude: hasLng ? blurCoordinate(value.longitude as number) : null,
|
||||
location_name: value.locationName.trim() || null,
|
||||
description: value.description.trim() || null,
|
||||
captured_at: new Date(value.capturedAt).toISOString(),
|
||||
const updated = await profileStore.updateCloud(viewedIdentifier.value, selectedCloud.value.id, {
|
||||
type_id: value.cloudCategoryId,
|
||||
latitude: blurCoordinate(value.latitude as number),
|
||||
longitude: blurCoordinate(value.longitude as number),
|
||||
description: value.description.trim(),
|
||||
...(value.capturedAt ? { captured_at: new Date(value.capturedAt).toISOString() } : {}),
|
||||
is_hidden: !value.isPublic,
|
||||
})
|
||||
selectedCloud.value = updated
|
||||
@@ -342,13 +328,13 @@ async function submitEditModal(value: CloudEditFormValue) {
|
||||
}
|
||||
|
||||
async function toggleSelectedCloudPrivacy() {
|
||||
if (!selectedCloud.value || !viewedUserId.value) return
|
||||
if (!selectedCloud.value || !viewedIdentifier.value) return
|
||||
|
||||
manageError.value = ''
|
||||
const nextHidden = !selectedCloud.value.is_hidden
|
||||
|
||||
try {
|
||||
await profileStore.updateCloudVisibility(viewedUserId.value, selectedCloud.value.id, nextHidden)
|
||||
await profileStore.updateCloudVisibility(viewedIdentifier.value, selectedCloud.value.id, nextHidden)
|
||||
selectedCloud.value = { ...selectedCloud.value, is_hidden: nextHidden }
|
||||
} catch (error) {
|
||||
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
||||
@@ -356,14 +342,14 @@ async function toggleSelectedCloudPrivacy() {
|
||||
}
|
||||
|
||||
async function deleteCloudByIds(cloudIds: string[]) {
|
||||
if (!viewedUserId.value || !cloudIds.length) return
|
||||
if (!viewedIdentifier.value || !cloudIds.length) return
|
||||
const confirmed = window.confirm(`确定删除 ${cloudIds.length} 张图片吗?删除后无法在页面中恢复。`)
|
||||
if (!confirmed) return
|
||||
|
||||
manageError.value = ''
|
||||
|
||||
try {
|
||||
const deletedCount = await profileStore.deleteClouds(viewedUserId.value, cloudIds)
|
||||
const deletedCount = await profileStore.deleteClouds(viewedIdentifier.value, cloudIds)
|
||||
if (selectedCloud.value && cloudIds.includes(selectedCloud.value.id)) {
|
||||
selectedCloud.value = null
|
||||
closeEditModal()
|
||||
@@ -390,13 +376,13 @@ async function loadProfilePage(force = false) {
|
||||
clearCloudSelection()
|
||||
selectionMode.value = false
|
||||
|
||||
const targetUserId = viewedUserId.value
|
||||
const targetIdentifier = viewedIdentifier.value
|
||||
|
||||
if (!targetUserId) {
|
||||
if (!targetIdentifier) {
|
||||
return
|
||||
}
|
||||
|
||||
await profileStore.fetchProfilePage(targetUserId, isOwnProfile.value, force)
|
||||
await profileStore.fetchProfilePage(targetIdentifier, isOwnProfile.value, force)
|
||||
|
||||
if (isOwnProfile.value) {
|
||||
await cloudsStore.fetchCloudTypes()
|
||||
@@ -458,10 +444,11 @@ watch(selectedUploadDate, async newValue => {
|
||||
class="profile-avatar flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-3xl font-bold text-slate-900"
|
||||
>
|
||||
<img
|
||||
v-if="profileData?.avatar_url"
|
||||
:src="profileData.avatar_url"
|
||||
v-if="profileData?.image && !profileImageFailed"
|
||||
:src="profileData.image"
|
||||
:alt="profileData.username"
|
||||
class="h-full w-full object-cover"
|
||||
@error="profileImageFailed = true"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ getProfileInitial(profileData) }}
|
||||
@@ -491,7 +478,7 @@ watch(selectedUploadDate, async newValue => {
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
|
||||
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
|
||||
<span v-if="isOwnProfile">注册于 {{ formatDate(profileData?.created_at || null) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -564,7 +551,7 @@ watch(selectedUploadDate, async newValue => {
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-slate-900">天空日志</h2>
|
||||
<p class="oc-field-help">
|
||||
按拍摄时间倒序展示,并按月份分组。热力图统计按上传日期计算。
|
||||
按拍摄时间展示;未记录拍摄时间时回退为上传时间。热力图按上传日期计算。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -669,8 +656,8 @@ watch(selectedUploadDate, async newValue => {
|
||||
/>
|
||||
选择
|
||||
</label>
|
||||
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[item.cloudTypeRarity].chip">
|
||||
{{ rarityMeta[item.cloudTypeRarity].label }}
|
||||
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityChip">
|
||||
稀有度 {{ item.cloudTypeRarity }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isOwnProfile"
|
||||
@@ -695,7 +682,7 @@ watch(selectedUploadDate, async newValue => {
|
||||
|
||||
<div class="p-5">
|
||||
<p class="truncate text-sm font-medium text-slate-900">
|
||||
{{ item.location_name || '未填写位置名称' }}
|
||||
纬度 {{ formatCoordinate(item.latitude) }} / 经度 {{ formatCoordinate(item.longitude) }}
|
||||
</p>
|
||||
<p class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500">
|
||||
{{ item.description || '没有留下额外说明。' }}
|
||||
@@ -707,7 +694,7 @@ watch(selectedUploadDate, async newValue => {
|
||||
</div>
|
||||
|
||||
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10">
|
||||
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '这位用户还没有公开云图')">
|
||||
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '该用户暂无公开云图')">
|
||||
<template #extra>
|
||||
<RouterLink
|
||||
v-if="isOwnProfile && !selectedUploadDate"
|
||||
@@ -731,9 +718,9 @@ watch(selectedUploadDate, async newValue => {
|
||||
:thumbnail-url="selectedCloud.thumbnail_url"
|
||||
:image-alt="selectedCloud.cloudTypeName"
|
||||
:title="selectedCloud.cloudTypeName"
|
||||
:subtitle="`拍摄时间:${formatCloudTime(selectedCloud)}`"
|
||||
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
|
||||
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
|
||||
:subtitle="formatCloudTime(selectedCloud)"
|
||||
:badge-label="`稀有度 ${selectedCloud.cloudTypeRarity}`"
|
||||
:badge-class="rarityChip"
|
||||
@close="selectedCloud = null"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
@@ -747,7 +734,7 @@ watch(selectedUploadDate, async newValue => {
|
||||
</div>
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">位置</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
|
||||
</div>
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">模糊化经纬度</p>
|
||||
@@ -775,7 +762,11 @@ watch(selectedUploadDate, async newValue => {
|
||||
<MiniLocationMap
|
||||
:latitude="selectedCloud.latitude"
|
||||
:longitude="selectedCloud.longitude"
|
||||
:location-name="selectedCloud.location_name"
|
||||
/>
|
||||
<CloudLikeButton
|
||||
v-if="selectedCloud.status === 'approved' && !selectedCloud.is_hidden"
|
||||
:cloud-id="selectedCloud.id"
|
||||
:count="selectedCloud.receivedLikeCount"
|
||||
/>
|
||||
|
||||
<template v-if="isOwnProfile" #actions>
|
||||
|
||||
+19
-111
@@ -1,23 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { NAlert, NButton, NCard, NIcon, NProgress, NResult, NTag } from 'naive-ui'
|
||||
import { NAlert, NButton, NCard, NIcon, NProgress, NResult } from 'naive-ui'
|
||||
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { downloadCloudBadgeCard } from '@/lib/cloudBadges'
|
||||
import { useUpload } from '@/composables/useUpload'
|
||||
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const cloudsStore = useCloudsStore()
|
||||
const { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateAll, uploadAll } = useUpload()
|
||||
|
||||
const activeId = ref<string | null>(null)
|
||||
const dragOver = ref(false)
|
||||
const successMsg = ref(false)
|
||||
const unlockedBadges = ref<Awaited<ReturnType<typeof uploadAll>>['unlockedBadges']>([])
|
||||
const errorMsg = ref('')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const mapPickerOpen = ref(false)
|
||||
@@ -87,10 +83,6 @@ function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
||||
closeMapPicker()
|
||||
}
|
||||
|
||||
function clearCoordinates() {
|
||||
updateActiveCoordinates(null, null)
|
||||
}
|
||||
|
||||
function updateActivePublicState(isPublic: boolean) {
|
||||
if (!activeItem.value) return
|
||||
activeItem.value.isHidden = !isPublic
|
||||
@@ -112,43 +104,17 @@ async function handleSubmit() {
|
||||
|
||||
const result = await uploadAll()
|
||||
if (result.ok) {
|
||||
unlockedBadges.value = result.unlockedBadges
|
||||
successMsg.value = true
|
||||
} else {
|
||||
errorMsg.value = '上传失败,请稍后重试'
|
||||
errorMsg.value = result.errorMessage || '上传失败,请稍后重试'
|
||||
}
|
||||
}
|
||||
|
||||
function resetAfterSuccess() {
|
||||
successMsg.value = false
|
||||
unlockedBadges.value = []
|
||||
errorMsg.value = ''
|
||||
}
|
||||
|
||||
async function saveBadge(badge: NonNullable<typeof unlockedBadges.value[number]>) {
|
||||
await downloadCloudBadgeCard({
|
||||
cloudName: badge.cloudName,
|
||||
cloudNameEn: badge.cloudNameEn,
|
||||
unlockedAt: badge.unlockedAt,
|
||||
username: authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户',
|
||||
rarity: badge.rarity,
|
||||
})
|
||||
}
|
||||
|
||||
function rarityLabel(rarity: NonNullable<typeof unlockedBadges.value[number]>['rarity']) {
|
||||
if (rarity === 'common') return '常见'
|
||||
if (rarity === 'uncommon') return '少见'
|
||||
return '罕见'
|
||||
}
|
||||
|
||||
function formatUnlockedAt(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function onLatInput(e: Event) {
|
||||
const val = parseFloat((e.target as HTMLInputElement).value)
|
||||
if (activeItem.value) {
|
||||
@@ -168,6 +134,7 @@ function onLngInput(e: Event) {
|
||||
}
|
||||
|
||||
function formatDatetimeLocal(iso: string): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
@@ -175,8 +142,8 @@ function formatDatetimeLocal(iso: string): string {
|
||||
|
||||
function onCapturedAtChange(e: Event) {
|
||||
const val = (e.target as HTMLInputElement).value
|
||||
if (activeItem.value && val) {
|
||||
activeItem.value.capturedAt = new Date(val).toISOString()
|
||||
if (activeItem.value) {
|
||||
activeItem.value.capturedAt = val ? new Date(val).toISOString() : ''
|
||||
if (activeItem.value.errors.capturedAt) {
|
||||
delete activeItem.value.errors.capturedAt
|
||||
}
|
||||
@@ -186,9 +153,7 @@ function onCapturedAtChange(e: Event) {
|
||||
function onCategoryChange(e: Event) {
|
||||
const val = (e.target as HTMLSelectElement).value
|
||||
if (!activeItem.value) return
|
||||
if (val === 'other') {
|
||||
activeItem.value.cloudCategoryId = 'other'
|
||||
} else if (val === '') {
|
||||
if (val === '') {
|
||||
activeItem.value.cloudCategoryId = null
|
||||
} else {
|
||||
activeItem.value.cloudCategoryId = Number(val)
|
||||
@@ -209,48 +174,21 @@ onUnmounted(() => {
|
||||
<p class="oc-page-eyebrow">Cloud Upload</p>
|
||||
<h1 class="oc-page-title">上传云图</h1>
|
||||
<p class="oc-page-description">
|
||||
批量整理你的云图记录。类别和拍摄时间必填,经纬度既可以手动输入,也可以在迷你地图里点选回填。
|
||||
批量整理你的云图记录。类别和经纬度必填;拍摄时间可留空,也可以从图片 EXIF 自动读取。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="oc-container oc-container--content oc-page-content">
|
||||
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
|
||||
<input ref="fileInput" type="file" accept="image/jpeg,image/png,image/webp" 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">
|
||||
<NResult status="success" title="已提交审核" />
|
||||
|
||||
<div v-if="unlockedBadges.length" class="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<NCard
|
||||
v-for="badge in unlockedBadges"
|
||||
:key="badge.cloudTypeId"
|
||||
class="oc-panel-card border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)]"
|
||||
>
|
||||
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white">
|
||||
{{ badge.cloudName.slice(0, 1) }}
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-2xl font-bold text-slate-900">{{ badge.cloudName }}</h3>
|
||||
<NTag :bordered="false" type="warning">
|
||||
{{ rarityLabel(badge.rarity) }}
|
||||
</NTag>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ badge.cloudNameEn }}</p>
|
||||
<p class="mt-4 text-sm text-slate-600">解锁时间:{{ formatUnlockedAt(badge.unlockedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3">
|
||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky flex-1" @click="saveBadge(badge)">保存分享卡片</NButton>
|
||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral flex-1" @click="router.push(`/encyclopedia/${badge.cloudTypeId}`)">查看详情</NButton>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="resetAfterSuccess">继续上传</NButton>
|
||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky" @click="router.push('/encyclopedia')">前往图鉴</NButton>
|
||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky" @click="router.push('/encyclopedia')">前往百科</NButton>
|
||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="router.push('/profile')">返回个人主页</NButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,7 +221,7 @@ onUnmounted(() => {
|
||||
>
|
||||
<NIcon size="48" class="mb-4 text-sky-600"><CloudUpload /></NIcon>
|
||||
<p class="mb-1 text-lg font-medium text-slate-700">点击或拖拽图片到此处</p>
|
||||
<p class="text-sm text-slate-400">支持 JPG、PNG 格式,可一次选择多张</p>
|
||||
<p class="text-sm text-slate-400">支持 JPEG、PNG、WebP,单张不超过 20MB,可一次选择多张</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
@@ -341,27 +279,16 @@ onUnmounted(() => {
|
||||
>
|
||||
<option value="" disabled>请选择</option>
|
||||
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
|
||||
{{ ct.name }}({{ ct.name_en }})
|
||||
{{ ct.name }}{{ ct.genus ? '(' + ct.genus + ')' : '' }}
|
||||
</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="oc-field-control"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="activeItem.errors.cloudCategory" class="oc-field-error">{{ activeItem.errors.cloudCategory }}</p>
|
||||
<p v-if="activeItem.errors.file" class="oc-field-error">{{ activeItem.errors.file }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 拍摄时间 * -->
|
||||
<!-- 拍摄时间 -->
|
||||
<div>
|
||||
<label class="oc-field-label">
|
||||
拍摄时间 <span class="oc-field-required">*</span>
|
||||
</label>
|
||||
<label class="oc-field-label">拍摄时间</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
||||
@@ -371,19 +298,9 @@ onUnmounted(() => {
|
||||
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">{{ activeItem.errors.capturedAt }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 经纬度 -->
|
||||
<!-- 经纬度 * -->
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between gap-3">
|
||||
<label class="oc-field-label mb-0">经纬度</label>
|
||||
<button
|
||||
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
|
||||
type="button"
|
||||
@click="clearCoordinates"
|
||||
class="oc-text-button oc-text-button--muted text-xs"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<label class="oc-field-label">经纬度 <span class="oc-field-required">*</span></label>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||
<div>
|
||||
<input
|
||||
@@ -419,18 +336,7 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-400">选填,可手动输入,或点击右侧小地图图标选择</p>
|
||||
</div>
|
||||
|
||||
<!-- 位置名称 -->
|
||||
<div>
|
||||
<label class="oc-field-label">位置名称</label>
|
||||
<input
|
||||
v-model="activeItem.locationName"
|
||||
type="text"
|
||||
placeholder="如:北京、成都"
|
||||
class="oc-field-control"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-slate-400">必填,可手动输入,或点击右侧地图图标选择</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片描述 -->
|
||||
@@ -439,9 +345,11 @@ onUnmounted(() => {
|
||||
<textarea
|
||||
v-model="activeItem.description"
|
||||
rows="3"
|
||||
maxlength="128"
|
||||
placeholder="描述一下这张图片..."
|
||||
class="oc-field-control resize-none"
|
||||
></textarea>
|
||||
<p v-if="activeItem.errors.description" class="oc-field-error">{{ activeItem.errors.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 公开状态 -->
|
||||
|
||||
Reference in New Issue
Block a user