重组前端功能目录并统一代码规范工具链 (#10)
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user