fix: 项目结构优化及后端同步适配 (#11)
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { toCloudDetail, toAuthUser } from '@/lib/api-adapters'
|
||||
import { collectPages } from '@/lib/pagination'
|
||||
import type {
|
||||
CloudPageResponse,
|
||||
UserProfileResponse,
|
||||
MessageResponse,
|
||||
CreateCloudTypeBody,
|
||||
UpdateCloudTypeBody,
|
||||
CloudTypeCreateResponse,
|
||||
ReviewCloudsBody,
|
||||
SetUserRoleBody,
|
||||
} from '@/types/api-contract'
|
||||
|
||||
export async function listUsers() {
|
||||
return (await apiRequest<UserProfileResponse[]>('/admin/users')).map(toAuthUser)
|
||||
}
|
||||
|
||||
export function listAdminClouds() {
|
||||
return collectPages(async page => {
|
||||
const result = await apiRequest<CloudPageResponse>('/admin/clouds', {
|
||||
query: { page, page_size: 100 },
|
||||
})
|
||||
return { ...result, items: result.items.map(toCloudDetail) }
|
||||
})
|
||||
}
|
||||
|
||||
export async function reviewClouds(ids: string[], reviewStatus: ReviewCloudsBody['review_status']) {
|
||||
for (let offset = 0; offset < ids.length; offset += 100) {
|
||||
const body: ReviewCloudsBody = {
|
||||
cloud_ids: ids.slice(offset, offset + 100),
|
||||
review_status: reviewStatus,
|
||||
}
|
||||
await apiRequest<MessageResponse>('/admin/clouds/review', { method: 'POST', body })
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteAdminCloud(id: string) {
|
||||
return apiRequest<MessageResponse>(`/admin/clouds/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function setUserRole(id: string, role: SetUserRoleBody['role']) {
|
||||
return apiRequest<MessageResponse>(`/admin/users/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
body: { role },
|
||||
})
|
||||
}
|
||||
|
||||
export function setUserPassword(id: string, newPassword: string) {
|
||||
return apiRequest<MessageResponse>(`/admin/users/${encodeURIComponent(id)}/password`, {
|
||||
method: 'PATCH',
|
||||
body: { new_password: newPassword },
|
||||
})
|
||||
}
|
||||
|
||||
export function createCloudType(body: CreateCloudTypeBody) {
|
||||
return apiRequest<CloudTypeCreateResponse>('/admin/cloud-types', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateCloudType(id: number, body: UpdateCloudTypeBody) {
|
||||
return apiRequest<MessageResponse>(`/admin/cloud-types/${id}`, { method: 'PATCH', body })
|
||||
}
|
||||
|
||||
export function deleteCloudTypeRecord(id: number) {
|
||||
return apiRequest<MessageResponse>(`/admin/cloud-types/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -36,19 +36,24 @@ import {
|
||||
import { useRouter } from 'vue-router'
|
||||
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
|
||||
import { apiRequest, toApiCloud, toAuthUser, toCloudType } from '@/lib/api'
|
||||
import { toCloudType } from '@/lib/api-adapters'
|
||||
import { listCloudTypeRecords } from '@/features/clouds/api'
|
||||
import {
|
||||
listUsers,
|
||||
listAdminClouds,
|
||||
reviewClouds,
|
||||
deleteAdminCloud,
|
||||
setUserRole,
|
||||
setUserPassword,
|
||||
createCloudType,
|
||||
updateCloudType,
|
||||
deleteCloudTypeRecord,
|
||||
} from '@/features/admin/api'
|
||||
import type { CloudTypeResponse } from '@/types/api-contract'
|
||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||
import { useCloudsStore } from '@/features/clouds/stores/clouds'
|
||||
import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia'
|
||||
import type {
|
||||
ActionResponse,
|
||||
ApiCloud,
|
||||
AuthUser,
|
||||
BackendCloudInfo,
|
||||
BackendCloudType,
|
||||
BackendUserProfile,
|
||||
CloudTypeCreateResponse,
|
||||
} from '@/types/api'
|
||||
import type { CloudDetail, AuthUser } from '@/types/view-models'
|
||||
|
||||
type AdminTab = 'dashboard' | 'review' | 'users' | 'cloud-types' | 'images'
|
||||
type CloudStatus = 'pending' | 'approved' | 'rejected'
|
||||
@@ -113,7 +118,7 @@ const dashboardStats = ref<DashboardStats>({
|
||||
})
|
||||
const users = ref<AuthUser[]>([])
|
||||
const images = ref<AdminCloud[]>([])
|
||||
const cloudTypes = ref<BackendCloudType[]>([])
|
||||
const cloudTypes = ref<CloudTypeResponse[]>([])
|
||||
const selectedReviewIds = ref<Set<string>>(new Set())
|
||||
const activeReviewId = ref<string | null>(null)
|
||||
const selectedImageIds = ref<Set<string>>(new Set())
|
||||
@@ -124,7 +129,7 @@ const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const passwordError = ref('')
|
||||
const cloudTypeEditorOpen = ref(false)
|
||||
const editingCloudType = ref<BackendCloudType | null>(null)
|
||||
const editingCloudType = ref<CloudTypeResponse | null>(null)
|
||||
const cloudTypeForm = ref<CloudTypeForm>(emptyCloudTypeForm())
|
||||
const cloudTypeFormError = ref('')
|
||||
|
||||
@@ -217,7 +222,7 @@ const reviewStatusStats = computed(() => [
|
||||
|
||||
const selectedReviewCount = computed(() => selectedReviewIds.value.size)
|
||||
|
||||
function toAdminCloud(row: ApiCloud): AdminCloud {
|
||||
function toAdminCloud(row: CloudDetail): AdminCloud {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
@@ -232,7 +237,7 @@ function toAdminCloud(row: ApiCloud): AdminCloud {
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
cloudTypeName: row.cloud_type_name || '未知云型',
|
||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
||||
cloudTypeRarity: row.cloud_type_rarity ?? 0,
|
||||
username: row.username || '匿名用户',
|
||||
}
|
||||
}
|
||||
@@ -290,23 +295,12 @@ function syncDashboardStats() {
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
const rows = await apiRequest<BackendUserProfile[]>('/admin/users')
|
||||
users.value = rows.map(toAuthUser)
|
||||
const rows = await listUsers()
|
||||
users.value = rows
|
||||
}
|
||||
|
||||
async function fetchImages() {
|
||||
const rows: BackendCloudInfo[] = []
|
||||
const pageSize = 100
|
||||
|
||||
for (let page = 1; ; page++) {
|
||||
const current = await apiRequest<BackendCloudInfo[]>(
|
||||
`/admin/clouds?page=${page}&page_size=${pageSize}`,
|
||||
)
|
||||
rows.push(...current)
|
||||
if (current.length < pageSize) break
|
||||
}
|
||||
|
||||
images.value = rows.map(toApiCloud).map(toAdminCloud)
|
||||
images.value = (await listAdminClouds()).map(toAdminCloud)
|
||||
selectedReviewIds.value = new Set(
|
||||
[...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)),
|
||||
)
|
||||
@@ -316,7 +310,7 @@ async function fetchImages() {
|
||||
}
|
||||
|
||||
async function fetchCloudTypes() {
|
||||
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
|
||||
const rows = await listCloudTypeRecords()
|
||||
cloudTypes.value = rows
|
||||
|
||||
const normalized = rows.map(toCloudType)
|
||||
@@ -382,12 +376,6 @@ function toggleAllFilteredImageSelection() {
|
||||
setSelectedImageIds(filteredImages.value.map(item => item.id))
|
||||
}
|
||||
|
||||
function chunkIds(ids: string[]) {
|
||||
return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) =>
|
||||
ids.slice(index * 100, (index + 1) * 100),
|
||||
)
|
||||
}
|
||||
|
||||
async function updateCloudStatus(ids: string[], status: Exclude<CloudStatus, 'pending'>) {
|
||||
if (!ids.length) return
|
||||
|
||||
@@ -395,15 +383,7 @@ async function updateCloudStatus(ids: string[], status: Exclude<CloudStatus, 'pe
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
for (const chunk of chunkIds(ids)) {
|
||||
await apiRequest<ActionResponse>('/admin/clouds/review', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
cloud_ids: chunk.map(cloudId => ({ cloud_id: cloudId })),
|
||||
status,
|
||||
},
|
||||
})
|
||||
}
|
||||
await reviewClouds(ids, status)
|
||||
|
||||
await fetchImages()
|
||||
syncDashboardStats()
|
||||
@@ -438,7 +418,7 @@ async function deleteImages(clouds: AdminCloud[]) {
|
||||
|
||||
for (const cloud of clouds) {
|
||||
try {
|
||||
await apiRequest<ActionResponse>(`/admin/clouds/${cloud.id}`, { method: 'DELETE' })
|
||||
await deleteAdminCloud(cloud.id)
|
||||
deletedIds.push(cloud.id)
|
||||
} catch (error) {
|
||||
firstError ||= getErrorMessage(error, '图片删除失败')
|
||||
@@ -483,10 +463,7 @@ async function updateUserRole(user: AuthUser, role: AuthUser['role']) {
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
await apiRequest<ActionResponse>(`/admin/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { user_role: role },
|
||||
})
|
||||
await setUserRole(user.id, role)
|
||||
users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item))
|
||||
message.success('用户角色已更新;该用户需要重新登录')
|
||||
} catch (error) {
|
||||
@@ -526,10 +503,7 @@ async function resetUserPassword() {
|
||||
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await apiRequest<ActionResponse>(`/admin/users/${user.id}/password`, {
|
||||
method: 'PATCH',
|
||||
body: { new_password: newPassword.value },
|
||||
})
|
||||
await setUserPassword(user.id, newPassword.value)
|
||||
passwordTarget.value = null
|
||||
message.success('密码已重置;该用户需要重新登录')
|
||||
} catch (error) {
|
||||
@@ -549,7 +523,7 @@ function emptyCloudTypeForm(): CloudTypeForm {
|
||||
}
|
||||
}
|
||||
|
||||
function openCloudTypeEditor(cloudType?: BackendCloudType) {
|
||||
function openCloudTypeEditor(cloudType?: CloudTypeResponse) {
|
||||
editingCloudType.value = cloudType ?? null
|
||||
cloudTypeForm.value = cloudType
|
||||
? {
|
||||
@@ -613,16 +587,10 @@ async function saveCloudType() {
|
||||
loadError.value = ''
|
||||
try {
|
||||
if (editingCloudType.value) {
|
||||
await apiRequest<ActionResponse>(`/admin/cloudtypes/${editingCloudType.value.id}`, {
|
||||
method: 'PATCH',
|
||||
body: payload,
|
||||
})
|
||||
await updateCloudType(editingCloudType.value.id, payload)
|
||||
message.success('云类型已更新')
|
||||
} else {
|
||||
await apiRequest<CloudTypeCreateResponse>('/admin/cloudtypes', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
await createCloudType(payload)
|
||||
message.success('云类型已添加')
|
||||
}
|
||||
|
||||
@@ -643,7 +611,7 @@ function cloudTypeUsageCount(cloudTypeId: number) {
|
||||
return images.value.filter(image => image.cloud_type_id === cloudTypeId).length
|
||||
}
|
||||
|
||||
async function deleteCloudType(cloudType: BackendCloudType) {
|
||||
async function deleteCloudType(cloudType: CloudTypeResponse) {
|
||||
const usageCount = cloudTypeUsageCount(cloudType.id)
|
||||
if (usageCount > 0) {
|
||||
message.warning(`“${cloudType.name}”仍被 ${usageCount} 张云图使用,无法删除`)
|
||||
@@ -655,7 +623,7 @@ async function deleteCloudType(cloudType: BackendCloudType) {
|
||||
actionLoading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
await apiRequest<ActionResponse>(`/admin/cloudtypes/${cloudType.id}`, { method: 'DELETE' })
|
||||
await deleteCloudTypeRecord(cloudType.id)
|
||||
await fetchCloudTypes()
|
||||
message.success('云类型已删除')
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api'
|
||||
import { AUTH_EXPIRED_EVENT } from '@/lib/api'
|
||||
import { assertAuthResult, authClient } from '@/features/auth/authClient'
|
||||
import type { BackendUserProfile } from '@/types/api'
|
||||
import type { Profile } from '@/types/database'
|
||||
import { getMyProfile, updateMyUsername } from '@/features/profile/api'
|
||||
import type { AuthUser } from '@/types/view-models'
|
||||
import type { Profile } from '@/types/models'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<ReturnType<typeof toAuthUser> | null>(null)
|
||||
const user = ref<AuthUser | null>(null)
|
||||
const profile = ref<Profile | null>(null)
|
||||
const loading = ref(true)
|
||||
let listenerAttached = false
|
||||
@@ -14,8 +15,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
const isAdmin = computed(() => profile.value?.role === 'admin')
|
||||
|
||||
function applyProfile(backendProfile: BackendUserProfile) {
|
||||
const normalized = toAuthUser(backendProfile)
|
||||
function applyProfile(normalized: AuthUser) {
|
||||
user.value = normalized
|
||||
profile.value = {
|
||||
id: normalized.id,
|
||||
@@ -33,7 +33,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me')
|
||||
const backendProfile = await getMyProfile()
|
||||
applyProfile(backendProfile)
|
||||
}
|
||||
|
||||
@@ -98,10 +98,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
async function updateUsername(username: string) {
|
||||
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me', {
|
||||
method: 'PATCH',
|
||||
body: { user_name: username },
|
||||
})
|
||||
const backendProfile = await updateMyUsername(username)
|
||||
applyProfile(backendProfile)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { imageUrl, toCloudDetail, toCloudType } from '@/lib/api-adapters'
|
||||
import { collectPages } from '@/lib/pagination'
|
||||
import type {
|
||||
CloudResponse,
|
||||
CloudPageResponse,
|
||||
CloudTypeResponse,
|
||||
CloudCreateResponse,
|
||||
MessageResponse,
|
||||
PublicCloudListQuery,
|
||||
CloudMapQuery,
|
||||
UpdateCloudBody,
|
||||
UploadCloudForm,
|
||||
PaginationQuery,
|
||||
} from '@/types/api-contract'
|
||||
|
||||
export { imageUrl }
|
||||
|
||||
export async function listClouds(query: PublicCloudListQuery) {
|
||||
const page = await apiRequest<CloudPageResponse>('/clouds', { query, auth: false })
|
||||
return { ...page, items: page.items.map(toCloudDetail) }
|
||||
}
|
||||
|
||||
export async function listCloudsByType(cloudTypeId: number, query: PaginationQuery) {
|
||||
const page = await apiRequest<CloudPageResponse>(`/cloud-types/${cloudTypeId}/clouds`, {
|
||||
query,
|
||||
auth: false,
|
||||
})
|
||||
return { ...page, items: page.items.map(toCloudDetail) }
|
||||
}
|
||||
|
||||
export async function listMapClouds(query: CloudMapQuery) {
|
||||
const rows = await apiRequest<CloudResponse[]>('/clouds/map', { query, auth: false })
|
||||
return rows.map(toCloudDetail)
|
||||
}
|
||||
|
||||
export async function getCloud(cloudId: string, auth = true) {
|
||||
return toCloudDetail(
|
||||
await apiRequest<CloudResponse>(`/clouds/${encodeURIComponent(cloudId)}`, { auth }),
|
||||
)
|
||||
}
|
||||
|
||||
export async function listCloudTypeRecords() {
|
||||
return apiRequest<CloudTypeResponse[]>('/cloud-types', { auth: false })
|
||||
}
|
||||
|
||||
export async function listCloudTypes() {
|
||||
return (await listCloudTypeRecords()).map(toCloudType)
|
||||
}
|
||||
|
||||
export function updateCloud(cloudId: string, body: UpdateCloudBody) {
|
||||
return apiRequest<MessageResponse>(`/clouds/${encodeURIComponent(cloudId)}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteClouds(cloudIds: string[], onDeleted?: (ids: string[]) => void) {
|
||||
for (let offset = 0; offset < cloudIds.length; offset += 100) {
|
||||
const ids = cloudIds.slice(offset, offset + 100)
|
||||
await apiRequest<MessageResponse>('/clouds', { method: 'DELETE', body: { cloud_ids: ids } })
|
||||
onDeleted?.(ids)
|
||||
}
|
||||
}
|
||||
|
||||
export function uploadCloud(input: UploadCloudForm) {
|
||||
const form = new FormData()
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) form.append(key, value instanceof Blob ? value : String(value))
|
||||
}
|
||||
return apiRequest<CloudCreateResponse>('/clouds', { method: 'POST', body: form })
|
||||
}
|
||||
|
||||
export function setCloudLike(cloudId: string, liked: boolean) {
|
||||
return apiRequest<MessageResponse>(`/clouds/${encodeURIComponent(cloudId)}/like`, {
|
||||
method: liked ? 'PUT' : 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function listLikedClouds() {
|
||||
return collectPages(async page => {
|
||||
const result = await apiRequest<CloudPageResponse>('/profiles/me/likes', {
|
||||
query: { page, page_size: 100 },
|
||||
})
|
||||
return { ...result, items: result.items.map(toCloudDetail) }
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NIcon } from 'naive-ui'
|
||||
import { Map as MapIcon, X } from '@vicons/tabler'
|
||||
import MapPickerModal from '@/shared/map/components/MapPickerModal.vue'
|
||||
import type { CloudType } from '@/types/database'
|
||||
import type { CloudType } from '@/types/models'
|
||||
|
||||
export interface CloudEditFormValue {
|
||||
cloudCategoryId: number | null
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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'
|
||||
import { listCloudTypes } from '@/features/clouds/api'
|
||||
import type { CloudType } from '@/types/models'
|
||||
|
||||
export const useCloudsStore = defineStore('clouds', () => {
|
||||
const cloudTypes = ref<CloudType[]>([])
|
||||
@@ -12,8 +11,7 @@ export const useCloudsStore = defineStore('clouds', () => {
|
||||
if (cloudTypes.value.length > 0) return
|
||||
loading.value = true
|
||||
try {
|
||||
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
|
||||
cloudTypes.value = rows.map(toCloudType)
|
||||
cloudTypes.value = await listCloudTypes()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { getCloud, listLikedClouds, setCloudLike } from '@/features/clouds/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()
|
||||
@@ -46,16 +43,8 @@ export const useLikesStore = defineStore('likes', () => {
|
||||
|
||||
loadingForUser = userId
|
||||
const request = (async () => {
|
||||
const nextIds = new Set<string>()
|
||||
for (let page = 1; ; page++) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
page_size: String(PAGE_SIZE),
|
||||
})
|
||||
const rows = await apiRequest<BackendCloudInfo[]>(`/profile/me/likes?${params}`)
|
||||
rows.forEach(row => nextIds.add(row.id))
|
||||
if (rows.length < PAGE_SIZE) break
|
||||
}
|
||||
const rows = await listLikedClouds()
|
||||
const nextIds = new Set(rows.map(row => row.id))
|
||||
if (authStore.user?.id === userId) {
|
||||
likedIds.value = nextIds
|
||||
loadedForUser.value = userId
|
||||
@@ -82,9 +71,7 @@ export const useLikesStore = defineStore('likes', () => {
|
||||
const wasLiked = isLiked(cloudId)
|
||||
pendingIds.value = new Set(pendingIds.value).add(cloudId)
|
||||
try {
|
||||
await apiRequest<ActionResponse>(`/cloud/${cloudId}/like`, {
|
||||
method: wasLiked ? 'DELETE' : 'PUT',
|
||||
})
|
||||
await setCloudLike(cloudId, !wasLiked)
|
||||
if (authStore.user?.id !== userId) return
|
||||
|
||||
const nextIds = new Set(likedIds.value)
|
||||
@@ -97,11 +84,10 @@ export const useLikesStore = defineStore('likes', () => {
|
||||
)
|
||||
|
||||
try {
|
||||
const cloud = await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`, {
|
||||
auth: false,
|
||||
})
|
||||
const cloud = await getCloud(cloudId, false)
|
||||
if (authStore.user?.id !== userId) return
|
||||
counts.value[cloudId] = cloud.received_like_count
|
||||
if (cloud.owner.id === authStore.user?.id) await authStore.refreshProfile()
|
||||
if (cloud.owner?.id === authStore.user?.id) await authStore.refreshProfile()
|
||||
} catch {
|
||||
// The mutation succeeded; keep the local count if a refresh fails.
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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'
|
||||
import { listCloudTypes } from '@/features/clouds/api'
|
||||
import type { CloudType } from '@/types/models'
|
||||
|
||||
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||
const cloudTypes = ref<CloudType[]>([])
|
||||
@@ -13,8 +12,7 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||
if (cloudTypesLoaded.value && !force) return
|
||||
loadingCloudTypes.value = true
|
||||
try {
|
||||
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
|
||||
cloudTypes.value = rows.map(toCloudType)
|
||||
cloudTypes.value = await listCloudTypes()
|
||||
cloudTypesLoaded.value = true
|
||||
} finally {
|
||||
loadingCloudTypes.value = false
|
||||
|
||||
@@ -5,17 +5,17 @@ import { RouterLink, useRoute } from 'vue-router'
|
||||
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
|
||||
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
|
||||
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
|
||||
import { apiRequest, imageUrl, toApiCloud } from '@/lib/api'
|
||||
import { imageUrl, listCloudsByType } from '@/features/clouds/api'
|
||||
import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia'
|
||||
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
||||
import type { CloudDetail } from '@/types/view-models'
|
||||
|
||||
const PAGE_SIZE = 24
|
||||
const route = useRoute()
|
||||
const encyclopediaStore = useEncyclopediaStore()
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
const gallery = ref<ApiCloud[]>([])
|
||||
const selectedItem = ref<ApiCloud | null>(null)
|
||||
const gallery = ref<CloudDetail[]>([])
|
||||
const selectedItem = ref<CloudDetail | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const hasNextPage = ref(false)
|
||||
|
||||
@@ -39,16 +39,10 @@ function formatDateTime(iso: string | null) {
|
||||
}
|
||||
|
||||
async function loadGallery(page: number) {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
||||
const rows = await apiRequest<BackendCloudInfo[]>(
|
||||
`/cloud/type/${cloudTypeId.value}?${params}`,
|
||||
{
|
||||
auth: false,
|
||||
},
|
||||
)
|
||||
gallery.value = rows.map(toApiCloud)
|
||||
const result = await listCloudsByType(cloudTypeId.value, { page, page_size: PAGE_SIZE })
|
||||
gallery.value = result.items
|
||||
currentPage.value = page
|
||||
hasNextPage.value = rows.length === PAGE_SIZE
|
||||
hasNextPage.value = result.has_more
|
||||
}
|
||||
|
||||
async function loadPage(page = 1) {
|
||||
@@ -144,7 +138,7 @@ watch(
|
||||
? 'border-white/35 bg-slate-950/25 backdrop-blur'
|
||||
: 'border-slate-200 bg-white'
|
||||
"
|
||||
>稀有度 {{ cloudType.rarity_value }}</span
|
||||
>稀有度 {{ cloudType.rarity }}</span
|
||||
>
|
||||
<h1 class="mt-4 text-4xl font-bold">{{ cloudType.name }}</h1>
|
||||
<p class="mt-2" :class="heroImage ? 'text-white/80' : 'text-slate-600'">
|
||||
@@ -236,7 +230,7 @@ watch(
|
||||
? `拍摄于 ${formatDateTime(selectedItem.captured_at)}`
|
||||
: `上传于 ${formatDateTime(selectedItem.created_at)}`
|
||||
"
|
||||
:badge-label="`稀有度 ${cloudType.rarity_value}`"
|
||||
:badge-label="`稀有度 ${cloudType.rarity}`"
|
||||
badge-class="border-slate-200 bg-slate-50 text-slate-600"
|
||||
@close="selectedItem = null"
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted } from 'vue'
|
||||
import { NEmpty, NSkeleton } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { imageUrl } from '@/lib/api'
|
||||
import { imageUrl } from '@/features/clouds/api'
|
||||
import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -99,7 +99,7 @@ onMounted(() => encyclopediaStore.fetchCloudTypes())
|
||||
: 'border-slate-200 bg-white text-slate-600'
|
||||
"
|
||||
>
|
||||
稀有度 {{ cloudType.rarity_value }}
|
||||
稀有度 {{ cloudType.rarity }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
|
||||
@@ -9,12 +9,14 @@ import CloudEditModal, {
|
||||
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
|
||||
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
|
||||
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { listClouds } from '@/features/clouds/api'
|
||||
import type { PublicCloudListQuery } from '@/types/api-contract'
|
||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||
import { useCloudsStore } from '@/features/clouds/stores/clouds'
|
||||
import { useProfileStore } from '@/features/profile/stores/profile'
|
||||
|
||||
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
||||
import type { CloudDetail } from '@/types/view-models'
|
||||
|
||||
interface GalleryCloud {
|
||||
id: string
|
||||
@@ -85,7 +87,7 @@ function formatCoordinate(value: number | null) {
|
||||
const normalizedSearch = computed(() => searchQuery.value.trim())
|
||||
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
|
||||
|
||||
function toGalleryCloud(row: ApiCloud) {
|
||||
function toGalleryCloud(row: CloudDetail) {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
@@ -100,7 +102,7 @@ function toGalleryCloud(row: ApiCloud) {
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
cloudTypeName: row.cloud_type_name || '未知',
|
||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
||||
cloudTypeRarity: row.cloud_type_rarity ?? 0,
|
||||
username: row.username || '匿名',
|
||||
receivedLikeCount: row.received_like_count,
|
||||
} satisfies GalleryCloud
|
||||
@@ -111,23 +113,23 @@ async function loadPage(page: number) {
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
||||
const query: PublicCloudListQuery = { page, page_size: PAGE_SIZE }
|
||||
if (normalizedSearch.value) {
|
||||
params.set('filter', isUserSearch.value ? 'owner' : 'type')
|
||||
params.set('value', normalizedSearch.value)
|
||||
query.filter = isUserSearch.value ? 'owner' : 'type'
|
||||
query.value = normalizedSearch.value
|
||||
} else if (selectedTypeId.value !== 'all') {
|
||||
const typeName = cloudsStore.cloudTypes.find(
|
||||
type => type.id === selectedTypeId.value,
|
||||
)?.name
|
||||
if (typeName) {
|
||||
params.set('filter', 'type')
|
||||
params.set('value', typeName)
|
||||
query.filter = 'type'
|
||||
query.value = typeName
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud?${params}`, { auth: false })
|
||||
galleryItems.value = rows.map(toApiCloud).map(toGalleryCloud)
|
||||
hasNextPage.value = rows.length === PAGE_SIZE
|
||||
const result = await listClouds(query)
|
||||
galleryItems.value = result.items.map(toGalleryCloud)
|
||||
hasNextPage.value = result.has_more
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404 && isUserSearch.value) {
|
||||
@@ -232,7 +234,7 @@ async function submitEditModal(value: CloudEditFormValue) {
|
||||
|
||||
try {
|
||||
const updated = await profileStore.updateCloud(authStore.user.id, current.id, {
|
||||
type_id: value.cloudCategoryId,
|
||||
cloud_type_id: value.cloudCategoryId,
|
||||
latitude: blurCoordinate(value.latitude as number),
|
||||
longitude: blurCoordinate(value.longitude as number),
|
||||
description: value.description.trim(),
|
||||
|
||||
@@ -4,10 +4,10 @@ import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
|
||||
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
|
||||
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
|
||||
import QuickUploadModal from '@/features/upload/components/QuickUploadModal.vue'
|
||||
import { apiRequest, toApiCloud } from '@/lib/api'
|
||||
import { listMapClouds } from '@/features/clouds/api'
|
||||
import { loadAMap } from '@/shared/map/amap'
|
||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
||||
import type { CloudDetail } from '@/types/view-models'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import {
|
||||
Adjustments,
|
||||
@@ -421,7 +421,7 @@ function handleZoomClick(direction: -1 | 1, event: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function toCloudMarker(row: ApiCloud): CloudMarkerData {
|
||||
function toCloudMarker(row: CloudDetail): CloudMarkerData {
|
||||
return {
|
||||
id: row.id,
|
||||
latitude: row.latitude as number,
|
||||
@@ -430,7 +430,7 @@ function toCloudMarker(row: ApiCloud): CloudMarkerData {
|
||||
thumbnailUrl: row.thumbnail_url,
|
||||
description: row.description,
|
||||
cloudTypeName: row.cloud_type_name || '未知',
|
||||
rarity: row.cloud_type_rarity_value ?? 0,
|
||||
rarity: row.cloud_type_rarity ?? 0,
|
||||
username: row.username || '匿名',
|
||||
capturedAt: row.captured_at || row.created_at,
|
||||
createdAt: row.created_at,
|
||||
@@ -443,14 +443,14 @@ async function fetchCloudsByRange(
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<CloudMarkerData[]> {
|
||||
const params = new URLSearchParams({
|
||||
const query = {
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
time_field: field,
|
||||
})
|
||||
}
|
||||
try {
|
||||
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud/map?${params}`, { auth: false })
|
||||
return rows.map(toApiCloud).map(toCloudMarker)
|
||||
const rows = await listMapClouds(query)
|
||||
return rows.map(toCloudMarker)
|
||||
} catch (error) {
|
||||
statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { apiRequest, ApiError } from '@/lib/api'
|
||||
import { toCloudDetail, toAuthUser } from '@/lib/api-adapters'
|
||||
import { collectPages } from '@/lib/pagination'
|
||||
import { listClouds } from '@/features/clouds/api'
|
||||
import type { CloudPageResponse, UserProfileResponse } from '@/types/api-contract'
|
||||
|
||||
export async function getMyProfile() {
|
||||
return toAuthUser(await apiRequest<UserProfileResponse>('/profiles/me'))
|
||||
}
|
||||
|
||||
export async function updateMyUsername(username: string) {
|
||||
return toAuthUser(
|
||||
await apiRequest<UserProfileResponse>('/profiles/me', {
|
||||
method: 'PATCH',
|
||||
body: { username },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getUserProfile(userId: string) {
|
||||
return toAuthUser(
|
||||
await apiRequest<UserProfileResponse>(`/profiles/${encodeURIComponent(userId)}`),
|
||||
)
|
||||
}
|
||||
|
||||
export function listMyClouds() {
|
||||
return collectPages(async page => {
|
||||
const result = await apiRequest<CloudPageResponse>('/profiles/me/clouds', {
|
||||
query: { page, page_size: 100 },
|
||||
})
|
||||
return { ...result, items: result.items.map(toCloudDetail) }
|
||||
})
|
||||
}
|
||||
|
||||
export async function listPublicClouds(username: string) {
|
||||
try {
|
||||
return await collectPages(page =>
|
||||
listClouds({ page, page_size: 100, filter: 'owner', value: `@${username}` }),
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
|
||||
import { getUserProfile, listMyClouds, listPublicClouds } from '@/features/profile/api'
|
||||
import {
|
||||
getCloud,
|
||||
updateCloud as patchCloud,
|
||||
deleteClouds as deleteCloudRecords,
|
||||
} from '@/features/clouds/api'
|
||||
import type { UpdateCloudBody } from '@/types/api-contract'
|
||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||
import type { ActionResponse, ApiCloud, BackendCloudInfo, BackendUserProfile } from '@/types/api'
|
||||
import type { Profile } from '@/types/database'
|
||||
import type { CloudDetail, AuthUser } from '@/types/view-models'
|
||||
import type { Profile } from '@/types/models'
|
||||
|
||||
export interface ProfileCloudItem {
|
||||
id: string
|
||||
@@ -22,7 +28,7 @@ export interface ProfileCloudItem {
|
||||
receivedLikeCount: number
|
||||
}
|
||||
|
||||
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
||||
function toProfileCloud(row: CloudDetail): ProfileCloudItem {
|
||||
return {
|
||||
id: row.id,
|
||||
cloud_type_id: row.cloud_type_id as number,
|
||||
@@ -36,7 +42,7 @@ function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
cloudTypeName: row.cloud_type_name || '未知',
|
||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
||||
cloudTypeRarity: row.cloud_type_rarity ?? 0,
|
||||
receivedLikeCount: row.received_like_count,
|
||||
}
|
||||
}
|
||||
@@ -67,27 +73,6 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
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<BackendCloudInfo[]>(`/cloud?${params}`, { auth: false })
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return rows
|
||||
throw error
|
||||
}
|
||||
rows.push(...current)
|
||||
if (current.length < 100) return rows
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfilePage(identifier: string, isOwnProfile: boolean, force = false) {
|
||||
const key = makeKey(identifier, isOwnProfile)
|
||||
if (!force && key in cloudsByKey.value && profilesByKey.value[identifier]) return
|
||||
@@ -96,9 +81,9 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
errorByKey.value[key] = ''
|
||||
try {
|
||||
const backendRows = isOwnProfile
|
||||
? await apiRequest<BackendCloudInfo[]>('/profile/me/clouds')
|
||||
: await fetchAllPublicClouds(identifier)
|
||||
const cloudRows = backendRows.map(toApiCloud).map(toProfileCloud)
|
||||
? await listMyClouds()
|
||||
: await listPublicClouds(identifier)
|
||||
const cloudRows = backendRows.map(toProfileCloud)
|
||||
|
||||
if (isOwnProfile && authStore.profile) {
|
||||
profilesByKey.value[identifier] = authStore.profile
|
||||
@@ -106,17 +91,17 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
profilesByKey.value[identifier] = authStore.profile
|
||||
} else {
|
||||
const owner = backendRows[0]?.owner
|
||||
let publicProfile: BackendUserProfile | null = null
|
||||
let publicProfile: AuthUser | null = null
|
||||
if (owner?.id && authStore.isLoggedIn) {
|
||||
try {
|
||||
publicProfile = await apiRequest<BackendUserProfile>(`/profile/${owner.id}`)
|
||||
publicProfile = await getUserProfile(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,
|
||||
username: owner?.username || identifier,
|
||||
image: publicProfile?.image ?? null,
|
||||
role: 'user',
|
||||
is_disabled: false,
|
||||
@@ -165,56 +150,22 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
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<ActionResponse>(`/cloud/${cloudId}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
})
|
||||
const updated = toProfileCloud(
|
||||
toApiCloud(await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`)),
|
||||
)
|
||||
async function updateCloud(identifier: string, cloudId: string, patch: UpdateCloudBody) {
|
||||
await patchCloud(cloudId, patch)
|
||||
const updated = toProfileCloud(await getCloud(cloudId))
|
||||
patchCachedCloud(identifier, cloudId, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) {
|
||||
await apiRequest<ActionResponse>(`/cloud/${cloudId}`, {
|
||||
method: 'PATCH',
|
||||
body: { is_hidden: isHidden },
|
||||
})
|
||||
await patchCloud(cloudId, { 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<ActionResponse>(`/cloud/${cloudIds[0]}`, { method: 'DELETE' })
|
||||
} else {
|
||||
for (let offset = 0; offset < cloudIds.length; offset += 100) {
|
||||
await apiRequest<ActionResponse>('/cloud', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
cloud_ids: cloudIds
|
||||
.slice(offset, offset + 100)
|
||||
.map(cloudId => ({ cloud_id: cloudId })),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
removeCachedClouds(identifier, cloudIds)
|
||||
await deleteCloudRecords(cloudIds, deletedIds => removeCachedClouds(identifier, deletedIds))
|
||||
return cloudIds.length
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import ContributionHeatmap from '@/features/profile/components/ContributionHeatm
|
||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
||||
import { useCloudsStore } from '@/features/clouds/stores/clouds'
|
||||
import { useProfileStore, type ProfileCloudItem } from '@/features/profile/stores/profile'
|
||||
import type { Profile } from '@/types/database'
|
||||
import type { Profile } from '@/types/models'
|
||||
|
||||
interface TimelineGroup {
|
||||
key: string
|
||||
@@ -325,7 +325,7 @@ async function submitEditModal(value: CloudEditFormValue) {
|
||||
viewedIdentifier.value,
|
||||
selectedCloud.value.id,
|
||||
{
|
||||
type_id: value.cloudCategoryId,
|
||||
cloud_type_id: value.cloudCategoryId,
|
||||
latitude: blurCoordinate(value.latitude as number),
|
||||
longitude: blurCoordinate(value.longitude as number),
|
||||
description: value.description.trim(),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { uploadCloud } from '@/features/clouds/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
|
||||
@@ -78,10 +77,6 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
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()
|
||||
@@ -173,18 +168,14 @@ export function useUpload() {
|
||||
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<CloudCreateResponse>('/cloud', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
await uploadCloud({
|
||||
image: item.file,
|
||||
cloud_type_id: item.cloudCategoryId!,
|
||||
latitude: item.latitude!,
|
||||
longitude: item.longitude!,
|
||||
description: item.description.trim() || undefined,
|
||||
captured_at: item.capturedAt.trim() || undefined,
|
||||
is_hidden: item.isHidden,
|
||||
})
|
||||
overallProgress.value = Math.round(((index + 1) / items.value.length) * 100)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { API_URL } from './api'
|
||||
import type { CloudResponse, CloudTypeResponse, UserProfileResponse } from '@/types/api-contract'
|
||||
import type { CloudDetail, AuthUser } from '@/types/view-models'
|
||||
import type { CloudType } from '@/types/models'
|
||||
|
||||
export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') {
|
||||
return `${API_URL}/images/${encodeURIComponent(id)}/${variant}`
|
||||
}
|
||||
|
||||
export function toAuthUser(profile: UserProfileResponse): AuthUser {
|
||||
return {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
username: profile.username,
|
||||
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: CloudTypeResponse): CloudType {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
genus: row.genus || '',
|
||||
icon_id: row.icon_id,
|
||||
rarity: row.rarity,
|
||||
description: row.description,
|
||||
created_at: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function toCloudDetail(row: CloudResponse): CloudDetail {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.owner.id,
|
||||
cloud_type_id: row.cloud_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.review_status,
|
||||
is_hidden: row.is_hidden,
|
||||
created_at: row.uploaded_at,
|
||||
updated_at: row.updated_at,
|
||||
cloud_type: {
|
||||
id: row.cloud_type.id,
|
||||
name: row.cloud_type.name,
|
||||
rarity: row.cloud_type.rarity,
|
||||
},
|
||||
owner: { id: row.owner.id, username: row.owner.username },
|
||||
cloud_type_name: row.cloud_type.name,
|
||||
cloud_type_rarity: row.cloud_type.rarity,
|
||||
username: row.owner.username,
|
||||
received_like_count: row.received_like_count,
|
||||
}
|
||||
}
|
||||
+28
-127
@@ -1,15 +1,6 @@
|
||||
import type {
|
||||
ApiCloud,
|
||||
AuthUser,
|
||||
BackendCloudInfo,
|
||||
BackendCloudType,
|
||||
BackendUserProfile,
|
||||
} from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
const DEFAULT_API_URL = 'http://localhost:3000'
|
||||
|
||||
export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
|
||||
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 {
|
||||
@@ -26,64 +17,23 @@ export class ApiError extends Error {
|
||||
|
||||
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: BodyInit | Record<string, unknown> | null
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
auth?: boolean
|
||||
}
|
||||
|
||||
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
|
||||
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 ''
|
||||
|
||||
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
|
||||
}
|
||||
if (!body || typeof body !== 'object') return false
|
||||
const prototype = Object.getPrototypeOf(body)
|
||||
return prototype === Object.prototype || prototype === null
|
||||
}
|
||||
|
||||
function errorMessage(payload: unknown, fallback: string) {
|
||||
if (!payload || typeof payload !== 'object') return fallback
|
||||
|
||||
if ('detail' in payload) {
|
||||
const detail = payload.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
const messages = detail
|
||||
.map(item =>
|
||||
item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '',
|
||||
)
|
||||
.filter(Boolean)
|
||||
if (messages.length) return messages.join(';')
|
||||
}
|
||||
}
|
||||
|
||||
if ('error' in payload) {
|
||||
const error = payload.error
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
return validationMessage(error.message) || fallback
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
return payload &&
|
||||
typeof payload === 'object' &&
|
||||
'message' in payload &&
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: fallback
|
||||
}
|
||||
|
||||
async function readPayload(response: Response) {
|
||||
@@ -111,7 +61,7 @@ async function toApiError(response: Response) {
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
const { body, auth = true, ...requestOptions } = options
|
||||
const { body, query, auth = true, ...requestOptions } = options
|
||||
const headers = new Headers(requestOptions.headers)
|
||||
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
||||
|
||||
@@ -120,7 +70,12 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
requestBody = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
if (value !== undefined) search.set(key, String(value))
|
||||
}
|
||||
const url = `${API_URL}${path}${search.size ? `?${search}` : ''}`
|
||||
const response = await fetch(url, {
|
||||
...requestOptions,
|
||||
body: requestBody,
|
||||
headers,
|
||||
@@ -132,72 +87,18 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
}
|
||||
|
||||
if (!response.ok) throw await toApiError(response)
|
||||
return (await readPayload(response)) as T
|
||||
if (response.status === 204) return undefined as T
|
||||
const payload = await readPayload(response)
|
||||
if (
|
||||
!response.headers.get('content-type')?.includes('json') ||
|
||||
typeof payload === 'string' ||
|
||||
payload === undefined
|
||||
) {
|
||||
throw new ApiError('服务器返回了无效的 JSON 响应', response.status, payload)
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
return request<T>(path, options)
|
||||
}
|
||||
|
||||
export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') {
|
||||
return `${API_URL}/image/${encodeURIComponent(id)}/${variant}`
|
||||
}
|
||||
|
||||
export function toAuthUser(profile: BackendUserProfile): AuthUser {
|
||||
return {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
username: profile.name,
|
||||
image: profile.image,
|
||||
role: profile.role,
|
||||
is_disabled: profile.is_disabled,
|
||||
cloud_count: profile.cloud_count,
|
||||
received_like_count: profile.received_like_count,
|
||||
created_at: profile.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function toCloudType(row: BackendCloudType): CloudType {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
genus: row.genus || '',
|
||||
icon_id: row.icon_id,
|
||||
// Compatibility value for the excluded legacy admin view. Migrated pages
|
||||
// display rarity_value directly because API.md defines no semantic mapping.
|
||||
rarity: 'common',
|
||||
rarity_value: row.rarity,
|
||||
description: row.description,
|
||||
created_at: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function toApiCloud(row: BackendCloudInfo): ApiCloud {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.owner.id,
|
||||
cloud_type_id: row.type.id,
|
||||
image_url: imageUrl(row.id, 'original'),
|
||||
thumbnail_url: imageUrl(row.id, 'preview'),
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
description: row.description,
|
||||
captured_at: row.captured_at,
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
created_at: row.uploaded_at,
|
||||
updated_at: row.updated_at,
|
||||
cloud_type: {
|
||||
id: row.type.id,
|
||||
name: row.type.name,
|
||||
rarity: 'common',
|
||||
rarity_value: row.type.rarity,
|
||||
},
|
||||
owner: { id: row.owner.id, username: row.owner.name },
|
||||
cloud_type_name: row.type.name,
|
||||
cloud_type_rarity: 'common',
|
||||
cloud_type_rarity_value: row.type.rarity,
|
||||
username: row.owner.name,
|
||||
received_like_count: row.received_like_count,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface Page<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
page_size: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
/** Collect only for screens that need a complete set (heatmaps, likes, admin). */
|
||||
export async function collectPages<T>(load: (page: number) => Promise<Page<T>>): Promise<T[]> {
|
||||
const items: T[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const result = await load(page)
|
||||
items.push(...result.items)
|
||||
if (!result.has_more) return items
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Generated from hono-api/src/schemas. Do not edit manually.
|
||||
export type AdminCloudListQuery = {
|
||||
page?: number
|
||||
page_size?: number
|
||||
review_status?: 'pending' | 'approved' | 'rejected'
|
||||
}
|
||||
export type CloudCreateResponse = { message: string; cloud_id: string }
|
||||
export type CloudIdParams = { cloud_id: string }
|
||||
export type CloudIdsBody = { cloud_ids: Array<string> }
|
||||
export type CloudImageParams = {
|
||||
cloud_id: string
|
||||
variant: 'original' | 'preview'
|
||||
}
|
||||
export type CloudListResponse = Array<{
|
||||
id: string
|
||||
owner: { id: string; username: string }
|
||||
cloud_type: {
|
||||
id: number
|
||||
name: string
|
||||
genus: string | null
|
||||
icon_id: string | null
|
||||
rarity: number
|
||||
description: string
|
||||
}
|
||||
latitude: number
|
||||
longitude: number
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
uploaded_at: string
|
||||
updated_at: string
|
||||
received_like_count: number
|
||||
review_status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
}>
|
||||
export type CloudMapQuery = {
|
||||
start: string
|
||||
end: string
|
||||
time_field?: 'captured_at' | 'uploaded_at'
|
||||
limit?: number
|
||||
}
|
||||
export type CloudPageResponse = {
|
||||
items: Array<{
|
||||
id: string
|
||||
owner: { id: string; username: string }
|
||||
cloud_type: {
|
||||
id: number
|
||||
name: string
|
||||
genus: string | null
|
||||
icon_id: string | null
|
||||
rarity: number
|
||||
description: string
|
||||
}
|
||||
latitude: number
|
||||
longitude: number
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
uploaded_at: string
|
||||
updated_at: string
|
||||
received_like_count: number
|
||||
review_status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
}>
|
||||
page: number
|
||||
page_size: number
|
||||
has_more: boolean
|
||||
}
|
||||
export type CloudResponse = {
|
||||
id: string
|
||||
owner: { id: string; username: string }
|
||||
cloud_type: {
|
||||
id: number
|
||||
name: string
|
||||
genus: string | null
|
||||
icon_id: string | null
|
||||
rarity: number
|
||||
description: string
|
||||
}
|
||||
latitude: number
|
||||
longitude: number
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
uploaded_at: string
|
||||
updated_at: string
|
||||
received_like_count: number
|
||||
review_status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
}
|
||||
export type CloudTypeCreateResponse = {
|
||||
message: string
|
||||
cloud_type_id: number
|
||||
}
|
||||
export type CloudTypeIdParams = { cloud_type_id: number }
|
||||
export type CloudTypeResponse = {
|
||||
id: number
|
||||
name: string
|
||||
genus: string | null
|
||||
icon_id: string | null
|
||||
rarity: number
|
||||
description: string
|
||||
created_at: string
|
||||
}
|
||||
export type CloudTypeSummary = {
|
||||
id: number
|
||||
name: string
|
||||
genus: string | null
|
||||
icon_id: string | null
|
||||
rarity: number
|
||||
description: string
|
||||
}
|
||||
export type CreateCloudTypeBody = {
|
||||
name: string
|
||||
genus?: string | null
|
||||
icon_id?: string | null
|
||||
rarity: number
|
||||
description: string
|
||||
}
|
||||
export type CreateUserBody = {
|
||||
username: string
|
||||
email: string
|
||||
password: string
|
||||
role: 'user' | 'admin'
|
||||
}
|
||||
export type ErrorResponse = {
|
||||
issues?: Array<{ path: string; message: string }>
|
||||
message: string
|
||||
}
|
||||
export type MessageResponse = { message: string }
|
||||
export type PaginationQuery = { page?: number; page_size?: number }
|
||||
export type PublicCloudListQuery = {
|
||||
page?: number
|
||||
page_size?: number
|
||||
filter?: 'type' | 'owner'
|
||||
value?: string
|
||||
}
|
||||
export type ReviewCloudsBody = {
|
||||
cloud_ids: Array<string>
|
||||
review_status: 'approved' | 'rejected'
|
||||
}
|
||||
export type ReviewStatus = 'pending' | 'approved' | 'rejected'
|
||||
export type SetUserPasswordBody = { new_password: string }
|
||||
export type SetUserRoleBody = { role: 'user' | 'admin' }
|
||||
export type UpdateCloudBody = {
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
cloud_type_id?: number
|
||||
description?: string
|
||||
captured_at?: string
|
||||
is_hidden?: boolean
|
||||
}
|
||||
export type UpdateCloudTypeBody = {
|
||||
name?: string
|
||||
genus?: string | null
|
||||
icon_id?: string | null
|
||||
rarity?: number
|
||||
description?: string
|
||||
}
|
||||
export type UpdateUsernameBody = { username: string }
|
||||
export type UploadCloudForm = {
|
||||
image: File
|
||||
cloud_type_id: number
|
||||
latitude: number
|
||||
longitude: number
|
||||
description?: string
|
||||
captured_at?: string
|
||||
is_hidden: boolean | '0' | '1' | 'true' | 'false'
|
||||
}
|
||||
export type UserIdParams = { user_id: string }
|
||||
export type UserListResponse = Array<{
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
image: string | null
|
||||
cloud_count: number
|
||||
received_like_count: number
|
||||
last_online: string | null
|
||||
role: 'user' | 'admin'
|
||||
is_disabled: boolean
|
||||
created_at: string
|
||||
}>
|
||||
export type UserProfileResponse = {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
image: string | null
|
||||
cloud_count: number
|
||||
received_like_count: number
|
||||
last_online: string | null
|
||||
role: 'user' | 'admin'
|
||||
is_disabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { Cloud, CloudType, Profile } from '@/types/database'
|
||||
|
||||
export interface BackendUserProfile {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
image: string | null
|
||||
cloud_count: number
|
||||
received_like_count: number
|
||||
last_online: string | null
|
||||
role: Profile['role']
|
||||
is_disabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BackendCloudType {
|
||||
id: number
|
||||
name: string
|
||||
genus: string | null
|
||||
icon_id: string | null
|
||||
rarity: number
|
||||
description: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BackendCloudOwner {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface BackendCloudInfo {
|
||||
id: string
|
||||
owner: BackendCloudOwner
|
||||
type: BackendCloudType
|
||||
latitude: number
|
||||
longitude: number
|
||||
description: string | null
|
||||
captured_at: string | null
|
||||
uploaded_at: string
|
||||
updated_at: string
|
||||
received_like_count: number
|
||||
status: Cloud['status']
|
||||
is_hidden: boolean
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
status_code: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CloudCreateResponse extends ActionResponse {
|
||||
cloud_id: string
|
||||
}
|
||||
|
||||
export interface CloudTypeCreateResponse extends ActionResponse {
|
||||
cloud_type_id: number
|
||||
}
|
||||
|
||||
// Internal view model. The backend profile is normalized here so existing
|
||||
// layout/admin consumers do not need to understand the wire representation.
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
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
|
||||
}
|
||||
|
||||
export interface ApiCloudOwner {
|
||||
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
|
||||
}
|
||||
@@ -3,8 +3,7 @@ export interface CloudType {
|
||||
name: string
|
||||
genus: string
|
||||
icon_id: string | null
|
||||
rarity: 'common' | 'uncommon' | 'rare'
|
||||
rarity_value: number
|
||||
rarity: number
|
||||
description: string | null
|
||||
created_at: string
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Cloud, Profile } from '@/types/models'
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
export interface CloudTypeSummary {
|
||||
id: number
|
||||
name: string
|
||||
rarity: number
|
||||
}
|
||||
|
||||
export interface CloudOwner {
|
||||
id: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface CloudDetail extends Cloud {
|
||||
updated_at: string
|
||||
cloud_type: CloudTypeSummary | null
|
||||
owner: CloudOwner | null
|
||||
cloud_type_name: string | null
|
||||
cloud_type_rarity: number | null
|
||||
username: string | null
|
||||
received_like_count: number
|
||||
}
|
||||
Reference in New Issue
Block a user