重组前端功能目录并统一代码规范工具链 (#10)

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-09-29 18:09:38 +08:00
parent 720ad29f06
commit 0af0256c97
101 changed files with 14644 additions and 10309 deletions
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
import { useAuthStore } from '@/features/auth/stores/auth'
const authStore = useAuthStore()
const message = useMessage()
const usernameDraft = ref('')
const currentPassword = ref('')
const newPassword = ref('')
const confirmNewPassword = ref('')
const error = ref('')
const success = ref('')
const usernameSaving = ref(false)
const passwordSaving = ref(false)
onMounted(() => {
usernameDraft.value = authStore.profile?.username || ''
})
async function saveUsername() {
const nextUsername = usernameDraft.value.trim()
error.value = ''
success.value = ''
if (!nextUsername) {
error.value = '昵称不能为空。'
return
}
if (nextUsername.length > 16) {
error.value = '昵称不能超过 16 个字符。'
return
}
if (nextUsername === authStore.profile?.username) {
success.value = '昵称没有变化。'
return
}
usernameSaving.value = true
try {
await authStore.updateUsername(nextUsername)
success.value = '昵称已更新。'
message.success('昵称已更新')
} catch (e) {
error.value = e instanceof Error ? e.message : '昵称更新失败'
} finally {
usernameSaving.value = false
}
}
async function savePassword() {
error.value = ''
success.value = ''
if (newPassword.value.length < 8) {
error.value = '新密码至少需要 8 位。'
return
}
if (newPassword.value !== confirmNewPassword.value) {
error.value = '两次输入的新密码不一致。'
return
}
if (!currentPassword.value) {
error.value = '请输入当前密码。'
return
}
passwordSaving.value = true
try {
await authStore.updatePassword(currentPassword.value, newPassword.value)
currentPassword.value = ''
newPassword.value = ''
confirmNewPassword.value = ''
success.value = '密码已更新。其他设备上的会话已退出。'
message.success('密码已更新')
} catch (e) {
error.value = e instanceof Error ? e.message : '密码更新失败'
} finally {
passwordSaving.value = false
}
}
</script>
<template>
<div
class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] py-10"
>
<div class="oc-container oc-container--content">
<RouterLink to="/profile">
<NButton
type="default"
secondary
strong
class="oc-panel-button oc-panel-button--neutral"
>← 返回个人主页</NButton
>
</RouterLink>
<div class="mt-6">
<p class="oc-page-eyebrow">Account Settings</p>
<h1 class="oc-page-title">个人资料设置</h1>
<p class="oc-page-description">
修改公开昵称和登录密码。昵称会显示在画廊、百科和地图中,且不能与其他用户重复。
</p>
</div>
<div class="mt-8 grid gap-6">
<NAlert v-if="error" type="error" :show-icon="false" :bordered="false">
{{ error }}
</NAlert>
<NAlert v-if="success" type="success" :show-icon="false" :bordered="false">
{{ success }}
</NAlert>
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
<div>
<h2 class="text-2xl font-bold text-slate-900">公开昵称</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
用于公开展示你的上传记录。保存前会检查是否与其他用户重复。
</p>
</div>
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
<NInput
v-model:value="usernameDraft"
placeholder="输入新的昵称"
maxlength="16"
show-count
/>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--teal"
:loading="usernameSaving"
@click="saveUsername"
>
保存昵称
</NButton>
</div>
</div>
</NCard>
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
<div>
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
</p>
</div>
<NForm class="grid gap-3" @submit.prevent="savePassword">
<input
:value="authStore.user?.email || ''"
type="text"
name="username"
autocomplete="username"
class="sr-only"
tabindex="-1"
aria-label="账户邮箱"
readonly
/>
<NInput
v-model:value="currentPassword"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'current-password',
name: 'current-password',
required: true,
}"
placeholder="当前密码"
/>
<NInput
v-model:value="newPassword"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'new-password',
required: true,
}"
placeholder="新密码"
/>
<NInput
v-model:value="confirmNewPassword"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'confirm-new-password',
required: true,
}"
placeholder="确认新密码"
/>
<div class="flex justify-end">
<NButton
secondary
strong
type="default"
attr-type="submit"
class="oc-panel-button oc-panel-button--teal"
:loading="passwordSaving"
>
保存密码
</NButton>
</div>
</NForm>
</div>
</NCard>
</div>
</div>
</div>
</template>
+937
View File
@@ -0,0 +1,937 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
import { Settings } from '@vicons/tabler'
import CloudEditModal, {
type CloudEditFormValue,
} from '@/features/clouds/components/CloudEditModal.vue'
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 ContributionHeatmap from '@/features/profile/components/ContributionHeatmap.vue'
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'
interface TimelineGroup {
key: string
label: string
items: ProfileCloudItem[]
}
const route = useRoute()
const authStore = useAuthStore()
const cloudsStore = useCloudsStore()
const profileStore = useProfileStore()
const message = useMessage()
const selectedCloud = ref<ProfileCloudItem | null>(null)
const selectedUploadDate = ref<string | null>(null)
const timelineSectionRef = ref<HTMLElement | null>(null)
const selectionMode = ref(false)
const selectedCloudIds = ref<Set<string>>(new Set())
const editModalOpen = ref(false)
const editSaving = ref(false)
const manageError = ref('')
const editInitialValue = ref<CloudEditFormValue | null>(null)
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
const statusMeta = {
approved: { label: '已通过', chip: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
pending: { label: '待审核', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
rejected: { label: '未通过', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
} satisfies Record<ProfileCloudItem['status'], { label: string; chip: string }>
const viewedIdentifier = computed(() => {
const routeUsername = typeof route.params.id === 'string' ? route.params.id : null
return routeUsername || authStore.user?.id || null
})
const isOwnProfile = computed(() => typeof route.params.id !== 'string')
const loading = computed(() => profileStore.isLoading(viewedIdentifier.value, isOwnProfile.value))
const errorMsg = computed(() => {
if (!viewedIdentifier.value) return '未找到要查看的用户。'
return profileStore.getError(viewedIdentifier.value, isOwnProfile.value)
})
const profileData = computed<Profile | null>(() =>
isOwnProfile.value ? authStore.profile : profileStore.getProfile(viewedIdentifier.value),
)
const profileImageFailed = ref(false)
watch(
() => profileData.value?.image,
() => {
profileImageFailed.value = false
},
)
const clouds = computed<ProfileCloudItem[]>(() =>
profileStore.getClouds(viewedIdentifier.value, isOwnProfile.value),
)
const receivedLikes = computed(() =>
isOwnProfile.value ? authStore.user?.received_like_count || 0 : 0,
)
const pageTitle = computed(() => {
return isOwnProfile.value
? '我的天空日志'
: `${profileData.value?.username || '这位用户'}的天空日志`
})
const totalShots = computed(() => clouds.value.length)
const approvedShots = computed(() => {
return clouds.value.filter(item => item.status === 'approved').length
})
const selectedCloudCount = computed(() => selectedCloudIds.value.size)
const cloudActionOptions = computed(() => {
if (!selectedCloud.value) return []
return [
{ label: '编辑', key: 'edit' },
{ label: '删除', key: 'delete' },
{
label: selectedCloud.value.is_hidden ? '更改为公开' : '更改为私密',
key: 'toggle-privacy',
},
]
})
const shootingDays = computed(() => {
return new Set(clouds.value.map(item => toDayKey(item.captured_at || item.created_at))).size
})
const uploadDayCounts = computed(() => {
const counts = new Map<string, number>()
for (const item of clouds.value) {
const key = toDayKey(item.created_at)
counts.set(key, (counts.get(key) || 0) + 1)
}
return counts
})
const mostCommonCloudType = computed(() => {
const counts = new Map<string, number>()
for (const item of clouds.value) {
counts.set(item.cloudTypeName, (counts.get(item.cloudTypeName) || 0) + 1)
}
let winner = '还没有记录'
let maxCount = 0
for (const [name, count] of counts.entries()) {
if (count > maxCount) {
winner = name
maxCount = count
}
}
return {
name: winner,
count: maxCount,
}
})
const filteredClouds = computed(() => {
if (!selectedUploadDate.value) return clouds.value
return clouds.value.filter(item => toDayKey(item.created_at) === selectedUploadDate.value)
})
const timelineGroups = computed<TimelineGroup[]>(() => {
const groups = new Map<string, TimelineGroup>()
for (const item of filteredClouds.value) {
const date = new Date(item.captured_at || item.created_at)
const key = `${date.getFullYear()}-${date.getMonth() + 1}`
const existing = groups.get(key)
if (existing) {
existing.items.push(item)
continue
}
groups.set(key, {
key,
label: `${date.getFullYear()}年${date.getMonth() + 1}月`,
items: [item],
})
}
return Array.from(groups.values())
})
const selectedUploadCount = computed(() => {
if (!selectedUploadDate.value) return 0
return uploadDayCounts.value.get(selectedUploadDate.value) || 0
})
const contributionCells = computed(() => {
return Array.from(uploadDayCounts.value.entries())
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date))
})
const activeUploadDays = computed(() => {
return contributionCells.value.filter(cell => cell.count > 0).length
})
function toDayKey(iso: string) {
const date = new Date(iso)
return toLocalDayKey(date)
}
function toLocalDayKey(date: Date) {
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
function formatDate(iso: string | null) {
if (!iso) return '未知时间'
return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function formatDayKey(dayKey: string | null) {
if (!dayKey) return ''
const [year, month, day] = dayKey.split('-').map(Number)
return new Date(year, (month || 1) - 1, day || 1).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function formatDateTime(iso: string | null) {
if (!iso) return '未知时间'
return new Date(iso).toLocaleString('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function formatCoordinate(value: number | null) {
return value === null ? '未记录' : value.toFixed(2)
}
function formatCloudTime(item: ProfileCloudItem) {
return item.captured_at
? `拍摄于 ${formatDateTime(item.captured_at)}`
: `上传于 ${formatDateTime(item.created_at)}`
}
function getProfileInitial(profile: Profile | null) {
return profile?.username?.slice(0, 1).toUpperCase() || '云'
}
function toggleSelectedUploadDate(date: string) {
selectedUploadDate.value = selectedUploadDate.value === date ? null : date
}
function clearSelectedUploadDate() {
selectedUploadDate.value = null
}
function setSelectedCloudIds(nextIds: Iterable<string>) {
selectedCloudIds.value = new Set(nextIds)
}
function toggleCloudSelection(cloudId: string, checked: boolean) {
const nextIds = new Set(selectedCloudIds.value)
if (checked) {
nextIds.add(cloudId)
} else {
nextIds.delete(cloudId)
}
setSelectedCloudIds(nextIds)
}
function clearCloudSelection() {
setSelectedCloudIds([])
}
function toggleSelectionMode() {
selectionMode.value = !selectionMode.value
if (!selectionMode.value) {
clearCloudSelection()
}
}
function formatDatetimeLocal(iso: string | null) {
if (!iso) return ''
const date = new Date(iso)
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function openEditModal(cloud: ProfileCloudItem) {
manageError.value = ''
editInitialValue.value = {
cloudCategoryId: cloud.cloud_type_id,
capturedAt: formatDatetimeLocal(cloud.captured_at),
latitude: cloud.latitude,
longitude: cloud.longitude,
description: cloud.description || '',
isPublic: !cloud.is_hidden,
}
editModalOpen.value = true
}
function closeEditModal() {
editModalOpen.value = false
editSaving.value = false
manageError.value = ''
}
function blurCoordinate(value: number): number {
return Math.round(value * 100) / 100
}
async function submitEditModal(value: CloudEditFormValue) {
if (!selectedCloud.value || !viewedIdentifier.value) return
if (!value.cloudCategoryId) {
manageError.value = '请选择云型类别。'
return
}
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
if (!hasLat || !hasLng) {
manageError.value = '经纬度为必填项。'
return
}
if (value.description.trim().length > 128) {
manageError.value = '图片描述不能超过 128 个字符。'
return
}
editSaving.value = true
manageError.value = ''
try {
const updated = await profileStore.updateCloud(
viewedIdentifier.value,
selectedCloud.value.id,
{
type_id: value.cloudCategoryId,
latitude: blurCoordinate(value.latitude as number),
longitude: blurCoordinate(value.longitude as number),
description: value.description.trim(),
...(value.capturedAt
? { captured_at: new Date(value.capturedAt).toISOString() }
: {}),
is_hidden: !value.isPublic,
},
)
selectedCloud.value = updated
closeEditModal()
} catch (error) {
manageError.value = error instanceof Error ? error.message : '图片信息更新失败'
} finally {
editSaving.value = false
}
}
async function toggleSelectedCloudPrivacy() {
if (!selectedCloud.value || !viewedIdentifier.value) return
manageError.value = ''
const nextHidden = !selectedCloud.value.is_hidden
try {
await profileStore.updateCloudVisibility(
viewedIdentifier.value,
selectedCloud.value.id,
nextHidden,
)
selectedCloud.value = { ...selectedCloud.value, is_hidden: nextHidden }
} catch (error) {
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
}
}
async function deleteCloudByIds(cloudIds: string[]) {
if (!viewedIdentifier.value || !cloudIds.length) return
const confirmed = window.confirm(
`确定删除 ${cloudIds.length} 张图片吗?删除后无法在页面中恢复。`,
)
if (!confirmed) return
manageError.value = ''
try {
const deletedCount = await profileStore.deleteClouds(viewedIdentifier.value, cloudIds)
if (selectedCloud.value && cloudIds.includes(selectedCloud.value.id)) {
selectedCloud.value = null
closeEditModal()
}
const nextSelected = new Set(selectedCloudIds.value)
for (const cloudId of cloudIds) nextSelected.delete(cloudId)
setSelectedCloudIds(nextSelected)
message.success(`已成功删除 ${deletedCount} 张照片`)
} catch (error) {
manageError.value = error instanceof Error ? error.message : '图片删除失败'
}
}
function handleCloudAction(key: string | number) {
if (!selectedCloud.value) return
if (key === 'edit') openEditModal(selectedCloud.value)
if (key === 'delete') deleteCloudByIds([selectedCloud.value.id])
if (key === 'toggle-privacy') toggleSelectedCloudPrivacy()
}
async function loadProfilePage(force = false) {
selectedCloud.value = null
selectedUploadDate.value = null
clearCloudSelection()
selectionMode.value = false
const targetIdentifier = viewedIdentifier.value
if (!targetIdentifier) {
return
}
await profileStore.fetchProfilePage(targetIdentifier, isOwnProfile.value, force)
if (isOwnProfile.value) {
await cloudsStore.fetchCloudTypes()
}
}
watch(
() => [route.params.id, authStore.user?.id],
() => {
loadProfilePage()
},
{ immediate: true },
)
watch(selectedUploadDate, async newValue => {
if (!newValue) return
await nextTick()
timelineSectionRef.value?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
})
</script>
<template>
<div class="relative">
<div class="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner">
<div v-if="loading" class="oc-page-hero__split">
<NCard class="oc-panel-card h-full border border-slate-200" :bordered="false">
<NSkeleton class="h-6 w-1/3" />
<NSkeleton class="mt-4 h-10 w-2/3" />
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
</NCard>
<div
v-if="isOwnProfile"
class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2"
>
<NCard
v-for="n in 4"
:key="n"
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
:bordered="false"
>
<NSkeleton class="h-3 w-2/3" />
<NSkeleton class="mt-3 h-7 w-1/2" />
</NCard>
</div>
<NCard
v-else
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
:bordered="false"
>
<NSkeleton class="h-5 w-1/3" />
<NSkeleton class="mt-4 h-8 w-1/2" />
<NSkeleton class="mt-5 h-3 w-full" />
</NCard>
</div>
<div v-else class="oc-page-hero__split oc-page-hero__split--stretch">
<NCard
class="oc-panel-card h-full border border-slate-200 bg-white/90 backdrop-blur"
:bordered="false"
>
<div class="flex gap-5">
<div
class="profile-avatar flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-3xl font-bold text-slate-900"
>
<img
v-if="profileData?.image && !profileImageFailed"
:src="profileData.image"
:alt="profileData.username"
class="h-full w-full object-cover"
@error="profileImageFailed = true"
/>
<template v-else>
{{ getProfileInitial(profileData) }}
</template>
</div>
<div class="min-w-0">
<p class="oc-page-eyebrow">Sky Journal</p>
<h1 class="oc-page-title">
{{ pageTitle }}
</h1>
<div class="mt-3 flex flex-wrap items-center gap-3">
<p class="text-lg font-medium text-slate-700">
@{{ profileData?.username || '未知用户' }}
</p>
<RouterLink v-if="isOwnProfile" to="/profile/settings">
<button
type="button"
class="oc-icon-button oc-icon-button--small"
title="个人资料设置"
aria-label="个人资料设置"
>
<NIcon size="17">
<Settings />
</NIcon>
</button>
</RouterLink>
</div>
<div
class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500"
>
<span v-if="isOwnProfile"
>注册于
{{ formatDate(profileData?.created_at || null) }}</span
>
</div>
</div>
</div>
</NCard>
<div
v-if="isOwnProfile"
class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2"
>
<NCard
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
:bordered="false"
>
<p class="oc-stat-label">总拍摄数</p>
<p class="oc-stat-value">{{ totalShots }}</p>
</NCard>
<NCard
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
:bordered="false"
>
<p class="oc-stat-label">最常拍云型</p>
<p class="mt-2 truncate text-xl font-bold text-slate-900">
{{ mostCommonCloudType.name }}
</p>
</NCard>
<NCard
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
:bordered="false"
>
<p class="oc-stat-label">拍摄天数</p>
<p class="oc-stat-value">{{ shootingDays }}</p>
</NCard>
<NCard
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
:bordered="false"
>
<p class="oc-stat-label">被点赞数</p>
<p class="oc-stat-value">{{ receivedLikes }}</p>
</NCard>
</div>
<NCard
v-if="!isOwnProfile"
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
:bordered="false"
>
<p class="text-sm text-slate-500">公开贡献</p>
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
</NCard>
</div>
</div>
</div>
<div class="oc-container oc-container--content oc-page-content">
<NAlert
v-if="errorMsg"
type="error"
:show-icon="false"
:bordered="false"
title="个人主页加载失败"
>
{{ errorMsg }}
</NAlert>
<NAlert
v-if="manageError"
class="mb-5"
type="error"
:show-icon="false"
:bordered="false"
title="操作失败"
>
{{ manageError }}
</NAlert>
<template v-else>
<section ref="timelineSectionRef">
<ContributionHeatmap
:cells="contributionCells"
:total-count="totalShots"
:active-days="activeUploadDays"
:active-date="selectedUploadDate"
:title="isOwnProfile ? '上传活跃度' : '公开活跃度'"
@select-date="toggleSelectedUploadDate"
/>
</section>
<section class="mt-8">
<div class="mb-5 flex items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-bold text-slate-900">天空日志</h2>
<p class="oc-field-help">
按拍摄时间展示;未记录拍摄时间时回退为上传时间。热力图按上传日期计算。
</p>
</div>
<div class="flex items-center gap-3">
<NButton
v-if="isOwnProfile && selectedCloudCount"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--danger"
@click="deleteCloudByIds(Array.from(selectedCloudIds))"
>
删除已选 {{ selectedCloudCount }}
</NButton>
<NButton
v-if="isOwnProfile"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="toggleSelectionMode"
>
{{ selectionMode ? '取消选择' : '选择图片' }}
</NButton>
<NButton
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="loadProfilePage(true)"
>
刷新数据
</NButton>
<NButton
v-if="selectedUploadDate"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="clearSelectedUploadDate"
>
清除筛选
</NButton>
<RouterLink
v-if="isOwnProfile"
to="/upload"
class="oc-panel-button oc-panel-button--sky"
>
继续上传
</RouterLink>
</div>
</div>
<div
v-if="selectedUploadDate"
class="mb-5 border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800"
>
当前正在查看
<span class="font-semibold">{{ formatDayKey(selectedUploadDate) }}</span>
上传的图片, 共
<span class="font-semibold">{{ selectedUploadCount }}</span> 张。
</div>
<div v-if="loading" class="space-y-8">
<div v-for="n in 2" :key="n">
<NSkeleton class="h-6 w-40" />
<div class="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<NCard
v-for="m in 3"
:key="m"
class="oc-panel-card border border-slate-200"
:bordered="false"
>
<NSkeleton class="h-52 w-full" />
<NSkeleton class="mt-4 h-5 w-2/3" />
<NSkeleton class="mt-3 h-4 w-1/2" />
</NCard>
</div>
</div>
</div>
<div v-else-if="timelineGroups.length" class="space-y-10">
<section v-for="group in timelineGroups" :key="group.key">
<div class="flex items-center gap-4">
<h3 class="text-xl font-bold text-slate-900">{{ group.label }}</h3>
<div class="h-px flex-1 bg-slate-200"></div>
</div>
<div class="mt-5 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<button
v-for="item in group.items"
:key="item.id"
type="button"
class="oc-interactive-card group overflow-hidden border bg-white text-left transition-all hover:-translate-y-1"
:class="
selectedUploadDate &&
toDayKey(item.created_at) === selectedUploadDate
? 'oc-interactive-card--selected'
: 'border-slate-200'
"
@click="selectedCloud = item"
>
<div class="relative">
<img
:src="item.thumbnail_url || item.image_url"
:alt="item.cloudTypeName"
class="h-56 w-full object-cover transition duration-500 group-hover:scale-[1.03]"
/>
<div
class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/82 via-slate-950/32 to-transparent"
></div>
<div class="absolute left-4 top-4 flex flex-wrap gap-2">
<label
v-if="isOwnProfile && selectionMode"
class="inline-flex items-center border border-white/60 bg-white/90 px-2 py-1 text-xs font-medium text-slate-700"
@click.stop
>
<input
type="checkbox"
class="mr-2 h-3.5 w-3.5 border-slate-300 text-sky-500 focus:ring-sky-500"
:checked="selectedCloudIds.has(item.id)"
@change="
toggleCloudSelection(
item.id,
($event.target as HTMLInputElement)
.checked,
)
"
/>
选择
</label>
<span
class="inline-flex border px-3 py-1 text-xs font-medium"
:class="rarityChip"
>
稀有度 {{ item.cloudTypeRarity }}
</span>
<span
v-if="isOwnProfile"
class="inline-flex border px-3 py-1 text-xs font-medium"
:class="statusMeta[item.status].chip"
>
{{ statusMeta[item.status].label }}
</span>
<span
v-if="isOwnProfile"
class="inline-flex border px-3 py-1 text-xs font-medium"
:class="
item.is_hidden
? 'border-slate-200 bg-slate-100 text-slate-600'
: 'border-emerald-200 bg-emerald-100 text-emerald-700'
"
>
{{ item.is_hidden ? '私密' : '公开' }}
</span>
</div>
<div class="absolute bottom-4 left-4 right-4 text-white">
<p class="truncate text-lg font-semibold">
{{ item.cloudTypeName }}
</p>
<p class="mt-1 truncate text-xs text-white/80">
{{ formatCloudTime(item) }}
</p>
</div>
</div>
<div class="p-5">
<p class="truncate text-sm font-medium text-slate-900">
纬度 {{ formatCoordinate(item.latitude) }} / 经度
{{ formatCoordinate(item.longitude) }}
</p>
<p
class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500"
>
{{ item.description || '没有留下额外说明。' }}
</p>
</div>
</button>
</div>
</section>
</div>
<div
v-else
class="oc-empty-card border border-dashed border-slate-300 bg-white p-10"
>
<NEmpty
:description="
selectedUploadDate
? '这一天没有上传记录'
: isOwnProfile
? '还没有上传任何云图'
: '该用户暂无公开云图'
"
>
<template #extra>
<RouterLink
v-if="isOwnProfile && !selectedUploadDate"
to="/upload"
class="oc-panel-button oc-panel-button--sky"
>
去上传第一张
</RouterLink>
<NButton
v-else-if="selectedUploadDate"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="clearSelectedUploadDate"
>返回全部记录</NButton
>
</template>
</NEmpty>
</div>
</section>
</template>
</div>
<ImageDetailModal
v-if="selectedCloud"
:open="!!selectedCloud"
:image-url="selectedCloud.image_url"
:thumbnail-url="selectedCloud.thumbnail_url"
:image-alt="selectedCloud.cloudTypeName"
:title="selectedCloud.cloudTypeName"
:subtitle="formatCloudTime(selectedCloud)"
:badge-label="`稀有度 ${selectedCloud.cloudTypeRarity}`"
:badge-class="rarityChip"
@close="selectedCloud = null"
>
<div class="grid gap-4 sm:grid-cols-2">
<div class="oc-inset-panel">
<p class="oc-section-label">上传时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">
{{ formatDateTime(selectedCloud.created_at) }}
</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">拍摄时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">
{{ formatDateTime(selectedCloud.captured_at) }}
</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">位置</p>
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">模糊化经纬度</p>
<p class="mt-2 text-sm font-medium text-slate-900">
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度
{{ formatCoordinate(selectedCloud.longitude) }}
</p>
</div>
<div class="oc-inset-panel">
<p class="oc-section-label">{{ isOwnProfile ? '审核状态' : '可见性' }}</p>
<p class="mt-2 text-sm font-medium text-slate-900">
<template v-if="isOwnProfile">
{{ statusMeta[selectedCloud.status].label }} /
{{ selectedCloud.is_hidden ? '私密' : '公开' }}
</template>
<template v-else>公开可见</template>
</p>
</div>
</div>
<div class="mt-5 border border-slate-200 bg-white p-5">
<p class="oc-section-label">图片说明</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
</p>
</div>
<MiniLocationMap
:latitude="selectedCloud.latitude"
:longitude="selectedCloud.longitude"
/>
<CloudLikeButton
v-if="selectedCloud.status === 'approved' && !selectedCloud.is_hidden"
:cloud-id="selectedCloud.id"
:count="selectedCloud.receivedLikeCount"
/>
<template v-if="isOwnProfile" #actions>
<div class="flex items-center justify-between gap-3">
<NDropdown
trigger="click"
placement="top-start"
:options="cloudActionOptions"
@select="handleCloudAction"
>
<button
type="button"
class="oc-icon-button"
title="图片管理"
aria-label="图片管理"
>
<NIcon size="20">
<Settings />
</NIcon>
</button>
</NDropdown>
<!-- <span class="text-xs text-slate-500">
{{ selectedCloud.is_hidden ? '当前为私密图片' : '当前会公开展示' }}
</span> -->
</div>
</template>
</ImageDetailModal>
<CloudEditModal
:open="editModalOpen && !!selectedCloud"
:saving="editSaving"
:error="manageError"
:cloud-types="cloudsStore.cloudTypes"
:initial-value="editInitialValue"
@close="closeEditModal"
@save="submitEditModal"
/>
</div>
</template>
<style scoped>
.profile-avatar {
box-shadow: var(--oc-shadow-sm);
}
</style>