feat: UI polish, chapter select improvements, save system enhancements, roadmap update

This commit is contained in:
2026-06-09 15:19:53 +08:00
parent 2748b2c16f
commit 72e442f2c3
7 changed files with 206 additions and 18 deletions

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import type { ChapterInfo } from '@engine/types'
defineProps<{
const props = defineProps<{
chapters: ChapterInfo[]
unlockedIds: Set<string>
}>()
@@ -10,20 +11,67 @@ const emit = defineEmits<{
select: [chapterId: string]
back: []
}>()
const focusIdx = ref(0)
const cardRefs = ref<(HTMLDivElement | null)[]>([])
function setRef(el: HTMLDivElement | null, i: number) {
cardRefs.value[i] = el
}
// when shown, focus first unlocked
watch(() => props.chapters.length, async (len) => {
if (len > 0) {
const first = props.chapters.findIndex(ch => props.unlockedIds.has(ch.id))
focusIdx.value = first >= 0 ? first : 0
await nextTick()
cardRefs.value[focusIdx.value]?.focus()
}
})
function onKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault()
const dir = e.key === 'ArrowRight' ? 1 : -1
const len = props.chapters.length
let next = (index + dir + len) % len
// skip locked ones
let tries = 0
while (!props.unlockedIds.has(props.chapters[next].id) && tries < len) {
next = (next + dir + len) % len
tries++
}
focusIdx.value = next
cardRefs.value[next]?.focus()
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (props.unlockedIds.has(props.chapters[index].id)) {
emit('select', props.chapters[index].id)
}
}
if (e.key === 'Escape' || e.key === 'Backspace') {
e.preventDefault()
emit('back')
}
}
</script>
<template>
<div class="chapter-overlay">
<div class="chapter-overlay" @keydown="(e: KeyboardEvent) => { if (e.key === 'Escape' || e.key === 'Backspace') { e.preventDefault(); emit('back'); } }">
<div class="chapter-panel">
<h2 class="chapter-title">章节选择</h2>
<div class="chapter-grid">
<div
v-for="ch in chapters"
v-for="(ch, i) in chapters"
:key="ch.id"
:ref="(el: any) => setRef(el, i)"
class="chapter-card"
:class="{ locked: !unlockedIds.has(ch.id) }"
:tabindex="unlockedIds.has(ch.id) ? 0 : -1"
@click="unlockedIds.has(ch.id) && emit('select', ch.id)"
@keydown="onKeydown($event, i)"
>
<div class="chapter-thumb">
<img v-if="ch.thumbnail" :src="ch.thumbnail" class="thumb-img" />
@@ -34,7 +82,7 @@ const emit = defineEmits<{
</div>
</div>
<button class="back-btn" @click="emit('back')">返回</button>
<button class="back-btn" @click="emit('back')">返回 (Esc)</button>
</div>
</div>
</template>
@@ -86,6 +134,7 @@ const emit = defineEmits<{
cursor: pointer;
transition: background 0.2s, border-color 0.2s, transform 0.15s;
width: 150px;
outline: none;
}
.chapter-card:hover:not(.locked) {
@@ -94,6 +143,13 @@ const emit = defineEmits<{
transform: translateY(-2px);
}
.chapter-card:focus-visible {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.chapter-card.locked {
opacity: 0.4;
cursor: default;

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref, watch, nextTick, computed } from 'vue'
import type { Choice } from '@engine/types'
const props = defineProps<{
@@ -11,6 +12,9 @@ const emit = defineEmits<{
choose: [index: number]
}>()
const focusIndex = ref(0)
const btnRefs = ref<(HTMLButtonElement | null)[]>([])
function timerPercent(): number {
if (props.timerTotal <= 0) return 0
return (props.timerRemaining / props.timerTotal) * 100
@@ -20,6 +24,32 @@ function timerClass(): string {
if (props.timerRemaining <= 3) return 'danger'
return ''
}
function setRef(el: HTMLButtonElement | null, index: number) {
btnRefs.value[index] = el
}
watch(() => props.choices.length, async (len) => {
if (len > 0) {
focusIndex.value = 0
await nextTick()
btnRefs.value[0]?.focus()
}
})
function onKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
const dir = e.key === 'ArrowDown' ? 1 : -1
const next = Math.max(0, Math.min(props.choices.length - 1, index + dir))
focusIndex.value = next
btnRefs.value[next]?.focus()
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
emit('choose', index)
}
}
</script>
<template>
@@ -37,8 +67,11 @@ function timerClass(): string {
<button
v-for="(choice, index) in choices"
:key="index"
:ref="(el: any) => setRef(el, index)"
class="choice-btn"
tabindex="0"
@click="emit('choose', index)"
@keydown="onKeydown($event, index)"
>
{{ choice.text }}
</button>
@@ -110,10 +143,17 @@ function timerClass(): string {
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
outline: none;
}
.choice-btn:hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.6);
}
.choice-btn:focus-visible {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
}
</style>

View File

@@ -15,7 +15,7 @@ const maxSlots = 5
</script>
<template>
<div class="save-overlay" @click.self="emit('close')">
<div class="save-overlay" @click.self="emit('close')" @keydown.escape="emit('close')">
<div class="save-panel">
<h2 class="save-title">存档 / 读档</h2>