feat: scaffold infrastructure - router, pinia, tailwind, amap, auth store, page placeholders
This commit is contained in:
+7
-2
@@ -1,7 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelloWorld />
|
||||
<div class="min-h-screen bg-gray-50 flex flex-col">
|
||||
<AppHeader />
|
||||
<main class="flex-1">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="bg-white border-b border-gray-200 sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<RouterLink to="/" class="flex items-center gap-2">
|
||||
<span class="text-2xl">☁️</span>
|
||||
<span class="text-xl font-bold text-gray-900">OpenCloud</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-6">
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="route.name === 'map' ? 'text-sky-600' : 'text-gray-600 hover:text-gray-900'"
|
||||
>
|
||||
地图
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/encyclopedia"
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? 'text-sky-600' : 'text-gray-600 hover:text-gray-900'"
|
||||
>
|
||||
图鉴
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/gallery"
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="route.name === 'gallery' ? 'text-sky-600' : 'text-gray-600 hover:text-gray-900'"
|
||||
>
|
||||
画廊
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<RouterLink
|
||||
v-if="authStore.isLoggedIn"
|
||||
to="/upload"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 bg-sky-500 text-white text-sm font-medium rounded-lg hover:bg-sky-600 transition-colors"
|
||||
>
|
||||
<span>📷</span>
|
||||
<span>上传</span>
|
||||
</RouterLink>
|
||||
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
<RouterLink
|
||||
to="/profile"
|
||||
class="text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
{{ authStore.profile?.username }}
|
||||
</RouterLink>
|
||||
<button
|
||||
@click="authStore.logout()"
|
||||
class="text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<RouterLink
|
||||
to="/login"
|
||||
class="text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
登录
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/register"
|
||||
class="inline-flex items-center px-4 py-2 bg-sky-500 text-white text-sm font-medium rounded-lg hover:bg-sky-600 transition-colors"
|
||||
>
|
||||
注册
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
import AMapLoader from '@amap/amap-jsapi-loader'
|
||||
|
||||
export function loadAMap() {
|
||||
return AMapLoader.load({
|
||||
key: import.meta.env.VITE_AMAP_KEY,
|
||||
version: '2.0',
|
||||
plugins: [
|
||||
'AMap.Scale',
|
||||
'AMap.ToolBar',
|
||||
'AMap.ControlBar',
|
||||
'AMap.Geolocation',
|
||||
'AMap.Marker',
|
||||
'AMap.InfoWindow',
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
throw new Error('Missing Supabase environment variables')
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey)
|
||||
+13
-2
@@ -1,5 +1,16 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
authStore.initialize().then(() => {
|
||||
app.mount('#app')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'map',
|
||||
component: () => import('@/views/map/MapView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/auth/LoginView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: () => import('@/views/auth/RegisterView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/upload',
|
||||
name: 'upload',
|
||||
component: () => import('@/views/upload/UploadView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/encyclopedia',
|
||||
name: 'encyclopedia',
|
||||
component: () => import('@/views/encyclopedia/EncyclopediaView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/encyclopedia/:id',
|
||||
name: 'cloud-type',
|
||||
component: () => import('@/views/encyclopedia/CloudTypeView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/gallery',
|
||||
name: 'gallery',
|
||||
component: () => import('@/views/gallery/GalleryView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('@/views/profile/ProfileView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/profile/:id',
|
||||
name: 'user-profile',
|
||||
component: () => import('@/views/profile/ProfileView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: () => import('@/views/admin/AdminView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
],
|
||||
scrollBehavior() {
|
||||
return { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
next({ name: 'map' })
|
||||
} else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) {
|
||||
next({ name: 'map' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,62 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
import type { Profile } from '@/types/database'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const profile = ref<Profile | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
const isAdmin = computed(() => profile.value?.role === 'admin')
|
||||
|
||||
async function fetchProfile() {
|
||||
if (!user.value) return
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', user.value.id)
|
||||
.single()
|
||||
profile.value = data
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
async function register(email: string, password: string, username: string) {
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: { username },
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
if (error) throw error
|
||||
user.value = null
|
||||
profile.value = null
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
user.value = session?.user ?? null
|
||||
if (user.value) await fetchProfile()
|
||||
loading.value = false
|
||||
|
||||
supabase.auth.onAuthStateChange((_event, session) => {
|
||||
user.value = session?.user ?? null
|
||||
if (user.value) fetchProfile()
|
||||
else profile.value = null
|
||||
})
|
||||
}
|
||||
|
||||
return { user, profile, loading, isLoggedIn, isAdmin, login, register, logout, initialize }
|
||||
})
|
||||
+1
-296
@@ -1,296 +1 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@import 'tailwindcss';
|
||||
|
||||
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
declare namespace AMap {
|
||||
class Map {
|
||||
constructor(container: string | HTMLElement, opts?: Record<string, unknown>)
|
||||
addControl(control: unknown): void
|
||||
add(layer: unknown): void
|
||||
remove(layer: unknown): void
|
||||
destroy(): void
|
||||
setCenter(center: [number, number]): void
|
||||
setZoom(zoom: number): void
|
||||
on(event: string, callback: (...args: unknown[]) => void): void
|
||||
off(event: string, callback: (...args: unknown[]) => void): void
|
||||
getCenter(): { lng: number; lat: number }
|
||||
getZoom(): number
|
||||
}
|
||||
|
||||
class Marker {
|
||||
constructor(opts?: Record<string, unknown>)
|
||||
on(event: string, callback: (...args: unknown[]) => void): void
|
||||
setPosition(position: [number, number]): void
|
||||
}
|
||||
|
||||
class InfoWindow {
|
||||
constructor(opts?: Record<string, unknown>)
|
||||
open(map: Map, position: [number, number]): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
class Scale {}
|
||||
class ToolBar {
|
||||
constructor(opts?: Record<string, unknown>)
|
||||
}
|
||||
class ControlBar {
|
||||
constructor(opts?: Record<string, unknown>)
|
||||
}
|
||||
class Geolocation {
|
||||
constructor(opts?: Record<string, unknown>)
|
||||
getCurrentPosition(
|
||||
onSuccess: (data: { position: { lng: number; lat: number } }) => void,
|
||||
onError?: (error: unknown) => void,
|
||||
): void
|
||||
}
|
||||
|
||||
class TileLayer {
|
||||
static Satellite: new () => unknown
|
||||
static RoadNet: new () => unknown
|
||||
}
|
||||
|
||||
class Pixel {
|
||||
constructor(x: number, y: number)
|
||||
}
|
||||
|
||||
class MarkerCluster {
|
||||
constructor(map: Map, markers: Marker[], opts?: Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
|
||||
declare function AMapLoader_load(opts: {
|
||||
key: string
|
||||
version: string
|
||||
plugins?: string[]
|
||||
}): Promise<typeof AMap>
|
||||
|
||||
declare module '@amap/amap-jsapi-loader' {
|
||||
const AMapLoader: {
|
||||
load: typeof AMapLoader_load
|
||||
}
|
||||
export default AMapLoader
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface CloudType {
|
||||
id: number
|
||||
name: string
|
||||
name_en: string
|
||||
genus: string
|
||||
rarity: 'common' | 'uncommon' | 'rare'
|
||||
description: string | null
|
||||
icon_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Cloud {
|
||||
id: string
|
||||
user_id: string
|
||||
cloud_type_id: number
|
||||
image_url: string
|
||||
thumbnail_url: string | null
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
location_name: string | null
|
||||
weather: string | null
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
created_at: string
|
||||
cloud_types?: CloudType
|
||||
profiles?: Profile
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
role: 'user' | 'admin'
|
||||
is_disabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UserCollection {
|
||||
id: string
|
||||
user_id: string
|
||||
cloud_type_id: number
|
||||
first_cloud_id: string | null
|
||||
unlocked_at: string
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: string
|
||||
user_id: string
|
||||
cloud_id: string
|
||||
emoji: string
|
||||
created_at: string
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto px-4 py-12">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">🔧 管理后台</h1>
|
||||
<p class="text-gray-500">管理后台开发中...</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login(email.value, password.value)
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.push(redirect)
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)] px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<h1 class="text-2xl font-bold text-gray-900 text-center mb-8">登录 OpenCloud</h1>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="至少8位"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-gray-500">
|
||||
没有账号?
|
||||
<RouterLink to="/register" class="text-sky-600 hover:text-sky-700 font-medium">去注册</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const username = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleRegister() {
|
||||
error.value = ''
|
||||
|
||||
if (password.value.length < 8) {
|
||||
error.value = '密码至少8位'
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = '两次密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.register(email.value, password.value, username.value)
|
||||
router.push('/')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : '注册失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)] px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<h1 class="text-2xl font-bold text-gray-900 text-center mb-8">注册 OpenCloud</h1>
|
||||
|
||||
<form @submit.prevent="handleRegister" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">用户名</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
required
|
||||
placeholder="你的昵称"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
placeholder="至少8位"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">确认密码</label>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
placeholder="再次输入密码"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full py-2.5 bg-sky-500 text-white font-medium rounded-lg hover:bg-sky-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-gray-500">
|
||||
已有账号?
|
||||
<RouterLink to="/login" class="text-sky-600 hover:text-sky-700 font-medium">去登录</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto px-4 py-12">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">云型详情</h1>
|
||||
<p class="text-gray-500">详情页开发中...</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto px-4 py-12">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">☁️ 云朵图鉴</h1>
|
||||
<p class="text-gray-500">图鉴功能开发中...</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto px-4 py-12">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">🖼️ 画廊</h1>
|
||||
<p class="text-gray-500">画廊功能开发中...</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)]">
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">☁️ OpenCloud</h1>
|
||||
<p class="text-gray-500">实时云图地图加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto px-4 py-12">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">个人主页</h1>
|
||||
<p class="text-gray-500">个人主页开发中...</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)]">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">📷 上传云图</h1>
|
||||
<p class="text-gray-500">上传功能开发中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user