feat: 前端

This commit is contained in:
Leon
2025-12-25 22:40:56 +08:00
parent 027df9d32b
commit ce0aec6110
42 changed files with 6170 additions and 0 deletions

View File

@@ -0,0 +1,321 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useSocialStore } from '../stores/social'
import Toaster from './Toaster.vue'
const props = defineProps<{ full?: boolean }>()
const auth = useAuthStore()
const social = useSocialStore()
const router = useRouter()
const route = useRoute()
const search = ref(typeof route.query.q === 'string' ? route.query.q : '')
watch(
() => route.query.q,
(v) => {
search.value = typeof v === 'string' ? v : ''
},
)
watch(
() => auth.isLoggedIn,
(v) => {
if (v) void social.refreshMine()
else social.clear()
},
{ immediate: true },
)
const userLabel = computed(() => {
if (!auth.isLoggedIn) return '未登录'
const username = auth.claims?.username ?? '(unknown)'
const accountId = auth.claims?.account_id
return accountId ? `${username} #${accountId}` : username
})
async function onSearch() {
const q = search.value.trim()
await router.push({ path: '/', query: q ? { q } : {} })
}
async function goLogin() {
await router.push('/account')
}
async function goSettings() {
await router.push('/settings')
}
</script>
<template>
<div class="dy-shell">
<aside class="dy-aside">
<RouterLink class="dy-logo" to="/">ShortVideo</RouterLink>
<nav class="dy-nav">
<RouterLink class="dy-nav-link" to="/">推荐</RouterLink>
<RouterLink class="dy-nav-link" to="/video">发布</RouterLink>
<RouterLink class="dy-nav-link" to="/account">账号</RouterLink>
<RouterLink class="dy-nav-link" to="/settings">设置</RouterLink>
</nav>
<div class="dy-aside-foot">
<div class="dy-user">
<span class="dy-user-dot" :class="auth.isLoggedIn ? 'ok' : 'bad'" />
<span class="dy-user-name">{{ userLabel }}</span>
</div>
<div class="dy-user-actions">
<button v-if="!auth.isLoggedIn" class="dy-btn dy-btn-primary" type="button" @click="goLogin">登录</button>
<button v-else class="dy-btn dy-btn-primary" type="button" @click="goSettings">设置</button>
</div>
</div>
</aside>
<div class="dy-main">
<header class="dy-topbar">
<div class="dy-top-left">
<div class="dy-tabs-hint">{{ route.name }}</div>
</div>
<div class="dy-search">
<input v-model="search" class="dy-search-input" placeholder="搜索标题 / 作者(本地过滤)" @keydown.enter="onSearch" />
<button class="dy-btn dy-btn-primary" type="button" @click="onSearch">搜索</button>
</div>
<div class="dy-top-right">
<RouterLink class="dy-btn dy-btn-ghost" to="/video">+ 发布视频</RouterLink>
</div>
</header>
<div class="dy-content" :class="props.full ? 'full' : 'padded'">
<template v-if="props.full">
<slot />
</template>
<template v-else>
<div class="container">
<slot />
</div>
</template>
</div>
</div>
<Toaster />
</div>
</template>
<style scoped>
.dy-shell {
height: 100vh;
display: grid;
grid-template-columns: 240px 1fr;
background: radial-gradient(1200px 900px at 20% -25%, rgba(254, 44, 85, 0.18), transparent 60%),
radial-gradient(900px 700px at 90% 10%, rgba(37, 244, 238, 0.12), transparent 55%), transparent;
}
.dy-aside {
border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(16px);
padding: 14px 12px;
display: flex;
flex-direction: column;
gap: 14px;
}
.dy-logo {
font-weight: 900;
letter-spacing: 0.4px;
font-size: 18px;
padding: 10px 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.dy-nav {
display: grid;
gap: 8px;
}
.dy-nav-link {
padding: 10px 10px;
border-radius: 12px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.04);
}
.dy-nav-link.router-link-active {
border-color: rgba(254, 44, 85, 0.42);
background: rgba(254, 44, 85, 0.12);
}
.dy-aside-foot {
margin-top: auto;
display: grid;
gap: 10px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.dy-user {
display: flex;
gap: 10px;
align-items: center;
}
.dy-user-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.25);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.06);
}
.dy-user-dot.ok {
background: rgba(34, 197, 94, 1);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.14);
}
.dy-user-dot.bad {
background: rgba(254, 44, 85, 1);
box-shadow: 0 0 0 3px rgba(254, 44, 85, 0.14);
}
.dy-user-name {
font-size: 13px;
color: rgba(255, 255, 255, 0.86);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dy-user-actions {
display: flex;
gap: 10px;
}
.dy-btn {
appearance: none;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.9);
border-radius: 12px;
padding: 10px 12px;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: center;
font-size: 13px;
}
.dy-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.dy-btn-primary {
border-color: rgba(254, 44, 85, 0.5);
background: rgba(254, 44, 85, 0.16);
}
.dy-btn-primary:hover {
background: rgba(254, 44, 85, 0.24);
}
.dy-btn-ghost {
border-color: rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.15);
}
.dy-main {
height: 100vh;
display: flex;
flex-direction: column;
min-width: 0;
}
.dy-topbar {
height: 56px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 0, 0, 0.28);
backdrop-filter: blur(16px);
display: grid;
grid-template-columns: 180px 1fr 180px;
gap: 12px;
align-items: center;
padding: 0 14px;
}
.dy-tabs-hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.55);
text-transform: uppercase;
letter-spacing: 0.16em;
}
.dy-search {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
max-width: 680px;
width: 100%;
justify-self: center;
}
.dy-search-input {
width: 100%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
color: rgba(255, 255, 255, 0.9);
padding: 10px 14px;
outline: none;
}
.dy-search-input:focus {
border-color: rgba(37, 244, 238, 0.42);
box-shadow: 0 0 0 3px rgba(37, 244, 238, 0.14);
}
.dy-top-right {
display: flex;
justify-content: flex-end;
}
.dy-content {
flex: 1;
min-height: 0;
}
.dy-content.padded {
overflow: auto;
}
.dy-content.full {
overflow: hidden;
}
@media (max-width: 900px) {
.dy-shell {
grid-template-columns: 1fr;
}
.dy-aside {
display: none;
}
.dy-topbar {
grid-template-columns: 1fr auto;
}
.dy-top-left {
display: none;
}
.dy-top-right {
display: none;
}
}
</style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { FeedVideoItem } from '../api/types'
const props = defineProps<{
item: FeedVideoItem
canLike: boolean
busy?: boolean
}>()
const emit = defineEmits<{
(e: 'toggle-like', item: FeedVideoItem): void
}>()
function onToggle() {
emit('toggle-like', props.item)
}
</script>
<template>
<div class="feed-card">
<div class="cover">
<img :src="item.cover_url" :alt="item.title" loading="lazy" />
</div>
<div class="content">
<div class="row" style="justify-content: space-between">
<div>
<div class="title">
<RouterLink :to="`/video/${item.id}`">{{ item.title }}</RouterLink>
</div>
<div class="subtle">
作者{{ item.author.username }} (#{{ item.author.id }}) · 创建时间{{ new Date(item.create_time * 1000).toLocaleString() }}
</div>
</div>
<div class="row">
<span class="pill mono"> {{ item.likes_count }}</span>
<button
v-if="canLike"
class="primary"
type="button"
:disabled="busy"
@click="onToggle"
:title="item.is_liked ? '取消点赞' : '点赞'"
>
{{ item.is_liked ? '已赞' : '点赞' }}
</button>
</div>
</div>
<div v-if="item.description" class="muted" style="margin-top: 8px">{{ item.description }}</div>
<div class="row" style="margin-top: 10px">
<a class="pill mono" :href="item.play_url" target="_blank" rel="noreferrer">播放地址</a>
<RouterLink class="pill" :to="`/video/${item.id}`">查看详情 / 评论</RouterLink>
</div>
</div>
</div>
</template>
<style scoped>
.feed-card {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
border-radius: 16px;
overflow: hidden;
}
.cover {
background: rgba(0, 0, 0, 0.25);
aspect-ratio: 16/9;
}
.cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.content {
padding: 12px 12px 14px;
}
@media (max-width: 900px) {
.feed-card {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ value: unknown }>()
const text = computed(() => {
try {
return JSON.stringify(props.value, null, 2)
} catch {
return String(props.value)
}
})
</script>
<template>
<pre class="pre mono">{{ text }}</pre>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { useToastStore } from '../stores/toast'
const toast = useToastStore()
</script>
<template>
<div class="toast-wrap" aria-live="polite" aria-relevant="additions removals">
<div v-for="t in toast.toasts" :key="t.id" class="toast" :class="t.type">
<div class="toast-msg">{{ t.message }}</div>
<button class="toast-x" type="button" aria-label="关闭" @click="toast.remove(t.id)">×</button>
</div>
</div>
</template>
<style scoped>
.toast-wrap {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: grid;
gap: 10px;
z-index: 200;
width: min(520px, calc(100vw - 24px));
pointer-events: none;
}
.toast {
pointer-events: auto;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
border-radius: 14px;
padding: 10px 12px;
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.55);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.toast.success {
border-color: rgba(34, 197, 94, 0.35);
}
.toast.error {
border-color: rgba(254, 44, 85, 0.45);
}
.toast.info {
border-color: rgba(37, 244, 238, 0.3);
}
.toast-msg {
font-size: 13px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.92);
}
.toast-x {
width: 30px;
height: 30px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.88);
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 0;
}
</style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
username: string
id?: number
size?: number
}>()
function hashToHue(input: string) {
let h = 0
for (let i = 0; i < input.length; i += 1) {
h = (h * 31 + input.charCodeAt(i)) >>> 0
}
return h % 360
}
const initial = computed(() => {
const s = (props.username ?? '').trim()
if (!s) return '?'
return s.slice(0, 1).toUpperCase()
})
const sizePx = computed(() => `${props.size ?? 40}px`)
const bg = computed(() => {
const seed = typeof props.id === 'number' ? String(props.id) : props.username
const hue = hashToHue(seed || '0')
const h1 = hue
const h2 = (hue + 40) % 360
return `linear-gradient(135deg, hsl(${h1} 90% 55%), hsl(${h2} 90% 55%))`
})
</script>
<template>
<div class="avatar" :style="{ width: sizePx, height: sizePx, backgroundImage: bg }" aria-hidden="true">
{{ initial }}
</div>
</template>
<style scoped>
.avatar {
display: grid;
place-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.92);
font-weight: 900;
letter-spacing: 0.2px;
user-select: none;
}
</style>