This commit is contained in:
fengchuanhn@gmail.com
2026-05-15 17:51:47 +08:00
parent bac4ced1fe
commit 584d4c47a1
23 changed files with 1952 additions and 304 deletions

12
src/App.vue Normal file
View File

@@ -0,0 +1,12 @@
<script setup>
import AppTitlebar from "./components/AppTitlebar.vue";
</script>
<template>
<div class="flex h-full flex-col">
<AppTitlebar />
<main class="min-h-0 flex-1 overflow-hidden">
<RouterView />
</main>
</div>
</template>

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#2D79C7" stroke="none">
<path d="M430 5109 c-130 -19 -248 -88 -325 -191 -53 -71 -83 -147 -96 -247
-6 -49 -9 -813 -7 -2166 l3 -2090 22 -65 c54 -159 170 -273 328 -323 l70 -22
2140 0 2140 0 66 23 c160 55 272 169 322 327 l22 70 0 2135 0 2135 -22 70
c-49 157 -155 265 -319 327 l-59 23 -2115 1 c-1163 1 -2140 -2 -2170 -7z
m3931 -2383 c48 -9 120 -26 160 -39 l74 -23 3 -237 c1 -130 0 -237 -2 -237 -3
0 -26 14 -53 30 -61 38 -197 84 -310 106 -110 20 -293 15 -368 -12 -111 -39
-175 -110 -175 -193 0 -110 97 -197 335 -300 140 -61 309 -146 375 -189 30
-20 87 -68 126 -107 119 -117 164 -234 164 -426 0 -310 -145 -518 -430 -613
-131 -43 -248 -59 -445 -60 -243 -1 -405 24 -577 90 l-68 26 0 242 c0 175 -3
245 -12 254 -9 9 -9 12 0 12 7 0 12 -4 12 -9 0 -17 139 -102 223 -138 136 -57
233 -77 382 -76 145 0 224 19 295 68 75 52 100 156 59 242 -41 84 -135 148
-374 253 -367 161 -522 300 -581 520 -23 86 -23 253 -1 337 73 275 312 448
682 492 109 13 401 6 506 -13z m-1391 -241 l0 -205 -320 0 -320 0 0 -915 0
-915 -255 0 -255 0 0 915 0 915 -320 0 -320 0 0 205 0 205 895 0 895 0 0 -205z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,10 @@
<template>
<header
class="flex h-9 shrink-0 items-center border-b border-cyan-500/10 bg-bg-base/80 px-3 backdrop-blur-sm select-none"
data-tauri-drag-region
>
<span class="font-mono text-xs tracking-widest text-text-muted uppercase">
aiclient
</span>
</header>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: "" },
});
</script>
<template>
<div class="auth-bg flex h-full items-center justify-center overflow-auto p-6">
<div class="relative z-10 w-full max-w-md">
<div class="mb-8 text-center">
<h1 class="gradient-text text-3xl font-bold tracking-tight">aiclient</h1>
<p v-if="subtitle" class="mt-2 text-sm text-text-muted">{{ subtitle }}</p>
</div>
<div class="glass-card rounded-xl p-6 md:p-8">
<h2 class="mb-6 font-mono text-lg font-semibold text-slate-100">
{{ title }}
</h2>
<slot />
</div>
</div>
</div>
</template>

35
src/main.js Normal file
View File

@@ -0,0 +1,35 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import PrimeVue from "primevue/config";
import Aura from "@primeuix/themes/aura";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Password from "primevue/password";
import Message from "primevue/message";
import Card from "primevue/card";
import App from "./App.vue";
import router from "./router";
import "./styles/main.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: ".dark",
cssLayer: false,
},
},
});
app.component("Button", Button);
app.component("InputText", InputText);
app.component("Password", Password);
app.component("Message", Message);
app.component("Card", Card);
app.mount("#app");

View File

@@ -1,22 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
let greetInputEl: HTMLInputElement | null;
let greetMsgEl: HTMLElement | null;
async function greet() {
if (greetMsgEl && greetInputEl) {
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
greetMsgEl.textContent = await invoke("greet", {
name: greetInputEl.value,
});
}
}
window.addEventListener("DOMContentLoaded", () => {
greetInputEl = document.querySelector("#greet-input");
greetMsgEl = document.querySelector("#greet-msg");
document.querySelector("#greet-form")?.addEventListener("submit", (e) => {
e.preventDefault();
greet();
});
});

44
src/router/index.js Normal file
View File

@@ -0,0 +1,44 @@
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "../stores/auth";
const routes = [
{
path: "/",
name: "home",
component: () => import("../views/HomeView.vue"),
meta: { requiresAuth: true },
},
{
path: "/login",
name: "login",
component: () => import("../views/LoginView.vue"),
},
{
path: "/register",
name: "register",
component: () => import("../views/RegisterView.vue"),
},
{
path: "/:pathMatch(.*)*",
redirect: "/login",
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { name: "login" };
}
if ((to.name === "login" || to.name === "register") && auth.isAuthenticated) {
return { name: "home" };
}
});
export default router;

