From 015e0dd2276699658c0e9852dac3873fd83865c7 Mon Sep 17 00:00:00 2001 From: Mplan Date: Thu, 27 Aug 2026 06:19:17 +0800 Subject: [PATCH 1/5] feat(admin): add cloud type management --- API.md | 76 ++++++- src/stores/clouds.ts | 6 +- src/stores/encyclopedia.ts | 6 + src/types/api.ts | 4 + src/views/admin/AdminView.vue | 365 +++++++++++++++++++++++++++++++++- 5 files changed, 448 insertions(+), 9 deletions(-) diff --git a/API.md b/API.md index 986c55f..e0d5dc8 100644 --- a/API.md +++ b/API.md @@ -1148,7 +1148,77 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose > ⚠️ **假端点**(2.3 第 4 条):通过 zod 校验后**原样回显请求体(含明文密码)**,不创建任何用户。响应为 `200 { name, email, password, role }`。请勿接入,更不要在任何持久化日志中记录其响应。 -### 8.6 `GET /admin/clouds` — 全状态云图列表 +### 8.6 `POST /admin/cloudtypes` — 添加云类型 + +- **认证**:admin + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `name` | string | 是 | 去除首尾空白后非空 | 云类型名称 | +| `genus` | string\|null | 否 | 默认 `null` | 属;传 `null` 表示未设置 | +| `icon_id` | uuid\|null | 否 | 默认 `null` | 图标 ID;传 `null` 表示未设置 | +| `rarity` | int | 是 | — | 稀有度 | +| `description` | string | 是 | 最长 128 字符 | 描述,可为空字符串 | + +```json +{ + "name": "积云", + "genus": "Cumulus", + "icon_id": null, + "rarity": 1, + "description": "底部平坦、顶部蓬松的白色云块" +} +``` + +**成功响应**:`201` + +```json +{ + "status_code": 201, + "message": "云类型添加成功", + "cloud_type_id": 12 +} +``` + +云类型 ID 由服务端生成;并发添加不会分配重复 ID。参数校验失败返回 400,数据库异常返回 500。 + +### 8.7 `PATCH /admin/cloudtypes/:cloud_type_id` — 修改云类型 + +- **认证**:admin + +**路径参数**:`cloud_type_id`(int)。请求体字段与 8.6 相同,但均为可选;至少须提供一个字段。仅提交的字段会被修改,`genus` 和 `icon_id` 可显式传 `null` 清空。 + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "云类型修改成功" +} +``` + +**错误**:参数或空请求体无效返回 400;云类型不存在返回 404 `云类型不存在`;数据库异常返回 500。 + +### 8.8 `DELETE /admin/cloudtypes/:cloud_type_id` — 删除云类型 + +- **认证**:admin + +**路径参数**:`cloud_type_id`(int)。 + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "云类型删除成功" +} +``` + +**错误**:参数无效返回 400;云类型不存在返回 404 `云类型不存在`;仍有云图使用该类型时返回 409 `云类型仍被云图使用,无法删除`;其他数据库异常返回 500。 + +### 8.9 `GET /admin/clouds` — 全状态云图列表 - **认证**:admin;可见任意状态与隐藏的云图 @@ -1167,7 +1237,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose | 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 | | 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | -### 8.7 `POST /admin/clouds/review` — 批量审核云图 +### 8.10 `POST /admin/clouds/review` — 批量审核云图 - **认证**:admin @@ -1205,7 +1275,7 @@ token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose **注意事项**:**部分成功语义**——消息中的 `N` 只统计状态**实际发生变化**的云图;已是目标状态的与不存在的一律静默跳过。审核只改变审核状态,不影响 `is_hidden` 与点赞数据。`pending ↔ approved/rejected`、`approved ↔ rejected` 均可。 -### 8.8 `DELETE /admin/clouds/:cloud_id` — 删除云图 +### 8.11 `DELETE /admin/clouds/:cloud_id` — 删除云图 - **认证**:admin diff --git a/src/stores/clouds.ts b/src/stores/clouds.ts index 04ef585..ac49833 100644 --- a/src/stores/clouds.ts +++ b/src/stores/clouds.ts @@ -19,5 +19,9 @@ export const useCloudsStore = defineStore('clouds', () => { } } - return { cloudTypes, loading, fetchCloudTypes } + function setCloudTypes(types: CloudType[]) { + cloudTypes.value = types + } + + return { cloudTypes, loading, fetchCloudTypes, setCloudTypes } }) diff --git a/src/stores/encyclopedia.ts b/src/stores/encyclopedia.ts index bff48dc..fd8bd1c 100644 --- a/src/stores/encyclopedia.ts +++ b/src/stores/encyclopedia.ts @@ -21,9 +21,15 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => { } } + function setCloudTypes(types: CloudType[]) { + cloudTypes.value = types + cloudTypesLoaded.value = true + } + return { cloudTypes, loadingCloudTypes, fetchCloudTypes, + setCloudTypes, } }) diff --git a/src/types/api.ts b/src/types/api.ts index 9a96902..34478b0 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -60,6 +60,10 @@ 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 { diff --git a/src/views/admin/AdminView.vue b/src/views/admin/AdminView.vue index f17eafb..848b0e6 100644 --- a/src/views/admin/AdminView.vue +++ b/src/views/admin/AdminView.vue @@ -5,13 +5,16 @@ import { Check, ChevronUp, ClipboardCheck, + Cloud, Dashboard, + Edit, Home, Key, LayoutSidebarLeftCollapse, LayoutSidebarLeftExpand, Logout, Photo, + Plus, Refresh, Settings, Shield, @@ -23,14 +26,32 @@ import { import { useRouter } from 'vue-router' import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue' import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue' -import { apiRequest, toApiCloud, toAuthUser } from '@/lib/api' +import { apiRequest, toApiCloud, toAuthUser, toCloudType } from '@/lib/api' import { useAuthStore } from '@/stores/auth' -import type { ActionResponse, ApiCloud, AuthUser, BackendCloudInfo, BackendUserProfile } from '@/types/api' +import { useCloudsStore } from '@/stores/clouds' +import { useEncyclopediaStore } from '@/stores/encyclopedia' +import type { + ActionResponse, + ApiCloud, + AuthUser, + BackendCloudInfo, + BackendCloudType, + BackendUserProfile, + CloudTypeCreateResponse, +} from '@/types/api' -type AdminTab = 'dashboard' | 'review' | 'users' | 'images' +type AdminTab = 'dashboard' | 'review' | 'users' | 'cloud-types' | 'images' type CloudStatus = 'pending' | 'approved' | 'rejected' type ImageFilter = 'all' | CloudStatus +interface CloudTypeForm { + name: string + genus: string + iconId: string + rarity: string + description: string +} + interface AdminCloud { id: string user_id: string @@ -60,6 +81,8 @@ interface DashboardStats { } const authStore = useAuthStore() +const cloudsStore = useCloudsStore() +const encyclopediaStore = useEncyclopediaStore() const message = useMessage() const router = useRouter() @@ -80,6 +103,7 @@ const dashboardStats = ref({ }) const users = ref([]) const images = ref([]) +const cloudTypes = ref([]) const selectedReviewIds = ref>(new Set()) const activeReviewId = ref(null) const selectedImageIds = ref>(new Set()) @@ -89,6 +113,10 @@ const passwordTarget = ref(null) const newPassword = ref('') const confirmPassword = ref('') const passwordError = ref('') +const cloudTypeEditorOpen = ref(false) +const editingCloudType = ref(null) +const cloudTypeForm = ref(emptyCloudTypeForm()) +const cloudTypeFormError = ref('') const statusMeta = { pending: { label: '待审核', chip: 'border-amber-200 bg-amber-100 text-amber-700' }, @@ -100,6 +128,7 @@ const tabs = [ { key: 'dashboard', label: '数据看板', description: '运行概览与内容分布', icon: Dashboard }, { key: 'review', label: '内容审核', description: '处理待审核上传', icon: ClipboardCheck }, { key: 'users', label: '用户管理', description: '角色与登录密码', icon: Users }, + { key: 'cloud-types', label: '云类型管理', description: '分类资料与稀有度', icon: Cloud }, { key: 'images', label: '图片管理', description: '内容审核与删除', icon: Photo }, ] satisfies Array<{ key: AdminTab; label: string; description: string; icon: Component }> @@ -191,7 +220,13 @@ function toAdminCloud(row: ApiCloud): AdminCloud { function formatDateTime(iso: string | null) { if (!iso) return '未知时间' - return new Date(iso).toLocaleString('zh-CN', { + const compatibleIso = (iso.includes('T') ? iso : iso.replace(' ', 'T')) + .replace(/\.(\d{3})\d+/, '.$1') + .replace(/([+-]\d{2})$/, '$1:00') + const date = new Date(compatibleIso) + if (Number.isNaN(date.getTime())) return '未知时间' + + return date.toLocaleString('zh-CN', { year: 'numeric', month: 'numeric', day: 'numeric', @@ -253,12 +288,21 @@ async function fetchImages() { selectedImageIds.value = new Set([...selectedImageIds.value].filter(id => images.value.some(item => item.id === id))) } +async function fetchCloudTypes() { + const rows = await apiRequest('/info/cloudtype', { auth: false }) + cloudTypes.value = rows + + const normalized = rows.map(toCloudType) + cloudsStore.setCloudTypes(normalized) + encyclopediaStore.setCloudTypes(normalized) +} + async function loadAdminData() { loading.value = true loadError.value = '' try { - await Promise.all([fetchUsers(), fetchImages()]) + await Promise.all([fetchUsers(), fetchImages(), fetchCloudTypes()]) syncDashboardStats() } catch (error) { loadError.value = error instanceof Error ? error.message : '管理后台加载失败' @@ -468,6 +512,131 @@ async function resetUserPassword() { } } +function emptyCloudTypeForm(): CloudTypeForm { + return { + name: '', + genus: '', + iconId: '', + rarity: '1', + description: '', + } +} + +function openCloudTypeEditor(cloudType?: BackendCloudType) { + editingCloudType.value = cloudType ?? null + cloudTypeForm.value = cloudType + ? { + name: cloudType.name, + genus: cloudType.genus ?? '', + iconId: cloudType.icon_id ?? '', + rarity: String(cloudType.rarity), + description: cloudType.description ?? '', + } + : emptyCloudTypeForm() + cloudTypeFormError.value = '' + cloudTypeEditorOpen.value = true +} + +function closeCloudTypeEditor() { + if (actionLoading.value) return + cloudTypeEditorOpen.value = false + editingCloudType.value = null + cloudTypeFormError.value = '' +} + +function cloudTypePayload() { + const name = cloudTypeForm.value.name.trim() + const genus = cloudTypeForm.value.genus.trim() + const iconId = cloudTypeForm.value.iconId.trim() + const description = cloudTypeForm.value.description + const rarity = Number(cloudTypeForm.value.rarity) + + if (!name) { + cloudTypeFormError.value = '请输入云类型名称。' + return null + } + if (!Number.isInteger(rarity)) { + cloudTypeFormError.value = '稀有度必须是整数。' + return null + } + if (description.length > 128) { + cloudTypeFormError.value = '描述最多可填写 128 个字符。' + return null + } + if (iconId && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(iconId)) { + cloudTypeFormError.value = '图标 ID 必须是有效的 UUID。' + return null + } + + return { + name, + genus: genus || null, + icon_id: iconId || null, + rarity, + description, + } +} + +async function saveCloudType() { + cloudTypeFormError.value = '' + const payload = cloudTypePayload() + if (!payload) return + + actionLoading.value = true + loadError.value = '' + try { + if (editingCloudType.value) { + await apiRequest(`/admin/cloudtypes/${editingCloudType.value.id}`, { + method: 'PATCH', + body: payload, + }) + message.success('云类型已更新') + } else { + await apiRequest('/admin/cloudtypes', { + method: 'POST', + body: payload, + }) + message.success('云类型已添加') + } + + await fetchCloudTypes() + cloudTypeEditorOpen.value = false + editingCloudType.value = null + } catch (error) { + cloudTypeFormError.value = getErrorMessage(error, editingCloudType.value ? '云类型更新失败' : '云类型添加失败') + } finally { + actionLoading.value = false + } +} + +function cloudTypeUsageCount(cloudTypeId: number) { + return images.value.filter(image => image.cloud_type_id === cloudTypeId).length +} + +async function deleteCloudType(cloudType: BackendCloudType) { + const usageCount = cloudTypeUsageCount(cloudType.id) + if (usageCount > 0) { + message.warning(`“${cloudType.name}”仍被 ${usageCount} 张云图使用,无法删除`) + return + } + + if (!window.confirm(`确定删除云类型“${cloudType.name}”吗?删除后无法在页面中恢复。`)) return + + actionLoading.value = true + loadError.value = '' + try { + await apiRequest(`/admin/cloudtypes/${cloudType.id}`, { method: 'DELETE' }) + await fetchCloudTypes() + message.success('云类型已删除') + } catch (error) { + const text = getErrorMessage(error, '云类型删除失败') + loadError.value = text + message.error(text) + } finally { + actionLoading.value = false + } +} + function toggleSidebar() { accountMenuOpen.value = false sidebarCollapsed.value = !sidebarCollapsed.value @@ -1167,6 +1336,103 @@ onMounted(loadAdminData) +
+
+
+ +

云类型管理

+

共 {{ cloudTypes.length }} 个类型,可维护名称、云属、稀有度和百科描述。

+
+ + + 添加云类型 + +
+ +
+
+
+
+

Type {{ cloudType.id }}

+

{{ cloudType.name }}

+

{{ cloudType.genus || '未设置云属' }}

+
+ + 稀有度 {{ cloudType.rarity }} + +
+ +
+

+ {{ cloudType.description || '暂无类型描述。' }} +

+
+
+
关联云图
+
{{ cloudTypeUsageCount(cloudType.id) }} 张
+
+
+
创建时间
+
{{ formatDateTime(cloudType.created_at) }}
+
+
+
+

图标 ID

+

{{ cloudType.icon_id }}

+
+
+ +
+ + + 编辑 + + + + 删除 + +
+
+
+ +
+ + + +
+
+
@@ -1274,6 +1540,95 @@ onMounted(loadAdminData)
+ +
+
+
+
+ +

+ {{ editingCloudType ? '编辑云类型' : '添加云类型' }} +

+

+ {{ editingCloudType ? `正在编辑类型 #${editingCloudType.id}` : '类型 ID 将由服务端自动生成。' }} +

+
+ +
+ +
+ + {{ cloudTypeFormError }} + + +
+ + +
+
+ + +

留空将保存为未设置。

+
+
+ + +

请输入整数,具体等级含义由后端数据约定。

+
+
+ + +

留空可清除已有图标关联。

