fix: 项目结构优化及后端同步适配 (#11)

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-09-30 12:11:32 +08:00
parent 0af0256c97
commit f31691c300
34 changed files with 1137 additions and 1623 deletions
+68
View File
@@ -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' })
}
+32 -64
View File
@@ -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) {
+8 -11
View File
@@ -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)
}
+87
View File
@@ -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
+3 -5
View File
@@ -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
}
+7 -21
View File
@@ -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
+15 -13
View File
@@ -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(),
+8 -8
View File
@@ -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 []
+44
View File
@@ -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
}
}
+22 -71
View File
@@ -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
}
+2 -2
View File
@@ -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(),
+9 -18
View File
@@ -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)
}
+62
View File
@@ -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
View File
@@ -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,
}
}
+16
View File
@@ -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
}
}
+191
View File
@@ -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
}
-94
View File
@@ -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
}
+36
View File
@@ -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
}