101
src/stores/auth.js Normal file
View File

@@ -0,0 +1,101 @@
import { defineStore } from "pinia";
const USERS_KEY = "aiclient_users";
const TOKEN_KEY = "aiclient_token";
const USER_KEY = "aiclient_current_user";
function loadUsers() {
try {
const raw = localStorage.getItem(USERS_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveUsers(users) {
localStorage.setItem(USERS_KEY, JSON.stringify(users));
}
function createToken(username) {
return btoa(`${username}:${Date.now()}`);
}
export const useAuthStore = defineStore("auth", {
state: () => ({
token: localStorage.getItem(TOKEN_KEY) || "",
currentUser: (() => {
try {
const raw = localStorage.getItem(USER_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
})(),
}),
getters: {
isAuthenticated: (state) => Boolean(state.token),
},
actions: {
register({ username, password, confirmPassword }) {
const name = username?.trim();
if (!name || !password) {
return { ok: false, message: "用户名和密码不能为空" };
}
if (password !== confirmPassword) {
return { ok: false, message: "两次输入的密码不一致" };
}
if (password.length < 6) {
return { ok: false, message: "密码长度至少为 6 位" };
}
const users = loadUsers();
if (users.some((u) => u.username === name)) {
return { ok: false, message: "用户名已存在" };
}
users.push({ username: name, password });
saveUsers(users);
return this.login({ username: name, password });
},
login({ username, password }) {
const name = username?.trim();
if (!name || !password) {
return { ok: false, message: "用户名和密码不能为空" };
}
const users = loadUsers();
const user = users.find((u) => u.username === name);
if (!user || user.password !== password) {
return { ok: false, message: "用户名或密码错误" };
}
const token = createToken(name);
const currentUser = { username: name };
this.token = token;
this.currentUser = currentUser;
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(currentUser));
return { ok: true, message: "登录成功" };
},
logout() {
this.token = "";
this.currentUser = null;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
return { ok: true, message: "已退出登录" };
},
// 预留:后续对接 Tauri invoke / HTTP API
async loginWithApi(_credentials) {
return { ok: false, message: "API 登录尚未实现" };
},
},
});

View File

@@ -1,144 +0,0 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.typescript:hover {
filter: drop-shadow(0 0 2em #2d79c7);
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
}
.titlebar {
display: flex;
align-items: center;
height: 36px;
padding: 0 12px;
user-select: none;
cursor: default;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.titlebar-title {
font-size: 13px;
font-weight: 500;
opacity: 0.85;
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
height: calc(100vh - 36px);
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
overflow: auto;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
.titlebar {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}

104
src/styles/main.css Normal file
View File

@@ -0,0 +1,104 @@
@import "tailwindcss";
@theme {
--color-bg-deep: #050810;
--color-bg-base: #0b1220;
--color-bg-elevated: #111827;
--color-accent-cyan: #00e5ff;
--color-accent-cyan-dim: #22d3ee;
--color-accent-indigo: #6366f1;
--color-accent-violet: #8b5cf6;
--color-border-glow: rgba(0, 229, 255, 0.25);
--color-text-muted: #94a3b8;
--font-display: ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, "Cascadia Code", "Segoe UI Mono", monospace;
}
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
font-family: var(--font-display);
background-color: var(--color-bg-deep);
color: #e2e8f0;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
.auth-bg {
position: relative;
min-height: 100%;
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 229, 255, 0.12), transparent),
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(99, 102, 241, 0.1), transparent),
linear-gradient(180deg, var(--color-bg-deep) 0%, var(--color-bg-base) 50%, var(--color-bg-deep) 100%);
}
.auth-bg::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0, 229, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 229, 255, 0.04) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 70% 60% at 50% 40%, black 20%, transparent 75%);
pointer-events: none;
}
.auth-bg::after {
content: "";
position: absolute;
top: 20%;
left: 50%;
width: 320px;
height: 320px;
transform: translateX(-50%);
background: radial-gradient(circle, rgba(0, 229, 255, 0.08) 0%, transparent 70%);
filter: blur(40px);
pointer-events: none;
animation: pulse-glow 6s ease-in-out infinite;
}
@keyframes pulse-glow {
0%,
100% {
opacity: 0.6;
transform: translateX(-50%) scale(1);
}
50% {
opacity: 1;
transform: translateX(-50%) scale(1.08);
}
}
.glass-card {
background: rgba(15, 23, 42, 0.65);
border: 1px solid var(--color-border-glow);
backdrop-filter: blur(16px);
box-shadow:
0 0 0 1px rgba(99, 102, 241, 0.08),
0 24px 48px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.gradient-text {
background: linear-gradient(135deg, var(--color-accent-cyan) 0%, var(--color-accent-indigo) 55%, var(--color-accent-violet) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.auth-link {
color: var(--color-accent-cyan-dim);
text-decoration: none;
transition: color 0.2s;
}
.auth-link:hover {
color: var(--color-accent-cyan);
}

37
src/views/HomeView.vue Normal file
View File

@@ -0,0 +1,37 @@
<script setup>
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const router = useRouter();
const auth = useAuthStore();
function onLogout() {
auth.logout();
router.push({ name: "login" });
}
</script>
<template>
<div
class="flex h-full flex-col items-center justify-center gap-6 bg-bg-base p-8"
style="
background:
radial-gradient(ellipse 60% 40% at 50% 0%, rgba(0, 229, 255, 0.08), transparent),
var(--color-bg-base);
"
>
<div class="text-center">
<p class="font-mono text-xs tracking-widest text-text-muted uppercase">
已连接
</p>
<h1 class="gradient-text mt-2 text-2xl font-bold">
欢迎{{ auth.currentUser?.username }}
</h1>
<p class="mt-3 max-w-sm text-sm text-text-muted">
主界面开发中当前为登录成功占位页
</p>
</div>
<Button label="退出登录" severity="secondary" outlined @click="onLogout" />
</div>
</template>

89
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,89 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
import AuthLayout from "../components/AuthLayout.vue";
const router = useRouter();
const auth = useAuthStore();
const username = ref("");
const password = ref("");
const loading = ref(false);
const feedback = ref({ severity: "", message: "" });
async function onSubmit() {
feedback.value = { severity: "", message: "" };
loading.value = true;
const result = auth.login({
username: username.value,
password: password.value,
});
loading.value = false;
if (result.ok) {
router.push({ name: "home" });
return;
}
feedback.value = { severity: "error", message: result.message };
}
</script>
<template>
<AuthLayout title="登录" subtitle="连接你的 AI 工作空间">
<form class="flex flex-col gap-5" @submit.prevent="onSubmit">
<Message
v-if="feedback.message"
:severity="feedback.severity"
:closable="false"
class="w-full"
>
{{ feedback.message }}
</Message>
<div class="flex flex-col gap-2">
<label for="login-username" class="font-mono text-xs text-text-muted uppercase">
用户名
</label>
<InputText
id="login-username"
v-model="username"
placeholder="请输入用户名"
autocomplete="username"
class="w-full"
/>
</div>
<div class="flex flex-col gap-2">
<label for="login-password" class="font-mono text-xs text-text-muted uppercase">
密码
</label>
<Password
id="login-password"
v-model="password"
placeholder="请输入密码"
:feedback="false"
toggle-mask
fluid
input-class="w-full"
autocomplete="current-password"
/>
</div>
<Button
type="submit"
label="登录"
:loading="loading"
class="w-full"
/>
<p class="text-center text-sm text-text-muted">
还没有账号
<RouterLink to="/register" class="auth-link">立即注册</RouterLink>
</p>
</form>
</AuthLayout>
</template>

105
src/views/RegisterView.vue Normal file
View File

@@ -0,0 +1,105 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
import AuthLayout from "../components/AuthLayout.vue";
const router = useRouter();
const auth = useAuthStore();
const username = ref("");
const password = ref("");
const confirmPassword = ref("");
const loading = ref(false);
const feedback = ref({ severity: "", message: "" });
async function onSubmit() {
feedback.value = { severity: "", message: "" };
loading.value = true;
const result = auth.register({
username: username.value,
password: password.value,
confirmPassword: confirmPassword.value,
});
loading.value = false;
if (result.ok) {
router.push({ name: "home" });
return;
}
feedback.value = { severity: "error", message: result.message };
}
</script>
<template>
<AuthLayout title="注册" subtitle="创建你的 AI 客户端账号">
<form class="flex flex-col gap-5" @submit.prevent="onSubmit">
<Message
v-if="feedback.message"
:severity="feedback.severity"
:closable="false"
class="w-full"
>
{{ feedback.message }}
</Message>
<div class="flex flex-col gap-2">
<label for="reg-username" class="font-mono text-xs text-text-muted uppercase">
用户名
</label>
<InputText
id="reg-username"
v-model="username"
placeholder="请输入用户名"
autocomplete="username"
class="w-full"
/>
</div>
<div class="flex flex-col gap-2">
<label for="reg-password" class="font-mono text-xs text-text-muted uppercase">
密码
</label>
<Password
id="reg-password"
v-model="password"
placeholder="至少 6 位"
:feedback="false"
toggle-mask
fluid
autocomplete="new-password"
/>
</div>
<div class="flex flex-col gap-2">
<label for="reg-confirm" class="font-mono text-xs text-text-muted uppercase">
确认密码
</label>
<Password
id="reg-confirm"
v-model="confirmPassword"
placeholder="再次输入密码"
:feedback="false"
toggle-mask
fluid
autocomplete="new-password"
/>
</div>
<Button
type="submit"
label="注册"
:loading="loading"
class="w-full"
/>
<p class="text-center text-sm text-text-muted">
已有账号
<RouterLink to="/login" class="auth-link">返回登录</RouterLink>
</p>
</form>
</AuthLayout>
</template>