+
+
+ + +
+
+ +
+ + 取消 + + + {{ editingCloudType ? '保存修改' : '添加类型' }} + +
+
+
+
+
Date: Mon, 28 Sep 2026 13:14:01 +0800 Subject: [PATCH 2/5] fix(auth): migrate frontend to Better Auth --- package-lock.json | 359 ++++++++++++++++++++++ package.json | 1 + src/components/layout/AppHeader.vue | 15 +- src/lib/api.ts | 5 +- src/lib/authClient.ts | 33 ++ src/stores/auth.ts | 74 ++--- src/types/api.ts | 8 - src/views/admin/AdminView.vue | 3 +- src/views/auth/ForgotPasswordView.vue | 2 +- src/views/auth/LoginView.vue | 7 +- src/views/auth/RegisterView.vue | 19 +- src/views/auth/ResetPasswordView.vue | 9 +- src/views/profile/ProfileSettingsView.vue | 7 +- 13 files changed, 456 insertions(+), 86 deletions(-) create mode 100644 src/lib/authClient.ts diff --git a/package-lock.json b/package-lock.json index c79bf87..cc1f611 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index 963028e..b4a3250 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue index 673bc55..79c9778 100644 --- a/src/components/layout/AppHeader.vue +++ b/src/components/layout/AppHeader.vue @@ -1,6 +1,6 @@ diff --git a/src/lib/api.ts b/src/lib/api.ts index 091f6e9..ebf0999 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -27,9 +27,6 @@ export class ApiError extends Error { interface ApiRequestOptions extends Omit { body?: BodyInit | Record | null auth?: boolean - // Kept for callers that explicitly disable retries. Cookie sessions do not - // use a refresh-and-retry flow, so this option is intentionally ignored. - retry?: boolean } function isJsonBody(body: ApiRequestOptions['body']): body is Record { @@ -105,7 +102,7 @@ async function toApiError(response: Response) { } async function request(path: string, options: ApiRequestOptions = {}) { - const { body, auth = true, retry: _retry, ...requestOptions } = options + const { body, auth = true, ...requestOptions } = options const headers = new Headers(requestOptions.headers) let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined diff --git a/src/lib/authClient.ts b/src/lib/authClient.ts new file mode 100644 index 0000000..394a953 --- /dev/null +++ b/src/lib/authClient.ts @@ -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) +} diff --git a/src/stores/auth.ts b/src/stores/auth.ts index df4def1..44d8476 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,7 +1,8 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api' -import type { ActionResponse, BackendUserProfile, LoginResponse, RegisterResponse } from '@/types/api' +import { assertAuthResult, authClient } from '@/lib/authClient' +import type { BackendUserProfile } from '@/types/api' import type { Profile } from '@/types/database' export const useAuthStore = defineStore('auth', () => { @@ -37,62 +38,45 @@ export const useAuthStore = defineStore('auth', () => { } async function login(email: string, password: string) { - await apiRequest('/auth/login', { - method: 'POST', - auth: false, - body: { email, password }, - }) + 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(name: string, email: string, password: string) { - return await apiRequest('/auth/register', { - method: 'POST', - auth: false, - body: { name, email, password }, - }) + assertAuthResult(await authClient.signUp.email({ name, email, password }), '注册失败,请稍后重试。') } async function resendConfirmation(email: string) { - return await apiRequest('/auth/resend-confirmation', { - method: 'POST', - auth: false, - body: { email }, - }) + assertAuthResult(await authClient.sendVerificationEmail({ email }), '验证邮件发送失败,请稍后重试。') } async function confirmEmail(token: string) { - return await apiRequest('/auth/confirm-email', { - method: 'POST', - auth: false, - body: { token }, - }) + assertAuthResult(await authClient.$fetch('/verify-email', { + method: 'GET', + query: { token }, + }), '确认链接无效或已过期。') } async function sendPasswordReset(email: string) { - return await apiRequest('/auth/forgot-password', { - method: 'POST', - auth: false, - body: { email }, - }) + assertAuthResult(await authClient.requestPasswordReset({ email }), '重置邮件发送失败,请稍后重试。') } - async function resetPassword(email: string, token: string, newPassword: string) { - const response = await apiRequest('/auth/reset-password', { - method: 'POST', - auth: false, - body: { email, token, new_password: newPassword }, - }) + async function resetPassword(token: string, newPassword: string) { + assertAuthResult(await authClient.resetPassword({ token, newPassword }), '密码重置失败,请重新申请重置邮件。') clearAuth() - return response } async function logout() { - try { - await apiRequest('/auth/logout', { method: 'POST', retry: false }) - } finally { + const result = await authClient.signOut() + if (result.error?.status === 401) { clearAuth() + return } + assertAuthResult(result, '退出登录失败,请稍后重试。') + clearAuth() } async function updateUsername(username: string) { @@ -104,14 +88,11 @@ export const useAuthStore = defineStore('auth', () => { } async function updatePassword(currentPassword: string, newPassword: string) { - await apiRequest('/auth/password', { - method: 'PATCH', - body: { - current_password: currentPassword, - new_password: newPassword, - }, - }) - clearAuth() + assertAuthResult(await authClient.changePassword({ + currentPassword, + newPassword, + revokeOtherSessions: true, + }), '密码更新失败,请检查当前密码。') } async function initialize() { @@ -121,7 +102,10 @@ export const useAuthStore = defineStore('auth', () => { } try { - await fetchMe() + const session = await authClient.getSession() + assertAuthResult(session, '获取登录会话失败,请稍后重试。') + if (session.data) await fetchMe() + else clearAuth() } catch { clearAuth() } finally { diff --git a/src/types/api.ts b/src/types/api.ts index 34478b0..5015fda 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -48,14 +48,6 @@ export interface ActionResponse { message: string } -export interface RegisterResponse extends ActionResponse { - email_sent: boolean -} - -export interface LoginResponse { - access_token: string -} - export interface CloudCreateResponse extends ActionResponse { cloud_id: string } diff --git a/src/views/admin/AdminView.vue b/src/views/admin/AdminView.vue index 848b0e6..0c04365 100644 --- a/src/views/admin/AdminView.vue +++ b/src/views/admin/AdminView.vue @@ -646,8 +646,9 @@ async function handleLogout() { accountMenuOpen.value = false try { await authStore.logout() - } finally { await router.push('/') + } catch (error) { + message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。') } } diff --git a/src/views/auth/ForgotPasswordView.vue b/src/views/auth/ForgotPasswordView.vue index 0092adb..a1e3965 100644 --- a/src/views/auth/ForgotPasswordView.vue +++ b/src/views/auth/ForgotPasswordView.vue @@ -70,7 +70,7 @@ onUnmounted(() => { if (timer) clearInterval(timer) })
Password Recovery

忘记密码

-

输入注册邮箱,我们会发送有效期为 30 分钟的重置链接。

+

输入注册邮箱,我们会发送有效期为 1 小时的重置链接。

diff --git a/src/views/auth/LoginView.vue b/src/views/auth/LoginView.vue index 8b93c7b..2621f42 100644 --- a/src/views/auth/LoginView.vue +++ b/src/views/auth/LoginView.vue @@ -1,7 +1,8 @@ + + diff --git a/src/components/layout/AppHeader.vue b/src/components/layout/AppHeader.vue index 79c9778..8a73fc4 100644 --- a/src/components/layout/AppHeader.vue +++ b/src/components/layout/AppHeader.vue @@ -19,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 @@ -237,9 +240,8 @@ async function handleLogout() { aria-haspopup="menu" :aria-expanded="accountCardOpen" > - - - + + @{{ displayUsername }} @@ -252,7 +254,8 @@ async function handleLogout() {
- {{ displayUsername.slice(0, 1).toUpperCase() }} + +

@{{ displayUsername }}

diff --git a/src/lib/api.ts b/src/lib/api.ts index ebf0999..bf567b1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -139,7 +139,7 @@ export function toAuthUser(profile: BackendUserProfile): AuthUser { id: profile.id, email: profile.email, username: profile.name, - avatar_id: profile.avatar_id, + image: profile.image, role: profile.role, is_disabled: profile.is_disabled, cloud_count: profile.cloud_count, diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 44d8476..52422cc 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -20,7 +20,7 @@ export const useAuthStore = defineStore('auth', () => { profile.value = { id: normalized.id, username: normalized.username, - avatar_id: normalized.avatar_id, + image: normalized.image, role: normalized.role, is_disabled: normalized.is_disabled, created_at: normalized.created_at, @@ -127,6 +127,7 @@ export const useAuthStore = defineStore('auth', () => { resetPassword, logout, updateUsername, + refreshProfile: fetchMe, updatePassword, initialize, } diff --git a/src/stores/likes.ts b/src/stores/likes.ts new file mode 100644 index 0000000..68bb63b --- /dev/null +++ b/src/stores/likes.ts @@ -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>(new Set()) + const counts = ref>({}) + const pendingIds = ref>(new Set()) + const loadedForUser = ref(null) + let loadPromise: Promise | 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() + for (let page = 1; ; page++) { + const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) }) + const rows = await apiRequest(`/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(`/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(`/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 } +}) diff --git a/src/stores/profile.ts b/src/stores/profile.ts index c3822e2..fed9ae3 100644 --- a/src/stores/profile.ts +++ b/src/stores/profile.ts @@ -2,7 +2,7 @@ import { ref } from 'vue' import { defineStore } from 'pinia' import { ApiError, apiRequest, toApiCloud } from '@/lib/api' import { useAuthStore } from '@/stores/auth' -import type { ActionResponse, ApiCloud, BackendCloudInfo } from '@/types/api' +import type { ActionResponse, ApiCloud, BackendCloudInfo, BackendUserProfile } from '@/types/api' import type { Profile } from '@/types/database' export interface ProfileCloudItem { @@ -101,12 +101,22 @@ export const useProfileStore = defineStore('profile-page', () => { 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(`/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, - avatar_id: null, + image: publicProfile?.image ?? null, role: 'user', is_disabled: false, created_at: '', diff --git a/src/types/api.ts b/src/types/api.ts index 5015fda..fa14979 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -4,7 +4,7 @@ export interface BackendUserProfile { id: string name: string email: string - avatar_id: string | null + image: string | null cloud_count: number received_like_count: number last_online: string | null @@ -62,7 +62,7 @@ export interface AuthUser { id: string email: string username: string - avatar_id: string | null + image: string | null role: Profile['role'] is_disabled: boolean cloud_count: number diff --git a/src/types/database.ts b/src/types/database.ts index 012df05..6487840 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -27,7 +27,7 @@ export interface Cloud { export interface Profile { id: string username: string - avatar_id: string | null + image: string | null role: 'user' | 'admin' is_disabled: boolean created_at: string diff --git a/src/views/encyclopedia/CloudTypeView.vue b/src/views/encyclopedia/CloudTypeView.vue index 907f76f..0ddf83f 100644 --- a/src/views/encyclopedia/CloudTypeView.vue +++ b/src/views/encyclopedia/CloudTypeView.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue' 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, imageUrl, toApiCloud } from '@/lib/api' import { useEncyclopediaStore } from '@/stores/encyclopedia' @@ -137,6 +138,7 @@ watch(() => route.params.id, () => loadPage()) >

{{ selectedItem.description || '上传者没有留下额外说明。' }}

+
diff --git a/src/views/gallery/GalleryView.vue b/src/views/gallery/GalleryView.vue index 2a7023e..1772d2a 100644 --- a/src/views/gallery/GalleryView.vue +++ b/src/views/gallery/GalleryView.vue @@ -4,6 +4,7 @@ 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 { ApiError, apiRequest, toApiCloud } from '@/lib/api' @@ -29,6 +30,7 @@ interface GalleryCloud { cloudTypeName: string cloudTypeRarity: number username: string + receivedLikeCount: number } const PAGE_SIZE = 50 @@ -98,6 +100,7 @@ function toGalleryCloud(row: ApiCloud) { cloudTypeName: row.cloud_type_name || '未知', cloudTypeRarity: row.cloud_type_rarity_value ?? 0, username: row.username || '匿名', + receivedLikeCount: row.received_like_count, } satisfies GalleryCloud } @@ -551,6 +554,7 @@ onUnmounted(() => { :latitude="selectedCloud.latitude" :longitude="selectedCloud.longitude" /> + - -
- - -
- - - -
- -
-
-
+ + 展开导航 + +
+ + diff --git a/src/components/profile/ContributionHeatmap.vue b/src/components/profile/ContributionHeatmap.vue deleted file mode 100644 index 838a888..0000000 --- a/src/components/profile/ContributionHeatmap.vue +++ /dev/null @@ -1,451 +0,0 @@ - - - - - diff --git a/src/composables/useUpload.ts b/src/composables/useUpload.ts deleted file mode 100644 index ecb83bc..0000000 --- a/src/composables/useUpload.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { ref } from 'vue' -import { apiRequest } from '@/lib/api' -import { useAuthStore } from '@/stores/auth' -import { useProfileStore } from '@/stores/profile' -import type { CloudCreateResponse } from '@/types/api' - -export interface UploadItem { - id: string - file: File - preview: string - cloudCategoryId: number | null - description: string - isHidden: boolean - latitude: number | null - longitude: number | null - capturedAt: string - errors: Record -} - -export interface UploadResult { - ok: boolean - errorMessage?: string -} - -let nextId = 0 - -function readFileAsArrayBuffer(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result as ArrayBuffer) - reader.onerror = reject - reader.readAsArrayBuffer(file) - }) -} - -function extractExifDate(buffer: ArrayBuffer): string | null { - const view = new DataView(buffer) - if (view.byteLength < 4 || view.getUint16(0, false) !== 0xffd8) return null - - let offset = 2 - while (offset < view.byteLength - 2) { - const marker = view.getUint16(offset, false) - offset += 2 - if ((marker & 0xff00) !== 0xff00) break - const length = view.getUint16(offset, false) - offset += 2 - - if (marker === 0xffe1) { - const exifStart = offset - const header = String.fromCharCode( - view.getUint8(exifStart), - view.getUint8(exifStart + 1), - view.getUint8(exifStart + 2), - view.getUint8(exifStart + 3), - ) - if (header !== 'Exif') break - - const tiffStart = exifStart + 6 - const isLE = view.getUint16(tiffStart, false) === 0x4949 - const ifdStart = tiffStart + view.getUint32(tiffStart + 4, isLE) - const numEntries = view.getUint16(ifdStart, isLE) - - for (let i = 0; i < numEntries; i++) { - const entryStart = ifdStart + 2 + i * 12 - if (entryStart + 12 > buffer.byteLength) break - if (view.getUint16(entryStart, isLE) !== 0x9003) continue - - const dataOffset = view.getUint32(entryStart + 8, isLE) - const dateStr = Array.from({ length: 20 }, (_, j) => - String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)), - ).join('') - const date = new Date(dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')) - if (!isNaN(date.getTime())) return date.toISOString() - } - } - offset += length - 2 - } - return null -} - -function appendOptional(form: FormData, key: string, value: string) { - if (value.trim()) form.append(key, value.trim()) -} - -export function useUpload() { - const authStore = useAuthStore() - const profileStore = useProfileStore() - const items = ref([]) - const uploading = ref(false) - const overallProgress = ref(0) - const currentItemIndex = ref(0) - const totalItems = ref(0) - - async function addFiles(files: File[]) { - const imageFiles = files.filter(file => ['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) - for (const file of imageFiles) { - let capturedAt = '' - try { - capturedAt = extractExifDate(await readFileAsArrayBuffer(file)) || '' - } catch { - capturedAt = '' - } - - items.value.push({ - id: String(++nextId), - file, - preview: URL.createObjectURL(file), - cloudCategoryId: null, - description: '', - isHidden: false, - latitude: null, - longitude: null, - capturedAt, - errors: {}, - }) - } - } - - function removeItem(id: string) { - const index = items.value.findIndex(item => item.id === id) - if (index === -1) return - URL.revokeObjectURL(items.value[index].preview) - items.value.splice(index, 1) - } - - function clearAll() { - items.value.forEach(item => URL.revokeObjectURL(item.preview)) - items.value = [] - } - - function validateItem(item: UploadItem) { - const errors: Record = {} - if (!item.cloudCategoryId) 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.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 - } - - function validateAll() { - return items.value.map(validateItem).every(Boolean) - } - - async function uploadAll(): Promise { - if (!validateAll() || !authStore.user) return { ok: false } - - uploading.value = true - totalItems.value = items.value.length - currentItemIndex.value = 0 - overallProgress.value = 0 - - try { - for (let index = 0; index < items.value.length; index++) { - const item = items.value[index] - currentItemIndex.value = index + 1 - overallProgress.value = Math.round(index / items.value.length * 100) - - const form = new FormData() - form.append('image', item.file) - 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)) - - await apiRequest('/cloud', { - method: 'POST', - body: form, - }) - overallProgress.value = Math.round((index + 1) / items.value.length * 100) - } - - clearAll() - profileStore.invalidateUser(authStore.user.id) - return { ok: true } - } catch (error) { - return { - ok: false, - errorMessage: error instanceof Error ? error.message : '上传失败,请稍后重试', - } - } finally { - uploading.value = false - } - } - - return { - items, - uploading, - overallProgress, - currentItemIndex, - totalItems, - addFiles, - removeItem, - clearAll, - validateItem, - validateAll, - uploadAll, - } -} diff --git a/src/features/admin/views/AdminView.vue b/src/features/admin/views/AdminView.vue new file mode 100644 index 0000000..c003bab --- /dev/null +++ b/src/features/admin/views/AdminView.vue @@ -0,0 +1,2370 @@ + + + + + diff --git a/src/features/auth/authClient.ts b/src/features/auth/authClient.ts new file mode 100644 index 0000000..487a839 --- /dev/null +++ b/src/features/auth/authClient.ts @@ -0,0 +1,35 @@ +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) +} diff --git a/src/features/auth/stores/auth.ts b/src/features/auth/stores/auth.ts new file mode 100644 index 0000000..eaec592 --- /dev/null +++ b/src/features/auth/stores/auth.ts @@ -0,0 +1,155 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api' +import { assertAuthResult, authClient } from '@/features/auth/authClient' +import type { BackendUserProfile } from '@/types/api' +import type { Profile } from '@/types/database' + +export const useAuthStore = defineStore('auth', () => { + const user = ref | null>(null) + const profile = ref(null) + const loading = ref(true) + let listenerAttached = false + + const isLoggedIn = computed(() => !!user.value) + const isAdmin = computed(() => profile.value?.role === 'admin') + + function applyProfile(backendProfile: BackendUserProfile) { + const normalized = toAuthUser(backendProfile) + user.value = normalized + profile.value = { + id: normalized.id, + username: normalized.username, + image: normalized.image, + role: normalized.role, + is_disabled: normalized.is_disabled, + created_at: normalized.created_at, + } + } + + function clearAuth() { + user.value = null + profile.value = null + } + + async function fetchMe() { + const backendProfile = await apiRequest('/profile/me') + applyProfile(backendProfile) + } + + async function login(email: string, password: string) { + 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(name: string, email: string, password: string) { + assertAuthResult( + await authClient.signUp.email({ name, email, password }), + '注册失败,请稍后重试。', + ) + } + + async function resendConfirmation(email: string) { + assertAuthResult( + await authClient.sendVerificationEmail({ email }), + '验证邮件发送失败,请稍后重试。', + ) + } + + async function confirmEmail(token: string) { + assertAuthResult( + await authClient.$fetch('/verify-email', { + method: 'GET', + query: { token }, + }), + '确认链接无效或已过期。', + ) + } + + async function sendPasswordReset(email: string) { + 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('/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 (!listenerAttached) { + window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth) + listenerAttached = true + } + + try { + const session = await authClient.getSession() + assertAuthResult(session, '获取登录会话失败,请稍后重试。') + if (session.data) await fetchMe() + else clearAuth() + } catch { + clearAuth() + } finally { + loading.value = false + } + } + + return { + user, + profile, + loading, + isLoggedIn, + isAdmin, + login, + register, + resendConfirmation, + confirmEmail, + sendPasswordReset, + resetPassword, + logout, + updateUsername, + refreshProfile: fetchMe, + updatePassword, + initialize, + } +}) diff --git a/src/features/auth/views/AuthConfirmView.vue b/src/features/auth/views/AuthConfirmView.vue new file mode 100644 index 0000000..6dc2260 --- /dev/null +++ b/src/features/auth/views/AuthConfirmView.vue @@ -0,0 +1,98 @@ + + + diff --git a/src/features/auth/views/ForgotPasswordView.vue b/src/features/auth/views/ForgotPasswordView.vue new file mode 100644 index 0000000..e382444 --- /dev/null +++ b/src/features/auth/views/ForgotPasswordView.vue @@ -0,0 +1,148 @@ + + + diff --git a/src/features/auth/views/LoginView.vue b/src/features/auth/views/LoginView.vue new file mode 100644 index 0000000..8aae372 --- /dev/null +++ b/src/features/auth/views/LoginView.vue @@ -0,0 +1,165 @@ + + + diff --git a/src/features/auth/views/RegisterView.vue b/src/features/auth/views/RegisterView.vue new file mode 100644 index 0000000..938297e --- /dev/null +++ b/src/features/auth/views/RegisterView.vue @@ -0,0 +1,240 @@ + + + diff --git a/src/features/auth/views/ResendConfirmationView.vue b/src/features/auth/views/ResendConfirmationView.vue new file mode 100644 index 0000000..cd64fd1 --- /dev/null +++ b/src/features/auth/views/ResendConfirmationView.vue @@ -0,0 +1,139 @@ + + + diff --git a/src/features/auth/views/ResetPasswordView.vue b/src/features/auth/views/ResetPasswordView.vue new file mode 100644 index 0000000..6cb5e37 --- /dev/null +++ b/src/features/auth/views/ResetPasswordView.vue @@ -0,0 +1,153 @@ + + + diff --git a/src/features/clouds/cloudTypes.ts b/src/features/clouds/cloudTypes.ts new file mode 100644 index 0000000..e582a25 --- /dev/null +++ b/src/features/clouds/cloudTypes.ts @@ -0,0 +1,17 @@ +export const CLOUD_TYPES = [ + { 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[]) { + const cloudTypeId = Number(Array.isArray(id) ? id[0] : id) + return CLOUD_TYPES.find(item => item.id === cloudTypeId)?.name ?? '云型详情' +} diff --git a/src/features/clouds/components/CloudEditModal.vue b/src/features/clouds/components/CloudEditModal.vue new file mode 100644 index 0000000..9a77785 --- /dev/null +++ b/src/features/clouds/components/CloudEditModal.vue @@ -0,0 +1,265 @@ + + + diff --git a/src/features/clouds/components/CloudLikeButton.vue b/src/features/clouds/components/CloudLikeButton.vue new file mode 100644 index 0000000..6bf74cc --- /dev/null +++ b/src/features/clouds/components/CloudLikeButton.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/features/clouds/components/ImageDetailModal.vue b/src/features/clouds/components/ImageDetailModal.vue new file mode 100644 index 0000000..4e8084a --- /dev/null +++ b/src/features/clouds/components/ImageDetailModal.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/src/features/clouds/stores/clouds.ts b/src/features/clouds/stores/clouds.ts new file mode 100644 index 0000000..b6e02c1 --- /dev/null +++ b/src/features/clouds/stores/clouds.ts @@ -0,0 +1,27 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { apiRequest, toCloudType } from '@/lib/api' +import type { BackendCloudType } from '@/types/api' +import type { CloudType } from '@/types/database' + +export const useCloudsStore = defineStore('clouds', () => { + const cloudTypes = ref([]) + const loading = ref(false) + + async function fetchCloudTypes() { + if (cloudTypes.value.length > 0) return + loading.value = true + try { + const rows = await apiRequest('/info/cloudtype', { auth: false }) + cloudTypes.value = rows.map(toCloudType) + } finally { + loading.value = false + } + } + + function setCloudTypes(types: CloudType[]) { + cloudTypes.value = types + } + + return { cloudTypes, loading, fetchCloudTypes, setCloudTypes } +}) diff --git a/src/features/clouds/stores/likes.ts b/src/features/clouds/stores/likes.ts new file mode 100644 index 0000000..aaec463 --- /dev/null +++ b/src/features/clouds/stores/likes.ts @@ -0,0 +1,116 @@ +import { ref, watch } from 'vue' +import { defineStore } from 'pinia' +import { apiRequest } from '@/lib/api' +import { useAuthStore } from '@/features/auth/stores/auth' +import type { ActionResponse, BackendCloudInfo } from '@/types/api' + +const PAGE_SIZE = 100 + +export const useLikesStore = defineStore('likes', () => { + const authStore = useAuthStore() + const likedIds = ref>(new Set()) + const counts = ref>({}) + const pendingIds = ref>(new Set()) + const loadedForUser = ref(null) + let loadPromise: Promise | 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() + for (let page = 1; ; page++) { + const params = new URLSearchParams({ + page: String(page), + page_size: String(PAGE_SIZE), + }) + const rows = await apiRequest(`/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(`/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(`/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 } +}) diff --git a/src/features/community/views/CommunityView.vue b/src/features/community/views/CommunityView.vue new file mode 100644 index 0000000..114d196 --- /dev/null +++ b/src/features/community/views/CommunityView.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/features/encyclopedia/stores/encyclopedia.ts b/src/features/encyclopedia/stores/encyclopedia.ts new file mode 100644 index 0000000..223a71a --- /dev/null +++ b/src/features/encyclopedia/stores/encyclopedia.ts @@ -0,0 +1,35 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { apiRequest, toCloudType } from '@/lib/api' +import type { BackendCloudType } from '@/types/api' +import type { CloudType } from '@/types/database' + +export const useEncyclopediaStore = defineStore('encyclopedia', () => { + const cloudTypes = ref([]) + const loadingCloudTypes = ref(false) + const cloudTypesLoaded = ref(false) + + async function fetchCloudTypes(force = false) { + if (cloudTypesLoaded.value && !force) return + loadingCloudTypes.value = true + try { + const rows = await apiRequest('/info/cloudtype', { auth: false }) + cloudTypes.value = rows.map(toCloudType) + cloudTypesLoaded.value = true + } finally { + loadingCloudTypes.value = false + } + } + + function setCloudTypes(types: CloudType[]) { + cloudTypes.value = types + cloudTypesLoaded.value = true + } + + return { + cloudTypes, + loadingCloudTypes, + fetchCloudTypes, + setCloudTypes, + } +}) diff --git a/src/features/encyclopedia/views/CloudTypeView.vue b/src/features/encyclopedia/views/CloudTypeView.vue new file mode 100644 index 0000000..3924eda --- /dev/null +++ b/src/features/encyclopedia/views/CloudTypeView.vue @@ -0,0 +1,256 @@ + + + diff --git a/src/features/encyclopedia/views/EncyclopediaView.vue b/src/features/encyclopedia/views/EncyclopediaView.vue new file mode 100644 index 0000000..7ac0c92 --- /dev/null +++ b/src/features/encyclopedia/views/EncyclopediaView.vue @@ -0,0 +1,133 @@ + + + diff --git a/src/features/gallery/views/GalleryView.vue b/src/features/gallery/views/GalleryView.vue new file mode 100644 index 0000000..11e03c3 --- /dev/null +++ b/src/features/gallery/views/GalleryView.vue @@ -0,0 +1,658 @@ + + + diff --git a/src/features/map/views/MapView.vue b/src/features/map/views/MapView.vue new file mode 100644 index 0000000..2d41326 --- /dev/null +++ b/src/features/map/views/MapView.vue @@ -0,0 +1,1031 @@ + + + + + diff --git a/src/features/profile/components/ContributionHeatmap.vue b/src/features/profile/components/ContributionHeatmap.vue new file mode 100644 index 0000000..0090348 --- /dev/null +++ b/src/features/profile/components/ContributionHeatmap.vue @@ -0,0 +1,468 @@ + + + + + diff --git a/src/features/profile/stores/profile.ts b/src/features/profile/stores/profile.ts new file mode 100644 index 0000000..f0e7d8a --- /dev/null +++ b/src/features/profile/stores/profile.ts @@ -0,0 +1,233 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' +import { ApiError, apiRequest, toApiCloud } from '@/lib/api' +import { useAuthStore } from '@/features/auth/stores/auth' +import type { ActionResponse, ApiCloud, BackendCloudInfo, BackendUserProfile } from '@/types/api' +import type { Profile } from '@/types/database' + +export interface ProfileCloudItem { + id: string + cloud_type_id: number + image_url: string + 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: number + receivedLikeCount: number +} + +function toProfileCloud(row: ApiCloud): ProfileCloudItem { + return { + id: row.id, + cloud_type_id: row.cloud_type_id as number, + image_url: row.image_url, + 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 || '未知', + cloudTypeRarity: row.cloud_type_rarity_value ?? 0, + receivedLikeCount: row.received_like_count, + } +} + +export const useProfileStore = defineStore('profile-page', () => { + const authStore = useAuthStore() + const profilesByKey = ref>({}) + const cloudsByKey = ref>({}) + const loadingKeys = ref>({}) + const errorByKey = ref>({}) + + const makeKey = (identifier: string, isOwnProfile: boolean) => + `${identifier}:${isOwnProfile ? 'own' : 'public'}` + + function getProfile(identifier: string | null) { + return identifier ? (profilesByKey.value[identifier] ?? null) : null + } + + function getClouds(identifier: string | null, isOwnProfile: boolean) { + return identifier ? (cloudsByKey.value[makeKey(identifier, isOwnProfile)] ?? []) : [] + } + + function getError(identifier: string | null, isOwnProfile: boolean) { + return identifier ? (errorByKey.value[makeKey(identifier, isOwnProfile)] ?? '') : '' + } + + function isLoaded(identifier: string | null, isOwnProfile: boolean) { + return !!identifier && makeKey(identifier, 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(`/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 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 backendRows = isOwnProfile + ? await apiRequest('/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(`/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] = [] + } finally { + loadingKeys.value[key] = false + } + } + + function isLoading(identifier: string | null, isOwnProfile: boolean) { + return !!identifier && !!loadingKeys.value[makeKey(identifier, isOwnProfile)] + } + + function patchCachedCloud( + identifier: string, + cloudId: string, + patch: Partial, + ) { + for (const isOwnProfile of [true, false]) { + const key = makeKey(identifier, isOwnProfile) + const current = cloudsByKey.value[key] + if (!current) continue + cloudsByKey.value[key] = current + .map(item => (item.id === cloudId ? { ...item, ...patch } : item)) + .filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden)) + } + } + + function removeCachedClouds(identifier: string, cloudIds: string[]) { + const ids = new Set(cloudIds) + for (const isOwnProfile of [true, false]) { + const key = makeKey(identifier, isOwnProfile) + const current = cloudsByKey.value[key] + if (current) cloudsByKey.value[key] = current.filter(item => !ids.has(item.id)) + } + } + + function invalidateUser(identifier: string) { + delete cloudsByKey.value[makeKey(identifier, true)] + delete cloudsByKey.value[makeKey(identifier, false)] + } + + async function updateCloud( + identifier: string, + cloudId: string, + patch: { + type_id: number + latitude: number + longitude: number + description?: string + captured_at?: string + is_hidden: boolean + }, + ) { + await apiRequest(`/cloud/${cloudId}`, { + method: 'PATCH', + body: patch, + }) + const updated = toProfileCloud( + toApiCloud(await apiRequest(`/cloud/${cloudId}`)), + ) + patchCachedCloud(identifier, cloudId, updated) + return updated + } + + async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) { + await apiRequest(`/cloud/${cloudId}`, { + method: 'PATCH', + body: { is_hidden: isHidden }, + }) + patchCachedCloud(identifier, cloudId, { is_hidden: isHidden }) + } + + async function deleteClouds(identifier: string, cloudIds: string[]) { + if (!cloudIds.length) return 0 + + if (cloudIds.length === 1) { + await apiRequest(`/cloud/${cloudIds[0]}`, { method: 'DELETE' }) + } else { + for (let offset = 0; offset < cloudIds.length; offset += 100) { + await apiRequest('/cloud', { + method: 'DELETE', + body: { + cloud_ids: cloudIds + .slice(offset, offset + 100) + .map(cloudId => ({ cloud_id: cloudId })), + }, + }) + } + } + + removeCachedClouds(identifier, cloudIds) + return cloudIds.length + } + + return { + getProfile, + getClouds, + getError, + isLoaded, + isLoading, + fetchProfilePage, + invalidateUser, + updateCloud, + updateCloudVisibility, + deleteClouds, + } +}) diff --git a/src/features/profile/views/ProfileSettingsView.vue b/src/features/profile/views/ProfileSettingsView.vue new file mode 100644 index 0000000..10bf787 --- /dev/null +++ b/src/features/profile/views/ProfileSettingsView.vue @@ -0,0 +1,217 @@ + + + diff --git a/src/features/profile/views/ProfileView.vue b/src/features/profile/views/ProfileView.vue new file mode 100644 index 0000000..9d53480 --- /dev/null +++ b/src/features/profile/views/ProfileView.vue @@ -0,0 +1,937 @@ + + + + + diff --git a/src/features/system/views/AuthRequiredView.vue b/src/features/system/views/AuthRequiredView.vue new file mode 100644 index 0000000..f829cef --- /dev/null +++ b/src/features/system/views/AuthRequiredView.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/features/system/views/ForbiddenView.vue b/src/features/system/views/ForbiddenView.vue new file mode 100644 index 0000000..d05d09d --- /dev/null +++ b/src/features/system/views/ForbiddenView.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/features/system/views/NotFoundView.vue b/src/features/system/views/NotFoundView.vue new file mode 100644 index 0000000..6918d23 --- /dev/null +++ b/src/features/system/views/NotFoundView.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/features/upload/components/QuickUploadModal.vue b/src/features/upload/components/QuickUploadModal.vue new file mode 100644 index 0000000..25fb7ba --- /dev/null +++ b/src/features/upload/components/QuickUploadModal.vue @@ -0,0 +1,495 @@ + + + diff --git a/src/features/upload/composables/useUpload.ts b/src/features/upload/composables/useUpload.ts new file mode 100644 index 0000000..c3c1ac4 --- /dev/null +++ b/src/features/upload/composables/useUpload.ts @@ -0,0 +1,218 @@ +import { ref } from 'vue' +import { apiRequest } from '@/lib/api' +import { useAuthStore } from '@/features/auth/stores/auth' +import { useProfileStore } from '@/features/profile/stores/profile' +import type { CloudCreateResponse } from '@/types/api' + +export interface UploadItem { + id: string + file: File + preview: string + cloudCategoryId: number | null + description: string + isHidden: boolean + latitude: number | null + longitude: number | null + capturedAt: string + errors: Record +} + +export interface UploadResult { + ok: boolean + errorMessage?: string +} + +let nextId = 0 + +function readFileAsArrayBuffer(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as ArrayBuffer) + reader.onerror = reject + reader.readAsArrayBuffer(file) + }) +} + +function extractExifDate(buffer: ArrayBuffer): string | null { + const view = new DataView(buffer) + if (view.byteLength < 4 || view.getUint16(0, false) !== 0xffd8) return null + + let offset = 2 + while (offset < view.byteLength - 2) { + const marker = view.getUint16(offset, false) + offset += 2 + if ((marker & 0xff00) !== 0xff00) break + const length = view.getUint16(offset, false) + offset += 2 + + if (marker === 0xffe1) { + const exifStart = offset + const header = String.fromCharCode( + view.getUint8(exifStart), + view.getUint8(exifStart + 1), + view.getUint8(exifStart + 2), + view.getUint8(exifStart + 3), + ) + if (header !== 'Exif') break + + const tiffStart = exifStart + 6 + const isLE = view.getUint16(tiffStart, false) === 0x4949 + const ifdStart = tiffStart + view.getUint32(tiffStart + 4, isLE) + const numEntries = view.getUint16(ifdStart, isLE) + + for (let i = 0; i < numEntries; i++) { + const entryStart = ifdStart + 2 + i * 12 + if (entryStart + 12 > buffer.byteLength) break + if (view.getUint16(entryStart, isLE) !== 0x9003) continue + + const dataOffset = view.getUint32(entryStart + 8, isLE) + const dateStr = Array.from({ length: 20 }, (_, j) => + String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)), + ).join('') + const date = new Date(dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')) + if (!isNaN(date.getTime())) return date.toISOString() + } + } + offset += length - 2 + } + return null +} + +function appendOptional(form: FormData, key: string, value: string) { + if (value.trim()) form.append(key, value.trim()) +} + +export function useUpload() { + const authStore = useAuthStore() + const profileStore = useProfileStore() + const items = ref([]) + const uploading = ref(false) + const overallProgress = ref(0) + const currentItemIndex = ref(0) + const totalItems = ref(0) + + async function addFiles(files: File[]) { + const imageFiles = files.filter(file => + ['image/jpeg', 'image/png', 'image/webp'].includes(file.type), + ) + for (const file of imageFiles) { + let capturedAt: string + try { + capturedAt = extractExifDate(await readFileAsArrayBuffer(file)) || '' + } catch { + capturedAt = '' + } + + items.value.push({ + id: String(++nextId), + file, + preview: URL.createObjectURL(file), + cloudCategoryId: null, + description: '', + isHidden: false, + latitude: null, + longitude: null, + capturedAt, + errors: {}, + }) + } + } + + function removeItem(id: string) { + const index = items.value.findIndex(item => item.id === id) + if (index === -1) return + URL.revokeObjectURL(items.value[index].preview) + items.value.splice(index, 1) + } + + function clearAll() { + items.value.forEach(item => URL.revokeObjectURL(item.preview)) + items.value = [] + } + + function validateItem(item: UploadItem) { + const errors: Record = {} + if (!item.cloudCategoryId) 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.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 + } + + function validateAll() { + return items.value.map(validateItem).every(Boolean) + } + + async function uploadAll(): Promise { + if (!validateAll() || !authStore.user) return { ok: false } + + uploading.value = true + totalItems.value = items.value.length + currentItemIndex.value = 0 + overallProgress.value = 0 + + try { + for (let index = 0; index < items.value.length; index++) { + const item = items.value[index] + currentItemIndex.value = index + 1 + overallProgress.value = Math.round((index / items.value.length) * 100) + + const form = new FormData() + form.append('image', item.file) + 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)) + + await apiRequest('/cloud', { + method: 'POST', + body: form, + }) + overallProgress.value = Math.round(((index + 1) / items.value.length) * 100) + } + + clearAll() + profileStore.invalidateUser(authStore.user.id) + return { ok: true } + } catch (error) { + return { + ok: false, + errorMessage: error instanceof Error ? error.message : '上传失败,请稍后重试', + } + } finally { + uploading.value = false + } + } + + return { + items, + uploading, + overallProgress, + currentItemIndex, + totalItems, + addFiles, + removeItem, + clearAll, + validateItem, + validateAll, + uploadAll, + } +} diff --git a/src/features/upload/views/UploadView.vue b/src/features/upload/views/UploadView.vue new file mode 100644 index 0000000..2e30cd0 --- /dev/null +++ b/src/features/upload/views/UploadView.vue @@ -0,0 +1,560 @@ + + + diff --git a/src/lib/amap.ts b/src/lib/amap.ts deleted file mode 100644 index aadbcb3..0000000 --- a/src/lib/amap.ts +++ /dev/null @@ -1,16 +0,0 @@ -import AMapLoader from '@amap/amap-jsapi-loader' - -export function loadAMap() { - return AMapLoader.load({ - key: import.meta.env.VITE_AMAP_KEY, - version: '2.0', - plugins: [ - 'AMap.Scale', - 'AMap.ToolBar', - 'AMap.ControlBar', - 'AMap.Geolocation', - 'AMap.Marker', - 'AMap.InfoWindow', - ], - }) -} diff --git a/src/lib/api.ts b/src/lib/api.ts index bf567b1..0f42e04 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,9 +1,9 @@ import type { - ApiCloud, - AuthUser, - BackendCloudInfo, - BackendCloudType, - BackendUserProfile, + ApiCloud, + AuthUser, + BackendCloudInfo, + BackendCloudType, + BackendUserProfile, } from '@/types/api' import type { CloudType } from '@/types/database' @@ -13,182 +13,191 @@ export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired' export class ApiError extends Error { - status: number - detail: unknown + status: number + detail: unknown - constructor(message: string, status: number, detail?: unknown) { - super(message) - this.name = 'ApiError' - this.status = status - this.detail = detail - } + constructor(message: string, status: number, detail?: unknown) { + super(message) + this.name = 'ApiError' + this.status = status + this.detail = detail + } } interface ApiRequestOptions extends Omit { - body?: BodyInit | Record | null - auth?: boolean + body?: BodyInit | Record | null + auth?: boolean } function isJsonBody(body: ApiRequestOptions['body']): body is Record { - return !!body - && typeof body === 'object' - && !(body instanceof FormData) - && !(body instanceof Blob) - && !(body instanceof URLSearchParams) - && !(body instanceof ArrayBuffer) + return ( + !!body && + typeof body === 'object' && + !(body instanceof FormData) && + !(body instanceof Blob) && + !(body instanceof URLSearchParams) && + !(body instanceof ArrayBuffer) + ) } function validationMessage(value: unknown) { - if (typeof value !== 'string') return '' + 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) - return messages.join(';') || value - } catch { - return value - } + 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) + return messages.join(';') || value + } catch { + return value + } } function errorMessage(payload: unknown, fallback: string) { - if (!payload || typeof payload !== 'object') return fallback + 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 ('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 + 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 + return fallback } async function readPayload(response: Response) { - const text = await response.text() - if (!text) return undefined + const text = await response.text() + if (!text) return undefined - const contentType = response.headers.get('content-type') || '' - if (!contentType.includes('json')) return text + const contentType = response.headers.get('content-type') || '' + if (!contentType.includes('json')) return text - try { - return JSON.parse(text) as unknown - } catch { - return text - } + try { + return JSON.parse(text) as unknown + } catch { + return text + } } async function toApiError(response: Response) { - const payload = await readPayload(response) - const fallback = typeof payload === 'string' && payload.trim() - ? payload.trim() - : `请求失败(${response.status})` + const payload = await readPayload(response) + const fallback = + typeof payload === 'string' && payload.trim() + ? payload.trim() + : `请求失败(${response.status})` - return new ApiError(errorMessage(payload, fallback), response.status, payload) + return new ApiError(errorMessage(payload, fallback), response.status, payload) } async function request(path: string, options: ApiRequestOptions = {}) { - const { body, auth = true, ...requestOptions } = options - const headers = new Headers(requestOptions.headers) - let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined + const { body, auth = true, ...requestOptions } = options + const headers = new Headers(requestOptions.headers) + let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined - if (isJsonBody(body)) { - headers.set('Content-Type', 'application/json') - requestBody = JSON.stringify(body) - } + if (isJsonBody(body)) { + headers.set('Content-Type', 'application/json') + requestBody = JSON.stringify(body) + } - const response = await fetch(`${API_URL}${path}`, { - ...requestOptions, - body: requestBody, - headers, - credentials: 'include', - }) + const response = await fetch(`${API_URL}${path}`, { + ...requestOptions, + body: requestBody, + headers, + credentials: 'include', + }) - if (response.status === 401 && auth && typeof window !== 'undefined') { - 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) - return await readPayload(response) as T + if (!response.ok) throw await toApiError(response) + return (await readPayload(response)) as T } export function apiRequest(path: string, options: ApiRequestOptions = {}) { - return request(path, options) + return request(path, options) } export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') { - return `${API_URL}/image/${encodeURIComponent(id)}/${variant}` + 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, - } + 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, - } + 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, - } + 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, + } } diff --git a/src/lib/authClient.ts b/src/lib/authClient.ts deleted file mode 100644 index 394a953..0000000 --- a/src/lib/authClient.ts +++ /dev/null @@ -1,33 +0,0 @@ -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) -} diff --git a/src/lib/cloudTypes.ts b/src/lib/cloudTypes.ts deleted file mode 100644 index 94b8821..0000000 --- a/src/lib/cloudTypes.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const CLOUD_TYPES = [ - { 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[]) { - const cloudTypeId = Number(Array.isArray(id) ? id[0] : id) - return CLOUD_TYPES.find(item => item.id === cloudTypeId)?.name ?? '云型详情' -} diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 9532722..b679a2f 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -4,60 +4,64 @@ const defaultTitle = 'OpenCloud' const defaultDescription = 'OpenCloud - 活的天空地图,拍云、识别、收藏' function resolveSiteUrl() { - return (import.meta.env.VITE_SITE_URL || window.location.origin).replace(/\/$/, '') + return (import.meta.env.VITE_SITE_URL || window.location.origin).replace(/\/$/, '') } function upsertMeta(name: string, content: string) { - let element = document.querySelector(`meta[name="${name}"]`) - if (!element) { - element = document.createElement('meta') - element.name = name - document.head.appendChild(element) - } - element.content = content + let element = document.querySelector(`meta[name="${name}"]`) + if (!element) { + element = document.createElement('meta') + element.name = name + document.head.appendChild(element) + } + element.content = content } function upsertPropertyMeta(property: string, content: string) { - let element = document.querySelector(`meta[property="${property}"]`) - if (!element) { - element = document.createElement('meta') - element.setAttribute('property', property) - document.head.appendChild(element) - } - element.content = content + let element = document.querySelector(`meta[property="${property}"]`) + if (!element) { + element = document.createElement('meta') + element.setAttribute('property', property) + document.head.appendChild(element) + } + element.content = content } function upsertCanonical(href: string) { - let element = document.querySelector('link[rel="canonical"]') - if (!element) { - element = document.createElement('link') - element.rel = 'canonical' - document.head.appendChild(element) - } - element.href = href + let element = document.querySelector('link[rel="canonical"]') + if (!element) { + element = document.createElement('link') + element.rel = 'canonical' + document.head.appendChild(element) + } + element.href = href } export function applyRouteSeo(route: RouteLocationNormalizedLoaded) { - const title = typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title - const description = typeof route.meta.description === 'function' ? route.meta.description(route) : route.meta.description - const robots = route.meta.noindex ? 'noindex, nofollow' : 'index, follow' - const canonicalPath = route.meta.canonicalPath || route.path - const canonical = `${resolveSiteUrl()}${canonicalPath}` - const pageTitle = title ? `${title} | OpenCloud` : defaultTitle - const pageDescription = description || defaultDescription + const title = + typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title + const description = + typeof route.meta.description === 'function' + ? route.meta.description(route) + : route.meta.description + const robots = route.meta.noindex ? 'noindex, nofollow' : 'index, follow' + const canonicalPath = route.meta.canonicalPath || route.path + const canonical = `${resolveSiteUrl()}${canonicalPath}` + const pageTitle = title ? `${title} | OpenCloud` : defaultTitle + const pageDescription = description || defaultDescription - document.title = pageTitle - upsertMeta('description', pageDescription) - upsertMeta('robots', robots) - upsertCanonical(canonical) + document.title = pageTitle + upsertMeta('description', pageDescription) + upsertMeta('robots', robots) + upsertCanonical(canonical) - upsertPropertyMeta('og:site_name', 'OpenCloud') - upsertPropertyMeta('og:type', 'website') - upsertPropertyMeta('og:title', pageTitle) - upsertPropertyMeta('og:description', pageDescription) - upsertPropertyMeta('og:url', canonical) + upsertPropertyMeta('og:site_name', 'OpenCloud') + upsertPropertyMeta('og:type', 'website') + upsertPropertyMeta('og:title', pageTitle) + upsertPropertyMeta('og:description', pageDescription) + upsertPropertyMeta('og:url', canonical) - upsertMeta('twitter:card', 'summary') - upsertMeta('twitter:title', pageTitle) - upsertMeta('twitter:description', pageDescription) + upsertMeta('twitter:card', 'summary') + upsertMeta('twitter:title', pageTitle) + upsertMeta('twitter:description', pageDescription) } diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 27df7aa..3eb2303 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -2,57 +2,57 @@ import type { GlobalThemeOverrides } from 'naive-ui' /** Keep these values aligned with the semantic tokens in styles/tokens.css. */ export const openCloudThemeOverrides: GlobalThemeOverrides = { - common: { - primaryColor: '#34745d', - primaryColorHover: '#2d5f4e', - primaryColorPressed: '#274c40', - primaryColorSuppl: '#34745d', - infoColor: '#0369a1', - successColor: '#4d7f3b', - successColorHover: '#3f6633', - successColorPressed: '#35522c', - successColorSuppl: '#4d7f3b', - warningColor: '#d97706', - errorColor: '#dc2626', - borderRadius: '0px', - borderRadiusSmall: '0px', - fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif', - fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace', - bodyColor: '#eef4f8', - cardColor: '#ffffff', - modalColor: '#ffffff', - popoverColor: '#ffffff', - tableColor: '#ffffff', - textColorBase: '#0f172a', - textColor1: '#0f172a', - textColor2: '#334155', - textColor3: '#64748b', - textColorDisabled: 'var(--oc-color-text-disabled)', - dividerColor: '#d9e3ee', - borderColor: '#d9e3ee', - inputColor: '#ffffff', - }, - Button: { - borderRadiusTiny: '0px', - borderRadiusSmall: '0px', - borderRadiusMedium: '0px', - borderRadiusLarge: '0px', - textColorPrimary: '#0f172a', - textColorHoverPrimary: '#0f172a', - }, - Card: { - borderRadius: '0px', - borderRadiusEmbedded: '0px', - paddingMedium: '24px', - }, - Input: { borderRadius: '0px' }, - Select: { - peers: { - InternalSelection: { borderRadius: '0px' }, + common: { + primaryColor: '#34745d', + primaryColorHover: '#2d5f4e', + primaryColorPressed: '#274c40', + primaryColorSuppl: '#34745d', + infoColor: '#0369a1', + successColor: '#4d7f3b', + successColorHover: '#3f6633', + successColorPressed: '#35522c', + successColorSuppl: '#4d7f3b', + warningColor: '#d97706', + errorColor: '#dc2626', + borderRadius: '0px', + borderRadiusSmall: '0px', + fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif', + fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace', + bodyColor: '#eef4f8', + cardColor: '#ffffff', + modalColor: '#ffffff', + popoverColor: '#ffffff', + tableColor: '#ffffff', + textColorBase: '#0f172a', + textColor1: '#0f172a', + textColor2: '#334155', + textColor3: '#64748b', + textColorDisabled: 'var(--oc-color-text-disabled)', + dividerColor: '#d9e3ee', + borderColor: '#d9e3ee', + inputColor: '#ffffff', }, - }, - Tabs: { tabBorderRadius: '0px' }, - Tag: { borderRadius: '0px' }, - Alert: { borderRadius: '0px' }, - Modal: { borderRadius: '0px' }, + Button: { + borderRadiusTiny: '0px', + borderRadiusSmall: '0px', + borderRadiusMedium: '0px', + borderRadiusLarge: '0px', + textColorPrimary: '#0f172a', + textColorHoverPrimary: '#0f172a', + }, + Card: { + borderRadius: '0px', + borderRadiusEmbedded: '0px', + paddingMedium: '24px', + }, + Input: { borderRadius: '0px' }, + Select: { + peers: { + InternalSelection: { borderRadius: '0px' }, + }, + }, + Tabs: { tabBorderRadius: '0px' }, + Tag: { borderRadius: '0px' }, + Alert: { borderRadius: '0px' }, + Modal: { borderRadius: '0px' }, } diff --git a/src/main.ts b/src/main.ts index 16d5621..7a11b71 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' -import { useAuthStore } from './stores/auth' +import { useAuthStore } from '@/features/auth/stores/auth' import './style.css' const app = createApp(App) @@ -12,6 +12,6 @@ app.use(pinia) const authStore = useAuthStore() authStore.initialize().then(() => { - app.use(router) - app.mount('#app') + app.use(router) + app.mount('#app') }) diff --git a/src/router/index.ts b/src/router/index.ts index b57f7ba..2740780 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,176 +1,177 @@ import { createRouter, createWebHistory } from 'vue-router' -import { getCloudTypeName } from '@/lib/cloudTypes' +import { getCloudTypeName } from '@/features/clouds/cloudTypes' import { applyRouteSeo } from '@/lib/seo' -import { useAuthStore } from '@/stores/auth' +import { useAuthStore } from '@/features/auth/stores/auth' const router = createRouter({ - history: createWebHistory(import.meta.env.BASE_URL), - routes: [ - { - path: '/', - name: 'map', - component: () => import('@/views/map/MapView.vue'), - meta: { - title: '实时云图地图', - description: '在 OpenCloud 地图上浏览社区拍摄的云图,按位置查看天空观测记录。', - }, + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + name: 'map', + component: () => import('@/features/map/views/MapView.vue'), + meta: { + title: '实时云图地图', + description: '在 OpenCloud 地图上浏览社区拍摄的云图,按位置查看天空观测记录。', + }, + }, + { + path: '/login', + name: 'login', + component: () => import('@/features/auth/views/LoginView.vue'), + meta: { title: '登录', noindex: true }, + }, + { + path: '/register', + name: 'register', + component: () => import('@/features/auth/views/RegisterView.vue'), + meta: { title: '注册', noindex: true }, + }, + { + path: '/resend-confirmation', + name: 'resend-confirmation', + component: () => import('@/features/auth/views/ResendConfirmationView.vue'), + meta: { title: '重发验证邮件', noindex: true }, + }, + { + path: '/forgot-password', + name: 'forgot-password', + component: () => import('@/features/auth/views/ForgotPasswordView.vue'), + meta: { title: '忘记密码', noindex: true }, + }, + { + path: '/verify-email', + alias: '/auth/confirm', + name: 'auth-confirm', + component: () => import('@/features/auth/views/AuthConfirmView.vue'), + meta: { title: '确认邮箱', noindex: true }, + }, + { + path: '/reset-password', + alias: '/auth/reset-password', + name: 'auth-reset-password', + component: () => import('@/features/auth/views/ResetPasswordView.vue'), + meta: { title: '重置密码', noindex: true }, + }, + { + path: '/upload', + name: 'upload', + component: () => import('@/features/upload/views/UploadView.vue'), + meta: { requiresAuth: true, title: '上传云图', noindex: true }, + }, + { + path: '/encyclopedia', + name: 'encyclopedia', + component: () => import('@/features/encyclopedia/views/EncyclopediaView.vue'), + meta: { + requiresAuth: true, + title: '云朵百科', + description: '浏览基础云属,了解云型特征和社区公开观测记录。', + noindex: true, + }, + }, + { + path: '/encyclopedia/:id', + name: 'cloud-type', + component: () => import('@/features/encyclopedia/views/CloudTypeView.vue'), + meta: { + requiresAuth: true, + title: route => getCloudTypeName(route.params.id), + description: route => + `查看 ${getCloudTypeName(route.params.id)} 的识别要点、公开云图和社区观测记录。`, + noindex: true, + }, + }, + { + path: '/gallery', + name: 'gallery', + component: () => import('@/features/gallery/views/GalleryView.vue'), + meta: { + title: '云图画廊', + description: '按时间浏览 OpenCloud 社区公开分享的云图照片和观测记录。', + }, + }, + { + path: '/community', + name: 'community', + component: () => import('@/features/community/views/CommunityView.vue'), + meta: { + title: '社区', + description: '查看 OpenCloud 社区动态,发现更多天空观测和云图收藏。', + }, + }, + { + path: '/profile', + name: 'profile', + component: () => import('@/features/profile/views/ProfileView.vue'), + meta: { requiresAuth: true, title: '个人主页', noindex: true }, + }, + { + path: '/profile/settings', + name: 'profile-settings', + component: () => import('@/features/profile/views/ProfileSettingsView.vue'), + meta: { requiresAuth: true, title: '账号设置', noindex: true }, + }, + { + path: '/profile/:id', + name: 'user-profile', + component: () => import('@/features/profile/views/ProfileView.vue'), + meta: { + title: '用户云图档案', + description: '按用户名查看用户公开分享的云图记录和拍摄时间线。', + }, + }, + { + path: '/admin', + name: 'admin', + component: () => import('@/features/admin/views/AdminView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: '管理后台', noindex: true }, + }, + { + path: '/403', + name: 'forbidden', + component: () => import('@/features/system/views/ForbiddenView.vue'), + meta: { title: '无权访问', noindex: true }, + }, + { + path: '/401', + name: 'auth-required', + component: () => import('@/features/system/views/AuthRequiredView.vue'), + meta: { title: '需要登录', noindex: true }, + }, + { + path: '/:pathMatch(.*)*', + name: 'not-found', + component: () => import('@/features/system/views/NotFoundView.vue'), + meta: { title: '页面不存在', noindex: true }, + }, + ], + scrollBehavior() { + return { top: 0 } }, - { - path: '/login', - name: 'login', - component: () => import('@/views/auth/LoginView.vue'), - meta: { title: '登录', noindex: true }, - }, - { - path: '/register', - name: 'register', - 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', - component: () => import('@/views/auth/ForgotPasswordView.vue'), - meta: { title: '忘记密码', noindex: true }, - }, - { - path: '/verify-email', - alias: '/auth/confirm', - name: 'auth-confirm', - component: () => import('@/views/auth/AuthConfirmView.vue'), - meta: { title: '确认邮箱', noindex: true }, - }, - { - path: '/reset-password', - alias: '/auth/reset-password', - name: 'auth-reset-password', - component: () => import('@/views/auth/ResetPasswordView.vue'), - meta: { title: '重置密码', noindex: true }, - }, - { - path: '/upload', - name: 'upload', - component: () => import('@/views/upload/UploadView.vue'), - meta: { requiresAuth: true, title: '上传云图', noindex: true }, - }, - { - path: '/encyclopedia', - name: 'encyclopedia', - component: () => import('@/views/encyclopedia/EncyclopediaView.vue'), - meta: { - requiresAuth: true, - title: '云朵百科', - description: '浏览基础云属,了解云型特征和社区公开观测记录。', - noindex: true, - }, - }, - { - path: '/encyclopedia/:id', - name: 'cloud-type', - component: () => import('@/views/encyclopedia/CloudTypeView.vue'), - meta: { - requiresAuth: true, - title: route => getCloudTypeName(route.params.id), - description: route => `查看 ${getCloudTypeName(route.params.id)} 的识别要点、公开云图和社区观测记录。`, - noindex: true, - }, - }, - { - path: '/gallery', - name: 'gallery', - component: () => import('@/views/gallery/GalleryView.vue'), - meta: { - title: '云图画廊', - description: '按时间浏览 OpenCloud 社区公开分享的云图照片和观测记录。', - }, - }, - { - path: '/community', - name: 'community', - component: () => import('@/views/community/CommunityView.vue'), - meta: { - title: '社区', - description: '查看 OpenCloud 社区动态,发现更多天空观测和云图收藏。', - }, - }, - { - path: '/profile', - name: 'profile', - component: () => import('@/views/profile/ProfileView.vue'), - meta: { requiresAuth: true, title: '个人主页', noindex: true }, - }, - { - path: '/profile/settings', - name: 'profile-settings', - component: () => import('@/views/profile/ProfileSettingsView.vue'), - meta: { requiresAuth: true, title: '账号设置', noindex: true }, - }, - { - path: '/profile/:id', - name: 'user-profile', - component: () => import('@/views/profile/ProfileView.vue'), - meta: { - title: '用户云图档案', - description: '按用户名查看用户公开分享的云图记录和拍摄时间线。', - }, - }, - { - path: '/admin', - name: 'admin', - component: () => import('@/views/admin/AdminView.vue'), - meta: { requiresAuth: true, requiresAdmin: true, title: '管理后台', noindex: true }, - }, - { - path: '/403', - name: 'forbidden', - component: () => import('@/views/system/ForbiddenView.vue'), - meta: { title: '无权访问', noindex: true }, - }, - { - path: '/401', - name: 'auth-required', - component: () => import('@/views/system/AuthRequiredView.vue'), - meta: { title: '需要登录', noindex: true }, - }, - { - path: '/:pathMatch(.*)*', - name: 'not-found', - component: () => import('@/views/system/NotFoundView.vue'), - meta: { title: '页面不存在', noindex: true }, - }, - ], - scrollBehavior() { - return { top: 0 } - }, }) router.beforeEach((to, _from, next) => { - const authStore = useAuthStore() + const authStore = useAuthStore() - if (to.meta.requiresAuth && !authStore.isLoggedIn) { - next({ - name: 'auth-required', - query: { - redirect: to.fullPath, - }, - }) - } else if (to.meta.requiresAdmin && !authStore.isAdmin) { - next({ name: 'forbidden' }) - } else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) { - next({ name: 'map' }) - } else { - next() - } + if (to.meta.requiresAuth && !authStore.isLoggedIn) { + next({ + name: 'auth-required', + query: { + redirect: to.fullPath, + }, + }) + } else if (to.meta.requiresAdmin && !authStore.isAdmin) { + next({ name: 'forbidden' }) + } else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) { + next({ name: 'map' }) + } else { + next() + } }) router.afterEach(to => { - applyRouteSeo(to) + applyRouteSeo(to) }) export default router diff --git a/src/shared/map/amap.ts b/src/shared/map/amap.ts new file mode 100644 index 0000000..516a7af --- /dev/null +++ b/src/shared/map/amap.ts @@ -0,0 +1,16 @@ +import AMapLoader from '@amap/amap-jsapi-loader' + +export function loadAMap() { + return AMapLoader.load({ + key: import.meta.env.VITE_AMAP_KEY, + version: '2.0', + plugins: [ + 'AMap.Scale', + 'AMap.ToolBar', + 'AMap.ControlBar', + 'AMap.Geolocation', + 'AMap.Marker', + 'AMap.InfoWindow', + ], + }) +} diff --git a/src/shared/map/components/MapPickerModal.vue b/src/shared/map/components/MapPickerModal.vue new file mode 100644 index 0000000..1e2cf03 --- /dev/null +++ b/src/shared/map/components/MapPickerModal.vue @@ -0,0 +1,238 @@ + + + diff --git a/src/shared/map/components/MiniLocationMap.vue b/src/shared/map/components/MiniLocationMap.vue new file mode 100644 index 0000000..c3f421a --- /dev/null +++ b/src/shared/map/components/MiniLocationMap.vue @@ -0,0 +1,138 @@ + + + diff --git a/src/shared/map/types/amap.d.ts b/src/shared/map/types/amap.d.ts new file mode 100644 index 0000000..65b5996 --- /dev/null +++ b/src/shared/map/types/amap.d.ts @@ -0,0 +1,126 @@ +declare namespace AMap { + class Map { + constructor(container: string | HTMLElement, opts?: MapOptions) + addControl(control: unknown): void + add(layer: unknown): void + remove(layer: unknown): void + destroy(): void + setCenter(center: [number, number]): void + setZoom(zoom: number, immediately?: boolean, duration?: number): void + setPitch(pitch: number): void + getPitch(): number + setRotation(rotation: number): void + getRotation(): number + on(event: string, callback: (...args: unknown[]) => void): void + off(event: string, callback: (...args: unknown[]) => void): void + getCenter(): { lng: number; lat: number } + getZoom(digits?: number): number + getAllOverlays(type?: string): unknown[] + addOverlay(overlay: unknown): void + removeOverlay(overlay: unknown): void + clearMap(): void + } + + interface MapOptions { + viewMode?: '2D' | '3D' + pitch?: number + rotation?: number + zoom?: number + zooms?: [number, number] + center?: [number, number] + mapStyle?: string + features?: string[] + layers?: unknown[] + skyColor?: string + maxPitch?: number + resizeEnable?: boolean + dragEnable?: boolean + zoomEnable?: boolean + keyboardEnable?: boolean + doubleClickZoom?: boolean + scrollWheel?: boolean + } + + class Marker { + constructor(opts?: MarkerOptions) + on(event: string, callback: (...args: unknown[]) => void): void + setPosition(position: [number, number]): void + getPosition(): { lng: number; lat: number } + setContent(content: string): void + setOpacity(opacity: number): void + setMap(map: Map | null): void + remove(): void + } + + interface MarkerOptions { + position?: [number, number] + content?: string | HTMLElement + offset?: Pixel + zIndex?: number + opacity?: number + title?: string + } + + class InfoWindow { + constructor(opts?: InfoWindowOptions) + open(map: Map, position?: [number, number]): void + close(): void + } + + interface InfoWindowOptions { + content?: string | HTMLElement + offset?: Pixel + size?: Size + isCustom?: boolean + } + + class Scale {} + class ToolBar { + constructor(opts?: Record) + } + class ControlBar { + constructor(opts?: Record) + } + class Geolocation { + constructor(opts?: Record) + getCurrentPosition( + onSuccess: (data: { position: { lng: number; lat: number } }) => void, + onError?: (error: unknown) => void, + ): void + } + + class TileLayer { + static Satellite: new () => TileLayer + static RoadNet: new () => TileLayer + setOpacity(opacity: number): void + } + + class Pixel { + constructor(x: number, y: number) + } + + class Size { + constructor(width: number, height: number) + } + + class MarkerCluster { + constructor(map: Map, markers: Marker[], opts?: Record) + } +} + +declare function AMapLoader_load(opts: { + key: string + version: string + plugins?: string[] +}): Promise + +declare namespace AMap { + function getConfig(): { appname?: string } +} + +declare module '@amap/amap-jsapi-loader' { + const AMapLoader: { + load: typeof AMapLoader_load + } + export default AMapLoader +} diff --git a/src/stores/auth.ts b/src/stores/auth.ts deleted file mode 100644 index 52422cc..0000000 --- a/src/stores/auth.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { computed, ref } from 'vue' -import { defineStore } from 'pinia' -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 | null>(null) - const profile = ref(null) - const loading = ref(true) - let listenerAttached = false - - const isLoggedIn = computed(() => !!user.value) - const isAdmin = computed(() => profile.value?.role === 'admin') - - function applyProfile(backendProfile: BackendUserProfile) { - const normalized = toAuthUser(backendProfile) - user.value = normalized - profile.value = { - id: normalized.id, - username: normalized.username, - image: normalized.image, - role: normalized.role, - is_disabled: normalized.is_disabled, - created_at: normalized.created_at, - } - } - - function clearAuth() { - user.value = null - profile.value = null - } - - async function fetchMe() { - const backendProfile = await apiRequest('/profile/me') - applyProfile(backendProfile) - } - - async function login(email: string, password: string) { - 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(name: string, email: string, password: string) { - assertAuthResult(await authClient.signUp.email({ name, email, password }), '注册失败,请稍后重试。') - } - - async function resendConfirmation(email: string) { - assertAuthResult(await authClient.sendVerificationEmail({ email }), '验证邮件发送失败,请稍后重试。') - } - - async function confirmEmail(token: string) { - assertAuthResult(await authClient.$fetch('/verify-email', { - method: 'GET', - query: { token }, - }), '确认链接无效或已过期。') - } - - async function sendPasswordReset(email: string) { - 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('/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 (!listenerAttached) { - window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth) - listenerAttached = true - } - - try { - const session = await authClient.getSession() - assertAuthResult(session, '获取登录会话失败,请稍后重试。') - if (session.data) await fetchMe() - else clearAuth() - } catch { - clearAuth() - } finally { - loading.value = false - } - } - - return { - user, - profile, - loading, - isLoggedIn, - isAdmin, - login, - register, - resendConfirmation, - confirmEmail, - sendPasswordReset, - resetPassword, - logout, - updateUsername, - refreshProfile: fetchMe, - updatePassword, - initialize, - } -}) diff --git a/src/stores/clouds.ts b/src/stores/clouds.ts deleted file mode 100644 index ac49833..0000000 --- a/src/stores/clouds.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ref } from 'vue' -import { defineStore } from 'pinia' -import { apiRequest, toCloudType } from '@/lib/api' -import type { BackendCloudType } from '@/types/api' -import type { CloudType } from '@/types/database' - -export const useCloudsStore = defineStore('clouds', () => { - const cloudTypes = ref([]) - const loading = ref(false) - - async function fetchCloudTypes() { - if (cloudTypes.value.length > 0) return - loading.value = true - try { - const rows = await apiRequest('/info/cloudtype', { auth: false }) - cloudTypes.value = rows.map(toCloudType) - } finally { - loading.value = false - } - } - - function setCloudTypes(types: CloudType[]) { - cloudTypes.value = types - } - - return { cloudTypes, loading, fetchCloudTypes, setCloudTypes } -}) diff --git a/src/stores/encyclopedia.ts b/src/stores/encyclopedia.ts deleted file mode 100644 index fd8bd1c..0000000 --- a/src/stores/encyclopedia.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ref } from 'vue' -import { defineStore } from 'pinia' -import { apiRequest, toCloudType } from '@/lib/api' -import type { BackendCloudType } from '@/types/api' -import type { CloudType } from '@/types/database' - -export const useEncyclopediaStore = defineStore('encyclopedia', () => { - const cloudTypes = ref([]) - const loadingCloudTypes = ref(false) - const cloudTypesLoaded = ref(false) - - async function fetchCloudTypes(force = false) { - if (cloudTypesLoaded.value && !force) return - loadingCloudTypes.value = true - try { - const rows = await apiRequest('/info/cloudtype', { auth: false }) - cloudTypes.value = rows.map(toCloudType) - cloudTypesLoaded.value = true - } finally { - loadingCloudTypes.value = false - } - } - - function setCloudTypes(types: CloudType[]) { - cloudTypes.value = types - cloudTypesLoaded.value = true - } - - return { - cloudTypes, - loadingCloudTypes, - fetchCloudTypes, - setCloudTypes, - } -}) diff --git a/src/stores/likes.ts b/src/stores/likes.ts deleted file mode 100644 index 68bb63b..0000000 --- a/src/stores/likes.ts +++ /dev/null @@ -1,105 +0,0 @@ -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>(new Set()) - const counts = ref>({}) - const pendingIds = ref>(new Set()) - const loadedForUser = ref(null) - let loadPromise: Promise | 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() - for (let page = 1; ; page++) { - const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) }) - const rows = await apiRequest(`/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(`/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(`/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 } -}) diff --git a/src/stores/profile.ts b/src/stores/profile.ts deleted file mode 100644 index fed9ae3..0000000 --- a/src/stores/profile.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { ref } from 'vue' -import { defineStore } from 'pinia' -import { ApiError, apiRequest, toApiCloud } from '@/lib/api' -import { useAuthStore } from '@/stores/auth' -import type { ActionResponse, ApiCloud, BackendCloudInfo, BackendUserProfile } from '@/types/api' -import type { Profile } from '@/types/database' - -export interface ProfileCloudItem { - id: string - cloud_type_id: number - image_url: string - 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: number - receivedLikeCount: number -} - -function toProfileCloud(row: ApiCloud): ProfileCloudItem { - return { - id: row.id, - cloud_type_id: row.cloud_type_id as number, - image_url: row.image_url, - 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 || '未知', - cloudTypeRarity: row.cloud_type_rarity_value ?? 0, - receivedLikeCount: row.received_like_count, - } -} - -export const useProfileStore = defineStore('profile-page', () => { - const authStore = useAuthStore() - const profilesByKey = ref>({}) - const cloudsByKey = ref>({}) - const loadingKeys = ref>({}) - const errorByKey = ref>({}) - - const makeKey = (identifier: string, isOwnProfile: boolean) => `${identifier}:${isOwnProfile ? 'own' : 'public'}` - - function getProfile(identifier: string | null) { - return identifier ? profilesByKey.value[identifier] ?? null : null - } - - function getClouds(identifier: string | null, isOwnProfile: boolean) { - return identifier ? cloudsByKey.value[makeKey(identifier, isOwnProfile)] ?? [] : [] - } - - function getError(identifier: string | null, isOwnProfile: boolean) { - return identifier ? errorByKey.value[makeKey(identifier, isOwnProfile)] ?? '' : '' - } - - function isLoaded(identifier: string | null, isOwnProfile: boolean) { - return !!identifier && makeKey(identifier, 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(`/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 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 backendRows = isOwnProfile - ? await apiRequest('/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(`/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] = [] - } finally { - loadingKeys.value[key] = false - } - } - - function isLoading(identifier: string | null, isOwnProfile: boolean) { - return !!identifier && !!loadingKeys.value[makeKey(identifier, isOwnProfile)] - } - - function patchCachedCloud(identifier: string, cloudId: string, patch: Partial) { - for (const isOwnProfile of [true, false]) { - const key = makeKey(identifier, isOwnProfile) - const current = cloudsByKey.value[key] - if (!current) continue - cloudsByKey.value[key] = current - .map(item => item.id === cloudId ? { ...item, ...patch } : item) - .filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden)) - } - } - - function removeCachedClouds(identifier: string, cloudIds: string[]) { - const ids = new Set(cloudIds) - for (const isOwnProfile of [true, false]) { - const key = makeKey(identifier, isOwnProfile) - const current = cloudsByKey.value[key] - if (current) cloudsByKey.value[key] = current.filter(item => !ids.has(item.id)) - } - } - - function invalidateUser(identifier: string) { - delete cloudsByKey.value[makeKey(identifier, true)] - delete cloudsByKey.value[makeKey(identifier, false)] - } - - async function updateCloud(identifier: string, cloudId: string, patch: { - type_id: number - latitude: number - longitude: number - description?: string - captured_at?: string - is_hidden: boolean - }) { - await apiRequest(`/cloud/${cloudId}`, { - method: 'PATCH', - body: patch, - }) - const updated = toProfileCloud(toApiCloud(await apiRequest(`/cloud/${cloudId}`))) - patchCachedCloud(identifier, cloudId, updated) - return updated - } - - async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) { - await apiRequest(`/cloud/${cloudId}`, { - method: 'PATCH', - body: { is_hidden: isHidden }, - }) - patchCachedCloud(identifier, cloudId, { is_hidden: isHidden }) - } - - async function deleteClouds(identifier: string, cloudIds: string[]) { - if (!cloudIds.length) return 0 - - if (cloudIds.length === 1) { - await apiRequest(`/cloud/${cloudIds[0]}`, { method: 'DELETE' }) - } else { - for (let offset = 0; offset < cloudIds.length; offset += 100) { - await apiRequest('/cloud', { - method: 'DELETE', - body: { - cloud_ids: cloudIds.slice(offset, offset + 100).map(cloudId => ({ cloud_id: cloudId })), - }, - }) - } - } - - removeCachedClouds(identifier, cloudIds) - return cloudIds.length - } - - return { - getProfile, - getClouds, - getError, - isLoaded, - isLoading, - fetchProfilePage, - invalidateUser, - updateCloud, - updateCloudVisibility, - deleteClouds, - } -}) diff --git a/src/styles/base.css b/src/styles/base.css index cfa5722..fee5276 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -1,27 +1,26 @@ @layer base { - html, - body, - #app { - min-height: 100%; - } + html, + body, + #app { + min-height: 100%; + } - body { - color: var(--oc-color-text); - background: - linear-gradient(180deg, rgb(15 23 42 / 2%), rgb(15 23 42 / 0%)), - var(--oc-color-canvas); - font-family: var(--oc-font-sans); - } + body { + color: var(--oc-color-text); + background: + linear-gradient(180deg, rgb(15 23 42 / 2%), rgb(15 23 42 / 0%)), var(--oc-color-canvas); + font-family: var(--oc-font-sans); + } - ::selection { - background: var(--oc-color-selection); - } + ::selection { + background: var(--oc-color-selection); + } } @layer utilities { - /* The atlas language is square by default. Documented exceptions may opt out. */ - [class~='rounded'], - [class*='rounded-'] { - border-radius: 0 !important; - } + /* The atlas language is square by default. Documented exceptions may opt out. */ + [class~='rounded'], + [class*='rounded-'] { + border-radius: 0 !important; + } } diff --git a/src/styles/components.css b/src/styles/components.css index 0ab2554..f38657f 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1,632 +1,645 @@ @layer components { - /* Application and page structure */ - .oc-app-shell { - @apply flex min-h-screen flex-col; - background: var(--oc-gradient-canvas); - } + /* Application and page structure */ + .oc-app-shell { + @apply flex min-h-screen flex-col; + background: var(--oc-gradient-canvas); + } - .oc-app-grid { - @apply pointer-events-none absolute inset-0 opacity-35; - background-image: var(--oc-gradient-grid); - background-size: 32px 32px; - } + .oc-app-grid { + @apply pointer-events-none absolute inset-0 opacity-35; + background-image: var(--oc-gradient-grid); + background-size: 32px 32px; + } - .oc-page-hero { - @apply border-b border-sky-100; - background: var(--oc-gradient-page); - } + .oc-page-hero { + @apply border-b border-sky-100; + background: var(--oc-gradient-page); + } - .oc-page-hero--soft { - background: var(--oc-gradient-page-soft); - } + .oc-page-hero--soft { + background: var(--oc-gradient-page-soft); + } - .oc-container { - @apply mx-auto w-full px-4 sm:px-6 lg:px-8; - } + .oc-container { + @apply mx-auto w-full px-4 sm:px-6 lg:px-8; + } - .oc-container--narrow { - @apply max-w-4xl; - } + .oc-container--narrow { + @apply max-w-4xl; + } - .oc-container--content { - @apply max-w-6xl; - } + .oc-container--content { + @apply max-w-6xl; + } - .oc-container--wide { - @apply max-w-7xl; - } + .oc-container--wide { + @apply max-w-7xl; + } - .oc-page-hero__inner { - @apply py-10; - } + .oc-page-hero__inner { + @apply py-10; + } - .oc-page-hero__split { - @apply grid gap-6; - } - - @media (min-width: 900px) { .oc-page-hero__split { - grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr); - align-items: start; + @apply grid gap-6; } - .oc-page-hero__split--stretch { - align-items: stretch; + @media (min-width: 900px) { + .oc-page-hero__split { + grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr); + align-items: start; + } + + .oc-page-hero__split--stretch { + align-items: stretch; + } } - } - .oc-page-content { - @apply py-8; - } - - .oc-page-eyebrow { - @apply text-sm font-medium uppercase tracking-[0.24em] text-sky-700; - } - - .oc-page-title { - @apply mt-3 text-4xl font-bold text-slate-900; - } - - .oc-page-description { - @apply mt-4 max-w-2xl text-sm leading-7 text-slate-600; - } - - .oc-brand-mark { - @apply flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-100; - box-shadow: var(--oc-shadow-sm); - } - - /* Reusable text hierarchy */ - .oc-section-label { - @apply text-xs uppercase tracking-[0.18em] text-slate-500; - } - - .oc-stat-label { - @apply text-xs uppercase tracking-[0.2em] text-slate-500; - } - - .oc-stat-value { - @apply mt-2 text-2xl font-bold text-slate-900; - } - - .oc-meta { - @apply text-sm text-slate-500; - } - - .oc-field-label { - @apply mb-1 block text-sm font-medium text-slate-700; - } - - .oc-field-required { - @apply text-rose-500; - } - - .oc-field-control { - @apply w-full border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-800 outline-none transition-colors; - @apply focus:border-sky-500 focus:ring-2 focus:ring-sky-100; - } - - .oc-field-error { - @apply mt-1 text-sm text-rose-600; - } - - .oc-field-help { - @apply mt-1 text-sm text-slate-500; - } - - /* Surfaces */ - .oc-panel-card { - box-shadow: var(--oc-shadow-md) !important; - } - - .oc-panel-card-xl { - box-shadow: var(--oc-shadow-xl) !important; - } - - .oc-floating-panel { - box-shadow: var(--oc-shadow-lg); - } - - .oc-panel-card-soft { - box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%) !important; - } - - .oc-empty-card { - box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%); - } - - .oc-surface { - @apply border border-slate-200 bg-white; - box-shadow: var(--oc-shadow-md); - } - - .oc-modal-shell { - @apply border border-slate-200 bg-white; - box-shadow: var(--oc-shadow-xl); - } - - .oc-inset-panel { - @apply border border-slate-200 bg-slate-50 p-4; - } - - .oc-map-button { - @apply flex h-10 w-10 items-center justify-center border border-slate-200 bg-white text-slate-600; - box-shadow: var(--oc-shadow-xs); - } - - /* Shared interactive contracts */ - .oc-panel-button:not(.n-button), - .oc-header-button, - .oc-icon-button, - .oc-map-button, - .oc-choice-button, - .oc-text-button, - .oc-menu-item, - .oc-nav-link, - .oc-interactive-card { - transition: - background-color var(--oc-duration-fast) var(--oc-ease-standard), - color var(--oc-duration-fast) var(--oc-ease-standard), - border-color var(--oc-duration-fast) var(--oc-ease-standard), - box-shadow var(--oc-duration-fast) var(--oc-ease-standard), - transform var(--oc-duration-fast) var(--oc-ease-standard); - } - - .oc-panel-button:not(.n-button) { - @apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium; - } - - .oc-panel-button:not(.n-button):not(:disabled):hover, - .oc-header-button:not(:disabled):hover, - .oc-icon-button:not(:disabled):hover, - .oc-map-button:not(:disabled):hover, - .oc-choice-button:not(:disabled):hover { - transform: translate(-1px, -1px); - } - - .oc-panel-button--neutral:not(.n-button) { - @apply border-slate-200 bg-white text-slate-600; - box-shadow: var(--oc-shadow-xs); - } - - .oc-panel-button--neutral:not(.n-button):hover, - .oc-panel-button--neutral:not(.n-button):focus-visible { - @apply border-teal-200 bg-teal-50 text-teal-800; - box-shadow: var(--oc-shadow-primary); - } - - .oc-panel-button--sky:not(.n-button) { - @apply border-sky-200 bg-sky-100 text-sky-800; - box-shadow: var(--oc-shadow-sky); - } - - .oc-panel-button--sky:not(.n-button):hover, - .oc-panel-button--sky:not(.n-button):focus-visible { - @apply border-sky-300 bg-sky-50 text-sky-900; - box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%); - } - - .oc-panel-button--teal:not(.n-button) { - @apply border-teal-200 bg-teal-50 text-teal-800; - box-shadow: var(--oc-shadow-teal); - } - - .oc-panel-button--teal:not(.n-button):hover, - .oc-panel-button--teal:not(.n-button):focus-visible { - @apply border-teal-300 bg-teal-100 text-teal-900; - box-shadow: var(--oc-shadow-primary-hover); - } - - .oc-panel-button--danger:not(.n-button) { - @apply border-rose-200 bg-rose-50 text-rose-700; - box-shadow: 4px 4px 0 0 rgb(244 63 94 / 10%); - } - - .oc-panel-button--danger:not(.n-button):hover, - .oc-panel-button--danger:not(.n-button):focus-visible { - @apply border-rose-300 bg-rose-100 text-rose-800; - box-shadow: 5px 5px 0 0 rgb(244 63 94 / 14%); - } - - .oc-panel-button--amber:not(.n-button) { - @apply border-amber-200 bg-amber-50 text-amber-800; - box-shadow: 4px 4px 0 0 rgb(245 158 11 / 10%); - } - - .oc-panel-button--amber:not(.n-button):hover, - .oc-panel-button--amber:not(.n-button):focus-visible { - @apply border-amber-300 bg-amber-100 text-amber-900; - box-shadow: 5px 5px 0 0 rgb(245 158 11 / 14%); - } - - .oc-icon-button { - @apply flex h-10 w-10 shrink-0 items-center justify-center border border-slate-200 bg-white text-slate-600; - box-shadow: var(--oc-shadow-xs); - } - - .oc-icon-button:hover, - .oc-icon-button:focus-visible, - .oc-map-button:hover, - .oc-map-button:focus-visible { - @apply border-teal-200 bg-teal-50 text-teal-800; - box-shadow: var(--oc-shadow-primary); - } - - .oc-icon-button--small { - @apply h-8 w-8; - } - - .oc-icon-button--tiny { - @apply h-6 w-6 text-xs; - box-shadow: 2px 2px 0 0 rgb(15 23 42 / 8%); - } - - .oc-icon-button--sky { - @apply border-sky-200 bg-sky-50 text-sky-700; - box-shadow: 3px 3px 0 0 rgb(14 165 233 / 12%); - } - - .oc-icon-button--sky:hover, - .oc-icon-button--sky:focus-visible { - @apply border-sky-300 bg-sky-100 text-sky-900; - box-shadow: 4px 4px 0 0 rgb(14 165 233 / 16%); - } - - .oc-icon-button--danger { - @apply border-rose-300 bg-rose-500 text-white; - box-shadow: 3px 3px 0 0 rgb(159 18 57 / 18%); - } - - .oc-icon-button--danger:hover, - .oc-icon-button--danger:focus-visible { - @apply border-rose-400 bg-rose-600 text-white; - box-shadow: 4px 4px 0 0 rgb(159 18 57 / 22%); - } - - .oc-overlay-button { - @apply flex shrink-0 items-center justify-center text-white/75 hover:bg-white/10 hover:text-white; - transition: - background-color var(--oc-duration-fast) var(--oc-ease-standard), - color var(--oc-duration-fast) var(--oc-ease-standard), - transform var(--oc-duration-fast) var(--oc-ease-standard); - } - - .oc-text-button { - @apply inline-flex items-center font-medium text-sky-700 underline-offset-4 hover:text-sky-900 hover:underline; - } - - .oc-text-button--muted { - @apply text-slate-500 hover:text-slate-800; - } - - .oc-text-button--danger { - @apply text-rose-600 hover:text-rose-800; - } - - .oc-choice-button { - @apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium; - } - - .oc-choice-button--inactive { - @apply border-slate-200 bg-white text-slate-600 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; - box-shadow: var(--oc-shadow-xs); - } - - .oc-choice-button--active { - @apply border-teal-200 bg-teal-50 text-teal-800; - box-shadow: var(--oc-shadow-primary); - } - - .oc-menu-item { - @apply flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-teal-50 hover:text-teal-800; - } - - .oc-menu-item--danger { - @apply text-rose-600 hover:bg-rose-50 hover:text-rose-700; - } - - .oc-menu-item--amber { - @apply hover:bg-amber-50 hover:text-amber-700; - } - - .oc-nav-link { - @apply px-3 py-2 text-sm font-medium uppercase tracking-[0.12em]; - } - - .oc-header-button { - @apply inline-flex h-8 items-center justify-center border px-3 text-sm font-medium md:h-10 md:px-4; - } - - .oc-header-button--neutral { - @apply border-slate-200 bg-white/80 text-slate-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; - box-shadow: var(--oc-shadow-xs); - } - - .oc-header-button--sky { - @apply border-sky-200 bg-sky-100 text-sky-800 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-900; - box-shadow: var(--oc-shadow-sky); - } - - .oc-header-button--teal { - @apply border-teal-100 bg-white/80 text-teal-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; - box-shadow: var(--oc-shadow-teal); - } - - .oc-header-button:hover { - box-shadow: 5px 5px 0 0 rgb(15 23 42 / 8%); - } - - .oc-interactive-card:focus-visible { - @apply outline-none ring-2 ring-sky-600 ring-offset-2; - } - - .oc-interactive-card { - box-shadow: var(--oc-shadow-md); - } - - .oc-interactive-card:hover { - box-shadow: var(--oc-shadow-lg); - } - - .oc-interactive-card--selected { - @apply border-sky-500; - box-shadow: var(--oc-shadow-sky) !important; - } - - .oc-interactive-row { - transition: - background-color var(--oc-duration-fast) var(--oc-ease-standard), - color var(--oc-duration-fast) var(--oc-ease-standard), - box-shadow var(--oc-duration-fast) var(--oc-ease-standard); - } - - .oc-interactive-row:hover { - @apply bg-sky-50/60; - } - - .oc-interactive-row:focus-visible, - .oc-heatmap-cell:focus-visible { - @apply outline-none ring-2 ring-sky-600 ring-offset-2; - } - - .oc-heatmap-cell { - transition: - border-color var(--oc-duration-fast) var(--oc-ease-standard), - box-shadow var(--oc-duration-fast) var(--oc-ease-standard), - transform var(--oc-duration-fast) var(--oc-ease-standard); - } - - :is( - .oc-primary-button, - .oc-panel-button, + .oc-page-content { + @apply py-8; + } + + .oc-page-eyebrow { + @apply text-sm font-medium uppercase tracking-[0.24em] text-sky-700; + } + + .oc-page-title { + @apply mt-3 text-4xl font-bold text-slate-900; + } + + .oc-page-description { + @apply mt-4 max-w-2xl text-sm leading-7 text-slate-600; + } + + .oc-brand-mark { + @apply flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-100; + box-shadow: var(--oc-shadow-sm); + } + + /* Reusable text hierarchy */ + .oc-section-label { + @apply text-xs uppercase tracking-[0.18em] text-slate-500; + } + + .oc-stat-label { + @apply text-xs uppercase tracking-[0.2em] text-slate-500; + } + + .oc-stat-value { + @apply mt-2 text-2xl font-bold text-slate-900; + } + + .oc-meta { + @apply text-sm text-slate-500; + } + + .oc-field-label { + @apply mb-1 block text-sm font-medium text-slate-700; + } + + .oc-field-required { + @apply text-rose-500; + } + + .oc-field-control { + @apply w-full border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-800 outline-none transition-colors; + @apply focus:border-sky-500 focus:ring-2 focus:ring-sky-100; + } + + .oc-field-error { + @apply mt-1 text-sm text-rose-600; + } + + .oc-field-help { + @apply mt-1 text-sm text-slate-500; + } + + /* Surfaces */ + .oc-panel-card { + box-shadow: var(--oc-shadow-md) !important; + } + + .oc-panel-card-xl { + box-shadow: var(--oc-shadow-xl) !important; + } + + .oc-floating-panel { + box-shadow: var(--oc-shadow-lg); + } + + .oc-panel-card-soft { + box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%) !important; + } + + .oc-empty-card { + box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%); + } + + .oc-surface { + @apply border border-slate-200 bg-white; + box-shadow: var(--oc-shadow-md); + } + + .oc-modal-shell { + @apply border border-slate-200 bg-white; + box-shadow: var(--oc-shadow-xl); + } + + .oc-inset-panel { + @apply border border-slate-200 bg-slate-50 p-4; + } + + .oc-map-button { + @apply flex h-10 w-10 items-center justify-center border border-slate-200 bg-white text-slate-600; + box-shadow: var(--oc-shadow-xs); + } + + /* Shared interactive contracts */ + .oc-panel-button:not(.n-button), .oc-header-button, .oc-icon-button, - .oc-overlay-button, .oc-map-button, .oc-choice-button, .oc-text-button, .oc-menu-item, - .oc-nav-link - ):focus-visible { - outline: 2px solid rgb(2 132 199); - outline-offset: 2px; - } + .oc-nav-link, + .oc-interactive-card { + transition: + background-color var(--oc-duration-fast) var(--oc-ease-standard), + color var(--oc-duration-fast) var(--oc-ease-standard), + border-color var(--oc-duration-fast) var(--oc-ease-standard), + box-shadow var(--oc-duration-fast) var(--oc-ease-standard), + transform var(--oc-duration-fast) var(--oc-ease-standard); + } - .oc-overlay-button:focus-visible { - outline-color: rgb(125 211 252); - } + .oc-panel-button:not(.n-button) { + @apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium; + } - :is(.oc-panel-button, .oc-header-button, .oc-icon-button, .oc-map-button, .oc-choice-button):disabled { - @apply cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400 shadow-none; - transform: none !important; - } + .oc-panel-button:not(.n-button):not(:disabled):hover, + .oc-header-button:not(:disabled):hover, + .oc-icon-button:not(:disabled):hover, + .oc-map-button:not(:disabled):hover, + .oc-choice-button:not(:disabled):hover { + transform: translate(-1px, -1px); + } - .oc-map-button:disabled { - box-shadow: var(--oc-shadow-xs); - } + .oc-panel-button--neutral:not(.n-button) { + @apply border-slate-200 bg-white text-slate-600; + box-shadow: var(--oc-shadow-xs); + } - :is(.oc-panel-button, .oc-primary-button, .oc-header-button, .oc-icon-button, .oc-map-button, .oc-choice-button):not(:disabled):active { - transform: translate(0, 0); - } + .oc-panel-button--neutral:not(.n-button):hover, + .oc-panel-button--neutral:not(.n-button):focus-visible { + @apply border-teal-200 bg-teal-50 text-teal-800; + box-shadow: var(--oc-shadow-primary); + } - .oc-system-page { - @apply flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-10; - } + .oc-panel-button--sky:not(.n-button) { + @apply border-sky-200 bg-sky-100 text-sky-800; + box-shadow: var(--oc-shadow-sky); + } - .oc-system-card { - @apply w-full max-w-2xl border border-slate-200 bg-white px-8 py-14 text-center; - box-shadow: var(--oc-shadow-xl); - } + .oc-panel-button--sky:not(.n-button):hover, + .oc-panel-button--sky:not(.n-button):focus-visible { + @apply border-sky-300 bg-sky-50 text-sky-900; + box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%); + } - /* Naive UI button contract */ - .oc-primary-button.n-button, - .oc-panel-button.n-button { - transition: - background-color var(--oc-duration-fast) var(--oc-ease-standard), - color var(--oc-duration-fast) var(--oc-ease-standard), - border-color var(--oc-duration-fast) var(--oc-ease-standard), - box-shadow var(--oc-duration-fast) var(--oc-ease-standard), - transform var(--oc-duration-fast) var(--oc-ease-standard); - } + .oc-panel-button--teal:not(.n-button) { + @apply border-teal-200 bg-teal-50 text-teal-800; + box-shadow: var(--oc-shadow-teal); + } - .oc-primary-button.n-button.n-button--disabled, - .oc-panel-button.n-button.n-button--disabled { - border-color: rgb(226 232 240) !important; - background: rgb(241 245 249) !important; - color: var(--oc-color-text-disabled) !important; - box-shadow: none !important; - opacity: 0.72; - transform: none !important; - } + .oc-panel-button--teal:not(.n-button):hover, + .oc-panel-button--teal:not(.n-button):focus-visible { + @apply border-teal-300 bg-teal-100 text-teal-900; + box-shadow: var(--oc-shadow-primary-hover); + } - .oc-primary-button.n-button .n-button__content, - .oc-panel-button.n-button .n-button__content { - font-weight: 500; - } + .oc-panel-button--danger:not(.n-button) { + @apply border-rose-200 bg-rose-50 text-rose-700; + box-shadow: 4px 4px 0 0 rgb(244 63 94 / 10%); + } - .oc-primary-button.n-button .n-button__content { - letter-spacing: 0.12em; - } + .oc-panel-button--danger:not(.n-button):hover, + .oc-panel-button--danger:not(.n-button):focus-visible { + @apply border-rose-300 bg-rose-100 text-rose-800; + box-shadow: 5px 5px 0 0 rgb(244 63 94 / 14%); + } - .oc-primary-button.n-button:not(.n-button--disabled):hover, - .oc-panel-button.n-button:not(.n-button--disabled):hover, - .oc-panel-button.n-button:focus-visible { - transform: translate(-1px, -1px); - } + .oc-panel-button--amber:not(.n-button) { + @apply border-amber-200 bg-amber-50 text-amber-800; + box-shadow: 4px 4px 0 0 rgb(245 158 11 / 10%); + } - .oc-primary-button--teal.n-button { - border-color: var(--color-teal-200) !important; - background: white !important; - color: var(--color-teal-700) !important; - box-shadow: var(--oc-shadow-teal) !important; - } + .oc-panel-button--amber:not(.n-button):hover, + .oc-panel-button--amber:not(.n-button):focus-visible { + @apply border-amber-300 bg-amber-100 text-amber-900; + box-shadow: 5px 5px 0 0 rgb(245 158 11 / 14%); + } - .oc-primary-button--teal.n-button:hover, - .oc-primary-button--teal.n-button:focus-visible { - border-color: var(--color-teal-300) !important; - background: var(--color-teal-50) !important; - color: var(--color-teal-800) !important; - } + .oc-icon-button { + @apply flex h-10 w-10 shrink-0 items-center justify-center border border-slate-200 bg-white text-slate-600; + box-shadow: var(--oc-shadow-xs); + } - .oc-primary-button--teal.n-button:not(.n-button--disabled):hover { - box-shadow: var(--oc-shadow-primary-hover) !important; - } + .oc-icon-button:hover, + .oc-icon-button:focus-visible, + .oc-map-button:hover, + .oc-map-button:focus-visible { + @apply border-teal-200 bg-teal-50 text-teal-800; + box-shadow: var(--oc-shadow-primary); + } - .oc-primary-button--sky.n-button { - border-color: rgb(186 230 253) !important; - background: rgb(255 255 255) !important; - color: rgb(7 89 133) !important; - box-shadow: var(--oc-shadow-sky) !important; - } + .oc-icon-button--small { + @apply h-8 w-8; + } - .oc-primary-button--sky.n-button:hover, - .oc-primary-button--sky.n-button:focus-visible { - border-color: rgb(125 211 252) !important; - background: rgb(240 249 255) !important; - color: rgb(12 74 110) !important; - } + .oc-icon-button--tiny { + @apply h-6 w-6 text-xs; + box-shadow: 2px 2px 0 0 rgb(15 23 42 / 8%); + } - .oc-primary-button--sky.n-button:not(.n-button--disabled):hover { - box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%) !important; - } + .oc-icon-button--sky { + @apply border-sky-200 bg-sky-50 text-sky-700; + box-shadow: 3px 3px 0 0 rgb(14 165 233 / 12%); + } - .oc-panel-button.n-button { - --n-color: white !important; - --n-color-hover: var(--color-teal-50) !important; - --n-color-pressed: var(--color-teal-100) !important; - --n-border: 1px solid rgb(226 232 240) !important; - --n-border-hover: 1px solid var(--color-teal-200) !important; - --n-border-pressed: 1px solid var(--color-teal-300) !important; - --n-text-color: rgb(71 85 105) !important; - --n-text-color-hover: var(--color-teal-800) !important; - --n-text-color-pressed: var(--color-teal-700) !important; - border-color: rgb(226 232 240) !important; - background: white !important; - color: rgb(71 85 105) !important; - box-shadow: var(--oc-shadow-xs) !important; - } + .oc-icon-button--sky:hover, + .oc-icon-button--sky:focus-visible { + @apply border-sky-300 bg-sky-100 text-sky-900; + box-shadow: 4px 4px 0 0 rgb(14 165 233 / 16%); + } - .oc-panel-button--neutral.n-button:hover, - .oc-panel-button--neutral.n-button:focus-visible { - border-color: var(--color-teal-200) !important; - background: var(--color-teal-50) !important; - color: var(--color-teal-800) !important; - box-shadow: var(--oc-shadow-primary) !important; - } + .oc-icon-button--danger { + @apply border-rose-300 bg-rose-500 text-white; + box-shadow: 3px 3px 0 0 rgb(159 18 57 / 18%); + } - .oc-panel-button--sky.n-button { - --n-color: rgb(224 242 254) !important; - --n-color-hover: rgb(240 249 255) !important; - --n-color-pressed: rgb(224 242 254) !important; - --n-border: 1px solid rgb(186 230 253) !important; - --n-border-hover: 1px solid rgb(125 211 252) !important; - --n-border-pressed: 1px solid rgb(56 189 248) !important; - --n-text-color: rgb(7 89 133) !important; - --n-text-color-hover: rgb(12 74 110) !important; - --n-text-color-pressed: rgb(12 74 110) !important; - border-color: rgb(186 230 253) !important; - background: rgb(224 242 254) !important; - color: rgb(7 89 133) !important; - box-shadow: var(--oc-shadow-sky) !important; - } + .oc-icon-button--danger:hover, + .oc-icon-button--danger:focus-visible { + @apply border-rose-400 bg-rose-600 text-white; + box-shadow: 4px 4px 0 0 rgb(159 18 57 / 22%); + } - .oc-panel-button--sky.n-button:hover, - .oc-panel-button--sky.n-button:focus-visible { - border-color: rgb(125 211 252) !important; - background: rgb(240 249 255) !important; - color: rgb(12 74 110) !important; - box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%) !important; - } + .oc-overlay-button { + @apply flex shrink-0 items-center justify-center text-white/75 hover:bg-white/10 hover:text-white; + transition: + background-color var(--oc-duration-fast) var(--oc-ease-standard), + color var(--oc-duration-fast) var(--oc-ease-standard), + transform var(--oc-duration-fast) var(--oc-ease-standard); + } - .oc-panel-button--teal.n-button { - --n-color: var(--color-teal-50) !important; - --n-color-hover: var(--color-teal-100) !important; - --n-color-pressed: var(--color-teal-200) !important; - --n-border: 1px solid var(--color-teal-200) !important; - --n-border-hover: 1px solid var(--color-teal-300) !important; - --n-border-pressed: 1px solid var(--color-teal-400) !important; - --n-text-color: var(--color-teal-700) !important; - --n-text-color-hover: var(--color-teal-800) !important; - --n-text-color-pressed: var(--color-teal-800) !important; - border-color: var(--color-teal-200) !important; - background: var(--color-teal-50) !important; - color: var(--color-teal-700) !important; - box-shadow: var(--oc-shadow-teal) !important; - } + .oc-text-button { + @apply inline-flex items-center font-medium text-sky-700 underline-offset-4 hover:text-sky-900 hover:underline; + } - .oc-panel-button--teal.n-button:hover, - .oc-panel-button--teal.n-button:focus-visible { - border-color: var(--color-teal-300) !important; - background: var(--color-teal-100) !important; - color: var(--color-teal-800) !important; - box-shadow: var(--oc-shadow-primary-hover) !important; - } + .oc-text-button--muted { + @apply text-slate-500 hover:text-slate-800; + } - .oc-panel-button--danger.n-button { - --n-color: rgb(255 241 242) !important; - --n-color-hover: rgb(255 228 230) !important; - --n-color-pressed: rgb(255 228 230) !important; - --n-border: 1px solid rgb(254 205 211) !important; - --n-border-hover: 1px solid rgb(253 164 175) !important; - --n-border-pressed: 1px solid rgb(251 113 133) !important; - --n-text-color: rgb(190 24 93) !important; - --n-text-color-hover: rgb(159 18 57) !important; - --n-text-color-pressed: rgb(159 18 57) !important; - border-color: rgb(254 205 211) !important; - background: rgb(255 241 242) !important; - color: rgb(190 24 93) !important; - box-shadow: 4px 4px 0 0 rgb(244 63 94 / 10%) !important; - } + .oc-text-button--danger { + @apply text-rose-600 hover:text-rose-800; + } - .oc-panel-button--danger.n-button:hover, - .oc-panel-button--danger.n-button:focus-visible { - border-color: rgb(253 164 175) !important; - background: rgb(255 228 230) !important; - color: rgb(159 18 57) !important; - box-shadow: 5px 5px 0 0 rgb(244 63 94 / 14%) !important; - } + .oc-choice-button { + @apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium; + } - .oc-panel-button--amber.n-button { - --n-color: rgb(255 251 235) !important; - --n-color-hover: rgb(254 243 199) !important; - --n-color-pressed: rgb(253 230 138) !important; - --n-border: 1px solid rgb(253 230 138) !important; - --n-border-hover: 1px solid rgb(252 211 77) !important; - --n-border-pressed: 1px solid rgb(245 158 11) !important; - --n-text-color: rgb(146 64 14) !important; - --n-text-color-hover: rgb(120 53 15) !important; - --n-text-color-pressed: rgb(120 53 15) !important; - border-color: rgb(253 230 138) !important; - background: rgb(255 251 235) !important; - color: rgb(146 64 14) !important; - box-shadow: 4px 4px 0 0 rgb(245 158 11 / 10%) !important; - } + .oc-choice-button--inactive { + @apply border-slate-200 bg-white text-slate-600 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; + box-shadow: var(--oc-shadow-xs); + } - .oc-panel-button--amber.n-button:hover, - .oc-panel-button--amber.n-button:focus-visible { - border-color: rgb(252 211 77) !important; - background: rgb(254 243 199) !important; - color: rgb(120 53 15) !important; - box-shadow: 5px 5px 0 0 rgb(245 158 11 / 14%) !important; - } + .oc-choice-button--active { + @apply border-teal-200 bg-teal-50 text-teal-800; + box-shadow: var(--oc-shadow-primary); + } + + .oc-menu-item { + @apply flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-teal-50 hover:text-teal-800; + } + + .oc-menu-item--danger { + @apply text-rose-600 hover:bg-rose-50 hover:text-rose-700; + } + + .oc-menu-item--amber { + @apply hover:bg-amber-50 hover:text-amber-700; + } + + .oc-nav-link { + @apply px-3 py-2 text-sm font-medium uppercase tracking-[0.12em]; + } + + .oc-header-button { + @apply inline-flex h-8 items-center justify-center border px-3 text-sm font-medium md:h-10 md:px-4; + } + + .oc-header-button--neutral { + @apply border-slate-200 bg-white/80 text-slate-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; + box-shadow: var(--oc-shadow-xs); + } + + .oc-header-button--sky { + @apply border-sky-200 bg-sky-100 text-sky-800 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-900; + box-shadow: var(--oc-shadow-sky); + } + + .oc-header-button--teal { + @apply border-teal-100 bg-white/80 text-teal-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800; + box-shadow: var(--oc-shadow-teal); + } + + .oc-header-button:hover { + box-shadow: 5px 5px 0 0 rgb(15 23 42 / 8%); + } + + .oc-interactive-card:focus-visible { + @apply outline-none ring-2 ring-sky-600 ring-offset-2; + } + + .oc-interactive-card { + box-shadow: var(--oc-shadow-md); + } + + .oc-interactive-card:hover { + box-shadow: var(--oc-shadow-lg); + } + + .oc-interactive-card--selected { + @apply border-sky-500; + box-shadow: var(--oc-shadow-sky) !important; + } + + .oc-interactive-row { + transition: + background-color var(--oc-duration-fast) var(--oc-ease-standard), + color var(--oc-duration-fast) var(--oc-ease-standard), + box-shadow var(--oc-duration-fast) var(--oc-ease-standard); + } + + .oc-interactive-row:hover { + @apply bg-sky-50/60; + } + + .oc-interactive-row:focus-visible, + .oc-heatmap-cell:focus-visible { + @apply outline-none ring-2 ring-sky-600 ring-offset-2; + } + + .oc-heatmap-cell { + transition: + border-color var(--oc-duration-fast) var(--oc-ease-standard), + box-shadow var(--oc-duration-fast) var(--oc-ease-standard), + transform var(--oc-duration-fast) var(--oc-ease-standard); + } + + :is( + .oc-primary-button, + .oc-panel-button, + .oc-header-button, + .oc-icon-button, + .oc-overlay-button, + .oc-map-button, + .oc-choice-button, + .oc-text-button, + .oc-menu-item, + .oc-nav-link + ):focus-visible { + outline: 2px solid rgb(2 132 199); + outline-offset: 2px; + } + + .oc-overlay-button:focus-visible { + outline-color: rgb(125 211 252); + } + + :is( + .oc-panel-button, + .oc-header-button, + .oc-icon-button, + .oc-map-button, + .oc-choice-button + ):disabled { + @apply cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400 shadow-none; + transform: none !important; + } + + .oc-map-button:disabled { + box-shadow: var(--oc-shadow-xs); + } + + :is( + .oc-panel-button, + .oc-primary-button, + .oc-header-button, + .oc-icon-button, + .oc-map-button, + .oc-choice-button + ):not(:disabled):active { + transform: translate(0, 0); + } + + .oc-system-page { + @apply flex min-h-[calc(100vh-4rem)] items-center justify-center px-4 py-10; + } + + .oc-system-card { + @apply w-full max-w-2xl border border-slate-200 bg-white px-8 py-14 text-center; + box-shadow: var(--oc-shadow-xl); + } + + /* Naive UI button contract */ + .oc-primary-button.n-button, + .oc-panel-button.n-button { + transition: + background-color var(--oc-duration-fast) var(--oc-ease-standard), + color var(--oc-duration-fast) var(--oc-ease-standard), + border-color var(--oc-duration-fast) var(--oc-ease-standard), + box-shadow var(--oc-duration-fast) var(--oc-ease-standard), + transform var(--oc-duration-fast) var(--oc-ease-standard); + } + + .oc-primary-button.n-button.n-button--disabled, + .oc-panel-button.n-button.n-button--disabled { + border-color: rgb(226 232 240) !important; + background: rgb(241 245 249) !important; + color: var(--oc-color-text-disabled) !important; + box-shadow: none !important; + opacity: 0.72; + transform: none !important; + } + + .oc-primary-button.n-button .n-button__content, + .oc-panel-button.n-button .n-button__content { + font-weight: 500; + } + + .oc-primary-button.n-button .n-button__content { + letter-spacing: 0.12em; + } + + .oc-primary-button.n-button:not(.n-button--disabled):hover, + .oc-panel-button.n-button:not(.n-button--disabled):hover, + .oc-panel-button.n-button:focus-visible { + transform: translate(-1px, -1px); + } + + .oc-primary-button--teal.n-button { + border-color: var(--color-teal-200) !important; + background: white !important; + color: var(--color-teal-700) !important; + box-shadow: var(--oc-shadow-teal) !important; + } + + .oc-primary-button--teal.n-button:hover, + .oc-primary-button--teal.n-button:focus-visible { + border-color: var(--color-teal-300) !important; + background: var(--color-teal-50) !important; + color: var(--color-teal-800) !important; + } + + .oc-primary-button--teal.n-button:not(.n-button--disabled):hover { + box-shadow: var(--oc-shadow-primary-hover) !important; + } + + .oc-primary-button--sky.n-button { + border-color: rgb(186 230 253) !important; + background: rgb(255 255 255) !important; + color: rgb(7 89 133) !important; + box-shadow: var(--oc-shadow-sky) !important; + } + + .oc-primary-button--sky.n-button:hover, + .oc-primary-button--sky.n-button:focus-visible { + border-color: rgb(125 211 252) !important; + background: rgb(240 249 255) !important; + color: rgb(12 74 110) !important; + } + + .oc-primary-button--sky.n-button:not(.n-button--disabled):hover { + box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%) !important; + } + + .oc-panel-button.n-button { + --n-color: white !important; + --n-color-hover: var(--color-teal-50) !important; + --n-color-pressed: var(--color-teal-100) !important; + --n-border: 1px solid rgb(226 232 240) !important; + --n-border-hover: 1px solid var(--color-teal-200) !important; + --n-border-pressed: 1px solid var(--color-teal-300) !important; + --n-text-color: rgb(71 85 105) !important; + --n-text-color-hover: var(--color-teal-800) !important; + --n-text-color-pressed: var(--color-teal-700) !important; + border-color: rgb(226 232 240) !important; + background: white !important; + color: rgb(71 85 105) !important; + box-shadow: var(--oc-shadow-xs) !important; + } + + .oc-panel-button--neutral.n-button:hover, + .oc-panel-button--neutral.n-button:focus-visible { + border-color: var(--color-teal-200) !important; + background: var(--color-teal-50) !important; + color: var(--color-teal-800) !important; + box-shadow: var(--oc-shadow-primary) !important; + } + + .oc-panel-button--sky.n-button { + --n-color: rgb(224 242 254) !important; + --n-color-hover: rgb(240 249 255) !important; + --n-color-pressed: rgb(224 242 254) !important; + --n-border: 1px solid rgb(186 230 253) !important; + --n-border-hover: 1px solid rgb(125 211 252) !important; + --n-border-pressed: 1px solid rgb(56 189 248) !important; + --n-text-color: rgb(7 89 133) !important; + --n-text-color-hover: rgb(12 74 110) !important; + --n-text-color-pressed: rgb(12 74 110) !important; + border-color: rgb(186 230 253) !important; + background: rgb(224 242 254) !important; + color: rgb(7 89 133) !important; + box-shadow: var(--oc-shadow-sky) !important; + } + + .oc-panel-button--sky.n-button:hover, + .oc-panel-button--sky.n-button:focus-visible { + border-color: rgb(125 211 252) !important; + background: rgb(240 249 255) !important; + color: rgb(12 74 110) !important; + box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%) !important; + } + + .oc-panel-button--teal.n-button { + --n-color: var(--color-teal-50) !important; + --n-color-hover: var(--color-teal-100) !important; + --n-color-pressed: var(--color-teal-200) !important; + --n-border: 1px solid var(--color-teal-200) !important; + --n-border-hover: 1px solid var(--color-teal-300) !important; + --n-border-pressed: 1px solid var(--color-teal-400) !important; + --n-text-color: var(--color-teal-700) !important; + --n-text-color-hover: var(--color-teal-800) !important; + --n-text-color-pressed: var(--color-teal-800) !important; + border-color: var(--color-teal-200) !important; + background: var(--color-teal-50) !important; + color: var(--color-teal-700) !important; + box-shadow: var(--oc-shadow-teal) !important; + } + + .oc-panel-button--teal.n-button:hover, + .oc-panel-button--teal.n-button:focus-visible { + border-color: var(--color-teal-300) !important; + background: var(--color-teal-100) !important; + color: var(--color-teal-800) !important; + box-shadow: var(--oc-shadow-primary-hover) !important; + } + + .oc-panel-button--danger.n-button { + --n-color: rgb(255 241 242) !important; + --n-color-hover: rgb(255 228 230) !important; + --n-color-pressed: rgb(255 228 230) !important; + --n-border: 1px solid rgb(254 205 211) !important; + --n-border-hover: 1px solid rgb(253 164 175) !important; + --n-border-pressed: 1px solid rgb(251 113 133) !important; + --n-text-color: rgb(190 24 93) !important; + --n-text-color-hover: rgb(159 18 57) !important; + --n-text-color-pressed: rgb(159 18 57) !important; + border-color: rgb(254 205 211) !important; + background: rgb(255 241 242) !important; + color: rgb(190 24 93) !important; + box-shadow: 4px 4px 0 0 rgb(244 63 94 / 10%) !important; + } + + .oc-panel-button--danger.n-button:hover, + .oc-panel-button--danger.n-button:focus-visible { + border-color: rgb(253 164 175) !important; + background: rgb(255 228 230) !important; + color: rgb(159 18 57) !important; + box-shadow: 5px 5px 0 0 rgb(244 63 94 / 14%) !important; + } + + .oc-panel-button--amber.n-button { + --n-color: rgb(255 251 235) !important; + --n-color-hover: rgb(254 243 199) !important; + --n-color-pressed: rgb(253 230 138) !important; + --n-border: 1px solid rgb(253 230 138) !important; + --n-border-hover: 1px solid rgb(252 211 77) !important; + --n-border-pressed: 1px solid rgb(245 158 11) !important; + --n-text-color: rgb(146 64 14) !important; + --n-text-color-hover: rgb(120 53 15) !important; + --n-text-color-pressed: rgb(120 53 15) !important; + border-color: rgb(253 230 138) !important; + background: rgb(255 251 235) !important; + color: rgb(146 64 14) !important; + box-shadow: 4px 4px 0 0 rgb(245 158 11 / 10%) !important; + } + + .oc-panel-button--amber.n-button:hover, + .oc-panel-button--amber.n-button:focus-visible { + border-color: rgb(252 211 77) !important; + background: rgb(254 243 199) !important; + color: rgb(120 53 15) !important; + box-shadow: 5px 5px 0 0 rgb(245 158 11 / 14%) !important; + } } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index f500f00..19e0911 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -5,87 +5,87 @@ * values live here so the Vue and Naive UI layers share one visual contract. */ :root { - color-scheme: light; + color-scheme: light; - /* + /* * Tailwind compatibility palettes. * Existing teal/emerald utilities intentionally resolve to OpenCloud's * fresh eucalyptus and leaf colors instead of Tailwind's vivid defaults. */ - --color-teal-50: #f2faf6; - --color-teal-100: #ddf2e6; - --color-teal-200: #bce4cf; - --color-teal-300: #8fceb0; - --color-teal-400: #60b18d; - --color-teal-500: #438f71; - --color-teal-600: #34745d; - --color-teal-700: #2d5f4e; - --color-teal-800: #274c40; - --color-teal-900: #213e35; + --color-teal-50: #f2faf6; + --color-teal-100: #ddf2e6; + --color-teal-200: #bce4cf; + --color-teal-300: #8fceb0; + --color-teal-400: #60b18d; + --color-teal-500: #438f71; + --color-teal-600: #34745d; + --color-teal-700: #2d5f4e; + --color-teal-800: #274c40; + --color-teal-900: #213e35; - --color-emerald-50: #f4faef; - --color-emerald-100: #e5f3d9; - --color-emerald-200: #cce7b7; - --color-emerald-300: #a8d58d; - --color-emerald-400: #7fbc63; - --color-emerald-500: #609d48; - --color-emerald-600: #4d7f3b; - --color-emerald-700: #3f6633; - --color-emerald-800: #35522c; - --color-emerald-900: #2d4427; + --color-emerald-50: #f4faef; + --color-emerald-100: #e5f3d9; + --color-emerald-200: #cce7b7; + --color-emerald-300: #a8d58d; + --color-emerald-400: #7fbc63; + --color-emerald-500: #609d48; + --color-emerald-600: #4d7f3b; + --color-emerald-700: #3f6633; + --color-emerald-800: #35522c; + --color-emerald-900: #2d4427; - --oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif; - --oc-font-mono: 'IBM Plex Mono', 'SFMono-Regular', monospace; + --oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif; + --oc-font-mono: 'IBM Plex Mono', 'SFMono-Regular', monospace; - --oc-color-canvas: #eef4f8; - --oc-color-surface: #ffffff; - --oc-color-surface-muted: #f8fafc; - --oc-color-text: #0f172a; - --oc-color-text-secondary: #334155; - --oc-color-text-muted: #64748b; - --oc-color-text-disabled: #94a3b8; - --oc-color-border: #d9e3ee; - --oc-color-primary: #34745d; - --oc-color-primary-hover: #2d5f4e; - --oc-color-primary-pressed: #274c40; - --oc-color-info: #0369a1; - --oc-color-success: #4d7f3b; - --oc-color-warning: #d97706; - --oc-color-danger: #dc2626; - --oc-color-selection: rgb(52 116 93 / 20%); + --oc-color-canvas: #eef4f8; + --oc-color-surface: #ffffff; + --oc-color-surface-muted: #f8fafc; + --oc-color-text: #0f172a; + --oc-color-text-secondary: #334155; + --oc-color-text-muted: #64748b; + --oc-color-text-disabled: #94a3b8; + --oc-color-border: #d9e3ee; + --oc-color-primary: #34745d; + --oc-color-primary-hover: #2d5f4e; + --oc-color-primary-pressed: #274c40; + --oc-color-info: #0369a1; + --oc-color-success: #4d7f3b; + --oc-color-warning: #d97706; + --oc-color-danger: #dc2626; + --oc-color-selection: rgb(52 116 93 / 20%); - /* Contribution heatmap: no activity + GitHub-style contribution quartiles. */ - --oc-heatmap-empty: #f1f5f9; - --oc-heatmap-empty-border: #d9e3ee; - --oc-heatmap-level-1: #ddf2e6; - --oc-heatmap-level-1-border: #bce4cf; - --oc-heatmap-level-2: #8fceb0; - --oc-heatmap-level-2-border: #60b18d; - --oc-heatmap-level-3: #438f71; - --oc-heatmap-level-3-border: #34745d; - --oc-heatmap-level-4: #274c40; - --oc-heatmap-level-4-border: #213e35; + /* Contribution heatmap: no activity + GitHub-style contribution quartiles. */ + --oc-heatmap-empty: #f1f5f9; + --oc-heatmap-empty-border: #d9e3ee; + --oc-heatmap-level-1: #ddf2e6; + --oc-heatmap-level-1-border: #bce4cf; + --oc-heatmap-level-2: #8fceb0; + --oc-heatmap-level-2-border: #60b18d; + --oc-heatmap-level-3: #438f71; + --oc-heatmap-level-3-border: #34745d; + --oc-heatmap-level-4: #274c40; + --oc-heatmap-level-4-border: #213e35; - --oc-gradient-canvas: - radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%), - linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%); - --oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%); - --oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%); - --oc-gradient-grid: - linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px), - linear-gradient(90deg, rgb(15 23 42 / 3%) 1px, transparent 1px); + --oc-gradient-canvas: + radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%), + linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%); + --oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%); + --oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%); + --oc-gradient-grid: + linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px), + linear-gradient(90deg, rgb(15 23 42 / 3%) 1px, transparent 1px); - --oc-shadow-xs: 3px 3px 0 0 rgb(15 23 42 / 6%); - --oc-shadow-sm: 4px 4px 0 0 rgb(15 23 42 / 6%); - --oc-shadow-md: 6px 6px 0 0 rgb(15 23 42 / 5%); - --oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%); - --oc-shadow-xl: 12px 12px 0 0 rgb(15 23 42 / 8%); - --oc-shadow-sky: 4px 4px 0 0 rgb(14 165 233 / 14%); - --oc-shadow-primary: 4px 4px 0 0 rgb(52 116 93 / 14%); - --oc-shadow-primary-hover: 5px 5px 0 0 rgb(52 116 93 / 20%); - --oc-shadow-amber: 5px 5px 0 0 rgb(245 158 11 / 12%); - --oc-shadow-teal: var(--oc-shadow-primary); + --oc-shadow-xs: 3px 3px 0 0 rgb(15 23 42 / 6%); + --oc-shadow-sm: 4px 4px 0 0 rgb(15 23 42 / 6%); + --oc-shadow-md: 6px 6px 0 0 rgb(15 23 42 / 5%); + --oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%); + --oc-shadow-xl: 12px 12px 0 0 rgb(15 23 42 / 8%); + --oc-shadow-sky: 4px 4px 0 0 rgb(14 165 233 / 14%); + --oc-shadow-primary: 4px 4px 0 0 rgb(52 116 93 / 14%); + --oc-shadow-primary-hover: 5px 5px 0 0 rgb(52 116 93 / 20%); + --oc-shadow-amber: 5px 5px 0 0 rgb(245 158 11 / 12%); + --oc-shadow-teal: var(--oc-shadow-primary); - --oc-duration-fast: 160ms; - --oc-ease-standard: ease; + --oc-duration-fast: 160ms; + --oc-ease-standard: ease; } diff --git a/src/types/amap.d.ts b/src/types/amap.d.ts deleted file mode 100644 index 2c53705..0000000 --- a/src/types/amap.d.ts +++ /dev/null @@ -1,126 +0,0 @@ -declare namespace AMap { - class Map { - constructor(container: string | HTMLElement, opts?: MapOptions) - addControl(control: unknown): void - add(layer: unknown): void - remove(layer: unknown): void - destroy(): void - setCenter(center: [number, number]): void - setZoom(zoom: number, immediately?: boolean, duration?: number): void - setPitch(pitch: number): void - getPitch(): number - setRotation(rotation: number): void - getRotation(): number - on(event: string, callback: (...args: unknown[]) => void): void - off(event: string, callback: (...args: unknown[]) => void): void - getCenter(): { lng: number; lat: number } - getZoom(digits?: number): number - getAllOverlays(type?: string): unknown[] - addOverlay(overlay: unknown): void - removeOverlay(overlay: unknown): void - clearMap(): void - } - - interface MapOptions { - viewMode?: '2D' | '3D' - pitch?: number - rotation?: number - zoom?: number - zooms?: [number, number] - center?: [number, number] - mapStyle?: string - features?: string[] - layers?: unknown[] - skyColor?: string - maxPitch?: number - resizeEnable?: boolean - dragEnable?: boolean - zoomEnable?: boolean - keyboardEnable?: boolean - doubleClickZoom?: boolean - scrollWheel?: boolean - } - - class Marker { - constructor(opts?: MarkerOptions) - on(event: string, callback: (...args: unknown[]) => void): void - setPosition(position: [number, number]): void - getPosition(): { lng: number; lat: number } - setContent(content: string): void - setOpacity(opacity: number): void - setMap(map: Map | null): void - remove(): void - } - - interface MarkerOptions { - position?: [number, number] - content?: string | HTMLElement - offset?: Pixel - zIndex?: number - opacity?: number - title?: string - } - - class InfoWindow { - constructor(opts?: InfoWindowOptions) - open(map: Map, position?: [number, number]): void - close(): void - } - - interface InfoWindowOptions { - content?: string | HTMLElement - offset?: Pixel - size?: Size - isCustom?: boolean - } - - class Scale {} - class ToolBar { - constructor(opts?: Record) - } - class ControlBar { - constructor(opts?: Record) - } - class Geolocation { - constructor(opts?: Record) - getCurrentPosition( - onSuccess: (data: { position: { lng: number; lat: number } }) => void, - onError?: (error: unknown) => void, - ): void - } - - class TileLayer { - static Satellite: new () => TileLayer - static RoadNet: new () => TileLayer - setOpacity(opacity: number): void - } - - class Pixel { - constructor(x: number, y: number) - } - - class Size { - constructor(width: number, height: number) - } - - class MarkerCluster { - constructor(map: Map, markers: Marker[], opts?: Record) - } -} - -declare function AMapLoader_load(opts: { - key: string - version: string - plugins?: string[] -}): Promise - -declare namespace AMap { - function getConfig(): { appname?: string } -} - -declare module '@amap/amap-jsapi-loader' { - const AMapLoader: { - load: typeof AMapLoader_load - } - export default AMapLoader -} diff --git a/src/types/api.ts b/src/types/api.ts index fa14979..c3cb752 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,94 +1,94 @@ 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 + 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 + 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 + 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 + 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 + status_code: number + message: string } export interface CloudCreateResponse extends ActionResponse { - cloud_id: string + cloud_id: string } export interface CloudTypeCreateResponse extends ActionResponse { - cloud_type_id: number + 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 - username: string - image: string | null - role: Profile['role'] - is_disabled: boolean - cloud_count: number - received_like_count: number - created_at: string + id: string + email: string + username: string + image: string | null + role: Profile['role'] + is_disabled: boolean + cloud_count: number + received_like_count: number + created_at: string } export interface ApiCloudTypeSummary { - id: number - name: string - rarity: CloudType['rarity'] - rarity_value: number + id: number + name: string + rarity: CloudType['rarity'] + rarity_value: number } export interface ApiCloudOwner { - id: string - username: string + id: string + username: string } export interface ApiCloud extends Cloud { - updated_at: string - cloud_type: ApiCloudTypeSummary | null - owner: ApiCloudOwner | null - cloud_type_name: string | null - cloud_type_rarity: CloudType['rarity'] | null - cloud_type_rarity_value: number | null - username: string | null - received_like_count: number + updated_at: string + cloud_type: ApiCloudTypeSummary | null + owner: ApiCloudOwner | null + cloud_type_name: string | null + cloud_type_rarity: CloudType['rarity'] | null + cloud_type_rarity_value: number | null + username: string | null + received_like_count: number } diff --git a/src/types/database.ts b/src/types/database.ts index 6487840..ed30fa0 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -1,34 +1,34 @@ export interface CloudType { - id: number - name: string - genus: string - icon_id: string | null - rarity: 'common' | 'uncommon' | 'rare' - rarity_value: number - description: string | null - created_at: string + id: number + name: string + genus: string + icon_id: string | null + rarity: 'common' | 'uncommon' | 'rare' + rarity_value: number + description: string | null + created_at: string } export interface Cloud { - id: string - user_id: string - cloud_type_id: number | null - image_url: string - thumbnail_url: string | null - latitude: number | null - longitude: number | null - description: string | null - captured_at: string | null - status: 'pending' | 'approved' | 'rejected' - is_hidden: boolean - created_at: string + id: string + user_id: string + cloud_type_id: number | null + image_url: string + thumbnail_url: string | null + latitude: number | null + longitude: number | null + description: string | null + captured_at: string | null + status: 'pending' | 'approved' | 'rejected' + is_hidden: boolean + created_at: string } export interface Profile { - id: string - username: string - image: string | null - role: 'user' | 'admin' - is_disabled: boolean - created_at: string + id: string + username: string + image: string | null + role: 'user' | 'admin' + is_disabled: boolean + created_at: string } diff --git a/src/types/router.d.ts b/src/types/router.d.ts index 6dcaa7e..2e9cca9 100644 --- a/src/types/router.d.ts +++ b/src/types/router.d.ts @@ -1,12 +1,12 @@ import type { RouteLocationNormalizedLoaded } from 'vue-router' declare module 'vue-router' { - interface RouteMeta { - title?: string | ((route: RouteLocationNormalizedLoaded) => string) - description?: string | ((route: RouteLocationNormalizedLoaded) => string) - canonicalPath?: string - noindex?: boolean - requiresAuth?: boolean - requiresAdmin?: boolean - } + interface RouteMeta { + title?: string | ((route: RouteLocationNormalizedLoaded) => string) + description?: string | ((route: RouteLocationNormalizedLoaded) => string) + canonicalPath?: string + noindex?: boolean + requiresAuth?: boolean + requiresAdmin?: boolean + } } diff --git a/src/views/admin/AdminView.vue b/src/views/admin/AdminView.vue deleted file mode 100644 index 0c04365..0000000 --- a/src/views/admin/AdminView.vue +++ /dev/null @@ -1,1763 +0,0 @@ - - - - - diff --git a/src/views/auth/AuthConfirmView.vue b/src/views/auth/AuthConfirmView.vue deleted file mode 100644 index 88c977f..0000000 --- a/src/views/auth/AuthConfirmView.vue +++ /dev/null @@ -1,74 +0,0 @@ - - - diff --git a/src/views/auth/ForgotPasswordView.vue b/src/views/auth/ForgotPasswordView.vue deleted file mode 100644 index a1e3965..0000000 --- a/src/views/auth/ForgotPasswordView.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - diff --git a/src/views/auth/LoginView.vue b/src/views/auth/LoginView.vue deleted file mode 100644 index 2621f42..0000000 --- a/src/views/auth/LoginView.vue +++ /dev/null @@ -1,150 +0,0 @@ - - - diff --git a/src/views/auth/RegisterView.vue b/src/views/auth/RegisterView.vue deleted file mode 100644 index 5c0d966..0000000 --- a/src/views/auth/RegisterView.vue +++ /dev/null @@ -1,150 +0,0 @@ - - - diff --git a/src/views/auth/ResendConfirmationView.vue b/src/views/auth/ResendConfirmationView.vue deleted file mode 100644 index 8724be3..0000000 --- a/src/views/auth/ResendConfirmationView.vue +++ /dev/null @@ -1,83 +0,0 @@ - - - diff --git a/src/views/auth/ResetPasswordView.vue b/src/views/auth/ResetPasswordView.vue deleted file mode 100644 index 409ce0c..0000000 --- a/src/views/auth/ResetPasswordView.vue +++ /dev/null @@ -1,94 +0,0 @@ - - - diff --git a/src/views/community/CommunityView.vue b/src/views/community/CommunityView.vue deleted file mode 100644 index f34297f..0000000 --- a/src/views/community/CommunityView.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/src/views/encyclopedia/CloudTypeView.vue b/src/views/encyclopedia/CloudTypeView.vue deleted file mode 100644 index 0ddf83f..0000000 --- a/src/views/encyclopedia/CloudTypeView.vue +++ /dev/null @@ -1,144 +0,0 @@ - - - diff --git a/src/views/encyclopedia/EncyclopediaView.vue b/src/views/encyclopedia/EncyclopediaView.vue deleted file mode 100644 index 443d36f..0000000 --- a/src/views/encyclopedia/EncyclopediaView.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - diff --git a/src/views/gallery/GalleryView.vue b/src/views/gallery/GalleryView.vue deleted file mode 100644 index 1772d2a..0000000 --- a/src/views/gallery/GalleryView.vue +++ /dev/null @@ -1,594 +0,0 @@ - - - diff --git a/src/views/map/MapView.vue b/src/views/map/MapView.vue deleted file mode 100644 index be12983..0000000 --- a/src/views/map/MapView.vue +++ /dev/null @@ -1,965 +0,0 @@ - - - - - diff --git a/src/views/profile/ProfileSettingsView.vue b/src/views/profile/ProfileSettingsView.vue deleted file mode 100644 index 40b2210..0000000 --- a/src/views/profile/ProfileSettingsView.vue +++ /dev/null @@ -1,219 +0,0 @@ - - - diff --git a/src/views/profile/ProfileView.vue b/src/views/profile/ProfileView.vue deleted file mode 100644 index 9dccee8..0000000 --- a/src/views/profile/ProfileView.vue +++ /dev/null @@ -1,814 +0,0 @@ - - - - - diff --git a/src/views/system/AuthRequiredView.vue b/src/views/system/AuthRequiredView.vue deleted file mode 100644 index 25a97f0..0000000 --- a/src/views/system/AuthRequiredView.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - diff --git a/src/views/system/ForbiddenView.vue b/src/views/system/ForbiddenView.vue deleted file mode 100644 index fbc768d..0000000 --- a/src/views/system/ForbiddenView.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/views/system/NotFoundView.vue b/src/views/system/NotFoundView.vue deleted file mode 100644 index 8b95fe0..0000000 --- a/src/views/system/NotFoundView.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/src/views/upload/UploadView.vue b/src/views/upload/UploadView.vue deleted file mode 100644 index 6c6f515..0000000 --- a/src/views/upload/UploadView.vue +++ /dev/null @@ -1,403 +0,0 @@ - - - diff --git a/tsconfig.app.json b/tsconfig.app.json index 3e52a52..1f71bf7 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -1,16 +1,16 @@ { - "extends": "@vue/tsconfig/tsconfig.dom.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "types": ["vite/client"], - "paths": { - "@/*": ["./src/*"] - }, + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "paths": { + "@/*": ["./src/*"] + }, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] } diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..eb69b0d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,4 @@ { - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] } diff --git a/tsconfig.node.json b/tsconfig.node.json index d3c52ea..63719f2 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,24 +1,24 @@ { - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "es2023", - "lib": ["ES2023"], - "module": "esnext", - "types": ["node"], - "skipLibCheck": true, + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, - /* Linting */ - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["vite.config.ts"] + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] } diff --git a/vercel.json b/vercel.json index 0e426d0..71b284c 100644 --- a/vercel.json +++ b/vercel.json @@ -1,36 +1,36 @@ { - "$schema": "https://openapi.vercel.sh/vercel.json", - "headers": [ - { - "source": "/(.*)", - "headers": [ + "$schema": "https://openapi.vercel.sh/vercel.json", + "headers": [ { - "key": "X-Frame-Options", - "value": "DENY" - }, - { - "key": "X-Content-Type-Options", - "value": "nosniff" - }, - { - "key": "Referrer-Policy", - "value": "strict-origin" - }, - { - "key": "Strict-Transport-Security", - "value": "max-age=31536000" - }, - { - "key": "Permissions-Policy", - "value": "camera=(), microphone=(), payment=(), usb=(), geolocation=(self)" + "source": "/(.*)", + "headers": [ + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin" + }, + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), payment=(), usb=(), geolocation=(self)" + } + ] } - ] - } - ], - "rewrites": [ - { - "source": "/(.*)", - "destination": "/index.html" - } - ] + ], + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] } diff --git a/vite.config.ts b/vite.config.ts index a1924f0..27f5c7c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,78 +4,74 @@ import { fileURLToPath, URL } from 'node:url' import vue from '@vitejs/plugin-vue' import tailwindcss from '@tailwindcss/vite' -const publicRoutes = [ - '/', - '/gallery', - '/community', -] +const publicRoutes = ['/', '/gallery', '/community'] const DEFAULT_SITE_URL = 'http://localhost:5173' function getSiteUrl(value?: string) { - const siteUrl = new URL(value?.trim() || DEFAULT_SITE_URL) - if (!['http:', 'https:'].includes(siteUrl.protocol)) { - throw new Error('VITE_SITE_URL 必须使用 http 或 https 协议') - } - return siteUrl.href.replace(/\/$/, '') + const siteUrl = new URL(value?.trim() || DEFAULT_SITE_URL) + if (!['http:', 'https:'].includes(siteUrl.protocol)) { + throw new Error('VITE_SITE_URL 必须使用 http 或 https 协议') + } + return siteUrl.href.replace(/\/$/, '') } function seoFilesPlugin(siteUrl: string) { - return { - name: 'opencloud-seo-files', - closeBundle() { - mkdirSync('dist/.well-known', { recursive: true }) + return { + name: 'opencloud-seo-files', + closeBundle() { + mkdirSync('dist/.well-known', { recursive: true }) - const robots = [ - 'User-agent: *', - 'Allow: /', - 'Disallow: /admin', - 'Disallow: /auth/', - 'Disallow: /login', - 'Disallow: /register', - 'Disallow: /upload', - 'Disallow: /encyclopedia', - 'Disallow: /profile/settings', - `Sitemap: ${siteUrl}/sitemap.xml`, - '', - ].join('\n') + const robots = [ + 'User-agent: *', + 'Allow: /', + 'Disallow: /admin', + 'Disallow: /auth/', + 'Disallow: /login', + 'Disallow: /register', + 'Disallow: /upload', + 'Disallow: /encyclopedia', + 'Disallow: /profile/settings', + `Sitemap: ${siteUrl}/sitemap.xml`, + '', + ].join('\n') - const sitemapUrls = publicRoutes - .map(route => ` \n ${siteUrl}${route}\n `) - .join('\n') + const sitemapUrls = publicRoutes + .map(route => ` \n ${siteUrl}${route}\n `) + .join('\n') - const sitemap = [ - '', - '', - sitemapUrls, - '', - '', - ].join('\n') + const sitemap = [ + '', + '', + sitemapUrls, + '', + '', + ].join('\n') - writeFileSync('dist/robots.txt', robots) - writeFileSync('dist/sitemap.xml', sitemap) - }, - } + writeFileSync('dist/robots.txt', robots) + writeFileSync('dist/sitemap.xml', sitemap) + }, + } } export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), 'VITE_') - const siteUrl = getSiteUrl(env.VITE_SITE_URL) + const env = loadEnv(mode, process.cwd(), 'VITE_') + const siteUrl = getSiteUrl(env.VITE_SITE_URL) - return { - plugins: [vue(), tailwindcss(), seoFilesPlugin(siteUrl)], - server: { - port: 5173, - strictPort: true, - }, - preview: { - port: 5173, - strictPort: true, - }, - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), - }, - }, - } + return { + plugins: [vue(), tailwindcss(), seoFilesPlugin(siteUrl)], + server: { + port: 5173, + strictPort: true, + }, + preview: { + port: 5173, + strictPort: true, + }, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + } }) -- 2.50.1 (Apple Git-155)