refactor: split project into web and server directories
- Move frontend to web/ directory - Add server/ directory for backend - Restructure project for前后端分离架构 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
27
web/src/components/shared/InfoRow.vue
Normal file
27
web/src/components/shared/InfoRow.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="info-row">
|
||||
<div class="rank">{{ rank }}</div>
|
||||
<div>
|
||||
<strong>{{ title }}</strong>
|
||||
<p>{{ note }}</p>
|
||||
</div>
|
||||
<span class="badge" :class="tone">{{ badge }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
rank: String,
|
||||
title: String,
|
||||
note: String,
|
||||
badge: String,
|
||||
tone: String
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.info-row { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: start; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface-soft); }
|
||||
.rank { min-width: 34px; height: 34px; display: grid; place-items: center; border-radius: 8px; background: #fff; color: var(--primary); font-size: 12px; font-weight: 850; box-shadow: inset 0 0 0 1px var(--line); }
|
||||
.info-row strong { color: var(--ink); }
|
||||
.info-row p { margin-top: 4px; color: var(--muted); }
|
||||
</style>
|
||||
21
web/src/components/shared/PanelHead.vue
Normal file
21
web/src/components/shared/PanelHead.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="panel-head">
|
||||
<div class="eyebrow">{{ eyebrow }}</div>
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ note }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
eyebrow: String,
|
||||
title: String,
|
||||
note: String
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.panel-head { margin-bottom: 18px; }
|
||||
.panel-head h2 { margin-top: 4px; color: var(--ink); font-size: 20px; }
|
||||
.panel-head p { margin-top: 6px; color: var(--muted); font-size: 13px; }
|
||||
</style>
|
||||
141
web/src/components/shared/RollingValue.vue
Normal file
141
web/src/components/shared/RollingValue.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<span ref="root" class="rv" :aria-label="value">
|
||||
<template v-for="(char, i) in chars" :key="`${i}-${charKinds[i]}`">
|
||||
<span
|
||||
v-if="isDigit(char)"
|
||||
class="rv-col"
|
||||
:style="{ '--y': `-${targetOffset(char)}em`, '--wait': `${i * 28 + 120}ms` }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="rv-strip" :class="{ go: on }">
|
||||
<span v-for="d in len" :key="d" class="rv-d">{{ (d - 1) % 10 }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="rv-static" :class="{ currency: char === '¥', unit: /[a-zA-Z%]/.test(char) }" aria-hidden="true">{{ char }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: String, required: true },
|
||||
preRoll: { type: Number, default: 2 }
|
||||
})
|
||||
|
||||
const PRE = props.preRoll * 10
|
||||
const chars = computed(() => props.value.split(''))
|
||||
const charKinds = computed(() => chars.value.map((char) => isDigit(char) ? 'digit' : 'static'))
|
||||
const len = (props.preRoll + 1) * 10
|
||||
|
||||
function isDigit(c) { return /\d/.test(c) }
|
||||
function targetOffset(c) { return PRE + Number(c) }
|
||||
|
||||
const on = ref(false)
|
||||
const root = ref(null)
|
||||
let observer = null
|
||||
|
||||
function roll() {
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
on.value = true
|
||||
return
|
||||
}
|
||||
|
||||
on.value = false
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
on.value = true
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
roll()
|
||||
return
|
||||
}
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
roll()
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, { threshold: 0.2 })
|
||||
|
||||
if (root.value) observer.observe(root.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
watch(() => props.value, roll)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rv {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rv-col {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: .64em;
|
||||
height: 1em;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
vertical-align: 0;
|
||||
}
|
||||
|
||||
.rv-strip {
|
||||
display: block;
|
||||
transform: translate3d(0, 0, 0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.rv-strip.go {
|
||||
animation: digitRoll 2.35s cubic-bezier(.45, 0, .18, 1) var(--wait, 0ms) both;
|
||||
}
|
||||
|
||||
.rv-d {
|
||||
height: 1em;
|
||||
display: block;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rv-static {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 1em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rv-static.currency {
|
||||
margin-right: .02em;
|
||||
}
|
||||
|
||||
.rv-static.unit {
|
||||
margin-left: .04em;
|
||||
}
|
||||
|
||||
@keyframes digitRoll {
|
||||
from {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(0, var(--y, 0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rv-strip {
|
||||
animation: none;
|
||||
transform: translateY(var(--y, 0));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
31
web/src/components/shared/ToastNotification.vue
Normal file
31
web/src/components/shared/ToastNotification.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<Transition name="toast">
|
||||
<div v-if="toastText" class="toast" role="status" aria-live="polite">{{ toastText }}</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
toastText: String
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 22px;
|
||||
bottom: 22px;
|
||||
max-width: min(380px, calc(100vw - 44px));
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
background: #0b1220;
|
||||
color: #fff;
|
||||
box-shadow: 0 18px 48px rgba(16,24,40,.16);
|
||||
z-index: 120;
|
||||
animation: fadeUp 180ms var(--ease) both;
|
||||
}
|
||||
.toast-enter-active { transition: opacity 180ms var(--ease), transform 180ms var(--ease); }
|
||||
.toast-leave-active { transition: opacity 160ms var(--ease), transform 160ms var(--ease); }
|
||||
.toast-enter-from { opacity: 0; transform: translateY(10px); }
|
||||
.toast-leave-to { opacity: 0; transform: translateY(-6px); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user