Files
opencatd-open/frontend/src/views/auth/Login.vue
T
Sakurasan 1e07c5903f redesign: site-wide UI/UX overhaul per web-interface-guidelines
- unified design system: focus-visible rings, reduced-motion, color-scheme,
  theme persistence, tightened emerald radius tokens (checkbox circle bug)
- redesigned all pages: Home/Login/Signup/404, dashboard shell, Overview hero
  (time-based gradient), list pages with empty states, form modals, detail pages
- role-scoped navigation: console menu vs admin console with cross-switch
  entries, Chinese route titles and breadcrumbs
- theme switcher (light/dark/auto) via composables/theme, home + dashboard
- state-driven dropdowns (touch-safe logout), menu divider pointer-events fix
- btn-primary unified to black-on-white / white-on-black per theme
- update REFACTOR_PLAN.md (increments 2-6)
2026-08-30 01:35:10 +08:00

143 lines
5.7 KiB
Vue

<template>
<div class="flex min-h-screen items-center justify-center bg-base-200 p-4">
<div class="card w-full max-w-md border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body p-6 sm:p-8">
<RouterLink to="/" class="mx-auto select-none" aria-label="Back to home">
<img src="@/assets/openteam.webp" alt="OpenTeam logo" width="176" height="128" class="h-28 w-auto" />
</RouterLink>
<h1 class="mt-2 text-center text-xl font-semibold tracking-tight">Log in to Dashboard</h1>
<div v-if="error" role="alert" class="alert alert-error mt-4 text-sm">
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
<span class="flex-1">{{ error }}</span>
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
</div>
<form class="mt-4 space-y-4" @submit.prevent="handleLogin">
<div>
<label class="mb-1 block text-sm font-medium" for="username">Account</label>
<input type="text" id="username" name="username" autocomplete="username" spellcheck="false"
placeholder="username or email" class="input input-bordered w-full"
v-model="user.username" required />
</div>
<div>
<label class="mb-1 block text-sm font-medium" for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password"
placeholder="••••••••" class="input input-bordered w-full"
v-model="user.password" minlength="4" required />
</div>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" name="remember" v-model="user.rember" class="checkbox checkbox-primary checkbox-sm" />
Remember me
</label>
<button type="submit" class="btn btn-primary w-full" :disabled="loggingIn">
<span v-if="loggingIn" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Log In
</button>
</form>
<div class="divider my-2 text-xs text-base-content/40">OR</div>
<button type="button" @click="handlePasskeyLogin" class="btn btn-outline w-full" :disabled="!supportWebAuth"
:aria-label="supportWebAuth ? 'Sign in with Passkey' : 'Passkey not supported in this browser'">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor"
d="M7 13.23q-.517 0-.874-.356T5.769 12t.357-.874t.874-.357t.874.357t.357.874t-.357.874t-.874.357M7 17q-2.077 0-3.538-1.461T2 12t1.462-3.538T7 7q1.54 0 2.778.835q1.238.834 1.807 2.165h9.204l2 2l-3.193 3.154l-1.712-1.288l-1.807 1.326L14.298 14h-2.713q-.57 1.312-1.807 2.156T7 17m0-1q1.477 0 2.52-.889T10.856 13h3.76l1.43.967l1.858-1.333l1.621 1.222L21.381 12l-1-1h-9.525q-.292-1.223-1.336-2.111T7 8Q5.35 8 4.175 9.175T3 12t1.175 2.825T7 16" />
</svg>
Sign in with Passkey
</button>
<p class="mt-5 text-center text-sm text-base-content/60">
Don't have an account?
<RouterLink to="/signup" class="link link-primary link-hover">Sign up</RouterLink>
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { CircleAlert } from '@lucide/vue'
import { useAuthStore } from '@/stores/auth';
import { useWebAuthStore } from '@/stores/webauth';
import { useToast } from '@/composables/toast';
const router = useRouter()
const authStore = useAuthStore();
const webauthStore = useWebAuthStore();
const { setToast } = useToast();
const error = ref<string | null>(null)
const loggingIn = ref(false)
const user = reactive({
username: localStorage.getItem('account') || '',
password: localStorage.getItem('password') || '',
rember: localStorage.getItem('rember') || false,
})
const supportWebAuth = ref(false);
onMounted(() => {
if (localStorage.getItem('token')) {
router.push('/dashboard')
}
supportWebAuth.value = !!window.PublicKeyCredential;
})
// store 的 catch 里 throw 的是 error ref,这里统一取出可展示的错误信息
const errMsg = (err: any): string => {
if (typeof err === 'string') return err
if (err?.__v_isRef) return errMsg(err.value)
return err?.response?.data?.error || err?.message || String(err)
}
const handleLogin = async () => {
error.value = null;
loggingIn.value = true;
try {
const response = await authStore.login({ username: user.username, password: user.password });
if (response.status === 200) {
if (user.rember) {
localStorage.setItem('account', user.username);
localStorage.setItem('password', user.password);
localStorage.setItem('rember', String(user.rember));
} else {
localStorage.removeItem('account');
localStorage.removeItem('password');
localStorage.removeItem('rember');
}
setToast('Logged in successfully.', 'success');
router.push('/dashboard');
}
} catch (err: any) {
console.error('Login error:', err);
error.value = errMsg(err)
} finally {
loggingIn.value = false;
}
}
const handlePasskeyLogin = async () => {
error.value = null;
loggingIn.value = true;
try {
const res = await webauthStore.loginPasskey();
if (!!res?.code && res.code === 200) {
setToast('Logged in successfully.', 'success');
router.push('/dashboard');
}
} catch (err: any) {
console.error('Passkey login error:', err);
error.value = errMsg(err)
} finally {
loggingIn.value = false;
}
};
</script>