Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbb4ff724b | ||
|
|
2da2cef338 | ||
|
|
358fa2c7d3 | ||
|
|
21a2db2afd | ||
|
|
ba2ee6b60a | ||
|
|
543456797c | ||
|
|
8ae004b466 | ||
|
|
1a7fd11eee | ||
|
|
ccf7049ae0 |
@@ -58,6 +58,8 @@ OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓
|
||||
|
||||
容器宽度只有三个语义档位:`oc-container--narrow`、`oc-container--content`、`oc-container--wide`。不要在同类页面重复手写 `max-w-* mx-auto px-*`。
|
||||
|
||||
Hero 同时包含左侧介绍和右侧状态卡时,使用 `oc-page-hero__split`。它会在有效视口达到 `900px` 时启用接近均衡的双栏,避免浏览器缩放到 125% 后过早堆叠;需要两栏等高时同时添加 `oc-page-hero__split--stretch`。
|
||||
|
||||
### 表单
|
||||
|
||||
```html
|
||||
@@ -76,7 +78,8 @@ OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓
|
||||
- `oc-section-label`:详情面板中的字段标题。
|
||||
- `oc-stat-label` / `oc-stat-value`:统计数据的标签和值。
|
||||
- `oc-inset-panel`:详情中的浅色信息块。
|
||||
- `oc-panel-card` / `oc-panel-card-soft`:Naive UI 卡片的硬阴影。
|
||||
- `oc-panel-card` / `oc-panel-card-soft` / `oc-panel-card-xl`:普通、柔和和大型卡片的硬阴影。
|
||||
- `oc-floating-panel` / `oc-modal-shell`:浮动菜单与普通方形弹窗的共享表面。
|
||||
- `oc-surface`:普通 HTML 容器的标准白色表面。
|
||||
- `oc-empty-card`:空状态表面。
|
||||
|
||||
|
||||
@@ -17,6 +17,18 @@ const deprecatedPatterns = [
|
||||
pattern: /#(?:f0fdfa|ccfbf1|99f6e4|5eead4|2dd4bf|14b8a6|0d9488|0f766e|115e59|134e4a)\b/gi,
|
||||
message: '检测到旧版高饱和 teal 色值,请改用自然绿色设计令牌。',
|
||||
},
|
||||
{
|
||||
pattern: /#(?:c084fc|a855f7|9333ea|7e22ce)\b/gi,
|
||||
message: '检测到禁用的紫色硬编码,请改用 rose 或项目语义令牌。',
|
||||
},
|
||||
{
|
||||
pattern: /#(?:1f2937|9ca3af)\b/gi,
|
||||
message: '检测到默认 gray 调色板硬编码,请改用对应的 slate 色值或语义令牌。',
|
||||
},
|
||||
{
|
||||
pattern: /#60a5fa\b/gi,
|
||||
message: '检测到默认 blue-400 硬编码,请按用途改用 sky 或 amber 等项目语义色。',
|
||||
},
|
||||
{
|
||||
pattern: /text-xs uppercase tracking-\[0\.18em\] text-slate-500/g,
|
||||
message: '重复的小节标签应使用 oc-section-label。',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { NAlert, NButton } from 'naive-ui'
|
||||
import { NAlert, NButton, NIcon } from 'naive-ui'
|
||||
import { Map as MapIcon, X } from '@vicons/tabler'
|
||||
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
@@ -115,7 +116,7 @@ watch(() => props.initialValue, value => {
|
||||
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/60 px-4 py-6"
|
||||
@click="close"
|
||||
>
|
||||
<div class="w-full max-w-2xl bg-white shadow-2xl" @click.stop>
|
||||
<div class="oc-modal-shell w-full max-w-2xl" @click.stop>
|
||||
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-slate-900">编辑图片信息</h3>
|
||||
@@ -128,7 +129,7 @@ watch(() => props.initialValue, value => {
|
||||
title="关闭编辑窗口"
|
||||
@click="close"
|
||||
>
|
||||
✕
|
||||
<NIcon size="20"><X /></NIcon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -223,7 +224,7 @@ watch(() => props.initialValue, value => {
|
||||
aria-label="地图选点"
|
||||
@click="mapPickerOpen = true"
|
||||
>
|
||||
<span class="text-lg leading-none">🗺️</span>
|
||||
<NIcon size="20"><MapIcon /></NIcon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,7 +234,7 @@ watch(() => props.initialValue, value => {
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="4"
|
||||
class="w-full resize-none border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
class="oc-field-control resize-none"
|
||||
placeholder="描述一下这张图片..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@@ -1,61 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NCard, NProgress } from 'naive-ui'
|
||||
import { NCard } from 'naive-ui'
|
||||
|
||||
const props = defineProps<{
|
||||
unlockedCount: number
|
||||
totalCount: number
|
||||
percent: number
|
||||
isLoggedIn: boolean
|
||||
}>()
|
||||
|
||||
function lerpHex(a: string, b: string, t: number) {
|
||||
const ah = parseInt(a.slice(1), 16)
|
||||
const bh = parseInt(b.slice(1), 16)
|
||||
const r = Math.round(((ah >> 16) & 0xff) + (((bh >> 16) & 0xff) - ((ah >> 16) & 0xff)) * t)
|
||||
const g = Math.round(((ah >> 8) & 0xff) + (((bh >> 8) & 0xff) - ((ah >> 8) & 0xff)) * t)
|
||||
const bv = Math.round((ah & 0xff) + ((bh & 0xff) - (ah & 0xff)) * t)
|
||||
return `#${((r << 16) | (g << 8) | bv).toString(16).padStart(6, '0')}`
|
||||
}
|
||||
|
||||
function progressColor(pct: number) {
|
||||
const t = Math.min(1, Math.max(0, pct / 100))
|
||||
if (t <= 0.5) return lerpHex('#0ea5e9', '#f59e0b', t * 2)
|
||||
return lerpHex('#f59e0b', '#ef4444', (t - 0.5) * 2)
|
||||
}
|
||||
|
||||
const progressText = computed(() => `${props.unlockedCount}/${props.totalCount}`)
|
||||
const currentColor = computed(() => progressColor(props.isLoggedIn ? props.percent : 0))
|
||||
const displayedUnlockedCount = computed(() => {
|
||||
if (!props.isLoggedIn) return 0
|
||||
return Math.min(Math.max(props.unlockedCount, 0), props.totalCount)
|
||||
})
|
||||
const remainingCount = computed(() => Math.max(props.totalCount - displayedUnlockedCount.value, 0))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||||
<div class="flex items-center justify-between">
|
||||
<NCard class="oc-panel-card-soft border border-sky-100 bg-white/88 backdrop-blur" :bordered="false">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">当前进度</p>
|
||||
<p class="mt-1 text-3xl font-bold" :style="{ color: currentColor }">{{ progressText }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center border text-xl font-semibold text-white transition-colors duration-500"
|
||||
:style="{ backgroundColor: currentColor, borderColor: currentColor }"
|
||||
>
|
||||
{{ isLoggedIn ? percent : 0 }}%
|
||||
<p class="oc-stat-label">Collection Status</p>
|
||||
<p class="mt-1 text-base font-semibold text-slate-900">图鉴收集进度</p>
|
||||
</div>
|
||||
<p class="shrink-0 font-mono tabular-nums text-slate-500">
|
||||
<span class="text-3xl font-bold text-sky-800">{{ displayedUnlockedCount }}</span>
|
||||
<span class="ml-1 text-sm">/ {{ totalCount }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NProgress
|
||||
class="mt-5"
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:percentage="isLoggedIn ? percent : 0"
|
||||
:color="{ stops: ['#0ea5e9', currentColor] }"
|
||||
:height="12"
|
||||
rail-color="#e2e8f0"
|
||||
/>
|
||||
<div
|
||||
class="mt-5 grid grid-cols-5 gap-2 sm:grid-cols-10"
|
||||
role="progressbar"
|
||||
aria-label="云属收集进度"
|
||||
aria-valuemin="0"
|
||||
:aria-valuemax="totalCount"
|
||||
:aria-valuenow="displayedUnlockedCount"
|
||||
:aria-valuetext="`已收录 ${displayedUnlockedCount} 种,共 ${totalCount} 种`"
|
||||
>
|
||||
<span
|
||||
v-for="index in totalCount"
|
||||
:key="index"
|
||||
aria-hidden="true"
|
||||
class="encyclopedia-progress-cell aspect-square border border-slate-300 bg-slate-50"
|
||||
:class="{ 'encyclopedia-progress-cell--active': index <= displayedUnlockedCount }"
|
||||
:style="{ animationDelay: `${(index - 1) * 90}ms` }"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-sm text-slate-500">
|
||||
<p class="mt-4 border-t border-slate-200 pt-3 text-sm text-slate-500">
|
||||
<template v-if="isLoggedIn">
|
||||
已解锁 {{ unlockedCount }} 枚徽章,还差 {{ Math.max(totalCount - unlockedCount, 0) }} 枚。
|
||||
已收录 {{ displayedUnlockedCount }} 种云属,还差 {{ remainingCount }} 种。
|
||||
</template>
|
||||
<template v-else>
|
||||
登录后可同步你的个人图鉴进度。
|
||||
@@ -63,3 +57,52 @@ const currentColor = computed(() => progressColor(props.isLoggedIn ? props.perce
|
||||
</p>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.encyclopedia-progress-cell--active {
|
||||
background: rgb(224 242 254);
|
||||
border-color: rgb(125 211 252);
|
||||
box-shadow: 3px 3px 0 0 rgb(3 105 161 / 14%);
|
||||
animation:
|
||||
encyclopedia-cell-activate 1ms linear both,
|
||||
encyclopedia-cell-pulse 220ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes encyclopedia-cell-activate {
|
||||
from {
|
||||
background: rgb(248 250 252);
|
||||
border-color: rgb(203 213 225);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
to {
|
||||
background: rgb(224 242 254);
|
||||
border-color: rgb(125 211 252);
|
||||
box-shadow: 3px 3px 0 0 rgb(3 105 161 / 14%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes encyclopedia-cell-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
28% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
62% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.encyclopedia-progress-cell--active {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -181,13 +181,13 @@ onBeforeUnmount(() => {
|
||||
@click="close"
|
||||
>
|
||||
<div
|
||||
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl"
|
||||
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden shadow-2xl"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="close"
|
||||
class="image-detail-close-button oc-overlay-button absolute right-4 top-4 z-30 h-10 w-10 rounded-full [&_svg]:stroke-[2.5]"
|
||||
class="image-detail-close-button oc-overlay-button absolute right-4 top-4 z-30 h-10 w-10 [&_svg]:stroke-[2.5]"
|
||||
aria-label="关闭大图"
|
||||
title="关闭大图"
|
||||
>
|
||||
@@ -223,7 +223,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleInfo"
|
||||
class="oc-overlay-button absolute bottom-4 right-4 z-30 h-11 w-11 rounded-full drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)]"
|
||||
class="image-detail-info-button oc-overlay-button absolute bottom-4 right-4 z-30 h-11 w-11 drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)]"
|
||||
:aria-label="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
||||
:title="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
||||
>
|
||||
@@ -242,7 +242,7 @@ onBeforeUnmount(() => {
|
||||
<h2 class="text-xl font-bold text-slate-900">{{ title }}</h2>
|
||||
<span
|
||||
v-if="badgeLabel"
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||
class="border px-3 py-1 text-xs font-medium"
|
||||
:class="badgeClass"
|
||||
>
|
||||
{{ badgeLabel }}
|
||||
@@ -254,11 +254,11 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
type="button"
|
||||
@click="infoOpen = false"
|
||||
class="oc-icon-button oc-icon-button--small absolute right-3 top-3 rounded-full"
|
||||
class="image-detail-panel-close oc-icon-button oc-icon-button--small absolute right-3 top-3"
|
||||
aria-label="隐藏详细信息"
|
||||
title="隐藏详细信息"
|
||||
>
|
||||
✕
|
||||
<NIcon size="16"><X /></NIcon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -301,7 +301,13 @@ onBeforeUnmount(() => {
|
||||
transform: translateY(10px) scale(0.97);
|
||||
}
|
||||
|
||||
.image-detail-close-button.image-detail-close-button {
|
||||
.image-detail-shell.image-detail-shell {
|
||||
border-radius: 30px !important;
|
||||
}
|
||||
|
||||
.image-detail-close-button.image-detail-close-button,
|
||||
.image-detail-info-button.image-detail-info-button,
|
||||
.image-detail-panel-close.image-detail-panel-close {
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { NButton, NIcon } from 'naive-ui'
|
||||
import { X } from '@vicons/tabler'
|
||||
import { loadAMap } from '@/lib/amap'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -49,6 +50,8 @@ function placeMarker(longitude: number, latitude: number) {
|
||||
mapPickerMarker = new mapPickerAMap.Marker({
|
||||
position: [longitude, latitude],
|
||||
title: '拍摄位置',
|
||||
content: '<div aria-hidden="true" class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(52,116,93,0.24)]"></div>',
|
||||
offset: new mapPickerAMap.Pixel(-8, -8),
|
||||
} as AMap.MarkerOptions)
|
||||
mapPickerMarker.setMap(mapPickerInstance)
|
||||
} else {
|
||||
@@ -85,6 +88,7 @@ async function initializeMap() {
|
||||
|
||||
try {
|
||||
mapPickerAMap = await loadAMap()
|
||||
mapPickerAMap.getConfig().appname = 'amap-jsapi-skill'
|
||||
|
||||
const hasCoordinates =
|
||||
typeof props.latitude === 'number' &&
|
||||
@@ -150,7 +154,7 @@ onBeforeUnmount(() => {
|
||||
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
||||
@click="close"
|
||||
>
|
||||
<div class="w-full max-w-3xl overflow-hidden bg-white shadow-2xl" @click.stop>
|
||||
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
|
||||
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-slate-900">{{ title }}</h3>
|
||||
@@ -163,7 +167,7 @@ onBeforeUnmount(() => {
|
||||
title="关闭地图选点窗口"
|
||||
@click="close"
|
||||
>
|
||||
✕
|
||||
<NIcon size="20"><X /></NIcon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ onBeforeUnmount(() => {
|
||||
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/55 px-4 py-6 backdrop-blur-sm"
|
||||
@click="close"
|
||||
>
|
||||
<div class="w-full max-w-3xl overflow-hidden border border-slate-200 bg-white shadow-2xl" @click.stop>
|
||||
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
|
||||
<div class="flex items-start justify-between border-b border-slate-200 px-5 py-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-sky-700">Quick Upload</p>
|
||||
@@ -207,7 +207,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="quick-upload-close oc-icon-button"
|
||||
class="oc-icon-button"
|
||||
aria-label="关闭快捷上传"
|
||||
title="关闭快捷上传"
|
||||
@click="close"
|
||||
@@ -228,7 +228,13 @@ onBeforeUnmount(() => {
|
||||
<span class="font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}</span>
|
||||
<span class="font-medium text-sky-700">{{ overallProgress }}%</span>
|
||||
</div>
|
||||
<NProgress type="line" :percentage="overallProgress" :show-indicator="false" color="#0ea5e9" rail-color="#dbeafe" />
|
||||
<NProgress
|
||||
type="line"
|
||||
:percentage="overallProgress"
|
||||
:show-indicator="false"
|
||||
color="var(--oc-color-info)"
|
||||
rail-color="var(--oc-color-border)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -251,9 +257,9 @@ onBeforeUnmount(() => {
|
||||
<div class="overflow-hidden border border-slate-900 bg-slate-950">
|
||||
<img :src="activeItem.preview" alt="图片预览" class="h-56 w-full object-contain" />
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<button type="button" class="oc-text-button text-sm" @click="fileInput?.click()">重新选择</button>
|
||||
<button type="button" class="oc-text-button oc-text-button--danger text-sm" @click="handleRemove(activeItem.id)">移除</button>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button type="button" class="oc-panel-button oc-panel-button--neutral flex-1" @click="fileInput?.click()">重新选择</button>
|
||||
<button type="button" class="oc-panel-button oc-panel-button--danger flex-1" @click="handleRemove(activeItem.id)">移除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -394,9 +400,3 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quick-upload-close.quick-upload-close {
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { NIcon, NSpace } from 'naive-ui'
|
||||
import { Logout, Settings, Shield, User } from '@vicons/tabler'
|
||||
import { ChevronDown, Logout, Settings, Shield, User } from '@vicons/tabler'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -138,7 +138,7 @@ async function handleLogout() {
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<RouterLink to="/" class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-100 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]">
|
||||
<div class="oc-brand-mark">
|
||||
<svg
|
||||
class="h-8 w-8"
|
||||
viewBox="0 0 16 16"
|
||||
@@ -170,7 +170,7 @@ async function handleLogout() {
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)] md:flex">
|
||||
<nav class="oc-panel-card hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 md:flex">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="oc-nav-link"
|
||||
@@ -228,7 +228,7 @@ async function handleLogout() {
|
||||
<button
|
||||
type="button"
|
||||
class="oc-header-button oc-header-button--teal max-w-[8.5rem] gap-2 md:max-w-none"
|
||||
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200 shadow-[4px_4px_0_0_rgba(52,116,93,0.16)]' : ''"
|
||||
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200' : ''"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="accountCardOpen"
|
||||
>
|
||||
@@ -243,7 +243,7 @@ async function handleLogout() {
|
||||
class="absolute right-0 top-full z-50 w-72 pt-3"
|
||||
role="menu"
|
||||
>
|
||||
<div class="border border-slate-200 bg-white shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]">
|
||||
<div class="oc-floating-panel border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800">
|
||||
@@ -364,26 +364,51 @@ async function handleLogout() {
|
||||
>
|
||||
图鉴
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="!authStore.loading && authStore.isLoggedIn"
|
||||
to="/profile"
|
||||
class="oc-nav-link shrink-0"
|
||||
:class="route.name === 'profile' ? activeNavClass : inactiveNavClass"
|
||||
>
|
||||
主页
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<button
|
||||
v-if="headerHidden"
|
||||
type="button"
|
||||
class="absolute left-1/2 top-3 flex -translate-x-1/2 items-center gap-2 border border-slate-300 bg-white/94 px-3 py-1.5 text-xs font-medium tracking-[0.16em] text-slate-700 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] backdrop-blur transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600"
|
||||
@click="showHeader(true)"
|
||||
>
|
||||
<span>▼</span>
|
||||
<span>展开导航</span>
|
||||
</button>
|
||||
<Transition name="header-reveal">
|
||||
<div
|
||||
v-if="headerHidden"
|
||||
class="pointer-events-none absolute inset-x-0 top-3 flex justify-center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="oc-header-button oc-header-button--neutral pointer-events-auto gap-2 bg-white/94 text-xs tracking-[0.16em] backdrop-blur"
|
||||
@click="showHeader(true)"
|
||||
>
|
||||
<NIcon size="15"><ChevronDown /></NIcon>
|
||||
<span>展开导航</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header-reveal-enter-active,
|
||||
.header-reveal-leave-active {
|
||||
transition:
|
||||
opacity 240ms ease,
|
||||
transform 240ms ease;
|
||||
}
|
||||
|
||||
.header-reveal-enter-from,
|
||||
.header-reveal-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.75rem);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.header-reveal-enter-active,
|
||||
.header-reveal-leave-active {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
|
||||
.header-reveal-enter-from,
|
||||
.header-reveal-leave-to {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,15 +22,11 @@ const props = withDefaults(defineProps<{
|
||||
cells: ContributionHeatmapCell[]
|
||||
totalCount: number
|
||||
activeDays: number
|
||||
currentStreak: number
|
||||
bestStreak: number
|
||||
activeDate?: string | null
|
||||
title?: string
|
||||
subtitle?: string
|
||||
}>(), {
|
||||
activeDate: null,
|
||||
title: '上传活跃度',
|
||||
subtitle: '按上传日期统计',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -154,31 +150,38 @@ const visibleCells = computed(() => {
|
||||
return cells
|
||||
})
|
||||
|
||||
const maxCount = computed(() => {
|
||||
return visibleCells.value.reduce((max, cell) => Math.max(max, cell.count), 0)
|
||||
const contributionQuartiles = computed(() => {
|
||||
const counts = visibleCells.value
|
||||
.filter(cell => cell.inRange && cell.count > 0)
|
||||
.map(cell => cell.count)
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
if (!counts.length) return [0, 0, 0] as const
|
||||
|
||||
const percentile = (value: number) => {
|
||||
const index = Math.ceil(counts.length * value) - 1
|
||||
return counts[Math.max(0, index)] || 0
|
||||
}
|
||||
|
||||
return [percentile(0.25), percentile(0.5), percentile(0.75)] as const
|
||||
})
|
||||
|
||||
function getLevel(count: number) {
|
||||
if (count <= 0) return 0
|
||||
|
||||
const max = Math.max(maxCount.value, 1)
|
||||
const ratio = count / max
|
||||
const [firstQuartile, secondQuartile, thirdQuartile] = contributionQuartiles.value
|
||||
|
||||
if (ratio >= 0.75 || count >= 8) return 4
|
||||
if (ratio >= 0.5 || count >= 5) return 3
|
||||
if (ratio >= 0.25 || count >= 3) return 2
|
||||
return 1
|
||||
if (count <= firstQuartile) return 1
|
||||
if (count <= secondQuartile) return 2
|
||||
if (count <= thirdQuartile) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
function getCellClass(cell: { count: number; inRange: boolean }) {
|
||||
if (!cell.inRange) return 'bg-transparent border-transparent'
|
||||
|
||||
const level = getLevel(cell.count)
|
||||
if (level === 0) return 'bg-zinc-100 border-zinc-200'
|
||||
if (level === 1) return 'bg-[#e0f2fe] border-[#bae6fd]'
|
||||
if (level === 2) return 'bg-[#7dd3fc] border-[#38bdf8]'
|
||||
if (level === 3) return 'bg-[#0ea5e9] border-[#0284c7]'
|
||||
return 'bg-[#0f3d63] border-[#0b2f4d]'
|
||||
return `oc-heatmap-cell--level-${level}`
|
||||
}
|
||||
|
||||
const weeks = computed(() => {
|
||||
@@ -284,7 +287,7 @@ function formatMonthTooltip(monthKey: string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="oc-panel-card border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div class="oc-panel-card border border-slate-200 bg-white p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-6">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
|
||||
@@ -396,12 +399,53 @@ function formatMonthTooltip(monthKey: string) {
|
||||
|
||||
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
|
||||
<span>少</span>
|
||||
<span class="h-3 w-3 border border-slate-200 bg-slate-100"></span>
|
||||
<span class="h-3 w-3 border border-sky-200 bg-sky-100"></span>
|
||||
<span class="h-3 w-3 border border-sky-400 bg-sky-300"></span>
|
||||
<span class="h-3 w-3 border border-teal-600 bg-teal-500"></span>
|
||||
<span class="h-3 w-3 border border-slate-900 bg-slate-900"></span>
|
||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-0"></span>
|
||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-1"></span>
|
||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-2"></span>
|
||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-3"></span>
|
||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-4"></span>
|
||||
<span>多</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.oc-heatmap-cell,
|
||||
.oc-heatmap-legend-cell {
|
||||
transition:
|
||||
background-color var(--oc-duration-fast) var(--oc-ease-standard),
|
||||
border-color var(--oc-duration-fast) var(--oc-ease-standard),
|
||||
transform var(--oc-duration-fast) var(--oc-ease-standard);
|
||||
}
|
||||
|
||||
.oc-heatmap-legend-cell {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.oc-heatmap-cell--level-0 {
|
||||
background: var(--oc-heatmap-empty);
|
||||
border-color: var(--oc-heatmap-empty-border);
|
||||
}
|
||||
|
||||
.oc-heatmap-cell--level-1 {
|
||||
background: var(--oc-heatmap-level-1);
|
||||
border-color: var(--oc-heatmap-level-1-border);
|
||||
}
|
||||
|
||||
.oc-heatmap-cell--level-2 {
|
||||
background: var(--oc-heatmap-level-2);
|
||||
border-color: var(--oc-heatmap-level-2-border);
|
||||
}
|
||||
|
||||
.oc-heatmap-cell--level-3 {
|
||||
background: var(--oc-heatmap-level-3);
|
||||
border-color: var(--oc-heatmap-level-3-border);
|
||||
}
|
||||
|
||||
.oc-heatmap-cell--level-4 {
|
||||
background: var(--oc-heatmap-level-4);
|
||||
border-color: var(--oc-heatmap-level-4-border);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@ export function loadAMap() {
|
||||
version: '2.0',
|
||||
plugins: [
|
||||
'AMap.Scale',
|
||||
'AMap.ToolBar',
|
||||
'AMap.ControlBar',
|
||||
'AMap.Geolocation',
|
||||
'AMap.Marker',
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ export const openCloudThemeOverrides: GlobalThemeOverrides = {
|
||||
borderRadiusSmall: '0px',
|
||||
fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif',
|
||||
fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace',
|
||||
bodyColor: '#f3f7fb',
|
||||
bodyColor: '#eef4f8',
|
||||
cardColor: '#ffffff',
|
||||
modalColor: '#ffffff',
|
||||
popoverColor: '#ffffff',
|
||||
@@ -27,7 +27,7 @@ export const openCloudThemeOverrides: GlobalThemeOverrides = {
|
||||
textColor1: '#0f172a',
|
||||
textColor2: '#334155',
|
||||
textColor3: '#64748b',
|
||||
textColorDisabled: '#94a3b8',
|
||||
textColorDisabled: 'var(--oc-color-text-disabled)',
|
||||
dividerColor: '#d9e3ee',
|
||||
borderColor: '#d9e3ee',
|
||||
inputColor: '#ffffff',
|
||||
|
||||
+18
-3
@@ -23,7 +23,14 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
function applyAuth(auth: AuthResponse) {
|
||||
setAccessToken(auth.access_token)
|
||||
user.value = auth.user
|
||||
profile.value = auth.profile
|
||||
profile.value = {
|
||||
id: auth.user.id,
|
||||
username: auth.user.username,
|
||||
avatar_url: auth.user.avatar_url,
|
||||
role: auth.user.role,
|
||||
is_disabled: auth.user.is_disabled,
|
||||
created_at: auth.user.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
@@ -58,11 +65,19 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
async function updateUsername(username: string) {
|
||||
const updatedProfile = await apiRequest<Profile>('/profiles/me', {
|
||||
const updatedUser = await apiRequest<AuthUser>('/profiles/me', {
|
||||
method: 'PATCH',
|
||||
body: { username },
|
||||
})
|
||||
profile.value = updatedProfile
|
||||
user.value = updatedUser
|
||||
profile.value = {
|
||||
id: updatedUser.id,
|
||||
username: updatedUser.username,
|
||||
avatar_url: updatedUser.avatar_url,
|
||||
role: updatedUser.role,
|
||||
is_disabled: updatedUser.is_disabled,
|
||||
created_at: updatedUser.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePassword(password: string) {
|
||||
|
||||
+20
-2
@@ -2,7 +2,7 @@ import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ApiCloud, ApiPage, PublicProfile } from '@/types/api'
|
||||
import type { ApiCloud, ApiPage, LikeStats, PublicProfile } from '@/types/api'
|
||||
import type { CloudType, Profile } from '@/types/database'
|
||||
|
||||
export interface ProfileCloudItem {
|
||||
@@ -21,6 +21,8 @@ export interface ProfileCloudItem {
|
||||
is_hidden: boolean
|
||||
cloudTypeName: string
|
||||
cloudTypeRarity: CloudType['rarity']
|
||||
favoriteCount: number
|
||||
isFavorited: boolean
|
||||
}
|
||||
|
||||
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
||||
@@ -40,6 +42,8 @@ function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
||||
is_hidden: row.is_hidden,
|
||||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
||||
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
||||
favoriteCount: row.favorite_count,
|
||||
isFavorited: row.is_favorited,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +59,7 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
const authStore = useAuthStore()
|
||||
const profilesById = ref<Record<string, Profile>>({})
|
||||
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
||||
const likeStatsByUserId = ref<Record<string, LikeStats>>({})
|
||||
const loadingKeys = ref<Record<string, boolean>>({})
|
||||
const errorByKey = ref<Record<string, string>>({})
|
||||
|
||||
@@ -68,6 +73,10 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
|
||||
}
|
||||
|
||||
function getLikeStats(userId: string | null) {
|
||||
return userId ? likeStatsByUserId.value[userId] ?? null : null
|
||||
}
|
||||
|
||||
function getError(userId: string | null, isOwnProfile: boolean) {
|
||||
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
|
||||
}
|
||||
@@ -99,9 +108,17 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
const profilePromise = isOwnProfile && authStore.profile?.id === userId
|
||||
? Promise.resolve(authStore.profile)
|
||||
: apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
|
||||
const [profile, cloudRows] = await Promise.all([profilePromise, fetchAllClouds(userId, isOwnProfile)])
|
||||
const likeStatsPromise = isOwnProfile
|
||||
? apiRequest<LikeStats>('/profiles/me/like-stats')
|
||||
: Promise.resolve(null)
|
||||
const [profile, cloudRows, likeStats] = await Promise.all([
|
||||
profilePromise,
|
||||
fetchAllClouds(userId, isOwnProfile),
|
||||
likeStatsPromise,
|
||||
])
|
||||
profilesById.value[userId] = profile
|
||||
cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
|
||||
if (likeStats) likeStatsByUserId.value[userId] = likeStats
|
||||
} catch (error) {
|
||||
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
|
||||
cloudsByKey.value[key] = []
|
||||
@@ -186,6 +203,7 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
return {
|
||||
getProfile,
|
||||
getClouds,
|
||||
getLikeStats,
|
||||
getError,
|
||||
isLoaded,
|
||||
isLoading,
|
||||
|
||||
@@ -40,6 +40,21 @@
|
||||
@apply py-10;
|
||||
}
|
||||
|
||||
.oc-page-hero__split {
|
||||
@apply grid gap-6;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.oc-page-hero__split {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.oc-page-hero__split--stretch {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.oc-page-content {
|
||||
@apply py-8;
|
||||
}
|
||||
@@ -56,6 +71,11 @@
|
||||
@apply mt-4 max-w-2xl text-sm leading-7 text-slate-600;
|
||||
}
|
||||
|
||||
.oc-brand-mark {
|
||||
@apply flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-100;
|
||||
box-shadow: var(--oc-shadow-sm);
|
||||
}
|
||||
|
||||
/* Reusable text hierarchy */
|
||||
.oc-section-label {
|
||||
@apply text-xs uppercase tracking-[0.18em] text-slate-500;
|
||||
@@ -99,6 +119,14 @@
|
||||
box-shadow: var(--oc-shadow-md) !important;
|
||||
}
|
||||
|
||||
.oc-panel-card-xl {
|
||||
box-shadow: var(--oc-shadow-xl) !important;
|
||||
}
|
||||
|
||||
.oc-floating-panel {
|
||||
box-shadow: var(--oc-shadow-lg);
|
||||
}
|
||||
|
||||
.oc-panel-card-soft {
|
||||
box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%) !important;
|
||||
}
|
||||
@@ -112,6 +140,11 @@
|
||||
box-shadow: var(--oc-shadow-md);
|
||||
}
|
||||
|
||||
.oc-modal-shell {
|
||||
@apply border border-slate-200 bg-white;
|
||||
box-shadow: var(--oc-shadow-xl);
|
||||
}
|
||||
|
||||
.oc-inset-panel {
|
||||
@apply border border-slate-200 bg-slate-50 p-4;
|
||||
}
|
||||
@@ -327,6 +360,19 @@
|
||||
@apply outline-none ring-2 ring-sky-600 ring-offset-2;
|
||||
}
|
||||
|
||||
.oc-interactive-card {
|
||||
box-shadow: var(--oc-shadow-md);
|
||||
}
|
||||
|
||||
.oc-interactive-card:hover {
|
||||
box-shadow: var(--oc-shadow-lg);
|
||||
}
|
||||
|
||||
.oc-interactive-card--selected {
|
||||
@apply border-sky-500;
|
||||
box-shadow: var(--oc-shadow-sky) !important;
|
||||
}
|
||||
|
||||
.oc-interactive-row {
|
||||
transition:
|
||||
background-color var(--oc-duration-fast) var(--oc-ease-standard),
|
||||
@@ -407,7 +453,7 @@
|
||||
.oc-panel-button.n-button.n-button--disabled {
|
||||
border-color: rgb(226 232 240) !important;
|
||||
background: rgb(241 245 249) !important;
|
||||
color: rgb(148 163 184) !important;
|
||||
color: var(--oc-color-text-disabled) !important;
|
||||
box-shadow: none !important;
|
||||
opacity: 0.72;
|
||||
transform: none !important;
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
--oc-color-text: #0f172a;
|
||||
--oc-color-text-secondary: #334155;
|
||||
--oc-color-text-muted: #64748b;
|
||||
--oc-color-text-disabled: #94a3b8;
|
||||
--oc-color-border: #d9e3ee;
|
||||
--oc-color-primary: #34745d;
|
||||
--oc-color-primary-hover: #2d5f4e;
|
||||
@@ -53,6 +54,18 @@
|
||||
--oc-color-danger: #dc2626;
|
||||
--oc-color-selection: rgb(52 116 93 / 20%);
|
||||
|
||||
/* Contribution heatmap: no activity + GitHub-style contribution quartiles. */
|
||||
--oc-heatmap-empty: #f1f5f9;
|
||||
--oc-heatmap-empty-border: #d9e3ee;
|
||||
--oc-heatmap-level-1: #ddf2e6;
|
||||
--oc-heatmap-level-1-border: #bce4cf;
|
||||
--oc-heatmap-level-2: #8fceb0;
|
||||
--oc-heatmap-level-2-border: #60b18d;
|
||||
--oc-heatmap-level-3: #438f71;
|
||||
--oc-heatmap-level-3-border: #34745d;
|
||||
--oc-heatmap-level-4: #274c40;
|
||||
--oc-heatmap-level-4-border: #213e35;
|
||||
|
||||
--oc-gradient-canvas:
|
||||
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
|
||||
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
|
||||
@@ -70,6 +83,7 @@
|
||||
--oc-shadow-sky: 4px 4px 0 0 rgb(14 165 233 / 14%);
|
||||
--oc-shadow-primary: 4px 4px 0 0 rgb(52 116 93 / 14%);
|
||||
--oc-shadow-primary-hover: 5px 5px 0 0 rgb(52 116 93 / 20%);
|
||||
--oc-shadow-amber: 5px 5px 0 0 rgb(245 158 11 / 12%);
|
||||
--oc-shadow-teal: var(--oc-shadow-primary);
|
||||
|
||||
--oc-duration-fast: 160ms;
|
||||
|
||||
+25
-3
@@ -4,6 +4,13 @@ export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
email_verified: boolean
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
role: Profile['role']
|
||||
is_disabled: boolean
|
||||
cloud_count: number
|
||||
public_cloud_count: number
|
||||
collection_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -12,12 +19,10 @@ export interface AuthResponse {
|
||||
token_type: 'bearer'
|
||||
expires_in: number
|
||||
user: AuthUser
|
||||
profile: Profile
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: AuthUser
|
||||
profile: Profile
|
||||
}
|
||||
|
||||
export interface PublicProfile {
|
||||
@@ -54,6 +59,24 @@ export interface ApiCloud extends Cloud {
|
||||
cloud_type_name: string | null
|
||||
cloud_type_rarity: CloudType['rarity'] | null
|
||||
username: string | null
|
||||
favorite_count: number
|
||||
is_favorited: boolean
|
||||
}
|
||||
|
||||
export interface LikeStatsPhoto {
|
||||
cloud_id: string
|
||||
image_url: string
|
||||
thumbnail_url: string
|
||||
favorite_count: number
|
||||
captured_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface LikeStats {
|
||||
user_id: string
|
||||
username: string
|
||||
total_likes: number
|
||||
photos: LikeStatsPhoto[]
|
||||
}
|
||||
|
||||
export interface ApiCollection extends UserCollection {
|
||||
@@ -89,5 +112,4 @@ export interface AdminStats {
|
||||
|
||||
export interface AdminUserItem {
|
||||
user: AuthUser
|
||||
profile: Profile
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ async function fetchStats() {
|
||||
|
||||
async function fetchUsers() {
|
||||
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
|
||||
users.value = result.items.map(item => item.profile)
|
||||
users.value = result.items.map(item => item.user)
|
||||
}
|
||||
|
||||
async function fetchImages() {
|
||||
@@ -435,7 +435,7 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
|
||||
method: 'PATCH',
|
||||
body: { role },
|
||||
})
|
||||
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
|
||||
users.value = users.value.map(item => item.id === user.id ? result.user : item)
|
||||
message.success('用户角色已更新')
|
||||
} catch (error) {
|
||||
const text = getErrorMessage(error, '用户角色更新失败')
|
||||
@@ -461,7 +461,7 @@ async function toggleUserDisabled(user: Profile) {
|
||||
method: 'PATCH',
|
||||
body: { is_disabled: nextDisabled },
|
||||
})
|
||||
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
|
||||
users.value = users.value.map(item => item.id === user.id ? result.user : item)
|
||||
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
|
||||
} catch (error) {
|
||||
const text = getErrorMessage(error, '用户状态更新失败')
|
||||
@@ -502,7 +502,7 @@ onMounted(loadAdminData)
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<span class="flex h-10 w-10 shrink-0 items-center justify-center border border-sky-200 bg-sky-50 text-sky-800 shadow-[3px_3px_0_0_rgba(14,165,233,0.12)]">
|
||||
<span class="admin-brand-icon flex h-10 w-10 shrink-0 items-center justify-center border border-sky-200 bg-sky-50 text-sky-800">
|
||||
<NIcon size="22"><Shield /></NIcon>
|
||||
</span>
|
||||
<span
|
||||
@@ -538,7 +538,7 @@ onMounted(loadAdminData)
|
||||
type="button"
|
||||
class="group relative flex min-w-[150px] items-center gap-3 overflow-hidden border px-3 py-3 text-left transition-[background-color,border-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 lg:w-full lg:min-w-0"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-teal-200 bg-teal-50 text-teal-900 shadow-[3px_3px_0_0_rgba(52,116,93,0.16)]'
|
||||
? 'admin-control-active border-teal-200 bg-teal-50 text-teal-900'
|
||||
: 'border-transparent bg-white text-slate-600 hover:border-slate-200 hover:bg-slate-50 hover:text-slate-950'"
|
||||
:aria-current="activeTab === tab.key ? 'page' : undefined"
|
||||
:aria-label="tab.label"
|
||||
@@ -580,7 +580,7 @@ onMounted(loadAdminData)
|
||||
type="button"
|
||||
class="flex h-14 min-w-0 flex-1 items-center gap-3 overflow-hidden border px-3 py-2 text-left transition-[background-color,border-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 lg:w-full"
|
||||
:class="accountMenuOpen
|
||||
? 'border-teal-200 bg-teal-50 text-teal-800 shadow-[3px_3px_0_0_rgba(52,116,93,0.16)]'
|
||||
? 'admin-control-active border-teal-200 bg-teal-50 text-teal-800'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800'"
|
||||
aria-label="打开管理员账户菜单"
|
||||
aria-haspopup="menu"
|
||||
@@ -611,7 +611,7 @@ onMounted(loadAdminData)
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="w-64 border border-slate-200 bg-white shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]"
|
||||
class="oc-floating-panel w-64 border border-slate-200 bg-white"
|
||||
role="menu"
|
||||
aria-label="管理员账户"
|
||||
>
|
||||
@@ -663,7 +663,7 @@ onMounted(loadAdminData)
|
||||
</NPopover>
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="flex h-[46px] shrink-0 items-center justify-start gap-3 overflow-hidden border border-slate-200 bg-white px-3 text-sm font-semibold text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-colors hover:border-sky-200 hover:bg-sky-50 hover:text-sky-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 lg:w-full"
|
||||
class="oc-panel-button oc-panel-button--neutral flex h-[46px] shrink-0 items-center justify-start gap-3 overflow-hidden px-3 font-semibold focus-visible:ring-2 focus-visible:ring-sky-600 lg:w-full"
|
||||
title="返回主页"
|
||||
>
|
||||
<NIcon size="20" class="shrink-0"><Home /></NIcon>
|
||||
@@ -728,8 +728,8 @@ onMounted(loadAdminData)
|
||||
<div
|
||||
class="flex flex-col justify-between gap-5 border p-6 sm:flex-row sm:items-center"
|
||||
:class="dashboardStats.pending
|
||||
? 'border-amber-200 bg-[linear-gradient(135deg,#fffbeb_0%,#fff_52%,#f2faf6_100%)] shadow-[5px_5px_0_0_rgba(245,158,11,0.12)]'
|
||||
: 'border-teal-200 bg-[linear-gradient(135deg,#f2faf6_0%,#fff_55%,#eff6ff_100%)] shadow-[5px_5px_0_0_rgba(52,116,93,0.16)]'"
|
||||
? 'admin-priority-card--pending border-amber-200 bg-[linear-gradient(135deg,#fffbeb_0%,#fff_52%,#f2faf6_100%)]'
|
||||
: 'admin-priority-card--clear border-teal-200 bg-[linear-gradient(135deg,#f2faf6_0%,#fff_55%,#eff6ff_100%)]'"
|
||||
>
|
||||
<div class="flex items-center gap-5">
|
||||
<div
|
||||
@@ -1341,3 +1341,21 @@ onMounted(loadAdminData)
|
||||
</ImageDetailModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-brand-icon {
|
||||
box-shadow: var(--oc-shadow-sky);
|
||||
}
|
||||
|
||||
.admin-control-active {
|
||||
box-shadow: var(--oc-shadow-primary);
|
||||
}
|
||||
|
||||
.admin-priority-card--pending {
|
||||
box-shadow: var(--oc-shadow-amber);
|
||||
}
|
||||
|
||||
.admin-priority-card--clear {
|
||||
box-shadow: var(--oc-shadow-primary-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,7 +53,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<NCard class="oc-panel-card-xl">
|
||||
<template v-if="state === 'success'">
|
||||
<NResult status="success" title="邮箱认证成功" description="你的邮箱已确认,现在可以登录了。">
|
||||
<template #footer>
|
||||
|
||||
@@ -74,7 +74,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||
<section class="min-w-0 overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_52%,#f2faf6_100%)] p-6 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)] sm:p-8">
|
||||
<section class="oc-panel-card-xl min-w-0 overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_52%,#f2faf6_100%)] p-6 sm:p-8">
|
||||
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Password Recovery</p>
|
||||
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
||||
重新拿回你的
|
||||
@@ -97,7 +97,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NCard class="min-w-0 shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<NCard class="oc-panel-card-xl min-w-0">
|
||||
<NResult
|
||||
v-if="submitted"
|
||||
status="success"
|
||||
@@ -121,7 +121,7 @@ onUnmounted(() => {
|
||||
{{ resendButtonLabel }}
|
||||
</NButton>
|
||||
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline">
|
||||
<NButton type="default" class="oc-panel-button oc-panel-button--neutral">返回登录</NButton>
|
||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">返回登录</NButton>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,8 +139,13 @@ onUnmounted(() => {
|
||||
<NFormItem label="邮箱">
|
||||
<NInput
|
||||
v-model:value="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
type="text"
|
||||
:input-props="{
|
||||
autocomplete: 'email',
|
||||
inputmode: 'email',
|
||||
name: 'email',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
@@ -43,7 +43,7 @@ async function handleLogin() {
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
|
||||
<section class="flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#f2faf6_0%,#ffffff_48%,#eff6ff_100%)] p-6 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)] sm:p-8">
|
||||
<section class="oc-panel-card-xl flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#f2faf6_0%,#ffffff_48%,#eff6ff_100%)] p-6 sm:p-8">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
|
||||
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
||||
@@ -68,7 +68,7 @@ async function handleLogin() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NCard class="min-w-0 h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
||||
<div class="mb-8">
|
||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Member Access</div>
|
||||
<h2 class="mt-3 text-3xl font-bold text-slate-900">登录 OpenCloud</h2>
|
||||
@@ -79,8 +79,13 @@ async function handleLogin() {
|
||||
<NFormItem label="邮箱">
|
||||
<NInput
|
||||
v-model:value="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
type="text"
|
||||
:input-props="{
|
||||
autocomplete: 'username',
|
||||
inputmode: 'email',
|
||||
name: 'username',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</NFormItem>
|
||||
@@ -89,9 +94,12 @@ async function handleLogin() {
|
||||
<NInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
required
|
||||
show-password-on="click"
|
||||
autocomplete="current-password"
|
||||
:input-props="{
|
||||
autocomplete: 'current-password',
|
||||
name: 'current-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
@@ -48,7 +48,7 @@ async function handleRegister() {
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
|
||||
<section class="flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f2faf6_100%)] p-6 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)] sm:p-8">
|
||||
<section class="oc-panel-card-xl flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f2faf6_100%)] p-6 sm:p-8">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
||||
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
||||
@@ -73,7 +73,7 @@ async function handleRegister() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NCard class="min-w-0 h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
||||
<NResult
|
||||
v-if="emailSent"
|
||||
status="success"
|
||||
@@ -105,8 +105,11 @@ async function handleRegister() {
|
||||
<NInput
|
||||
v-model:value="username"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
:input-props="{
|
||||
autocomplete: 'username',
|
||||
name: 'username',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="你的昵称"
|
||||
maxlength="20"
|
||||
/>
|
||||
@@ -115,8 +118,13 @@ async function handleRegister() {
|
||||
<NFormItem label="邮箱">
|
||||
<NInput
|
||||
v-model:value="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
type="text"
|
||||
:input-props="{
|
||||
autocomplete: 'email',
|
||||
inputmode: 'email',
|
||||
name: 'email',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</NFormItem>
|
||||
@@ -125,9 +133,12 @@ async function handleRegister() {
|
||||
<NInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
required
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="至少8位"
|
||||
/>
|
||||
</NFormItem>
|
||||
@@ -136,9 +147,12 @@ async function handleRegister() {
|
||||
<NInput
|
||||
v-model:value="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'confirm-new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="再次输入密码"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
@@ -105,7 +105,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<NCard class="oc-panel-card-xl">
|
||||
<NResult
|
||||
v-if="state === 'success'"
|
||||
status="success"
|
||||
@@ -142,13 +142,24 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<NForm @submit.prevent="handleResetPassword">
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
class="sr-only"
|
||||
tabindex="-1"
|
||||
aria-label="账户用户名"
|
||||
/>
|
||||
<NFormItem label="新密码">
|
||||
<NInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
required
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="至少 8 位"
|
||||
/>
|
||||
</NFormItem>
|
||||
@@ -157,9 +168,12 @@ onUnmounted(() => {
|
||||
<NInput
|
||||
v-model:value="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'confirm-new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f2faf6_0%,#f8fafc_100%)] px-4 py-16">
|
||||
<section class="mx-auto max-w-4xl border border-slate-200 bg-white p-10 shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<section class="oc-panel-card-xl mx-auto max-w-4xl border border-slate-200 bg-white p-10">
|
||||
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-teal-700">Community</p>
|
||||
<h1 class="mt-4 text-4xl font-bold text-slate-900">社区</h1>
|
||||
<p class="mt-5 max-w-2xl text-sm leading-7 text-slate-600">
|
||||
|
||||
@@ -147,7 +147,7 @@ watch(() => route.params.id, () => {
|
||||
<div v-if="loading" class="space-y-6">
|
||||
<NSkeleton class="h-80 w-full" />
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<NCard v-for="n in 3" :key="n">
|
||||
<NCard v-for="n in 3" :key="n" class="oc-panel-card border border-slate-200">
|
||||
<NSkeleton class="h-4 w-1/3" />
|
||||
<NSkeleton class="mt-4 h-7 w-1/2" />
|
||||
</NCard>
|
||||
@@ -165,7 +165,7 @@ watch(() => route.params.id, () => {
|
||||
</NAlert>
|
||||
|
||||
<template v-else-if="cloudType">
|
||||
<section class="overflow-hidden border border-slate-200 bg-white shadow-sm">
|
||||
<section class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
|
||||
<div class="grid lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<div class="relative min-h-[360px] overflow-hidden">
|
||||
<img
|
||||
@@ -197,7 +197,7 @@ watch(() => route.params.id, () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NCard class="mt-8 border border-slate-200">
|
||||
<div class="oc-inset-panel mt-8">
|
||||
<p class="text-sm text-slate-500">你的收集状态</p>
|
||||
<template v-if="isUnlocked && collectionEntry">
|
||||
<p class="oc-stat-value">已解锁</p>
|
||||
@@ -207,21 +207,21 @@ watch(() => route.params.id, () => {
|
||||
<p class="oc-stat-value">尚未解锁</p>
|
||||
<p class="mt-2 text-sm text-slate-600">拍到并上传这种云朵后,这枚徽章就会被点亮。</p>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<NCard class="border border-slate-200 shadow-sm">
|
||||
<NCard class="oc-panel-card border border-slate-200">
|
||||
<p class="text-sm text-slate-500">稀有度</p>
|
||||
<p class="oc-stat-value">{{ rarityMeta[cloudType.rarity].label }}</p>
|
||||
</NCard>
|
||||
<NCard class="border border-slate-200 shadow-sm">
|
||||
<NCard class="oc-panel-card border border-slate-200">
|
||||
<p class="text-sm text-slate-500">公开云图</p>
|
||||
<p class="oc-stat-value">{{ publicCount }}</p>
|
||||
</NCard>
|
||||
<NCard class="border border-slate-200 shadow-sm">
|
||||
<NCard class="oc-panel-card border border-slate-200">
|
||||
<p class="text-sm text-slate-500">首次解锁</p>
|
||||
<p class="mt-2 text-xl font-bold text-slate-900">
|
||||
{{ collectionEntry ? formatDate(collectionEntry.unlocked_at) : '等待记录' }}
|
||||
@@ -242,7 +242,7 @@ watch(() => route.params.id, () => {
|
||||
v-for="item in gallery"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left transition-transform duration-300 hover:-translate-y-1"
|
||||
@click="openGalleryDetail(item)"
|
||||
>
|
||||
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-56 w-full object-cover" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { NAlert, NCard, NEmpty, NIcon, NSkeleton } from 'naive-ui'
|
||||
import { NAlert, NEmpty, NIcon, NSkeleton } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||
@@ -64,8 +64,8 @@ onMounted(async () => {
|
||||
<div class="relative">
|
||||
<div class="oc-page-hero">
|
||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
||||
<div class="grid gap-6 lg:grid-cols-[1.4fr_0.9fr] lg:items-start">
|
||||
<div>
|
||||
<div class="oc-page-hero__split">
|
||||
<div class="min-w-0">
|
||||
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
|
||||
<h1 class="oc-page-title">云朵图鉴</h1>
|
||||
<p class="oc-page-description">
|
||||
@@ -76,7 +76,6 @@ onMounted(async () => {
|
||||
<EncyclopediaProgressCard
|
||||
:unlocked-count="authStore.isLoggedIn ? encyclopediaStore.unlockedCount : 0"
|
||||
:total-count="encyclopediaStore.cloudTypes.length || 10"
|
||||
:percent="encyclopediaStore.unlockPercent"
|
||||
:is-logged-in="authStore.isLoggedIn"
|
||||
/>
|
||||
</div>
|
||||
@@ -95,81 +94,75 @@ onMounted(async () => {
|
||||
图鉴收藏数据暂时不可用:{{ encyclopediaStore.collectionError }}
|
||||
</NAlert>
|
||||
|
||||
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<NCard v-for="n in 6" :key="n" class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||
<NSkeleton class="h-44 w-full" />
|
||||
<NSkeleton class="mt-5 h-5 w-2/5" />
|
||||
<NSkeleton class="mt-3 h-4 w-1/3" />
|
||||
<NSkeleton class="mt-6 h-4 w-full" :repeat="2" />
|
||||
</NCard>
|
||||
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-for="n in 6" :key="n" class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
|
||||
<NSkeleton class="h-44 w-full border-b border-slate-200" />
|
||||
<div class="p-5">
|
||||
<NSkeleton class="h-3 w-1/3" />
|
||||
<NSkeleton class="mt-3 h-6 w-2/5" />
|
||||
<NSkeleton class="mt-2 h-3 w-1/2" />
|
||||
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
|
||||
<NSkeleton class="mt-5 h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
<button
|
||||
v-for="cloudType in encyclopediaStore.cloudTypes"
|
||||
:key="cloudType.id"
|
||||
type="button"
|
||||
@click="openCard(cloudType)"
|
||||
class="oc-interactive-card group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
|
||||
:class="isUnlocked(cloudType.id) ? 'border-amber-300 shadow-amber-100/60' : 'border-slate-200 hover:border-slate-300'"
|
||||
class="encyclopedia-type-card oc-interactive-card oc-panel-card flex h-full flex-col overflow-hidden border bg-white text-left"
|
||||
:class="isUnlocked(cloudType.id) ? 'border-sky-200' : 'border-slate-200'"
|
||||
>
|
||||
<div
|
||||
class="relative h-44 overflow-hidden border-b"
|
||||
:class="isUnlocked(cloudType.id) ? 'border-white/40' : 'border-slate-200'"
|
||||
>
|
||||
<div class="relative h-44 shrink-0 overflow-hidden border-b border-slate-200 bg-sky-50">
|
||||
<img
|
||||
v-if="getCollectionEntry(cloudType.id)?.firstCloud?.image_url"
|
||||
:src="getCollectionEntry(cloudType.id)?.firstCloud?.thumbnail_url || getCollectionEntry(cloudType.id)?.firstCloud?.image_url || ''"
|
||||
:alt="cloudType.name"
|
||||
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"
|
||||
:class="isUnlocked(cloudType.id) ? '' : 'grayscale'"
|
||||
class="encyclopedia-type-card__image h-full w-full object-cover"
|
||||
:class="isUnlocked(cloudType.id) ? '' : 'grayscale contrast-75'"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center bg-gradient-to-br"
|
||||
:class="isUnlocked(cloudType.id) ? rarityMeta[cloudType.rarity].glow : 'from-slate-300 to-slate-100'"
|
||||
:class="isUnlocked(cloudType.id) ? rarityMeta[cloudType.rarity].glow : 'from-slate-200 to-slate-50'"
|
||||
>
|
||||
<span class="text-6xl font-bold text-slate-800/85">{{ cloudType.name.slice(0, 1) }}</span>
|
||||
<span class="font-mono text-5xl font-bold text-slate-500/75">{{ cloudType.name.slice(0, 1) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent"></div>
|
||||
|
||||
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/22 backdrop-blur-[2px]">
|
||||
<div class="border border-white/45 bg-white/15 px-4 py-2 text-sm font-medium text-white">
|
||||
<span style="display: inline-flex; align-items: center; gap: 4px;">
|
||||
<NIcon size="16" style="display: inline-flex;">
|
||||
<Lock />
|
||||
</NIcon>
|
||||
<span>尚未解锁</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute left-4 top-4">
|
||||
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[cloudType.rarity].chip">
|
||||
{{ rarityMeta[cloudType.rarity].label }}
|
||||
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/12">
|
||||
<span class="encyclopedia-lock-badge inline-flex items-center gap-2 border border-slate-300 bg-white/92 px-3 py-2 text-xs font-semibold text-slate-700 backdrop-blur">
|
||||
<NIcon size="15"><Lock /></NIcon>
|
||||
尚未收录
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-4 left-4 right-4 text-white">
|
||||
<p class="text-xl font-semibold">{{ cloudType.name }}</p>
|
||||
<p class="mt-1 text-sm text-white/80">{{ cloudType.name_en }}</p>
|
||||
<div class="absolute left-4 top-4">
|
||||
<span class="inline-flex border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em]" :class="rarityMeta[cloudType.rarity].chip">
|
||||
{{ rarityMeta[cloudType.rarity].label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<p class="line-clamp-2 min-h-[3.25rem] text-sm leading-6 text-slate-600">
|
||||
<div class="flex flex-1 flex-col p-5">
|
||||
<p class="oc-section-label">Cloud Genus</p>
|
||||
<h2 class="mt-2 text-xl font-bold text-slate-900">{{ cloudType.name }}</h2>
|
||||
<p class="mt-1 font-mono text-xs uppercase tracking-[0.12em] text-slate-500">{{ cloudType.name_en }}</p>
|
||||
|
||||
<p class="mt-4 line-clamp-2 min-h-[3.25rem] text-sm leading-6 text-slate-600">
|
||||
{{ cloudType.description || '云层特征和识别要点会显示在这里。' }}
|
||||
</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between text-sm">
|
||||
<div class="mt-5 flex items-center justify-between gap-3 border-t border-slate-200 pt-4 text-xs">
|
||||
<template v-if="isUnlocked(cloudType.id) && getCollectionEntry(cloudType.id)">
|
||||
<span class="font-medium text-amber-700">已收录</span>
|
||||
<span class="text-slate-500">首次记录于 {{ formatUnlockedAt(getCollectionEntry(cloudType.id)!.unlocked_at) }}</span>
|
||||
<span class="font-semibold uppercase tracking-[0.12em] text-sky-800">已收录</span>
|
||||
<span class="text-right text-slate-500">首次记录 {{ formatUnlockedAt(getCollectionEntry(cloudType.id)!.unlocked_at) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="font-medium text-slate-700">等待发现</span>
|
||||
<span class="text-slate-400">拍到后点亮</span>
|
||||
<span class="font-semibold uppercase tracking-[0.12em] text-slate-600">等待发现</span>
|
||||
<span class="text-right text-slate-400">拍到后点亮</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,9 +176,50 @@ onMounted(async () => {
|
||||
|
||||
<div
|
||||
v-if="lockHint"
|
||||
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 border border-slate-900 bg-slate-900 px-4 py-2 text-sm text-white shadow-lg"
|
||||
class="encyclopedia-lock-hint fixed bottom-6 left-1/2 z-50 -translate-x-1/2 border border-slate-900 bg-slate-900 px-4 py-2 text-sm text-white"
|
||||
>
|
||||
{{ lockHint }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.encyclopedia-lock-badge {
|
||||
box-shadow: var(--oc-shadow-xs);
|
||||
}
|
||||
|
||||
.encyclopedia-lock-hint {
|
||||
box-shadow: var(--oc-shadow-lg);
|
||||
}
|
||||
|
||||
.encyclopedia-type-card:hover {
|
||||
border-color: rgb(125 211 252);
|
||||
box-shadow: var(--oc-shadow-lg) !important;
|
||||
transform: translate(-1px, -1px);
|
||||
}
|
||||
|
||||
.encyclopedia-type-card:active {
|
||||
box-shadow: var(--oc-shadow-md) !important;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.encyclopedia-type-card__image {
|
||||
transition: transform 500ms ease;
|
||||
}
|
||||
|
||||
.encyclopedia-type-card:hover .encyclopedia-type-card__image {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.encyclopedia-type-card,
|
||||
.encyclopedia-type-card__image {
|
||||
transition-duration: 1ms;
|
||||
}
|
||||
|
||||
.encyclopedia-type-card:hover,
|
||||
.encyclopedia-type-card:hover .encyclopedia-type-card__image {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -342,7 +342,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<section class="oc-page-hero">
|
||||
<div class="oc-container oc-container--wide oc-page-hero__inner">
|
||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
||||
<p class="oc-page-eyebrow">Community Gallery</p>
|
||||
<h1 class="oc-page-title">云图画廊</h1>
|
||||
<p class="oc-page-description">
|
||||
@@ -351,9 +351,9 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="oc-container oc-container--wide oc-page-content">
|
||||
<div class="oc-container oc-container--content oc-page-content">
|
||||
<section>
|
||||
<div class="mb-4 border border-slate-200 bg-white p-3 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)]">
|
||||
<div class="oc-panel-card mb-4 border border-slate-200 bg-white p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<NIcon size="20" class="shrink-0 text-slate-400">
|
||||
<Search />
|
||||
@@ -430,7 +430,7 @@ onUnmounted(() => {
|
||||
:key="cloud.id"
|
||||
type="button"
|
||||
@click="openDetail(cloud)"
|
||||
class="oc-interactive-card group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
class="oc-interactive-card group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<img
|
||||
:src="cloud.thumbnail_url || cloud.image_url"
|
||||
|
||||
+12
-12
@@ -68,8 +68,8 @@ let zoomHoldStarted = false
|
||||
|
||||
const rarityColors: Record<string, string> = {
|
||||
common: '#cbd5e1',
|
||||
uncommon: '#60a5fa',
|
||||
rare: '#c084fc',
|
||||
uncommon: '#f59e0b',
|
||||
rare: '#f43f5e',
|
||||
}
|
||||
|
||||
const rarityMeta = {
|
||||
@@ -218,15 +218,15 @@ function hoverCardContent(cloud: CloudMarkerData): HTMLElement {
|
||||
|
||||
const name = document.createElement('span')
|
||||
name.textContent = cloud.cloudTypeName
|
||||
name.style.cssText = 'font-size:13px;font-weight:600;color:#1f2937'
|
||||
name.style.cssText = 'font-size:13px;font-weight:600;color:#1e293b'
|
||||
|
||||
const rarity = document.createElement('span')
|
||||
rarity.textContent = label
|
||||
rarity.style.cssText = `font-size:10px;padding:1px 5px;border-radius:3px;color:${border};background:${border}22`
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.textContent = `📷 ${cloud.username} · ${timeAgo(cloud.capturedAt)}`
|
||||
meta.style.cssText = 'font-size:11px;color:#9ca3af;margin-top:3px'
|
||||
meta.textContent = `${cloud.username} · ${timeAgo(cloud.capturedAt)}`
|
||||
meta.style.cssText = 'font-size:11px;color:#94a3b8;margin-top:3px'
|
||||
|
||||
title.append(name, rarity)
|
||||
body.append(title, meta)
|
||||
@@ -727,7 +727,7 @@ onUnmounted(() => {
|
||||
<div class="relative w-[min(42vw,420px)] min-w-[220px]">
|
||||
<div
|
||||
v-if="sliderDragging"
|
||||
class="absolute bottom-8 border border-slate-200 bg-white/95 px-3 py-1.5 text-xs font-medium text-slate-800 shadow-md backdrop-blur"
|
||||
class="oc-panel-card absolute bottom-8 border border-slate-200 bg-white/95 px-3 py-1.5 text-xs font-medium text-slate-800 backdrop-blur"
|
||||
:style="{ left: `${selectedMinutePercent}%`, transform: 'translateX(-50%)' }"
|
||||
>
|
||||
{{ selectedTimeLabel }}
|
||||
@@ -772,7 +772,7 @@ onUnmounted(() => {
|
||||
|
||||
<div
|
||||
v-if="archivePanelOpen"
|
||||
class="absolute bottom-16 right-0 w-72 border border-slate-200 bg-white p-4 shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]"
|
||||
class="oc-panel-card absolute bottom-16 right-0 w-72 border border-slate-200 bg-white p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold text-slate-900">历史位置</p>
|
||||
@@ -841,19 +841,19 @@ onUnmounted(() => {
|
||||
@close="previewCloud = null"
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl bg-slate-50 p-4">
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">上传时间</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4">
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">拍摄时间</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.capturedAt) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4">
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">位置</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ previewCloud.locationName || '未填写位置名称' }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-slate-50 p-4">
|
||||
<div class="oc-inset-panel">
|
||||
<p class="oc-section-label">模糊化经纬度</p>
|
||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||
纬度 {{ formatCoordinate(previewCloud.latitude) }} / 经度 {{ formatCoordinate(previewCloud.longitude) }}
|
||||
@@ -861,7 +861,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-[28px] border border-slate-200 bg-white p-5">
|
||||
<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">
|
||||
{{ previewCloud.description || '上传者没有留下额外说明。' }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NInput, useMessage } from 'naive-ui'
|
||||
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
@@ -79,8 +79,8 @@ async function savePassword() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] px-4 py-10">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<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>
|
||||
@@ -111,7 +111,7 @@ async function savePassword() {
|
||||
{{ success }}
|
||||
</NAlert>
|
||||
|
||||
<NCard class="oc-panel-card border border-slate-200 bg-white shadow-sm" :bordered="false">
|
||||
<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>
|
||||
@@ -140,7 +140,7 @@ async function savePassword() {
|
||||
</div>
|
||||
</NCard>
|
||||
|
||||
<NCard class="oc-panel-card border border-slate-200 bg-white shadow-sm" :bordered="false">
|
||||
<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>
|
||||
@@ -148,19 +148,37 @@ async function savePassword() {
|
||||
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<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="newPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="新密码"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="confirmNewPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
:input-props="{
|
||||
autocomplete: 'new-password',
|
||||
name: 'confirm-new-password',
|
||||
required: true,
|
||||
}"
|
||||
placeholder="确认新密码"
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
@@ -168,14 +186,14 @@ async function savePassword() {
|
||||
secondary
|
||||
strong
|
||||
type="default"
|
||||
attr-type="submit"
|
||||
class="oc-panel-button oc-panel-button--teal"
|
||||
:loading="passwordSaving"
|
||||
@click="savePassword"
|
||||
>
|
||||
保存密码
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NForm>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
+100
-153
@@ -2,7 +2,6 @@
|
||||
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 EncyclopediaProgressCard from '@/components/cloud/EncyclopediaProgressCard.vue'
|
||||
import { Settings } from '@vicons/tabler'
|
||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
@@ -10,7 +9,6 @@ import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||
import { useProfileStore, type ProfileCloudItem } from '@/stores/profile'
|
||||
import type { CloudType, Profile } from '@/types/database'
|
||||
|
||||
@@ -23,7 +21,6 @@ interface TimelineGroup {
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const cloudsStore = useCloudsStore()
|
||||
const encyclopediaStore = useEncyclopediaStore()
|
||||
const profileStore = useProfileStore()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -65,6 +62,7 @@ const errorMsg = computed(() => {
|
||||
})
|
||||
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedUserId.value))
|
||||
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedUserId.value, isOwnProfile.value))
|
||||
const receivedLikes = computed(() => profileStore.getLikeStats(viewedUserId.value)?.total_likes || 0)
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
|
||||
@@ -83,10 +81,6 @@ const approvedShots = computed(() => {
|
||||
return clouds.value.filter(item => item.status === 'approved').length
|
||||
})
|
||||
|
||||
const pendingShots = computed(() => {
|
||||
return clouds.value.filter(item => item.status === 'pending').length
|
||||
})
|
||||
|
||||
const selectedCloudCount = computed(() => selectedCloudIds.value.size)
|
||||
|
||||
const cloudActionOptions = computed(() => {
|
||||
@@ -184,44 +178,6 @@ const activeUploadDays = computed(() => {
|
||||
return contributionCells.value.filter(cell => cell.count > 0).length
|
||||
})
|
||||
|
||||
const currentUploadStreak = computed(() => {
|
||||
let streak = 0
|
||||
const cursor = startOfDay(new Date())
|
||||
|
||||
while (true) {
|
||||
const key = toLocalDayKey(cursor)
|
||||
if ((uploadDayCounts.value.get(key) || 0) <= 0) break
|
||||
streak += 1
|
||||
cursor.setDate(cursor.getDate() - 1)
|
||||
}
|
||||
|
||||
return streak
|
||||
})
|
||||
|
||||
const longestUploadStreak = computed(() => {
|
||||
let best = 0
|
||||
let current = 0
|
||||
let previousDate: Date | null = null
|
||||
|
||||
for (const cell of contributionCells.value) {
|
||||
if (cell.count <= 0) continue
|
||||
|
||||
const currentDate = startOfDay(new Date(cell.date))
|
||||
|
||||
if (!previousDate) {
|
||||
current = 1
|
||||
} else {
|
||||
const diffDays = Math.round((currentDate.getTime() - previousDate.getTime()) / 86400000)
|
||||
current = diffDays === 1 ? current + 1 : 1
|
||||
}
|
||||
|
||||
if (current > best) best = current
|
||||
previousDate = currentDate
|
||||
}
|
||||
|
||||
return best
|
||||
})
|
||||
|
||||
function toDayKey(iso: string) {
|
||||
const date = new Date(iso)
|
||||
return toLocalDayKey(date)
|
||||
@@ -233,10 +189,6 @@ function toLocalDayKey(date: Date) {
|
||||
return `${date.getFullYear()}-${month}-${day}`
|
||||
}
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return '未知时间'
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
@@ -448,8 +400,6 @@ async function loadProfilePage(force = false) {
|
||||
|
||||
if (isOwnProfile.value) {
|
||||
await cloudsStore.fetchCloudTypes()
|
||||
await encyclopediaStore.fetchCloudTypes(force)
|
||||
await encyclopediaStore.fetchMyCollection(force)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,77 +425,98 @@ watch(selectedUploadDate, async newValue => {
|
||||
<div class="relative">
|
||||
<div class="oc-page-hero">
|
||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
||||
<div v-if="loading" class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr]">
|
||||
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||
<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>
|
||||
<NCard class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||||
<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="grid gap-6 lg:grid-cols-[1.25fr_0.95fr] lg:items-start">
|
||||
<div class="flex gap-5">
|
||||
<div
|
||||
class="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 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]"
|
||||
>
|
||||
<img
|
||||
v-if="profileData?.avatar_url"
|
||||
:src="profileData.avatar_url"
|
||||
:alt="profileData.username"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ getProfileInitial(profileData) }}
|
||||
</template>
|
||||
</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?.avatar_url"
|
||||
:src="profileData.avatar_url"
|
||||
:alt="profileData.username"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<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>
|
||||
<!-- <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||
{{ profileSubtitle }}
|
||||
</p> -->
|
||||
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
|
||||
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
|
||||
<!-- <NTag v-if="isOwnProfile" type="primary" :bordered="false">你的主页</NTag>
|
||||
<NTag v-else :bordered="false">公开展示</NTag> -->
|
||||
<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>注册于 {{ 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>
|
||||
|
||||
<EncyclopediaProgressCard
|
||||
v-if="isOwnProfile"
|
||||
:unlocked-count="encyclopediaStore.unlockedCount"
|
||||
:total-count="encyclopediaStore.cloudTypes.length || 10"
|
||||
:percent="encyclopediaStore.unlockPercent"
|
||||
:is-logged-in="true"
|
||||
/>
|
||||
|
||||
<NCard v-else class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||||
<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>
|
||||
@@ -577,50 +548,12 @@ watch(selectedUploadDate, async newValue => {
|
||||
</NAlert>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<NCard v-for="n in 4" :key="n">
|
||||
<NSkeleton class="h-4 w-1/3" />
|
||||
<NSkeleton class="mt-4 h-8 w-2/3" />
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||
<p class="text-sm text-slate-500">总拍摄数</p>
|
||||
<p class="mt-2 text-3xl font-bold text-slate-900">{{ totalShots }}</p>
|
||||
</NCard>
|
||||
|
||||
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||
<p class="text-sm text-slate-500">最常拍云型</p>
|
||||
<p class="oc-stat-value">{{ mostCommonCloudType.name }}</p>
|
||||
<p class="mt-2 text-sm text-slate-500" v-if="mostCommonCloudType.count">
|
||||
共 {{ mostCommonCloudType.count }} 张
|
||||
</p>
|
||||
</NCard>
|
||||
|
||||
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||
<p class="text-sm text-slate-500">拍摄天数</p>
|
||||
<p class="mt-2 text-3xl font-bold text-slate-900">{{ shootingDays }}</p>
|
||||
</NCard>
|
||||
|
||||
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||
<p class="text-sm text-slate-500">{{ isOwnProfile ? '已通过 / 待审核' : '公开通过数' }}</p>
|
||||
<p class="oc-stat-value">
|
||||
<template v-if="isOwnProfile">{{ approvedShots }} / {{ pendingShots }}</template>
|
||||
<template v-else>{{ approvedShots }}</template>
|
||||
</p>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<section ref="timelineSectionRef" class="mt-8">
|
||||
<section ref="timelineSectionRef">
|
||||
<ContributionHeatmap
|
||||
:cells="contributionCells"
|
||||
:total-count="totalShots"
|
||||
:active-days="activeUploadDays"
|
||||
:current-streak="currentUploadStreak"
|
||||
:best-streak="longestUploadStreak"
|
||||
:active-date="selectedUploadDate"
|
||||
:subtitle="isOwnProfile ? '按上传日期统计' : '按公开上传日期统计'"
|
||||
:title="isOwnProfile ? '上传活跃度' : '公开活跃度'"
|
||||
@select-date="toggleSelectedUploadDate"
|
||||
/>
|
||||
@@ -668,8 +601,12 @@ watch(selectedUploadDate, async newValue => {
|
||||
>
|
||||
清除筛选
|
||||
</NButton>
|
||||
<RouterLink v-if="isOwnProfile" to="/upload">
|
||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky">继续上传</NButton>
|
||||
<RouterLink
|
||||
v-if="isOwnProfile"
|
||||
to="/upload"
|
||||
class="oc-panel-button oc-panel-button--sky"
|
||||
>
|
||||
继续上传
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -686,7 +623,7 @@ watch(selectedUploadDate, async newValue => {
|
||||
<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 shadow-sm" :bordered="false">
|
||||
<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" />
|
||||
@@ -708,8 +645,8 @@ watch(selectedUploadDate, async newValue => {
|
||||
:key="item.id"
|
||||
type="button"
|
||||
@click="selectedCloud = item"
|
||||
class="oc-interactive-card group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
|
||||
:class="selectedUploadDate && toDayKey(item.created_at) === selectedUploadDate ? 'border-sky-500 shadow-sky-100' : 'border-slate-200'"
|
||||
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'"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
@@ -772,8 +709,12 @@ watch(selectedUploadDate, async newValue => {
|
||||
<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">
|
||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky">去上传第一张</NButton>
|
||||
<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>
|
||||
@@ -874,3 +815,9 @@ watch(selectedUploadDate, async newValue => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-avatar {
|
||||
box-shadow: var(--oc-shadow-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NButton } from 'naive-ui'
|
||||
import { NButton, NIcon } from 'naive-ui'
|
||||
import { Lock } from '@vicons/tabler'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -30,7 +31,7 @@ const description = computed(() => {
|
||||
<template>
|
||||
<div class="oc-system-page">
|
||||
<div class="oc-system-card">
|
||||
<div class="text-7xl leading-none">🔐</div>
|
||||
<NIcon size="72" class="text-sky-600"><Lock /></NIcon>
|
||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-sky-600">401 Login Required</p>
|
||||
<h1 class="oc-page-title">{{ title }}</h1>
|
||||
<p class="oc-page-description mx-auto max-w-xl">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { NButton, NIcon } from 'naive-ui'
|
||||
import { HandStop } from '@vicons/tabler'
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="oc-system-page">
|
||||
<div class="oc-system-card">
|
||||
<div class="text-7xl leading-none">🖐️</div>
|
||||
<NIcon size="72" class="text-rose-600"><HandStop /></NIcon>
|
||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-rose-600">403 Forbidden</p>
|
||||
<h1 class="oc-page-title">这里不让进</h1>
|
||||
<p class="oc-page-description mx-auto max-w-xl">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { NButton, NIcon } from 'naive-ui'
|
||||
import { Help } from '@vicons/tabler'
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="oc-system-page">
|
||||
<div class="oc-system-card">
|
||||
<div class="text-7xl leading-none">🤔</div>
|
||||
<NIcon size="72" class="text-amber-600"><Help /></NIcon>
|
||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-amber-600">404 Not Found</p>
|
||||
<h1 class="oc-page-title">我也没想明白</h1>
|
||||
<p class="oc-page-description mx-auto max-w-xl">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { NAlert, NButton, NCard, NProgress, NResult, NTag } from 'naive-ui'
|
||||
import { NAlert, NButton, NCard, NIcon, NProgress, NResult, NTag } from 'naive-ui'
|
||||
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -224,9 +225,9 @@ onUnmounted(() => {
|
||||
<NCard
|
||||
v-for="badge in unlockedBadges"
|
||||
:key="badge.cloudTypeId"
|
||||
class="border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)] shadow-sm"
|
||||
class="oc-panel-card border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)]"
|
||||
>
|
||||
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white shadow-sm">
|
||||
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white">
|
||||
{{ badge.cloudName.slice(0, 1) }}
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
@@ -256,7 +257,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="uploading" class="mb-6 border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div v-if="uploading" class="oc-panel-card mb-6 border border-slate-200 bg-white p-5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
|
||||
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
|
||||
@@ -265,8 +266,8 @@ onUnmounted(() => {
|
||||
type="line"
|
||||
:show-indicator="false"
|
||||
:percentage="overallProgress"
|
||||
color="#0ea5e9"
|
||||
rail-color="#dbe4ee"
|
||||
color="var(--oc-color-info)"
|
||||
rail-color="var(--oc-color-border)"
|
||||
:height="10"
|
||||
/>
|
||||
</div>
|
||||
@@ -277,19 +278,19 @@ onUnmounted(() => {
|
||||
@dragleave.prevent="dragOver = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@click="fileInput?.click()"
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed py-20 cursor-pointer transition-colors bg-white shadow-sm"
|
||||
class="oc-panel-card flex cursor-pointer flex-col items-center justify-center border-2 border-dashed bg-white py-20 transition-colors"
|
||||
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-slate-300 hover:border-sky-400 hover:bg-slate-50'"
|
||||
>
|
||||
<span class="text-5xl mb-4">☁️</span>
|
||||
<NIcon size="48" class="mb-4 text-sky-600"><CloudUpload /></NIcon>
|
||||
<p class="mb-1 text-lg font-medium text-slate-700">点击或拖拽图片到此处</p>
|
||||
<p class="text-sm text-slate-400">支持 JPG、PNG 格式,可一次选择多张</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex gap-6">
|
||||
<div class="flex-shrink-0 w-[480px]">
|
||||
<div class="flex min-w-0 flex-col gap-6 lg:flex-row">
|
||||
<div class="w-full min-w-0 lg:w-[480px] lg:flex-shrink-0">
|
||||
<div v-if="activeItem" class="mb-3 overflow-hidden border border-slate-900 bg-slate-900">
|
||||
<img :src="activeItem.preview" alt="预览" class="w-full h-[360px] object-contain" />
|
||||
<img :src="activeItem.preview" alt="预览" class="h-64 w-full object-contain sm:h-[360px]" />
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||
<div
|
||||
@@ -307,7 +308,9 @@ onUnmounted(() => {
|
||||
class="oc-icon-button oc-icon-button--danger oc-icon-button--tiny absolute -right-1 -top-1"
|
||||
title="移除图片"
|
||||
aria-label="移除图片"
|
||||
>✕</button>
|
||||
>
|
||||
<NIcon size="12"><X /></NIcon>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@click="fileInput?.click()"
|
||||
@@ -319,7 +322,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0" v-if="activeItem">
|
||||
<NCard class="border border-slate-200 shadow-sm">
|
||||
<NCard class="oc-panel-card border border-slate-200">
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-900">图片信息</h2>
|
||||
@@ -412,7 +415,7 @@ onUnmounted(() => {
|
||||
aria-label="地图选点"
|
||||
class="oc-icon-button oc-icon-button--sky"
|
||||
>
|
||||
<span class="text-lg leading-none">🗺️</span>
|
||||
<NIcon size="20"><MapIcon /></NIcon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -462,9 +465,9 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p class="text-sm text-slate-500">共 {{ items.length }} 张图片</p>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearAll()" :disabled="uploading">清空</NButton>
|
||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" @click="handleSubmit" :disabled="uploading">
|
||||
{{ uploading ? '上传中...' : '提交上传' }}
|
||||
|
||||
Reference in New Issue
Block a user