remove the desktop-pet system
The pet feature was three loosely coupled subsystems that no core flow depended on: the Petdex catalog page, the pet install/preference store, and the pet-state socket. All three are removed. The only cross-boundary touch point was a try/catch-wrapped observer that mapped chat-run events to pet animations. Removing it leaves the /chat-run event contract unchanged: no event name, field name, field type, or emission timing changed. Removing petdexPublicRoutes also closes the unauthenticated outbound proxy at /api/hermes/petdex/asset, which shrinks the SSRF surface.
This commit is contained in:
@@ -1,87 +0,0 @@
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { getActiveProfileName, getApiKey, getBaseUrlValue } from '@/api/client'
|
||||
|
||||
export interface PetActivity {
|
||||
busy?: boolean
|
||||
reasoning?: boolean
|
||||
toolRunning?: boolean
|
||||
awaitingInput?: boolean
|
||||
error?: boolean
|
||||
justCompleted?: boolean
|
||||
celebrate?: boolean
|
||||
}
|
||||
|
||||
export type PetState = 'idle' | 'run' | 'review' | 'failed' | 'wave' | 'jump' | 'waiting'
|
||||
|
||||
export interface PetStateSnapshot {
|
||||
profile: string
|
||||
state: PetState
|
||||
activity: PetActivity
|
||||
updatedAt: number
|
||||
sessionId?: string
|
||||
runId?: string
|
||||
activeTools: string[]
|
||||
awaiting: Array<'approval' | 'clarify' | 'input'>
|
||||
}
|
||||
|
||||
let socket: Socket | null = null
|
||||
let socketProfile: string | null = null
|
||||
|
||||
function activeProfile(profile?: string | null): string {
|
||||
return profile || getActiveProfileName() || 'default'
|
||||
}
|
||||
|
||||
export function connectPetStateSocket(profile?: string | null): Socket {
|
||||
const nextProfile = activeProfile(profile)
|
||||
if (socket && socketProfile === nextProfile) return socket
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
socket = null
|
||||
}
|
||||
|
||||
socketProfile = nextProfile
|
||||
socket = io(`${getBaseUrlValue()}/pet-state`, {
|
||||
auth: { token: getApiKey() },
|
||||
query: { profile: nextProfile },
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 30000,
|
||||
randomizationFactor: 0.5,
|
||||
timeout: 30000,
|
||||
})
|
||||
return socket
|
||||
}
|
||||
|
||||
export function disconnectPetStateSocket(): void {
|
||||
socket?.disconnect()
|
||||
socket = null
|
||||
socketProfile = null
|
||||
}
|
||||
|
||||
export function onPetStateSnapshot(
|
||||
handler: (snapshot: PetStateSnapshot) => void,
|
||||
profile?: string | null,
|
||||
): () => void {
|
||||
const activeSocket = connectPetStateSocket(profile)
|
||||
activeSocket.on('pet.state.snapshot', handler)
|
||||
activeSocket.on('pet.state.updated', handler)
|
||||
return () => {
|
||||
activeSocket.off('pet.state.snapshot', handler)
|
||||
activeSocket.off('pet.state.updated', handler)
|
||||
}
|
||||
}
|
||||
|
||||
export function requestPetStateSnapshot(profile?: string | null): Promise<PetStateSnapshot> {
|
||||
const activeSocket = connectPetStateSocket(profile)
|
||||
return new Promise((resolve, reject) => {
|
||||
activeSocket.timeout(10000).emit('pet.state.get', (err: Error | null, snapshot: PetStateSnapshot) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve(snapshot)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { request } from '@/api/client'
|
||||
|
||||
export interface PetdexPet {
|
||||
slug: string
|
||||
displayName: string
|
||||
kind: string
|
||||
submittedBy: string
|
||||
spritesheetUrl: string
|
||||
previewUrl?: string
|
||||
petJsonUrl: string
|
||||
zipUrl: string
|
||||
}
|
||||
|
||||
export interface PetdexManifest {
|
||||
generatedAt: string
|
||||
total: number
|
||||
pets: PetdexPet[]
|
||||
}
|
||||
|
||||
export function fetchPetdexManifest(force = false): Promise<PetdexManifest> {
|
||||
return request<PetdexManifest>(`/api/hermes/petdex/manifest${force ? '?force=1' : ''}`)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { request } from '../client'
|
||||
|
||||
export interface WebPetPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface ActivePet {
|
||||
enabled: boolean
|
||||
slug: string
|
||||
displayName: string
|
||||
kind: string
|
||||
submittedBy: string
|
||||
source: 'petdex'
|
||||
mime: string
|
||||
spritesheetDataUrl: string
|
||||
spritesheetRevision: number
|
||||
frameW: number
|
||||
frameH: number
|
||||
framesPerState: number
|
||||
loopMs: number
|
||||
scale: number
|
||||
position?: WebPetPosition
|
||||
stateRows: string[]
|
||||
installedAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export async function fetchActivePet(): Promise<ActivePet | null> {
|
||||
const res = await request<{ pet: ActivePet | null }>('/api/hermes/pets/active')
|
||||
return res.pet
|
||||
}
|
||||
|
||||
export async function adoptPet(slug: string): Promise<ActivePet> {
|
||||
const res = await request<{ pet: ActivePet }>('/api/hermes/pets/adopt', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ slug }),
|
||||
})
|
||||
return res.pet
|
||||
}
|
||||
|
||||
export async function updateActivePetPreferences(input: {
|
||||
scale?: number
|
||||
position?: WebPetPosition
|
||||
enabled?: boolean
|
||||
}): Promise<ActivePet | null> {
|
||||
const res = await request<{ pet: ActivePet | null }>('/api/hermes/pets/active', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
return res.pet
|
||||
}
|
||||
@@ -153,15 +153,6 @@ function handleUpdateClick() {
|
||||
</svg>
|
||||
<span>{{ t("sidebar.mcp") }}</span>
|
||||
</RouteLinkItem>
|
||||
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.petdex' }" :active="selectedKey === 'hermes.petdex'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l7 4v6c0 4-3 7-7 8-4-1-7-4-7-8V7l7-4z" />
|
||||
<path d="M9 11h.01" />
|
||||
<path d="M15 11h.01" />
|
||||
<path d="M9.5 15c1.6 1.1 3.4 1.1 5 0" />
|
||||
</svg>
|
||||
<span>{{ t("sidebar.petdex") }}</span>
|
||||
</RouteLinkItem>
|
||||
<RouteLinkItem class="nav-item" :to="{ name: 'hermes.memory' }" :active="selectedKey === 'hermes.memory'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 18h6" />
|
||||
|
||||
@@ -208,7 +208,6 @@ export default {
|
||||
skills: 'Skills',
|
||||
plugins: 'Plugins',
|
||||
mcp: 'MCP',
|
||||
petdex: 'Pets',
|
||||
memory: 'Memory',
|
||||
logs: 'Logs',
|
||||
usage: 'Usage',
|
||||
@@ -1191,29 +1190,6 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
petdex: {
|
||||
title: 'Petdex Pets',
|
||||
subtitle: 'Browse the public petdex catalog used by Hermes pets.',
|
||||
refresh: 'Refresh',
|
||||
adopt: 'Adopt',
|
||||
active: 'Active',
|
||||
adopted: 'Adopted {name}',
|
||||
adoptFailed: 'Failed to adopt pet',
|
||||
loadFailed: 'Failed to load petdex catalog',
|
||||
searchPlaceholder: 'Search by name, slug, author, or kind',
|
||||
kindFilter: 'Kind',
|
||||
spritesheet: 'Spritesheet',
|
||||
submittedBy: 'By {name}',
|
||||
empty: 'No pets match the current filters.',
|
||||
showMore: 'Show {count} more',
|
||||
summary: {
|
||||
total: 'Catalog total',
|
||||
visible: 'Matching',
|
||||
kinds: 'Kinds',
|
||||
generatedAt: 'Generated at',
|
||||
},
|
||||
},
|
||||
|
||||
// Memory
|
||||
memory: {
|
||||
title: 'Memory',
|
||||
|
||||
@@ -207,7 +207,6 @@ export default {
|
||||
profiles: '用户',
|
||||
plugins: '插件',
|
||||
mcp: 'MCP',
|
||||
petdex: '宠物',
|
||||
skills: '技能',
|
||||
memory: '记忆',
|
||||
logs: '日志',
|
||||
@@ -1191,29 +1190,6 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
petdex: {
|
||||
title: 'Petdex 宠物',
|
||||
subtitle: '浏览 Hermes 桌宠使用的 petdex 公共图库。',
|
||||
refresh: '刷新',
|
||||
adopt: '领取',
|
||||
active: '使用中',
|
||||
adopted: '已领取 {name}',
|
||||
adoptFailed: '领取宠物失败',
|
||||
loadFailed: '加载 petdex 图库失败',
|
||||
searchPlaceholder: '按名称、slug、作者或类型搜索',
|
||||
kindFilter: '类型',
|
||||
spritesheet: '精灵图',
|
||||
submittedBy: '作者 {name}',
|
||||
empty: '没有符合当前筛选的宠物。',
|
||||
showMore: '再显示 {count} 个',
|
||||
summary: {
|
||||
total: '图库总数',
|
||||
visible: '匹配数量',
|
||||
kinds: '类型数',
|
||||
generatedAt: '生成时间',
|
||||
},
|
||||
},
|
||||
|
||||
// 记忆
|
||||
memory: {
|
||||
title: '记忆',
|
||||
|
||||
@@ -109,11 +109,6 @@ const router = createRouter({
|
||||
name: 'hermes.plugins',
|
||||
component: () => import('@/views/hermes/PluginsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/hermes/petdex',
|
||||
name: 'hermes.petdex',
|
||||
component: () => import('@/views/hermes/PetdexView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/hermes/memory',
|
||||
name: 'hermes.memory',
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import {
|
||||
connectPetStateSocket,
|
||||
disconnectPetStateSocket,
|
||||
onPetStateSnapshot,
|
||||
requestPetStateSnapshot,
|
||||
type PetActivity,
|
||||
type PetState,
|
||||
type PetStateSnapshot,
|
||||
} from '@/api/hermes/pet-state'
|
||||
|
||||
function emptySnapshot(profile = 'default'): PetStateSnapshot {
|
||||
return {
|
||||
profile,
|
||||
state: 'idle',
|
||||
activity: {},
|
||||
updatedAt: Date.now(),
|
||||
activeTools: [],
|
||||
awaiting: [],
|
||||
}
|
||||
}
|
||||
|
||||
export const usePetStateStore = defineStore('petState', () => {
|
||||
const snapshot = ref<PetStateSnapshot>(emptySnapshot())
|
||||
const connected = ref(false)
|
||||
const error = ref('')
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
const state = computed<PetState>(() => snapshot.value.state)
|
||||
const activity = computed<PetActivity>(() => snapshot.value.activity)
|
||||
|
||||
async function connect(profile?: string | null): Promise<void> {
|
||||
disconnect()
|
||||
error.value = ''
|
||||
const socket = connectPetStateSocket(profile)
|
||||
socket.on('connect', () => {
|
||||
connected.value = true
|
||||
error.value = ''
|
||||
})
|
||||
socket.on('disconnect', () => {
|
||||
connected.value = false
|
||||
})
|
||||
socket.on('connect_error', (err: Error) => {
|
||||
connected.value = false
|
||||
error.value = err.message
|
||||
})
|
||||
unsubscribe = onPetStateSnapshot(next => {
|
||||
snapshot.value = next
|
||||
}, profile)
|
||||
try {
|
||||
snapshot.value = await requestPetStateSnapshot(profile)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect(): void {
|
||||
unsubscribe?.()
|
||||
unsubscribe = null
|
||||
disconnectPetStateSocket()
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
activity,
|
||||
connect,
|
||||
connected,
|
||||
disconnect,
|
||||
error,
|
||||
snapshot,
|
||||
state,
|
||||
}
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import * as petsApi from '@/api/hermes/pets'
|
||||
import type { ActivePet, WebPetPosition } from '@/api/hermes/pets'
|
||||
|
||||
export const usePetsStore = defineStore('pets', () => {
|
||||
const activePet = ref<ActivePet | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const hasActivePet = computed(() => !!activePet.value?.enabled)
|
||||
|
||||
async function loadActivePet(): Promise<ActivePet | null> {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
activePet.value = await petsApi.fetchActivePet()
|
||||
return activePet.value
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || 'Failed to load active pet'
|
||||
activePet.value = null
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function adopt(slug: string): Promise<ActivePet> {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
activePet.value = await petsApi.adoptPet(slug)
|
||||
return activePet.value
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || 'Failed to adopt pet'
|
||||
throw err
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePreferences(input: { scale?: number; position?: WebPetPosition; enabled?: boolean }): Promise<void> {
|
||||
if (!activePet.value) return
|
||||
activePet.value = {
|
||||
...activePet.value,
|
||||
...(typeof input.scale === 'number' ? { scale: input.scale } : {}),
|
||||
...(input.position ? { position: input.position } : {}),
|
||||
...(typeof input.enabled === 'boolean' ? { enabled: input.enabled } : {}),
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const saved = await petsApi.updateActivePetPreferences(input)
|
||||
if (saved) activePet.value = saved
|
||||
else if (input.enabled === false) activePet.value = null
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function hideActivePet(): Promise<void> {
|
||||
await savePreferences({ enabled: false })
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
activePet.value = null
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
return {
|
||||
activePet,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
hasActivePet,
|
||||
loadActivePet,
|
||||
adopt,
|
||||
savePreferences,
|
||||
hideActivePet,
|
||||
clear,
|
||||
}
|
||||
})
|
||||
@@ -1,339 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { fetchPetdexManifest, type PetdexManifest, type PetdexPet } from '@/api/hermes/petdex'
|
||||
import { usePetsStore } from '@/stores/hermes/pets'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const petsStore = usePetsStore()
|
||||
|
||||
const manifest = ref<PetdexManifest | null>(null)
|
||||
const loading = ref(false)
|
||||
const adoptingSlug = ref('')
|
||||
const error = ref('')
|
||||
const searchQuery = ref('')
|
||||
const kindFilter = ref<string | undefined>(undefined)
|
||||
const visibleLimit = ref(96)
|
||||
|
||||
const pets = computed(() => manifest.value?.pets ?? [])
|
||||
const generatedAt = computed(() => {
|
||||
const value = manifest.value?.generatedAt
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
||||
})
|
||||
|
||||
const kindOptions = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const pet of pets.value) {
|
||||
counts.set(pet.kind, (counts.get(pet.kind) ?? 0) + 1)
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([kind, count]) => ({
|
||||
label: `${kind} (${count})`,
|
||||
value: kind,
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredPets = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
return pets.value.filter((pet) => {
|
||||
if (kindFilter.value && pet.kind !== kindFilter.value) return false
|
||||
if (!query) return true
|
||||
return [pet.slug, pet.displayName, pet.kind, pet.submittedBy]
|
||||
.some(value => String(value || '').toLowerCase().includes(query))
|
||||
})
|
||||
})
|
||||
|
||||
const visiblePets = computed(() => filteredPets.value.slice(0, visibleLimit.value))
|
||||
const canShowMore = computed(() => visiblePets.value.length < filteredPets.value.length)
|
||||
|
||||
async function loadManifest(force = false) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
manifest.value = await fetchPetdexManifest(force)
|
||||
visibleLimit.value = 96
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || t('petdex.loadFailed')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showMore() {
|
||||
visibleLimit.value += 96
|
||||
}
|
||||
|
||||
function assetLinks(pet: PetdexPet) {
|
||||
return [
|
||||
{ label: t('petdex.spritesheet'), href: pet.spritesheetUrl },
|
||||
...(pet.petJsonUrl ? [{ label: 'pet.json', href: pet.petJsonUrl }] : []),
|
||||
...(pet.zipUrl ? [{ label: 'zip', href: pet.zipUrl }] : []),
|
||||
]
|
||||
}
|
||||
|
||||
async function adopt(pet: PetdexPet) {
|
||||
adoptingSlug.value = pet.slug
|
||||
try {
|
||||
await petsStore.adopt(pet.slug)
|
||||
message.success(t('petdex.adopted', { name: pet.displayName }))
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('petdex.adoptFailed'))
|
||||
} finally {
|
||||
adoptingSlug.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadManifest()
|
||||
void petsStore.loadActivePet()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="petdex-view">
|
||||
<header class="page-header">
|
||||
<h2 class="header-title">{{ t('petdex.title') }}</h2>
|
||||
<a-button size="small" type="text" :loading="loading" @click="loadManifest(true)">
|
||||
{{ t('petdex.refresh') }}
|
||||
</a-button>
|
||||
</header>
|
||||
|
||||
<div class="petdex-content cy-view" :class="{ 'is-loading': loading && !manifest }">
|
||||
<div v-if="loading && !manifest" class="loading-state">
|
||||
<a-spin />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<a-alert v-if="error" type="error" class="notice">
|
||||
{{ error }}
|
||||
</a-alert>
|
||||
|
||||
<div v-if="manifest" class="cy-kpis petdex-kpis">
|
||||
<div class="cy-kpi acc">
|
||||
<div class="k">{{ t('petdex.summary.total') }}</div>
|
||||
<div class="v">{{ manifest.total }}</div>
|
||||
</div>
|
||||
<div class="cy-kpi ok">
|
||||
<div class="k">{{ t('petdex.summary.visible') }}</div>
|
||||
<div class="v">{{ filteredPets.length }}</div>
|
||||
</div>
|
||||
<div class="cy-kpi info">
|
||||
<div class="k">{{ t('petdex.summary.kinds') }}</div>
|
||||
<div class="v">{{ kindOptions.length }}</div>
|
||||
</div>
|
||||
<div class="cy-kpi flat">
|
||||
<div class="k">{{ t('petdex.summary.generatedAt') }}</div>
|
||||
<div class="v kpi-date">{{ generatedAt || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<a-input v-model="searchQuery" :placeholder="t('petdex.searchPlaceholder')" allow-clear />
|
||||
<a-select v-model="kindFilter" :options="kindOptions" :placeholder="t('petdex.kindFilter')" allow-clear />
|
||||
</div>
|
||||
|
||||
<div v-if="visiblePets.length" class="cy-grid4 pet-grid">
|
||||
<article v-for="pet in visiblePets" :key="pet.slug" class="pet-card cy-panel">
|
||||
<div class="cy-panel-h">
|
||||
<h3 class="pet-name">{{ pet.displayName }}</h3>
|
||||
<span class="cy-tag">{{ pet.kind }}</span>
|
||||
<div class="cy-panel-actions">
|
||||
<span v-if="petsStore.activePet?.slug === pet.slug" class="cy-tag ok">
|
||||
{{ t('petdex.active') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pet-preview">
|
||||
<div class="pet-frame" :style="{ backgroundImage: `url(${pet.previewUrl || pet.spritesheetUrl})` }" />
|
||||
</div>
|
||||
<div class="pet-body">
|
||||
<div class="pet-slug cy-code">{{ pet.slug }}</div>
|
||||
<div v-if="pet.submittedBy" class="pet-meta">
|
||||
{{ t('petdex.submittedBy', { name: pet.submittedBy }) }}
|
||||
</div>
|
||||
<div class="pet-links">
|
||||
<a v-for="link in assetLinks(pet)" :key="link.href" :href="link.href" target="_blank" rel="noopener noreferrer">
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="pet-actions">
|
||||
<a-button
|
||||
size="small"
|
||||
:type="petsStore.activePet?.slug === pet.slug ? 'secondary' : 'primary'"
|
||||
long
|
||||
:loading="adoptingSlug === pet.slug"
|
||||
:disabled="!!adoptingSlug"
|
||||
@click="adopt(pet)"
|
||||
>
|
||||
{{ petsStore.activePet?.slug === pet.slug ? t('petdex.active') : t('petdex.adopt') }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<a-empty v-else :description="t('petdex.empty')" />
|
||||
|
||||
<div v-if="canShowMore" class="load-more">
|
||||
<a-button :disabled="loading" @click="showMore">
|
||||
{{ t('petdex.showMore', { count: Math.min(96, filteredPets.length - visiblePets.length) }) }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/variables' as *;
|
||||
|
||||
.petdex-view {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.petdex-content {
|
||||
min-height: 0;
|
||||
|
||||
&.is-loading {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.petdex-kpis {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.kpi-date {
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1fr) 220px;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.pet-grid {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pet-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
// 网格内不吃 .cy-panel 的纵向堆叠间距。
|
||||
& + .pet-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pet-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pet-preview {
|
||||
height: 132px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid $border-light;
|
||||
// 透明底纹棋盘格:用 muted 三元组上色,跟着主题走而不是写死中灰
|
||||
background:
|
||||
linear-gradient(45deg, rgba(var(--text-muted-rgb), 0.08) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(var(--text-muted-rgb), 0.08) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(var(--text-muted-rgb), 0.08) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(var(--text-muted-rgb), 0.08) 75%);
|
||||
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.pet-frame {
|
||||
width: 92px;
|
||||
aspect-ratio: 192 / 208;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 0;
|
||||
background-size: 800% auto;
|
||||
image-rendering: auto;
|
||||
filter: drop-shadow($shadow-md);
|
||||
}
|
||||
|
||||
.pet-body {
|
||||
padding: 12px 14px 14px;
|
||||
}
|
||||
|
||||
.pet-slug,
|
||||
.pet-meta {
|
||||
margin-top: 5px;
|
||||
color: $text-muted;
|
||||
font-size: 11.5px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pet-slug {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.pet-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
a {
|
||||
color: $accent-primary;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pet-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 24px 0 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.filter-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user