feat: 登录记住本机+密码可见、内容分简介与主体、Markdown 渲染、首页随机条目
All checks were successful
Build and Deploy / build (push) Successful in 56s
Build and Deploy / deploy (push) Successful in 28s

This commit is contained in:
cheney 2026-09-01 10:53:17 +08:00
parent a5c5d24083
commit c09857cc81
18 changed files with 180 additions and 41 deletions

13
package-lock.json generated
View File

@ -14,6 +14,7 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"marked": "^18.0.11",
"node-cron": "^4.6.0", "node-cron": "^4.6.0",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
@ -1139,6 +1140,18 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/marked": {
"version": "18.0.11",
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.11.tgz",
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@ -20,6 +20,7 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"marked": "^18.0.11",
"node-cron": "^4.6.0", "node-cron": "^4.6.0",
"zod": "^3.25.76" "zod": "^3.25.76"
} }

View File

@ -1171,6 +1171,81 @@ button.chip:hover {
overflow-x: auto; overflow-x: auto;
white-space: pre; white-space: pre;
} }
.md-body {
line-height: 1.7;
font-size: 14.5px;
}
.md-body h1,
.md-body h2,
.md-body h3,
.md-body h4 {
font-weight: 700;
margin: 22px 0 10px;
}
.md-body h2 {
font-size: 20px;
}
.md-body h3 {
font-size: 16.5px;
}
.md-body p {
margin: 10px 0;
}
.md-body a {
color: var(--riso-blue);
text-decoration: underline;
}
.md-body ul,
.md-body ol {
margin: 10px 0;
padding-left: 22px;
}
.md-body li {
margin: 4px 0;
}
.md-body code {
font-family: var(--font-mono);
font-size: 12.5px;
background: var(--riso-blue-soft);
border-radius: 5px;
padding: 2px 5px;
}
.md-body pre {
background: var(--panel);
color: #cdd3ea;
border-radius: var(--r-md);
padding: 16px 18px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 12.5px;
line-height: 1.6;
margin: 14px 0;
}
.md-body pre code {
background: none;
padding: 0;
color: inherit;
}
.md-body blockquote {
border-left: 3px solid var(--ink-faint);
padding-left: 14px;
color: var(--ink-dim);
margin: 12px 0;
}
.md-body table {
border-collapse: collapse;
margin: 14px 0;
font-size: 13.5px;
}
.md-body th,
.md-body td {
border: 1.5px solid var(--ink-faint);
padding: 7px 12px;
text-align: left;
}
.md-body th {
background: var(--riso-blue-soft);
}
.side-card { .side-card {
background: var(--paper-raised); background: var(--paper-raised);
border: var(--border); border: var(--border);
@ -1603,6 +1678,13 @@ button.chip:hover {
width: 100%; width: 100%;
padding-right: 42px; padding-right: 42px;
} }
.pwd-field {
position: relative;
}
.pwd-field .input {
width: 100%;
padding-right: 42px;
}
.eye-toggle { .eye-toggle {
position: absolute; position: absolute;
right: 8px; right: 8px;

View File

@ -139,19 +139,6 @@
}); });
} }
document.querySelectorAll("[data-eye-toggle]").forEach(function (btn) {
btn.addEventListener("click", function () {
var input = btn.closest("form").querySelector("input[name=key]");
var show = input.type === "password";
input.type = show ? "text" : "password";
btn.setAttribute("aria-label", show ? "隐藏密钥" : "显示密钥");
var open = btn.querySelector(".eye-open");
var closed = btn.querySelector(".eye-closed");
if (open) open.hidden = show;
if (closed) closed.hidden = !show;
});
});
document.querySelectorAll("form.source-config").forEach(function (form) { document.querySelectorAll("form.source-config").forEach(function (form) {
form.addEventListener("submit", function (e) { form.addEventListener("submit", function (e) {
e.preventDefault(); e.preventDefault();

View File

@ -118,6 +118,22 @@
}); });
}); });
document.querySelectorAll("[data-eye-toggle]").forEach(function (btn) {
btn.addEventListener("click", function () {
var input = btn.closest(".pwd-field, .ai-key-field")
? btn.closest(".pwd-field, .ai-key-field").querySelector("input")
: null;
if (!input) return;
var show = input.type === "password";
input.type = show ? "text" : "password";
btn.setAttribute("aria-label", show ? "隐藏密码" : "显示密码");
var open = btn.querySelector(".eye-open");
var closed = btn.querySelector(".eye-closed");
if (open) open.hidden = show;
if (closed) closed.hidden = !show;
});
});
var loginForm = document.getElementById("login-form"); var loginForm = document.getElementById("login-form");
if (loginForm) { if (loginForm) {
loginForm.addEventListener("submit", function (e) { loginForm.addEventListener("submit", function (e) {
@ -126,6 +142,7 @@
postJson("/api/admin/login", { postJson("/api/admin/login", {
username: loginForm.username.value, username: loginForm.username.value,
password: loginForm.password.value, password: loginForm.password.value,
remember: loginForm.remember ? loginForm.remember.checked : true,
}) })
.then(function () { .then(function () {
toast("登录成功,正在进入管理台…"); toast("登录成功,正在进入管理台…");

View File

@ -2,6 +2,7 @@ import express from "express";
import session from "express-session"; import session from "express-session";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { marked } from "marked";
import { loadUser } from "./middleware/auth.js"; import { loadUser } from "./middleware/auth.js";
import { notFoundHandler, errorHandler } from "./middleware/errors.js"; import { notFoundHandler, errorHandler } from "./middleware/errors.js";
import { pagesRouter } from "./routes/pages.routes.js"; import { pagesRouter } from "./routes/pages.routes.js";
@ -26,6 +27,14 @@ export function buildApp(config) {
}; };
app.locals.fmtDate = (d) => app.locals.fmtDate = (d) =>
d ? new Date(d).toISOString().slice(0, 10) : "—"; d ? new Date(d).toISOString().slice(0, 10) : "—";
app.locals.md = (s) => marked.parse(String(s || ""));
app.locals.excerpt = (s, n = 200) => {
const plain = String(s || "")
.replace(/[#*`>_\[\]()!-]/g, " ")
.replace(/\s+/g, " ")
.trim();
return plain.length > n ? plain.slice(0, n).trim() + "…" : plain;
};
app.locals.qs = (obj) => { app.locals.qs = (obj) => {
const p = new URLSearchParams(); const p = new URLSearchParams();
for (const [k, v] of Object.entries(obj)) { for (const [k, v] of Object.entries(obj)) {

View File

@ -81,6 +81,7 @@ export async function seedTagsAndEntries() {
export async function runMigrate(config) { export async function runMigrate(config) {
await runSchema(); await runSchema();
await run(`ALTER TABLE sources ADD COLUMN IF NOT EXISTS api_key VARCHAR`); await run(`ALTER TABLE sources ADD COLUMN IF NOT EXISTS api_key VARCHAR`);
await run(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS summary VARCHAR DEFAULT ''`);
const admin = await findByUsername("admin"); const admin = await findByUsername("admin");
if (!admin) { if (!admin) {

View File

@ -54,6 +54,7 @@ CREATE TABLE IF NOT EXISTS entries (
title VARCHAR NOT NULL, title VARCHAR NOT NULL,
slug VARCHAR UNIQUE NOT NULL, slug VARCHAR UNIQUE NOT NULL,
type VARCHAR NOT NULL, type VARCHAR NOT NULL,
summary VARCHAR DEFAULT '',
description_md TEXT DEFAULT '', description_md TEXT DEFAULT '',
url VARCHAR DEFAULT '', url VARCHAR DEFAULT '',
license VARCHAR DEFAULT '', license VARCHAR DEFAULT '',

View File

@ -23,6 +23,11 @@ adminRouter.post("/api/admin/login", async (req, res) => {
req.session.userId = user.id; req.session.userId = user.id;
req.session.username = user.username; req.session.username = user.username;
req.session.role = user.role; req.session.role = user.role;
if (req.body.remember === true || req.body.remember === "true") {
req.session.cookie.maxAge = 1000 * 60 * 60 * 24 * 14;
} else {
req.session.cookie.maxAge = null;
}
res.json({ ok: true, data: { username: user.username, role: user.role } }); res.json({ ok: true, data: { username: user.username, role: user.role } });
}); });

View File

@ -44,6 +44,7 @@ const entryInput = z.object({
title: z.string().min(1), title: z.string().min(1),
url: z.string().url(), url: z.string().url(),
type: z.enum(entryService.ENTRY_TYPES), type: z.enum(entryService.ENTRY_TYPES),
summary: z.string().max(1000).optional(),
description_md: z.string().max(5000).optional(), description_md: z.string().max(5000).optional(),
license: z.string().optional(), license: z.string().optional(),
stars: z.number().nonnegative().optional(), stars: z.number().nonnegative().optional(),
@ -77,6 +78,7 @@ const patchInput = z.object({
title: z.string().min(1).optional(), title: z.string().min(1).optional(),
url: z.string().url().optional(), url: z.string().url().optional(),
type: z.enum(entryService.ENTRY_TYPES).optional(), type: z.enum(entryService.ENTRY_TYPES).optional(),
summary: z.string().max(1000).optional(),
description_md: z.string().max(5000).optional(), description_md: z.string().max(5000).optional(),
license: z.string().optional(), license: z.string().optional(),
stars: z.number().nonnegative().optional(), stars: z.number().nonnegative().optional(),

View File

@ -21,10 +21,19 @@ pagesRouter.get("/healthz", (_req, res) => {
pagesRouter.get("/", async (_req, res, next) => { pagesRouter.get("/", async (_req, res, next) => {
try { try {
const [stats, recent, hero] = await Promise.all([
searchService.stats(),
entryService.listRecent(4),
(async () => {
const rand = await searchService.randomActive();
return rand ? entryService.getById(rand.id) : null;
})(),
]);
res.render("index", { res.render("index", {
title: "Awesome Index · 把网上牛逼的东西收进一个库", title: "Awesome Index · 把网上牛逼的东西收进一个库",
stats: await searchService.stats(), stats,
recent: await entryService.listRecent(4), recent,
hero,
}); });
} catch (e) { } catch (e) {
next(e); next(e);
@ -72,6 +81,7 @@ pagesRouter.get("/entries/:slug", async (req, res, next) => {
id: entry.slug, id: entry.slug,
type: entry.type, type: entry.type,
name: entry.title, name: entry.title,
summary: entry.summary,
description: entry.description_md, description: entry.description_md,
url: entry.url, url: entry.url,
tags: entry.tags, tags: entry.tags,
@ -162,6 +172,7 @@ pagesRouter.post("/admin/entries/:id/edit", async (req, res, next) => {
const patch = { const patch = {
title: String(req.body.title || "").trim(), title: String(req.body.title || "").trim(),
type: req.body.type, type: req.body.type,
summary: String(req.body.summary || "").trim(),
description_md: String(req.body.description_md || ""), description_md: String(req.body.description_md || ""),
url: String(req.body.url || "").trim(), url: String(req.body.url || "").trim(),
license: String(req.body.license || "").trim(), license: String(req.body.license || "").trim(),

View File

@ -83,12 +83,13 @@ export async function createEntry(data, { source } = {}) {
const status = decideStatus(source, data.status); const status = decideStatus(source, data.status);
const slug = data.slug || (await slugify(data.title)); const slug = data.slug || (await slugify(data.title));
await run( await run(
`INSERT INTO entries (title, slug, type, description_md, url, license, stars, status, source_id, verified_at) `INSERT INTO entries (title, slug, type, summary, description_md, url, license, stars, status, source_id, verified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
data.title.trim(), data.title.trim(),
slug, slug,
data.type, data.type,
data.summary || "",
data.description_md || "", data.description_md || "",
data.url.trim(), data.url.trim(),
data.license || "", data.license || "",
@ -103,7 +104,7 @@ export async function createEntry(data, { source } = {}) {
return { created: true, id: row.id, status }; return { created: true, id: row.id, status };
} }
const EDITABLE = ["title", "type", "description_md", "url", "license", "stars"]; const EDITABLE = ["title", "type", "summary", "description_md", "url", "license", "stars"];
export async function updateEntry(id, patch, { isHumanEditor = false } = {}) { export async function updateEntry(id, patch, { isHumanEditor = false } = {}) {
const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]); const entry = await get(`SELECT * FROM entries WHERE id = ?`, [id]);
@ -116,9 +117,9 @@ export async function updateEntry(id, patch, { isHumanEditor = false } = {}) {
if (patch[key] !== undefined) next[key] = patch[key]; if (patch[key] !== undefined) next[key] = patch[key];
} }
await run( await run(
`UPDATE entries SET title = ?, type = ?, description_md = ?, url = ?, license = ?, stars = ?, updated_at = now() `UPDATE entries SET title = ?, type = ?, summary = ?, description_md = ?, url = ?, license = ?, stars = ?, updated_at = now()
WHERE id = ?`, WHERE id = ?`,
[next.title, next.type, next.description_md, next.url, next.license, Number(next.stars) || 0, id] [next.title, next.type, next.summary || "", next.description_md, next.url, next.license, Number(next.stars) || 0, id]
); );
if (patch.tags !== undefined) await linkTags(id, patch.tags); if (patch.tags !== undefined) await linkTags(id, patch.tags);
if (isHumanEditor) { if (isHumanEditor) {
@ -185,7 +186,7 @@ export async function getBySlug(slug) {
export async function listRecent(limit = 4) { export async function listRecent(limit = 4) {
const rows = await all( const rows = await all(
`SELECT e.id, e.title, e.slug, e.type, e.description_md, e.url, e.stars, e.updated_at `SELECT e.id, e.title, e.slug, e.type, e.summary, e.description_md, e.url, e.stars, e.updated_at
FROM entries e WHERE e.status = 'active' FROM entries e WHERE e.status = 'active'
ORDER BY e.created_at DESC LIMIT ?`, ORDER BY e.created_at DESC LIMIT ?`,
[limit] [limit]

View File

@ -56,7 +56,7 @@ export async function searchEntries({
[...cteParams, ...params] [...cteParams, ...params]
); );
const items = await all( const items = await all(
`${cte} SELECT e.id, e.title, e.slug, e.type, e.description_md, e.url, e.license, e.stars, e.status, e.updated_at `${cte} SELECT e.id, e.title, e.slug, e.type, e.summary, e.description_md, e.url, e.license, e.stars, e.status, e.updated_at
FROM entries e ${whereSql} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, FROM entries e ${whereSql} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
[...cteParams, ...params, perPage, offset] [...cteParams, ...params, perPage, offset]
); );

View File

@ -15,6 +15,8 @@
<form id="entry-edit-form" method="post" action="/admin/entries/<%= entry.id %>/edit" style="margin-top:22px;display:grid;gap:18px;max-width:820px"> <form id="entry-edit-form" method="post" action="/admin/entries/<%= entry.id %>/edit" style="margin-top:22px;display:grid;gap:18px;max-width:820px">
<div class="panel-box"> <div class="panel-box">
<h3>基础信息</h3> <h3>基础信息</h3>
<div class="field" style="margin-top:0"><label for="f-summary">简介≤200 字,列表与详情页头部展示)</label>
<input class="input" id="f-summary" name="summary" maxlength="200" value="<%= entry.summary || '' %>" /></div>
<div class="form-row"> <div class="form-row">
<div class="field" style="flex:2"><label for="f-title">标题</label> <div class="field" style="flex:2"><label for="f-title">标题</label>
<input class="input" id="f-title" name="title" value="<%= entry.title %>" required /></div> <input class="input" id="f-title" name="title" value="<%= entry.title %>" required /></div>

View File

@ -14,16 +14,16 @@
<button class="btn btn-sm" type="button" data-copy='请通过 awesome-index 的 MCP 接口调用 get_entryslug 为 "<%= entry.slug %>"'>复制给 AI</button> <button class="btn btn-sm" type="button" data-copy='请通过 awesome-index 的 MCP 接口调用 get_entryslug 为 "<%= entry.slug %>"'>复制给 AI</button>
</div> </div>
</div> </div>
<p style="margin-top:12px;color:var(--ink-dim);max-width:56ch"><%= entry.description_md %></p> <p style="margin-top:12px;color:var(--ink-dim);max-width:64ch"><%= excerpt(entry.summary || entry.description_md, 200) %></p>
</div> </div>
<div class="detail-layout"> <div class="detail-layout">
<article class="detail-body"> <article class="detail-body">
<section> <section>
<p class="anno">// entry.body · markdown source</p> <p class="anno">// entry.body · markdown rendered</p>
<h2>这是什么</h2> <h2>这是什么</h2>
<p><%= entry.description_md || '暂无介绍。' %></p> <div class="md-body"><%- md(entry.description_md || '暂无介绍。') %></div>
<p style="margin-top:10px">仓库地址:<a class="mono" style="color:var(--riso-blue)" href="<%= entry.url %>" target="_blank" rel="noopener"><%= entry.url %></a></p> <p style="margin-top:16px">仓库地址:<a class="mono" style="color:var(--riso-blue)" href="<%= entry.url %>" target="_blank" rel="noopener"><%= entry.url %></a></p>
</section> </section>
<section> <section>

View File

@ -33,21 +33,22 @@
</div> </div>
<div class="hero-visual rise d3" role="presentation"> <div class="hero-visual rise d3" role="presentation">
<p class="spec-label mono">// get_entry("ripgrep") · 同一条数据,两种读者</p> <% const h = hero || { slug: 'ripgrep', title: 'ripgrep', type: '工具', summary: '', description_md: '以正则递归搜索目录,默认尊重 .gitignore是代码库里最快的找东西方式。', tags: ['Rust', 'CLI'], stars: 52300 }; %>
<p class="spec-label mono">// get_entry("<%= h.slug %>") · 同一条数据,两种读者</p>
<div class="spec-machine mono" aria-hidden="true">{ <div class="spec-machine mono" aria-hidden="true">{
"id": "ripgrep", "id": "<%= h.slug %>",
"type": "tool", "type": "<%= h.type %>",
"name": "ripgrep", "name": "<%= h.title %>",
"tags": ["Rust", "CLI"], "tags": <%= JSON.stringify(h.tags || []) %>,
"stars": <%= typeof recent[0] !== 'undefined' ? Number(recent[0].stars) : 52300 %>, "stars": <%= Number(h.stars) %>,
"status": "active" "status": "active"
}</div> }</div>
<div class="spec-human" aria-hidden="true"> <div class="spec-human" aria-hidden="true">
<span class="chip chip-type">工具</span> <span class="chip chip-type"><%= h.type %></span>
<h3>ripgrep</h3> <h3><%= h.title %></h3>
<p>以正则递归搜索目录,默认尊重 .gitignore是代码库里最快的找东西方式。</p> <p><%= h.summary || excerpt(h.description_md, 90) %></p>
<div class="card-tags"><span class="chip">Rust</span><span class="chip">CLI</span></div> <div class="card-tags"><% for (const t of (h.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %></div>
<p class="card-meta">★ <%= fmtStars(52300) %> · 终端找东西的事实标准</p> <p class="card-meta">★ <%= fmtStars(h.stars) %> · 随机一条,看看这个库</p>
</div> </div>
<button class="btn btn-random" id="random-btn" type="button" title="随机打开一条收录,没有头绪时让库替你挑一个"> <button class="btn btn-random" id="random-btn" type="button" title="随机打开一条收录,没有头绪时让库替你挑一个">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M3 7h4l10 10h4M17 3l4 4-4 4M3 17h4l2.5-2.5M21 17l-4 4"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M3 7h4l10 10h4M17 3l4 4-4 4M3 17h4l2.5-2.5M21 17l-4 4"/></svg>
@ -129,7 +130,7 @@
<a class="card" href="/entries/<%= e.slug %>"> <a class="card" href="/entries/<%= e.slug %>">
<span class="chip chip-type"><%= e.type %></span> <span class="chip chip-type"><%= e.type %></span>
<h3><%= e.title %></h3> <h3><%= e.title %></h3>
<p><%= e.description_md %></p> <p><%= e.summary || excerpt(e.description_md, 120) %></p>
<div class="card-tags"> <div class="card-tags">
<% for (const t of (e.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %> <% for (const t of (e.tags || []).slice(0, 3)) { %><span class="chip"><%= t %></span><% } %>
</div> </div>

View File

@ -78,7 +78,7 @@
<span class="chip chip-type"><%= e.type %></span> <span class="chip chip-type"><%= e.type %></span>
<div class="result-main"> <div class="result-main">
<h3><%= e.title %></h3> <h3><%= e.title %></h3>
<p><%= e.description_md %></p> <p><%= e.summary || excerpt(e.description_md, 160) %></p>
<div class="card-tags"> <div class="card-tags">
<% for (const t of e.tags.slice(0, 4)) { %><span class="chip"><%= t %></span><% } %> <% for (const t of e.tags.slice(0, 4)) { %><span class="chip"><%= t %></span><% } %>
</div> </div>

View File

@ -16,10 +16,16 @@
</div> </div>
<div class="field"> <div class="field">
<label for="login-pass">密码</label> <label for="login-pass">密码</label>
<div class="pwd-field">
<input class="input" id="login-pass" name="password" type="password" placeholder="••••••••" autocomplete="current-password" required /> <input class="input" id="login-pass" name="password" type="password" placeholder="••••••••" autocomplete="current-password" required />
<button type="button" class="eye-toggle" data-eye-toggle aria-label="显示密码">
<svg class="eye-open" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="eye-closed" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" hidden><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
</div>
</div> </div>
<div class="auth-row"> <div class="auth-row">
<label class="checkline" style="padding:0"><input type="checkbox" checked /> 记住这台设备</label> <label class="checkline" style="padding:0"><input type="checkbox" name="remember" checked /> 记住这台设备</label>
<a href="mailto:admin@honor3.com">忘记密码?</a> <a href="mailto:admin@honor3.com">忘记密码?</a>
</div> </div>
<button class="btn btn-primary btn-block" type="submit">登 录</button> <button class="btn btn-primary btn-block" type="submit">登 录</button>