一、代码去向总表
原 index.html 各块去向
| 原内容 | 去向 |
|---|---|
<head> 里 <link> |
改为 global.css |
.titlebar 整块 |
留在新 index.html |
<main class="login"> 整块 |
→ templates/login.html |
<div class="toast"> |
留在新 index.html |
<script src="main.ts"> |
留在新 index.html |
原 login.css 各块去向
| 原内容 | 去向 |
|---|---|
:root { --var... } |
→ global.css(变量全局共用) |
* { box-sizing... } |
→ global.css |
html, body { ... } |
→ global.css |
input, button { ... } |
→ global.css |
.app { ... } |
→ global.css |
.titlebar 系列 |
→ global.css |
.win-btn 系列 |
→ global.css |
.login { ... } |
→ login.css |
.brand 系列 |
→ login.css |
.form / .field 系列 |
→ login.css |
.options / .check 系列 |
→ login.css |
.error |
→ login.css |
.btn-login 系列 |
→ login.css |
@keyframes spin |
→ login.css |
.foot 系列 |
→ login.css |
.toast 系列 |
→ global.css |
原 main.ts 各块去向
| 原内容 | 去向 |
|---|---|
import getCurrentWindow |
→ router.ts |
import apiLogin |
→ pages/login.ts |
const $ |
两个文件各留一份(小工具) |
STORAGE_* 常量 |
→ pages/login.ts |
showToast |
→ router.ts(改名 toast) |
setError |
→ pages/login.ts |
bindWindow |
→ router.ts(改名 initGlobal) |
bindPassword |
→ pages/login.ts |
restoreAccount |
→ pages/login.ts |
persistAccount |
→ pages/login.ts |
submit |
→ pages/login.ts |
init |
拆到 main.ts(启动)+ pages/login.ts(挂载) |
DOMContentLoaded |
→ main.ts |
二、最终文件清单
text
a11/
├── index.html # 改:极简容器
├── src/
│ ├── main.ts # 改:入口
│ ├── router.ts # 新
│ ├── styles/
│ │ ├── global.css # 新:全局(变量 + 标题栏 + toast)
│ │ └── login.css # 改:只留登录页样式
│ ├── templates/
│ │ └── login.html # 新:登录页 HTML 片段
│ ├── pages/
│ │ └── login.ts # 新:登录页逻辑
│ └── api/
│ └── auth.ts # 不变
三、index.html(改)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>a11</title>
<link rel="stylesheet" href="/src/styles/global.css" />
</head>
<body>
<div class="app">
<!-- 自定义标题栏 -->
<header class="titlebar">
<div class="titlebar__drag" data-tauri-drag-region>
<span class="titlebar__logo">a11</span>
</div>
<div class="titlebar__actions">
<button type="button" class="win-btn" id="btn-min" aria-label="最小化">
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M0 5h10" stroke="currentColor" stroke-width="1.2" />
</svg>
</button>
<button type="button" class="win-btn win-btn--close" id="btn-close" aria-label="关闭">
<svg width="10" height="10" viewBox="0 0 10 10">
<path d="M0 0l10 10M10 0L0 10" stroke="currentColor" stroke-width="1.2" />
</svg>
</button>
</div>
</header>
<!-- 页面挂载点:router 往这里填充 -->
<main id="page-container"></main>
<div class="toast" id="toast" role="status"></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
变化:
<link>从login.css改为global.css- 删除
<main class="login">整块(→templates/login.html) - 新增
<main id="page-container">作为挂载点
四、src/styles/global.css(新)
来源 :原 login.css 中变量、重置、.app、.titlebar、.win-btn、.toast 全部原样搬过来。
css
/* ============================================================
* global.css --- 全局样式
*
* 来源:原 login.css 的全局部分
* ============================================================ */
:root {
--bg: #f5f7fa;
--panel: #ffffff;
--text: #1f2329;
--text-2: #6b7280;
--brand: #07c160;
--brand-hover: #06ad56;
--border: #e4e7eb;
--field-bg: #f5f6f8;
--danger: #e5484d;
--titlebar-h: 36px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: transparent;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
color: var(--text);
user-select: none;
-webkit-user-select: none;
overflow: hidden;
}
input, button { font-family: inherit; }
.app {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--panel);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
}
/* ---------- 标题栏 ---------- */
.titlebar {
height: var(--titlebar-h);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding-left: 14px;
background: var(--panel);
}
.titlebar__drag {
flex: 1;
height: 100%;
display: flex;
align-items: center;
cursor: default;
}
.titlebar__logo {
font-size: 13px;
font-weight: 600;
color: var(--text-2);
letter-spacing: 0.5px;
}
.titlebar__actions { display: flex; height: 100%; }
.win-btn {
width: 44px;
height: 100%;
border: none;
background: transparent;
color: var(--text-2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.win-btn:hover { background: #eceff3; color: var(--text); }
.win-btn--close:hover { background: #e5484d; color: #fff; }
/* ---------- 页面挂载点 ---------- */
#page-container {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 40px;
transform: translate(-50%, 12px);
background: rgba(0, 0, 0, 0.78);
color: #fff;
font-size: 12px;
padding: 8px 16px;
border-radius: 6px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
z-index: 999;
}
.toast.is-show {
opacity: 1;
transform: translate(-50%, 0);
}
五、src/styles/login.css(改)
来源 :原 login.css 去掉全局部分后的登录页专属样式。
css
/* ============================================================
* login.css --- 登录页专属样式
*
* 来源:原 login.css 的登录页部分
* (变量、重置、标题栏、toast 已移到 global.css)
* ============================================================ */
.login {
flex: 1;
display: flex;
flex-direction: column;
padding: 8px 40px 24px;
overflow: hidden;
}
.brand { text-align: center; padding: 12px 0 24px; }
.brand__avatar {
width: 72px;
height: 72px;
margin: 0 auto 12px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.brand__title {
font-size: 20px;
font-weight: 600;
letter-spacing: 2px;
color: var(--text);
}
.brand__desc {
margin-top: 6px;
font-size: 12px;
color: var(--text-2);
}
.form { display: flex; flex-direction: column; gap: 14px; }
.field {
position: relative;
display: flex;
align-items: center;
height: 42px;
border-radius: 8px;
background: var(--field-bg);
border: 1px solid transparent;
transition: border-color 0.15s, background 0.15s;
}
.field:focus-within {
background: #fff;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(7, 193, 96, 0.12);
}
.field.is-error {
border-color: var(--danger);
background: #fff;
}
.field__icon {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-2);
flex-shrink: 0;
}
.field input {
flex: 1;
height: 100%;
border: none;
outline: none;
background: transparent;
font-size: 14px;
color: var(--text);
padding-right: 8px;
user-select: text;
-webkit-user-select: text;
}
.field input::placeholder { color: #9aa1ab; }
.field__toggle {
width: 40px;
height: 100%;
border: none;
background: transparent;
color: var(--text-2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.field__toggle:hover { color: var(--text); }
.field__toggle .eye-slash { display: none; }
.field__toggle.is-visible .eye-slash { display: block; }
.options {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 2px;
}
.check {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 12px;
color: var(--text-2);
user-select: none;
}
.check input { display: none; }
.check__box {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1.4px solid #c4c9d0;
background: #fff;
display: inline-block;
position: relative;
transition: all 0.15s;
}
.check input:checked + .check__box {
background: var(--brand);
border-color: var(--brand);
}
.check input:checked + .check__box::after {
content: "";
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid #fff;
border-width: 0 1.6px 1.6px 0;
transform: rotate(45deg);
}
.error {
min-height: 16px;
font-size: 12px;
color: var(--danger);
margin-top: -6px;
padding-left: 2px;
visibility: hidden;
}
.error.is-show { visibility: visible; }
.btn-login {
position: relative;
height: 42px;
border: none;
border-radius: 8px;
background: var(--brand);
color: #fff;
font-size: 15px;
font-weight: 500;
letter-spacing: 2px;
cursor: pointer;
transition: background 0.15s, transform 0.08s;
overflow: hidden;
}
.btn-login:hover:not(:disabled) { background: var(--brand-hover); }
.btn-login:active:not(:disabled) { transform: scale(0.99); }
.btn-login:disabled { opacity: 0.75; cursor: not-allowed; }
.btn-login__spinner {
display: none;
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.btn-login.is-loading .btn-login__text { visibility: hidden; }
.btn-login.is-loading .btn-login__spinner { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
.foot {
margin-top: auto;
padding-top: 18px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-2);
}
.foot a {
color: var(--brand);
text-decoration: none;
margin-left: 4px;
}
.foot a:hover { text-decoration: underline; }
六、src/templates/login.html(新)
来源 :原 index.html 里 <main class="login">...</main> 整块,外层标签 main 改 div。
html
<div class="login">
<div class="brand">
<div class="brand__avatar">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="72" height="72">
<rect x="0" y="0" width="1024" height="1024" rx="240" fill="#07C160" />
<text x="512" y="545"
font-family="Arial, Helvetica, sans-serif"
font-size="820"
font-weight="bold"
fill="#FFFFFF"
text-anchor="middle"
dominant-baseline="central"
letter-spacing="0">A</text>
</svg>
</div>
<h1 class="brand__title">a11</h1>
<p class="brand__desc">登录后开始您的工作</p>
</div>
<form class="form" id="login-form" novalidate>
<div class="field">
<span class="field__icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="5.2" r="3.2" stroke="currentColor" stroke-width="1.3"/>
<path d="M2.2 14c0-2.9 2.6-4.6 5.8-4.6s5.8 1.7 5.8 4.6"
stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</span>
<input id="account" name="account" type="text"
autocomplete="username" spellcheck="false"
placeholder="姓名 / 手机号 / 邮箱" />
</div>
<div class="field">
<span class="field__icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="3" y="7" width="10" height="7" rx="2"
stroke="currentColor" stroke-width="1.3"/>
<path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"
stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</span>
<input id="password" name="password" type="password"
autocomplete="current-password"
placeholder="密码" />
<button type="button" class="field__toggle" id="btn-eye" aria-label="显示密码">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M1 8s2.5-4.5 7-4.5S15 8 15 8s-2.5 4.5-7 4.5S1 8 1 8Z"
stroke="currentColor" stroke-width="1.2"/>
<circle cx="8" cy="8" r="2" stroke="currentColor" stroke-width="1.2"/>
<path class="eye-slash" d="M2.5 2.5l11 11"
stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="options">
<label class="check">
<input type="checkbox" id="remember" />
<span class="check__box"></span>
<span>记住账号</span>
</label>
<label class="check">
<input type="checkbox" id="auto-login" />
<span class="check__box"></span>
<span>自动登录</span>
</label>
</div>
<p class="error" id="error" role="alert"></p>
<button type="submit" class="btn-login" id="btn-login">
<span class="btn-login__text">登 录</span>
<span class="btn-login__spinner" aria-hidden="true"></span>
</button>
</form>
<footer class="foot">
<span>还没有账号?<a href="#" id="link-register">立即注册</a></span>
<a href="#" id="link-help">忘记密码</a>
</footer>
</div>
变化 :<main class="login"> → <div class="login">。
七、src/router.ts(新)
来源 :原 main.ts 的 bindWindow + showToast,加上 goto。
typescript
//! 页面路由 + 全局工具
import { getCurrentWindow } from "@tauri-apps/api/window";
import { mountLogin, unmountLogin } from "./pages/login";
// ============================================================
// 类型
// ============================================================
export type PageName = "login" | "bootstrap" | "main";
interface PageModule {
mount: (container: HTMLElement, params?: any) => Promise<void> | void;
unmount?: () => void;
}
// ============================================================
// 页面注册表
// ============================================================
const PAGES: Record<string, PageModule> = {
login: {
mount: mountLogin,
unmount: unmountLogin,
},
// bootstrap / main 后续加
};
// ============================================================
// 状态
// ============================================================
let currentPage: string | null = null;
// ============================================================
// 路由
// ============================================================
/// 切换页面
export async function goto(name: PageName, params?: any): Promise<void> {
const container = document.getElementById("page-container");
if (!container) {
throw new Error("#page-container 不存在");
}
// 1. 卸载旧页面
if (currentPage && PAGES[currentPage]?.unmount) {
try {
PAGES[currentPage].unmount!();
} catch (e) {
console.error(`unmount ${currentPage} failed:`, e);
}
}
// 2. 清空容器
container.innerHTML = "";
// 3. 挂载新页面
const page = PAGES[name];
if (!page) {
throw new Error(`未注册的页面:${name}`);
}
await page.mount(container, params);
// 4. 记录
currentPage = name;
}
export function current(): string | null {
return currentPage;
}
// ============================================================
// 全局工具
// ============================================================
/// 全局 toast(来源:原 main.ts 的 showToast)
export function toast(text: string, ms = 1800): void {
const el = document.getElementById("toast");
if (!el) return;
el.textContent = text;
el.classList.add("is-show");
window.setTimeout(() => el.classList.remove("is-show"), ms);
}
/// 初始化全局(来源:原 main.ts 的 bindWindow)
export function initGlobal(): void {
try {
const win = getCurrentWindow();
document.getElementById("btn-min")?.addEventListener("click", () => win.minimize());
document.getElementById("btn-close")?.addEventListener("click", () => win.close());
} catch (e) {
console.warn("Tauri window API 不可用:", e);
}
}
八、src/pages/login.ts(新)
来源 :原 main.ts 的 STORAGE_* / setError / bindPassword / restoreAccount / persistAccount / submit / init(部分)。
typescript
//! 登录页逻辑
import templateHtml from "../templates/login.html?raw";
import "../styles/login.css";
import { login as apiLogin } from "../api/auth";
import { toast, goto } from "../router";
// ============================================================
// 存储键
// ============================================================
export const STORAGE = {
ACCOUNT: "a11_account",
AUTO_LOGIN: "a11_auto_login",
TOKEN: "a11_token",
NICKNAME: "a11_nickname",
USER_ID: "a11_user_id",
} as const;
// ============================================================
// 工具
// ============================================================
const $ = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
// 来源:原 main.ts 的 setError
function setError(msg: string, fieldId?: string) {
const el = $("error");
el.textContent = msg;
el.classList.toggle("is-show", !!msg);
document.querySelectorAll(".field.is-error").forEach((f) =>
f.classList.remove("is-error"),
);
if (fieldId) $(fieldId).closest(".field")?.classList.add("is-error");
}
// ============================================================
// 密码显隐(来源:原 main.ts 的 bindPassword)
// ============================================================
function bindPasswordToggle() {
const input = $<HTMLInputElement>("password");
const btn = $("btn-eye");
btn.addEventListener("click", () => {
const visible = btn.classList.toggle("is-visible");
input.type = visible ? "text" : "password";
const pos = input.value.length;
input.focus();
input.setSelectionRange(pos, pos);
});
}
// ============================================================
// 账号回填(来源:原 main.ts 的 restoreAccount / persistAccount)
// ============================================================
function restoreAccount() {
const saved = localStorage.getItem(STORAGE.ACCOUNT);
if (saved) {
$<HTMLInputElement>("account").value = saved;
$<HTMLInputElement>("remember").checked = true;
}
if (localStorage.getItem(STORAGE.AUTO_LOGIN) === "true") {
$<HTMLInputElement>("auto-login").checked = true;
}
}
function persistAccount(account: string) {
const remember = $<HTMLInputElement>("remember").checked;
const auto = $<HTMLInputElement>("auto-login").checked;
if (remember || auto) localStorage.setItem(STORAGE.ACCOUNT, account);
else localStorage.removeItem(STORAGE.ACCOUNT);
if (auto) localStorage.setItem(STORAGE.AUTO_LOGIN, "true");
else localStorage.removeItem(STORAGE.AUTO_LOGIN);
}
// ============================================================
// 提交(来源:原 main.ts 的 submit)
// ============================================================
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
const account = $<HTMLInputElement>("account").value.trim();
const password = $<HTMLInputElement>("password").value;
const btn = $<HTMLButtonElement>("btn-login");
if (!account) return setError("请输入账号", "account");
if (!password) return setError("请输入密码", "password");
setError("");
btn.classList.add("is-loading");
btn.disabled = true;
try {
const result = await apiLogin(account, password);
localStorage.setItem(STORAGE.TOKEN, result.token);
localStorage.setItem(STORAGE.NICKNAME, result.nickname);
localStorage.setItem(STORAGE.USER_ID, result.user_id);
persistAccount(account);
toast(`欢迎,${result.nickname}`);
// TODO: 后续根据是否引导期 → goto("bootstrap") 或 goto("main")
// 暂时保留在登录页
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
} finally {
btn.classList.remove("is-loading");
btn.disabled = false;
}
}
// ============================================================
// 挂载 / 卸载
// ============================================================
export async function mountLogin(container: HTMLElement): Promise<void> {
// 加载 HTML 模板
container.innerHTML = templateHtml;
// 回填账号
restoreAccount();
// 密码显隐
bindPasswordToggle();
// 输入时清错误
["account", "password"].forEach((id) => {
$(id).addEventListener("input", () => setError(""));
});
// 表单提交
$("login-form").addEventListener("submit", onSubmit);
// 自动聚焦
$<HTMLInputElement>("account").focus();
}
export function unmountLogin(): void {
// 元素随 innerHTML 清空,事件监听器自动 GC
}
九、src/main.ts(改)
来源 :原 main.ts 的 init 和 DOMContentLoaded,但只保留"启动"部分。
typescript
//! 应用入口
//!
//! 只做两件事:
//! 1. 初始化全局(标题栏按钮)
//! 2. 启动路由,进入登录页
import { goto, initGlobal } from "./router";
async function start() {
initGlobal();
await goto("login");
}
window.addEventListener("DOMContentLoaded", start);
十、src/api/auth.ts(不变)
保持原样。如果之前是假数据或 fetch,先不动。
十一、代码不丢失核对清单
| 原代码块 | 新位置 | 是否保留 |
|---|---|---|
index.html 的 <head> 引用 |
改为 global.css | ✅ |
| index.html 的 titlebar | 新 index.html | ✅ |
index.html 的 <main class="login"> |
templates/login.html | ✅ |
| index.html 的 toast | 新 index.html | ✅ |
| index.html 的 script | 新 index.html | ✅ |
login.css 的 :root |
global.css | ✅ |
login.css 的 * / html, body |
global.css | ✅ |
login.css 的 input, button |
global.css | ✅ |
login.css 的 .app |
global.css | ✅ |
login.css 的 .titlebar / .win-btn |
global.css | ✅ |
login.css 的 .toast |
global.css | ✅ |
login.css 的 .login / .brand / .form / .field / .check / .error / .btn-login / .foot |
login.css | ✅ |
| main.ts 的 STORAGE_* | pages/login.ts | ✅ |
| main.ts 的 showToast | router.ts(toast) | ✅ |
| main.ts 的 setError | pages/login.ts | ✅ |
| main.ts 的 bindWindow | router.ts(initGlobal) | ✅ |
| main.ts 的 bindPassword | pages/login.ts | ✅ |
| main.ts 的 restoreAccount | pages/login.ts | ✅ |
| main.ts 的 persistAccount | pages/login.ts | ✅ |
| main.ts 的 submit | pages/login.ts | ✅ |
| main.ts 的 init | main.ts + pages/login.ts | ✅ |
| main.ts 的 DOMContentLoaded | main.ts | ✅ |
没有代码丢失,只是分散到对应职责的文件里。
十二、迁移步骤
- 备份 原
index.html/login.css/main.ts(改名前先复制一份 .bak) - 新建目录 :
src/styles/src/templates/src/pages/
- 写
src/styles/global.css - 改
src/styles/login.css - 写
src/templates/login.html - 写
src/router.ts - 写
src/pages/login.ts - 改
src/main.ts - 改
index.html - 跑一遍 :
npm run tauri:dev,确认登录功能不变
十三、验证预期
- 打开窗口 → 显示登录页(外观与之前完全一致)
- 标题栏按钮(最小化 / 关闭)可用
- 输入 admin / admin11 → 登录成功 → toast "欢迎,管理员"
- 输入错误密码 → 显示错误
- 勾选"记住账号" → 重启后账号回填
- 仍然停在登录页(goto 跳转待后续实现)
十四、确认
- 代码去向表清晰,无丢失,对吗?
- global.css 收纳变量 + 重置 + 标题栏 + toast,对吗?
- login.css 只留登录页专属,对吗?
- templates/login.html 从 index.html 搬出,对吗?
- pages/login.ts 收纳所有登录逻辑,对吗?
- router.ts 提供 goto / toast / initGlobal,对吗?
- main.ts 只做启动,对吗?
十五、下一步
拆完后,第二步就是:
- 建
templates/bootstrap.html/styles/bootstrap.css/pages/bootstrap.ts - router 注册 bootstrap
- 登录成功后判断 →
goto("bootstrap")或goto("main")
要继续吗?