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)
+
+
+
+
Cloud Catalog
+
云类型管理
+
共 {{ 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)
+
+
+
+
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 @@
+
+
+
+
+
+ {{ liked ? '取消点赞' : '点赞' }}
+
+ 登录后点赞
+ {{ count }} 次点赞
+ {{ error }}
+
+
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.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/views/map/MapView.vue b/src/views/map/MapView.vue
index cb3bf52..be12983 100644
--- a/src/views/map/MapView.vue
+++ b/src/views/map/MapView.vue
@@ -1,6 +1,7 @@
-
+
+
+