feat: 前端
This commit is contained in:
321
frontend/src/components/AppShell.vue
Normal file
321
frontend/src/components/AppShell.vue
Normal 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>
|
||||
89
frontend/src/components/FeedVideoCard.vue
Normal file
89
frontend/src/components/FeedVideoCard.vue
Normal 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>
|
||||
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal 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>
|
||||
17
frontend/src/components/JsonBox.vue
Normal file
17
frontend/src/components/JsonBox.vue
Normal 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>
|
||||
73
frontend/src/components/Toaster.vue
Normal file
73
frontend/src/components/Toaster.vue
Normal 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>
|
||||
54
frontend/src/components/UserAvatar.vue
Normal file
54
frontend/src/components/UserAvatar.vue
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user