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:
2026-05-06 11:00:38 +08:00
parent 9a7b0794a1
commit 9785fb527b
85 changed files with 10474 additions and 10047 deletions

View 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>

View 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>

View 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>

View 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>