mirror of
https://github.com/SMNETSTUDIO/WeChat-AI.git
synced 2026-08-12 22:23:42 +08:00
6297 lines
221 KiB
HTML
6297 lines
221 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5" />
|
||
<meta name="theme-color" content="#f5f5f7" id="themeColor" />
|
||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||
<meta name="mobile-web-app-capable" content="yes" />
|
||
<meta name="format-detection" content="telephone=no" />
|
||
<meta name="description" content="WeChat-AI:基于微信的多用户角色扮演机器人平台。扫码绑定机器人、人设广场、长期记忆与私聊分配,一站管理你的智能体。" />
|
||
<!-- Open Graph / Twitter:绝对 URL 由服务端按 PUBLIC_BASE_URL 注入;下方为可抓取的默认值 -->
|
||
<meta property="og:title" content="WeChat-AI — 微信角色扮演机器人" />
|
||
<meta property="og:description" content="WeChat-AI:基于微信的多用户角色扮演机器人平台。扫码绑定机器人、人设广场、长期记忆与私聊分配,一站管理你的智能体。支持 LINUX DO 登录与多机器人。" />
|
||
<meta property="og:image" content="/og.jpg" />
|
||
<meta property="og:image:type" content="image/jpeg" />
|
||
<meta property="og:image:alt" content="WeChat-AI" />
|
||
<meta property="og:url" content="/app" />
|
||
<meta property="og:type" content="website" />
|
||
<meta property="og:site_name" content="WeChat-AI" />
|
||
<meta property="og:locale" content="zh_CN" />
|
||
<meta name="twitter:card" content="summary_large_image" />
|
||
<meta name="twitter:title" content="WeChat-AI — 微信角色扮演机器人" />
|
||
<meta name="twitter:description" content="扫码绑定机器人、人设广场、长期记忆与私聊分配。多用户角色扮演,一站管理智能体。" />
|
||
<meta name="twitter:image" content="/og.jpg" />
|
||
<link rel="canonical" href="/app" />
|
||
<!-- Robot favicon (inline SVG) -->
|
||
<link
|
||
rel="icon"
|
||
type="image/svg+xml"
|
||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%235e5ce6'/%3E%3Cstop offset='100%25' stop-color='%23bf5af2'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Cg fill='none' stroke='%23fff' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='14' y='22' width='36' height='28' rx='8'/%3E%3Ccircle cx='26' cy='36' r='2.4' fill='%23fff' stroke='none'/%3E%3Ccircle cx='38' cy='36' r='2.4' fill='%23fff' stroke='none'/%3E%3Cpath d='M32 14v8M24 50v4M40 50v4M14 34h-4M54 34h-4'/%3E%3C/g%3E%3C/svg%3E"
|
||
/>
|
||
<script>
|
||
(function () {
|
||
try {
|
||
var k = "wa_theme";
|
||
var t = localStorage.getItem(k);
|
||
if (t === "system" || (t !== "light" && t !== "dark")) {
|
||
if (localStorage.getItem(k) === "system") {
|
||
document.documentElement.setAttribute("data-theme-pref", "system");
|
||
}
|
||
t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||
}
|
||
document.documentElement.setAttribute("data-theme", t);
|
||
} catch (e) {
|
||
document.documentElement.setAttribute("data-theme", "light");
|
||
}
|
||
})();
|
||
</script>
|
||
<!-- Icon font: render-blocking cross-origin CSS that then chain-loads a woff2.
|
||
preconnect collapses the DNS+TLS round trips; media=print + onload takes it
|
||
off the critical path so first paint never waits on jsdelivr. -->
|
||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css"
|
||
media="print" onload="this.media='all';this.onload=null" />
|
||
<noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css" /></noscript>
|
||
<title>WeChat-AI — 微信角色扮演机器人</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f5f5f7;
|
||
--bg-elevated: rgba(255, 255, 255, 0.72);
|
||
--surface: #ffffff;
|
||
--surface-2: #f5f5f7;
|
||
--text: #1d1d1f;
|
||
--text-secondary: #6e6e73;
|
||
--text-tertiary: #86868b;
|
||
--separator: rgba(0, 0, 0, 0.08);
|
||
--blue: #0071e3;
|
||
--blue-hover: #0077ed;
|
||
--blue-soft: rgba(0, 113, 227, 0.1);
|
||
--green: #34c759;
|
||
--green-soft: rgba(52, 199, 89, 0.12);
|
||
--green-text: #1a7f37;
|
||
--red: #ff3b30;
|
||
--red-soft: rgba(255, 59, 48, 0.12);
|
||
--orange: #ff9f0a;
|
||
--orange-soft: rgba(255, 159, 10, 0.14);
|
||
--orange-text: #b45309;
|
||
--chip: rgba(0, 0, 0, 0.05);
|
||
--input-bg: rgba(0, 0, 0, 0.03);
|
||
--code-bg: rgba(0, 0, 0, 0.04);
|
||
--glow-a: rgba(0, 113, 227, 0.08);
|
||
--glow-b: rgba(175, 82, 222, 0.06);
|
||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||
--shadow-md: 0 4px 24px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||
--toast-bg: rgba(29, 29, 31, 0.92);
|
||
--toast-err: rgba(255, 59, 48, 0.95);
|
||
--focus-border: rgba(0, 113, 227, 0.55);
|
||
--ring: var(--blue-soft);
|
||
--scrim: rgba(0, 0, 0, 0.42);
|
||
--radius: 18px;
|
||
--radius-sm: 12px;
|
||
--radius-xs: 8px;
|
||
--radius-lg: 22px;
|
||
--radius-pill: 980px;
|
||
--control-h: 42px;
|
||
--control-h-sm: 36px;
|
||
--font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
|
||
"Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
||
sans-serif;
|
||
--ease: cubic-bezier(0.25, 0.1, 0.25, 1);
|
||
--theme-icon-sun: none;
|
||
--theme-icon-moon: inline;
|
||
--safe-t: env(safe-area-inset-top, 0px);
|
||
--safe-b: env(safe-area-inset-bottom, 0px);
|
||
--safe-l: env(safe-area-inset-left, 0px);
|
||
--safe-r: env(safe-area-inset-right, 0px);
|
||
--nav-h: 56px;
|
||
/* Fluid page gutter + content width: continuous phone → 4K, no jumps */
|
||
--gutter: clamp(14px, 3.4vw, 26px);
|
||
--content-max: 1080px;
|
||
/* ---- Liquid glass ----
|
||
Layered translucency: tinted base + blurred backdrop + specular rim
|
||
(bright top edge, dim bottom edge) that reads as glass thickness
|
||
rather than a flat frosted panel. */
|
||
--glass-bg: rgba(255, 255, 255, 0.6);
|
||
--glass-bg-strong: rgba(255, 255, 255, 0.76);
|
||
--glass-blur: 24px;
|
||
--glass-hi: rgba(255, 255, 255, 0.95);
|
||
--glass-lo: rgba(255, 255, 255, 0.3);
|
||
--glass-ring: rgba(0, 0, 0, 0.07);
|
||
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 2px 8px rgba(0, 0, 0, 0.05);
|
||
--sk-sheen: rgba(255, 255, 255, 0.6);
|
||
/* ---- 动态玻璃:滚动深度驱动 ---- */
|
||
--glass-rgb: 255, 255, 255;
|
||
--glass-a-min: 0.3;
|
||
--glass-a-max: 0.76;
|
||
--glass-blur-min: 4px;
|
||
--glass-blur-max: 24px;
|
||
--glass-progress: 0;
|
||
--glass-a: calc(var(--glass-a-min) + (var(--glass-a-max) - var(--glass-a-min)) * var(--glass-progress));
|
||
--glass-blur-live: calc(var(--glass-blur-min) + (var(--glass-blur-max) - var(--glass-blur-min)) * var(--glass-progress));
|
||
}
|
||
@media (min-width: 1600px) {
|
||
:root { --content-max: 1240px; }
|
||
}
|
||
|
||
html[data-theme="dark"] {
|
||
--bg: #000000;
|
||
--bg-elevated: rgba(28, 28, 30, 0.72);
|
||
--surface: #1c1c1e;
|
||
--surface-2: #2c2c2e;
|
||
--text: #f5f5f7;
|
||
--text-secondary: #a1a1a6;
|
||
--text-tertiary: #6e6e73;
|
||
--separator: rgba(255, 255, 255, 0.1);
|
||
--blue: #0a84ff;
|
||
--blue-hover: #409cff;
|
||
--blue-soft: rgba(10, 132, 255, 0.18);
|
||
--green: #30d158;
|
||
--green-soft: rgba(48, 209, 88, 0.16);
|
||
--green-text: #30d158;
|
||
--red: #ff453a;
|
||
--red-soft: rgba(255, 69, 58, 0.16);
|
||
--orange: #ff9f0a;
|
||
--orange-soft: rgba(255, 159, 10, 0.16);
|
||
--orange-text: #ffb340;
|
||
--chip: rgba(255, 255, 255, 0.08);
|
||
--input-bg: rgba(255, 255, 255, 0.06);
|
||
--code-bg: rgba(255, 255, 255, 0.08);
|
||
--glow-a: rgba(10, 132, 255, 0.12);
|
||
--glow-b: rgba(191, 90, 242, 0.1);
|
||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||
--shadow-md: 0 4px 24px rgba(0, 0, 0, 0.45);
|
||
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.55);
|
||
--toast-bg: rgba(44, 44, 46, 0.95);
|
||
--toast-err: rgba(255, 69, 58, 0.95);
|
||
--focus-border: rgba(10, 132, 255, 0.65);
|
||
--ring: var(--blue-soft);
|
||
--scrim: rgba(0, 0, 0, 0.55);
|
||
--theme-icon-sun: inline;
|
||
--theme-icon-moon: none;
|
||
--glass-bg: rgba(30, 30, 32, 0.58);
|
||
--glass-bg-strong: rgba(32, 32, 34, 0.78);
|
||
--glass-hi: rgba(255, 255, 255, 0.16);
|
||
--glass-lo: rgba(255, 255, 255, 0.04);
|
||
--glass-ring: rgba(255, 255, 255, 0.1);
|
||
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.35);
|
||
--sk-sheen: rgba(255, 255, 255, 0.08);
|
||
--glass-rgb: 30, 30, 32;
|
||
--glass-a-min: 0.32;
|
||
--glass-a-max: 0.78;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html {
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
color-scheme: light;
|
||
-webkit-text-size-adjust: 100%;
|
||
text-size-adjust: 100%;
|
||
scrollbar-gutter: stable;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--chip) transparent;
|
||
}
|
||
/* Thin Apple-like scrollbars (Windows/Linux) */
|
||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
*::-webkit-scrollbar-track { background: transparent; }
|
||
*::-webkit-scrollbar-thumb {
|
||
background: var(--chip);
|
||
border-radius: 99px;
|
||
border: 2px solid transparent;
|
||
background-clip: padding-box;
|
||
}
|
||
*::-webkit-scrollbar-thumb:hover { background: var(--separator); background-clip: padding-box; }
|
||
html[data-theme="dark"] { color-scheme: dark; }
|
||
body {
|
||
margin: 0;
|
||
min-height: 100vh;
|
||
min-height: 100dvh;
|
||
font-family: var(--font);
|
||
color: var(--text);
|
||
background:
|
||
radial-gradient(1200px 600px at 10% -10%, var(--glow-a), transparent 50%),
|
||
radial-gradient(900px 500px at 100% 0%, var(--glow-b), transparent 45%),
|
||
var(--bg);
|
||
line-height: 1.47;
|
||
transition: background 0.25s var(--ease), color 0.2s var(--ease);
|
||
overflow-x: hidden;
|
||
}
|
||
button, .btn, .theme-toggle, .tabs button, .var-chip, .vis-seg label {
|
||
-webkit-tap-highlight-color: transparent;
|
||
touch-action: manipulation;
|
||
}
|
||
.btn, .tabs button, .theme-toggle, .var-chip, .fab-refresh, .badge, .tag, .vis-seg label {
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
}
|
||
|
||
a { color: var(--blue); text-decoration: none; }
|
||
a:hover { text-decoration: underline; }
|
||
|
||
/* ---------- Liquid glass material ---------- */
|
||
.glass {
|
||
position: relative;
|
||
background: var(--glass-bg);
|
||
backdrop-filter: blur(var(--glass-blur)) saturate(180%);
|
||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(180%);
|
||
box-shadow:
|
||
inset 0 1px 0 0 var(--glass-hi),
|
||
inset 0 -1px 0 0 var(--glass-lo),
|
||
var(--glass-shadow);
|
||
}
|
||
.glass-strong { background: var(--glass-bg-strong); }
|
||
/* 吸顶层专用:底下有内容滚过时才逐渐加深,滚动到顶部时近乎透明。
|
||
与 iOS 导航栏一致;.glass 保持静态,供不随滚动变化的浮层使用。 */
|
||
.glass-dynamic {
|
||
background: rgba(var(--glass-rgb), var(--glass-a));
|
||
backdrop-filter: blur(var(--glass-blur-live)) saturate(180%);
|
||
-webkit-backdrop-filter: blur(var(--glass-blur-live)) saturate(180%);
|
||
box-shadow:
|
||
inset 0 1px 0 0 rgba(255, 255, 255, calc(0.95 * var(--glass-progress))),
|
||
0 1px 0 0 rgba(0, 0, 0, calc(0.08 * var(--glass-progress)));
|
||
}
|
||
html[data-theme="dark"] .glass-dynamic {
|
||
box-shadow:
|
||
inset 0 1px 0 0 rgba(255, 255, 255, calc(0.16 * var(--glass-progress))),
|
||
0 1px 0 0 rgba(255, 255, 255, calc(0.1 * var(--glass-progress)));
|
||
}
|
||
.wa-lens-defs { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||
/* 折射只在 Chromium 桌面开启,见下方网关脚本。降低模糊让折射看得见——
|
||
已经糊成一团的内容没法再折射,两者此消彼长。 */
|
||
html.wa-lens .glass,
|
||
html.wa-lens .glass-strong {
|
||
backdrop-filter: blur(14px) saturate(180%) url(#waGlassLens);
|
||
-webkit-backdrop-filter: blur(14px) saturate(180%);
|
||
}
|
||
/* Specular rim traced around the shape, masked to a 1px ring so the fill
|
||
stays clear. Gives the edge-lit look that separates liquid glass from
|
||
plain frosted blur. */
|
||
.glass-rim::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: inherit;
|
||
padding: 1px;
|
||
background: linear-gradient(145deg, var(--glass-hi) 0%, transparent 38%, transparent 62%, var(--glass-lo) 100%);
|
||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||
-webkit-mask-composite: xor;
|
||
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||
mask-composite: exclude;
|
||
pointer-events: none;
|
||
z-index: 3;
|
||
}
|
||
.glass-rim > * { position: relative; z-index: 2; }
|
||
|
||
/* ---------- Staggered entrance for injected cards ---------- */
|
||
@keyframes riseIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
/* "backwards", not "both": a forwards-filling animation keeps holding its
|
||
final keyframe values (transform: none) at the animation cascade origin,
|
||
which outranks normal declarations and would permanently kill
|
||
.persona-card:hover { transform: translateY(-1px) }. "backwards" still
|
||
applies the from-state during the stagger delay (no flash-in) but
|
||
releases the properties once the animation ends. */
|
||
.stagger > * { animation: riseIn 0.42s var(--ease) backwards; }
|
||
.stagger > *:nth-child(1) { animation-delay: 0.02s; }
|
||
.stagger > *:nth-child(2) { animation-delay: 0.06s; }
|
||
.stagger > *:nth-child(3) { animation-delay: 0.1s; }
|
||
.stagger > *:nth-child(4) { animation-delay: 0.14s; }
|
||
.stagger > *:nth-child(5) { animation-delay: 0.18s; }
|
||
.stagger > *:nth-child(6) { animation-delay: 0.22s; }
|
||
.stagger > *:nth-child(7) { animation-delay: 0.26s; }
|
||
.stagger > *:nth-child(8) { animation-delay: 0.3s; }
|
||
.stagger > *:nth-child(n + 9) { animation-delay: 0.34s; }
|
||
|
||
/* ---------- Generic busy button spinner ---------- */
|
||
.btn.is-busy {
|
||
pointer-events: none;
|
||
position: relative;
|
||
color: transparent !important;
|
||
}
|
||
.btn.is-busy > * { visibility: hidden; }
|
||
.btn.is-busy::after {
|
||
content: "";
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
width: 15px;
|
||
height: 15px;
|
||
margin: -8px 0 0 -8px;
|
||
border-radius: 50%;
|
||
border: 2px solid var(--spin-track, rgba(0, 0, 0, 0.2));
|
||
border-top-color: var(--spin-head, var(--text));
|
||
animation: spin 0.7s linear infinite;
|
||
}
|
||
.btn-primary.is-busy, .btn-danger.is-busy { --spin-track: rgba(255, 255, 255, 0.35); --spin-head: #fff; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* ---------- Top loading bar (in-flight requests) ---------- */
|
||
.load-bar {
|
||
position: fixed; top: 0; left: 0; right: 0; height: 2px; z-index: 90;
|
||
pointer-events: none; opacity: 0; background: transparent;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.load-bar.on {
|
||
opacity: 1;
|
||
background: linear-gradient(90deg, transparent, var(--blue), transparent);
|
||
background-size: 40% 100%;
|
||
animation: loadSlide 0.9s linear infinite;
|
||
}
|
||
@keyframes loadSlide { from { background-position: -40% 0; } to { background-position: 140% 0; } }
|
||
|
||
/* Nav */
|
||
.nav {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 50;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
min-height: var(--nav-h);
|
||
padding: calc(10px + var(--safe-t)) max(16px, var(--safe-r)) 10px max(16px, var(--safe-l));
|
||
/* 底色、模糊、上边高光与下边分隔线全部来自 .glass-dynamic:滚动到顶部时
|
||
导航栏近乎透明,内容滚过时才逐渐凝实。原来的静态阴影栈(以及配套的
|
||
.nav.scrolled 二段式阴影和 box-shadow 过渡)会盖住动态值,已移除;
|
||
.scrolled 类仍由脚本照常切换,只是不再有对应样式。 */
|
||
}
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-weight: 600;
|
||
font-size: 15px;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.brand-mark {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 8px;
|
||
flex-shrink: 0;
|
||
display: inline-grid;
|
||
place-items: center;
|
||
background: linear-gradient(145deg, #5e5ce6 0%, #bf5af2 100%);
|
||
box-shadow:
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.28),
|
||
0 2px 8px rgba(94, 92, 230, 0.28);
|
||
color: #fff;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
}
|
||
.brand-mark .bi {
|
||
font-size: 0.95em;
|
||
line-height: 1;
|
||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
|
||
}
|
||
.nav-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.theme-toggle {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
border: none;
|
||
background: var(--chip);
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 16px;
|
||
transition: background 0.15s, transform 0.15s var(--ease);
|
||
}
|
||
.theme-toggle:hover { background: var(--blue-soft); }
|
||
.theme-toggle:active { transform: scale(0.95); }
|
||
.theme-toggle .sun { display: var(--theme-icon-sun); }
|
||
.theme-toggle .moon { display: var(--theme-icon-moon); }
|
||
html[data-theme-pref="system"] .theme-toggle .sun,
|
||
html[data-theme-pref="system"] .theme-toggle .moon { display: none; }
|
||
html[data-theme-pref="system"] .theme-toggle .auto { display: grid !important; place-items: center; }
|
||
|
||
.avatar-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 4px 12px 4px 4px;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--chip);
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.avatar-dot {
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(145deg, #64d2ff, #0071e3);
|
||
color: #fff;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
display: grid;
|
||
place-items: center;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
.avatar-dot img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.avatar-dot.has-img {
|
||
background: var(--chip);
|
||
color: transparent;
|
||
}
|
||
|
||
/* Buttons */
|
||
.btn {
|
||
appearance: none;
|
||
border: none;
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
letter-spacing: -0.01em;
|
||
border-radius: var(--radius-pill);
|
||
padding: 9px 18px;
|
||
min-height: var(--control-h-sm);
|
||
white-space: nowrap;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
transition: transform 0.15s var(--ease), background 0.15s var(--ease), opacity 0.15s;
|
||
}
|
||
a.btn:hover { text-decoration: none; }
|
||
.btn:active { transform: scale(0.98); }
|
||
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||
.btn:focus-visible,
|
||
.tabs button:focus-visible,
|
||
.theme-toggle:focus-visible,
|
||
.var-chip:focus-visible,
|
||
.fab-refresh:focus-visible,
|
||
a:focus-visible {
|
||
outline: 2px solid var(--blue);
|
||
outline-offset: 2px;
|
||
box-shadow: 0 0 0 4px var(--ring);
|
||
}
|
||
.btn-primary {
|
||
background: var(--blue);
|
||
color: #fff;
|
||
box-shadow: 0 1px 2px color-mix(in srgb, var(--blue) 28%, transparent);
|
||
}
|
||
.btn-primary:hover:not(:disabled) { background: var(--blue-hover); }
|
||
.btn-secondary {
|
||
background: var(--chip);
|
||
color: var(--text);
|
||
}
|
||
.btn-secondary:hover:not(:disabled) { filter: brightness(0.96); }
|
||
html[data-theme="dark"] .btn-secondary:hover:not(:disabled) { filter: brightness(1.15); }
|
||
.btn-danger {
|
||
background: var(--red-soft);
|
||
color: var(--red);
|
||
}
|
||
.btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--red) 18%, transparent); }
|
||
.btn-ghost {
|
||
background: transparent;
|
||
color: var(--blue);
|
||
padding: 9px 12px;
|
||
}
|
||
.btn-ghost:hover:not(:disabled) {
|
||
background: var(--blue-soft);
|
||
color: var(--blue);
|
||
}
|
||
.btn-fork {
|
||
background: linear-gradient(
|
||
135deg,
|
||
rgba(175, 82, 222, 0.14),
|
||
rgba(0, 113, 227, 0.12)
|
||
);
|
||
color: #7b46c1;
|
||
border: 1px solid rgba(175, 82, 222, 0.22);
|
||
gap: 6px;
|
||
}
|
||
.btn-fork:hover:not(:disabled) {
|
||
filter: brightness(0.97);
|
||
border-color: rgba(175, 82, 222, 0.4);
|
||
}
|
||
html[data-theme="dark"] .btn-fork {
|
||
color: #d4a8ff;
|
||
background: linear-gradient(
|
||
135deg,
|
||
rgba(191, 90, 242, 0.18),
|
||
rgba(10, 132, 255, 0.12)
|
||
);
|
||
border-color: rgba(191, 90, 242, 0.28);
|
||
}
|
||
html[data-theme="dark"] .btn-fork:hover:not(:disabled) {
|
||
filter: brightness(1.12);
|
||
}
|
||
.btn-fork i { font-size: 14px; line-height: 1; }
|
||
.badge-fork {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
padding: 3px 9px;
|
||
border-radius: var(--radius-pill);
|
||
background: rgba(175, 82, 222, 0.12);
|
||
color: #7b46c1;
|
||
border: 1px solid rgba(175, 82, 222, 0.18);
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
html[data-theme="dark"] .badge-fork {
|
||
background: rgba(191, 90, 242, 0.16);
|
||
color: #d4a8ff;
|
||
border-color: rgba(191, 90, 242, 0.28);
|
||
}
|
||
.badge-fork i { font-size: 11px; opacity: 0.9; }
|
||
.btn-lg { padding: 14px 28px; font-size: 16px; }
|
||
|
||
/* Layout */
|
||
.wrap {
|
||
width: min(var(--content-max), 100%);
|
||
margin: 0 auto;
|
||
padding: clamp(20px, 3vw, 36px)
|
||
max(var(--gutter), var(--safe-r))
|
||
calc(clamp(56px, 7vw, 80px) + var(--safe-b))
|
||
max(var(--gutter), var(--safe-l));
|
||
}
|
||
.page-title {
|
||
font-size: clamp(25px, 4.6vw, 40px);
|
||
font-weight: 700;
|
||
letter-spacing: -0.03em;
|
||
margin: 0 0 8px;
|
||
line-height: 1.1;
|
||
}
|
||
.page-sub {
|
||
margin: 0 0 clamp(20px, 3vw, 32px);
|
||
color: var(--text-secondary);
|
||
font-size: clamp(14.5px, 1.9vw, 17px);
|
||
font-weight: 400;
|
||
}
|
||
|
||
.card {
|
||
background: var(--surface);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow-sm);
|
||
border: 1px solid var(--separator);
|
||
padding: 24px 28px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.card-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
margin-bottom: 18px;
|
||
}
|
||
.card h2 {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
font-weight: 600;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.card-desc {
|
||
margin: 4px 0 0;
|
||
font-size: 14px;
|
||
color: var(--text-tertiary);
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
input, select {
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
color: var(--text);
|
||
background: var(--input-bg);
|
||
border: 1px solid var(--separator);
|
||
border-radius: var(--radius-sm);
|
||
padding: 10px 14px;
|
||
outline: none;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
min-width: 0;
|
||
}
|
||
.toolbar input,
|
||
.toolbar select,
|
||
.form-field input,
|
||
.form-fields input,
|
||
.name-edit input {
|
||
min-width: 160px;
|
||
}
|
||
input:focus, select:focus {
|
||
border-color: var(--focus-border);
|
||
box-shadow: 0 0 0 4px var(--ring);
|
||
}
|
||
input::placeholder { color: var(--text-tertiary); }
|
||
|
||
.table-wrap {
|
||
overflow-x: auto;
|
||
margin: 0;
|
||
-webkit-overflow-scrolling: touch;
|
||
border-radius: var(--radius-xs);
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: separate;
|
||
border-spacing: 0;
|
||
font-size: 14px;
|
||
table-layout: auto;
|
||
}
|
||
th {
|
||
text-align: left;
|
||
font-weight: 500;
|
||
font-size: 12px;
|
||
letter-spacing: 0.02em;
|
||
text-transform: uppercase;
|
||
color: var(--text-tertiary);
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid var(--separator);
|
||
white-space: nowrap;
|
||
}
|
||
td {
|
||
padding: 14px 12px;
|
||
border-bottom: 1px solid var(--separator);
|
||
vertical-align: middle;
|
||
transition: background 0.12s;
|
||
}
|
||
tr:last-child td { border-bottom: none; }
|
||
tbody tr:hover td { background: var(--surface-2); }
|
||
code {
|
||
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
background: var(--code-bg);
|
||
padding: 3px 7px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
/* Peers table: prevent column crush / vertical CJK text */
|
||
table.peers-table {
|
||
table-layout: fixed;
|
||
min-width: 720px;
|
||
}
|
||
table.peers-table th:nth-child(1),
|
||
table.peers-table td:nth-child(1) { width: 22%; }
|
||
table.peers-table th:nth-child(2),
|
||
table.peers-table td:nth-child(2) { width: 14%; }
|
||
table.peers-table th:nth-child(3),
|
||
table.peers-table td:nth-child(3) { width: 10%; }
|
||
table.peers-table th:nth-child(4),
|
||
table.peers-table td:nth-child(4) { width: 22%; }
|
||
table.peers-table th:nth-child(5),
|
||
table.peers-table td:nth-child(5) { width: 12%; }
|
||
table.peers-table th:nth-child(6),
|
||
table.peers-table td:nth-child(6) { width: 20%; }
|
||
table.peers-table td {
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
table.peers-table td.col-persona,
|
||
table.peers-table td.col-actions {
|
||
overflow: visible;
|
||
white-space: normal;
|
||
}
|
||
table.peers-table .id-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
table.peers-table .id-cell code {
|
||
display: block;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: 11px;
|
||
}
|
||
table.peers-table .id-cell .sub {
|
||
font-size: 11px;
|
||
color: var(--text-tertiary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
table.peers-table .persona-select {
|
||
width: 100%;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
padding: 8px 10px;
|
||
font-size: 13px;
|
||
}
|
||
table.peers-table .col-actions .row-actions {
|
||
flex-wrap: nowrap;
|
||
gap: 6px;
|
||
}
|
||
table.peers-table .col-actions .btn {
|
||
flex-shrink: 0;
|
||
}
|
||
table.peers-table .badge {
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 3px 10px;
|
||
border-radius: var(--radius-pill);
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
background: var(--chip);
|
||
color: var(--text-secondary);
|
||
white-space: nowrap;
|
||
}
|
||
.badge::before {
|
||
content: "";
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
opacity: 0.7;
|
||
flex-shrink: 0;
|
||
}
|
||
.badge.ok {
|
||
background: var(--green-soft);
|
||
color: var(--green-text);
|
||
}
|
||
.badge.warn {
|
||
background: var(--orange-soft);
|
||
color: var(--orange-text);
|
||
}
|
||
.badge.err {
|
||
background: var(--red-soft);
|
||
color: var(--red);
|
||
}
|
||
.badge.accent {
|
||
background: var(--blue-soft);
|
||
color: var(--blue);
|
||
}
|
||
|
||
table td .row-actions {
|
||
flex-wrap: wrap;
|
||
justify-content: flex-start;
|
||
gap: 8px;
|
||
}
|
||
table td .row-actions .btn {
|
||
white-space: nowrap;
|
||
}
|
||
.row-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
|
||
|
||
/* Specular sweep skeleton */
|
||
.skeleton {
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: var(--chip);
|
||
border-radius: 10px;
|
||
height: 14px;
|
||
}
|
||
.skeleton::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0;
|
||
transform: translateX(-100%);
|
||
background: linear-gradient(90deg, transparent, var(--sk-sheen), transparent);
|
||
animation: skSweep 1.5s var(--ease) infinite;
|
||
}
|
||
@keyframes skSweep { to { transform: translateX(100%); } }
|
||
.sk-rows { display: grid; gap: 10px; padding: 6px 0; }
|
||
.sk-rows .skeleton { height: 48px; border-radius: 12px; }
|
||
.sk-tile { height: 190px; border-radius: var(--radius); }
|
||
/* boot splash while /auth/me resolves — avoids flashing the login hero */
|
||
#bootSplash {
|
||
min-height: calc(100dvh - var(--nav-h) - var(--safe-t));
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
#bootSplash .brand-mark {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 12px;
|
||
font-size: 22px;
|
||
animation: bootPulse 1.2s ease-in-out infinite;
|
||
}
|
||
@keyframes bootPulse {
|
||
0%, 100% { opacity: 0.45; transform: scale(0.96); }
|
||
50% { opacity: 1; transform: scale(1); }
|
||
}
|
||
.tabs {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: nowrap;
|
||
margin-bottom: 24px;
|
||
padding: 4px;
|
||
/* 底色与模糊来自 .glass-dynamic,边缘高光来自 .glass-rim。
|
||
position: relative 原先由 .glass 提供,换成动态玻璃后要在这里补回,
|
||
否则桌面端(非 sticky)的 .glass-rim::before 会脱离胶囊定位。 */
|
||
position: relative;
|
||
border-radius: var(--radius-pill);
|
||
width: fit-content;
|
||
max-width: 100%;
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none;
|
||
overscroll-behavior-x: contain;
|
||
}
|
||
/* 胶囊形状不适合 .glass-dynamic 默认的「上高光 + 下分隔线」,
|
||
改成一圈柔和落影,同样按滚动深度淡入。 */
|
||
.tabs.glass-dynamic { box-shadow: inset 0 1px 0 0 rgba(255,255,255,calc(0.9*var(--glass-progress))), 0 2px 12px rgba(0,0,0,calc(0.06*var(--glass-progress))); }
|
||
html[data-theme="dark"] .tabs.glass-dynamic { box-shadow: inset 0 1px 0 0 rgba(255,255,255,calc(0.14*var(--glass-progress))), 0 2px 12px rgba(0,0,0,calc(0.4*var(--glass-progress))); }
|
||
.tabs::-webkit-scrollbar { display: none; }
|
||
.tabs button {
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
font: inherit;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
padding: 10px 16px;
|
||
min-height: 40px;
|
||
border-radius: var(--radius-pill);
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
transition: color 0.15s, transform 0.12s var(--ease);
|
||
}
|
||
.tabs button:active { transform: scale(0.96); }
|
||
.tabs button.active {
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.pane { display: none; }
|
||
.pane.active { display: block; animation: paneIn 0.22s var(--ease); }
|
||
/* opacity-only: transform would create a containing block and break sticky tabs */
|
||
@keyframes paneIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
|
||
.persona-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
@media (min-width: 861px) and (max-width: 1100px) {
|
||
.persona-grid {
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
}
|
||
}
|
||
.persona-grid > .empty {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.pager {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
margin-top: 12px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--separator);
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.pager .row-actions { gap: 6px; }
|
||
.pager .btn:disabled { opacity: 0.45; }
|
||
.persona-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--separator);
|
||
border-radius: var(--radius);
|
||
padding: 18px;
|
||
box-shadow: var(--shadow-sm);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
transition: transform 0.15s var(--ease), box-shadow 0.15s;
|
||
}
|
||
.persona-card:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--shadow-md);
|
||
}
|
||
.persona-card h4 {
|
||
margin: 0;
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.persona-card .desc {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
flex: 1;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
.persona-card .meta-line {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.persona-card .card-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: 2px;
|
||
}
|
||
.persona-card .card-actions .btn {
|
||
min-height: 36px;
|
||
}
|
||
.persona-card .card-links {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px 10px;
|
||
align-items: center;
|
||
margin-top: auto;
|
||
padding-top: 2px;
|
||
}
|
||
.persona-card .card-links .btn-ghost {
|
||
padding: 4px 2px;
|
||
font-size: 13px;
|
||
min-height: auto;
|
||
}
|
||
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||
.tag {
|
||
font-size: 11px;
|
||
padding: 2px 8px;
|
||
border-radius: var(--radius-pill);
|
||
background: var(--blue-soft);
|
||
color: var(--blue);
|
||
}
|
||
.form-grid {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
.form-grid > label,
|
||
.form-field {
|
||
display: grid;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
min-width: 0;
|
||
}
|
||
.form-grid input,
|
||
.form-grid textarea,
|
||
.form-field input,
|
||
.form-field textarea {
|
||
width: 100%;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
}
|
||
/* name | desc | tags — 桌面三列,窄屏自动折行 */
|
||
.form-fields {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.4fr) minmax(0, 1fr);
|
||
gap: 12px;
|
||
}
|
||
.form-grid select,
|
||
.form-field select {
|
||
width: 100%;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
font: inherit;
|
||
color: var(--text);
|
||
background: var(--input-bg);
|
||
border: 1px solid var(--separator);
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px 14px;
|
||
outline: none;
|
||
}
|
||
.form-grid select:focus,
|
||
.form-field select:focus {
|
||
border-color: var(--focus-border);
|
||
box-shadow: 0 0 0 4px var(--ring);
|
||
}
|
||
.check-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
margin-top: 4px;
|
||
}
|
||
.check-row input[type="checkbox"] {
|
||
width: 18px;
|
||
height: 18px;
|
||
accent-color: var(--blue);
|
||
flex-shrink: 0;
|
||
}
|
||
.llm-prov-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin-top: 4px;
|
||
}
|
||
.llm-prov-card {
|
||
display: grid;
|
||
gap: 8px;
|
||
padding: 12px 14px;
|
||
border: 1px solid var(--separator);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-2);
|
||
}
|
||
.llm-prov-card .row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.llm-prov-card .meta {
|
||
font-size: 12px;
|
||
color: var(--text-tertiary);
|
||
word-break: break-all;
|
||
line-height: 1.45;
|
||
}
|
||
.llm-prov-card h4 {
|
||
margin: 0;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
}
|
||
.badge.soft {
|
||
background: var(--chip);
|
||
color: var(--text-secondary);
|
||
}
|
||
.badge.mode-cf {
|
||
background: rgba(175, 82, 222, 0.14);
|
||
color: #af52de;
|
||
}
|
||
textarea {
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
color: var(--text);
|
||
background: var(--input-bg);
|
||
border: 1px solid var(--separator);
|
||
border-radius: var(--radius-sm);
|
||
padding: 12px 14px;
|
||
min-height: 140px;
|
||
resize: vertical;
|
||
outline: none;
|
||
}
|
||
textarea:focus {
|
||
border-color: var(--focus-border);
|
||
box-shadow: 0 0 0 4px var(--ring);
|
||
}
|
||
|
||
/* Persona editor: variable chips + panel */
|
||
.editor-block {
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 14px 16px 16px;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--separator);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.editor-block > .field-label {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
}
|
||
.var-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
.var-toolbar .hint {
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--text-tertiary);
|
||
margin-right: 2px;
|
||
}
|
||
.var-chip {
|
||
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
padding: 6px 12px;
|
||
border-radius: var(--radius-pill);
|
||
border: 1px solid var(--separator);
|
||
background: var(--surface);
|
||
color: var(--blue);
|
||
cursor: pointer;
|
||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||
}
|
||
.var-chip:hover {
|
||
background: var(--blue-soft);
|
||
border-color: rgba(0, 113, 227, 0.35);
|
||
}
|
||
.var-chip:active { transform: scale(0.97); }
|
||
.editor-help {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--text-tertiary);
|
||
line-height: 1.55;
|
||
}
|
||
.editor-help code {
|
||
font-size: 11px;
|
||
}
|
||
.name-edit {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-items: center;
|
||
min-width: 0;
|
||
}
|
||
.name-edit input {
|
||
min-width: 100px;
|
||
max-width: 160px;
|
||
padding: 8px 12px;
|
||
font-weight: 600;
|
||
}
|
||
/* Proactive contact: compact button + modal (no inline form) */
|
||
.pro-open-btn {
|
||
gap: 6px;
|
||
}
|
||
.pro-open-btn .pro-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
padding: 2px 8px;
|
||
border-radius: 999px;
|
||
line-height: 1.3;
|
||
}
|
||
.pro-open-btn .pro-pill.on {
|
||
background: var(--green-soft, rgba(52, 199, 89, 0.16));
|
||
color: var(--green, #30d158);
|
||
}
|
||
.pro-open-btn .pro-pill.off {
|
||
background: var(--chip);
|
||
color: var(--text-tertiary);
|
||
}
|
||
.pro-open-btn.is-disabled {
|
||
opacity: 0.55;
|
||
}
|
||
.pro-modal-form {
|
||
display: grid;
|
||
gap: 14px;
|
||
margin: 14px 0 4px;
|
||
}
|
||
.pro-modal-form .pro-switch {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.pro-modal-form .pro-switch input {
|
||
width: 18px;
|
||
height: 18px;
|
||
accent-color: var(--blue);
|
||
}
|
||
.pro-modal-form .pro-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
.pro-modal-form .pro-grid label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
min-width: 0;
|
||
}
|
||
.pro-modal-form .pro-grid label .hint {
|
||
font-weight: 400;
|
||
opacity: 0.75;
|
||
margin-left: 4px;
|
||
}
|
||
.pro-modal-form .pro-grid input {
|
||
font-size: 14px;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--separator);
|
||
background: var(--bg, var(--surface-2));
|
||
color: var(--text);
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
min-height: 42px;
|
||
}
|
||
.pro-modal-form .pro-grid input:focus {
|
||
outline: none;
|
||
border-color: var(--focus-border);
|
||
box-shadow: 0 0 0 3px var(--ring);
|
||
}
|
||
.pro-modal-form .pro-note {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
color: var(--text-tertiary);
|
||
line-height: 1.5;
|
||
}
|
||
.pro-modal-form .pro-note.warn { color: var(--orange-text); }
|
||
@media (max-width: 520px) {
|
||
.pro-modal-form .pro-grid { grid-template-columns: 1fr; }
|
||
}
|
||
label.peer-pro {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
color: var(--text-secondary);
|
||
user-select: none;
|
||
}
|
||
label.peer-pro input {
|
||
width: 16px;
|
||
height: 16px;
|
||
margin: 0;
|
||
flex-shrink: 0;
|
||
accent-color: var(--blue, #0a84ff);
|
||
}
|
||
label.peer-pro.is-disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
label.peer-pro .peer-pro-txt { color: var(--text); font-weight: 500; }
|
||
.btn-sm {
|
||
padding: 5px 12px;
|
||
font-size: 13px;
|
||
min-height: 32px;
|
||
}
|
||
@media (pointer: coarse) {
|
||
.btn { min-height: 42px; }
|
||
.btn-sm { min-height: 38px; }
|
||
}
|
||
#pubPrompt {
|
||
min-height: 180px;
|
||
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||
font-size: 13px;
|
||
line-height: 1.55;
|
||
background: var(--surface);
|
||
}
|
||
|
||
/* 可见性分段控件 + 操作栏 */
|
||
.form-footer {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px 16px;
|
||
margin-top: 2px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--separator);
|
||
}
|
||
.vis-seg {
|
||
display: inline-flex;
|
||
padding: 3px;
|
||
background: var(--chip);
|
||
border-radius: var(--radius-pill);
|
||
gap: 2px;
|
||
}
|
||
.vis-seg label {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
padding: 8px 14px;
|
||
min-height: 36px;
|
||
border-radius: var(--radius-pill);
|
||
transition: color 0.15s, background 0.15s, box-shadow 0.15s;
|
||
-webkit-user-select: none;
|
||
user-select: none;
|
||
}
|
||
.vis-seg label:hover { color: var(--text); }
|
||
.vis-seg input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
width: 1px;
|
||
height: 1px;
|
||
min-width: 0;
|
||
margin: 0;
|
||
padding: 0;
|
||
border: none;
|
||
pointer-events: none;
|
||
}
|
||
.vis-seg label:has(input:checked) {
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.vis-seg label:has(input:focus-visible) {
|
||
outline: 2px solid var(--blue);
|
||
outline-offset: 2px;
|
||
}
|
||
.form-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 紧凑空状态 */
|
||
.empty {
|
||
text-align: center;
|
||
padding: 28px 16px;
|
||
color: var(--text-tertiary);
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
}
|
||
.empty.compact {
|
||
padding: 20px 12px;
|
||
background: var(--surface-2);
|
||
border: 1px dashed var(--separator);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.empty .empty-title {
|
||
display: block;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 4px;
|
||
}
|
||
.empty .btn { margin-top: 12px; }
|
||
/* empty rows aren't interactive — no row-hover flash */
|
||
tbody tr:has(.empty):hover td,
|
||
.empty:hover { background: transparent !important; }
|
||
|
||
/* Login gate */
|
||
#loginGate {
|
||
min-height: calc(100dvh - var(--nav-h) - var(--safe-t));
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 40px 20px calc(40px + var(--safe-b));
|
||
}
|
||
.hero {
|
||
width: min(440px, 100%);
|
||
text-align: center;
|
||
padding: 40px 36px 36px;
|
||
background: var(--surface);
|
||
border-radius: calc(var(--radius-lg) + 6px);
|
||
box-shadow: var(--shadow-lg);
|
||
border: 1px solid var(--separator);
|
||
animation: heroIn 0.45s var(--ease) both;
|
||
position: relative;
|
||
overflow: clip;
|
||
}
|
||
.hero::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0 0 auto 0;
|
||
height: 3px;
|
||
background: linear-gradient(90deg, #5e5ce6, #0071e3, #bf5af2);
|
||
opacity: 0.9;
|
||
}
|
||
@keyframes heroIn {
|
||
from { opacity: 0; transform: translateY(14px) scale(0.985); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
.hero .brand-mark {
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: 16px;
|
||
margin: 0 auto 16px;
|
||
font-size: 28px;
|
||
}
|
||
.hero h1 {
|
||
margin: 0 0 8px;
|
||
font-size: 26px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.hero > p,
|
||
.hero .hero-sub {
|
||
margin: 0 0 20px;
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
line-height: 1.55;
|
||
}
|
||
/* Invite welcome banner (from ?invite= link) */
|
||
.invite-banner {
|
||
display: none;
|
||
text-align: left;
|
||
margin: 0 0 18px;
|
||
padding: 12px 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid color-mix(in srgb, var(--green) 28%, transparent);
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--green-soft),
|
||
color-mix(in srgb, var(--blue) 8%, transparent)
|
||
);
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
}
|
||
.invite-banner.show {
|
||
display: flex;
|
||
animation: inviteIn 0.35s var(--ease) both;
|
||
}
|
||
.invite-banner.invalid {
|
||
border-color: color-mix(in srgb, var(--red) 28%, transparent);
|
||
background: linear-gradient(
|
||
135deg,
|
||
var(--red-soft),
|
||
var(--orange-soft)
|
||
);
|
||
}
|
||
.invite-banner .invite-banner-icon {
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 10px;
|
||
flex-shrink: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
background: rgba(52, 199, 89, 0.16);
|
||
color: var(--green);
|
||
font-size: 18px;
|
||
}
|
||
.invite-banner.invalid .invite-banner-icon {
|
||
background: var(--red-soft);
|
||
color: var(--red);
|
||
}
|
||
.invite-banner .invite-banner-body {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
.invite-banner .invite-banner-title {
|
||
margin: 0 0 2px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.invite-banner .invite-banner-meta {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.45;
|
||
word-break: break-word;
|
||
}
|
||
.invite-banner .invite-banner-meta strong {
|
||
color: var(--text);
|
||
font-weight: 600;
|
||
}
|
||
.invite-banner .invite-banner-meta code {
|
||
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||
font-size: 11.5px;
|
||
padding: 1px 6px;
|
||
border-radius: 6px;
|
||
background: var(--code-bg);
|
||
color: var(--text);
|
||
word-break: break-all;
|
||
}
|
||
html[data-theme="dark"] .invite-banner {
|
||
border-color: rgba(48, 209, 88, 0.28);
|
||
background: linear-gradient(
|
||
135deg,
|
||
rgba(48, 209, 88, 0.12),
|
||
rgba(10, 132, 255, 0.08)
|
||
);
|
||
}
|
||
html[data-theme="dark"] .invite-banner.invalid {
|
||
border-color: rgba(255, 69, 58, 0.3);
|
||
background: linear-gradient(
|
||
135deg,
|
||
rgba(255, 69, 58, 0.12),
|
||
rgba(255, 159, 10, 0.06)
|
||
);
|
||
}
|
||
@keyframes inviteIn {
|
||
from { opacity: 0; transform: translateY(6px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
.auth-tabs {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 4px;
|
||
margin: 0 0 18px;
|
||
padding: 4px;
|
||
background: var(--chip);
|
||
border-radius: 14px;
|
||
border: 1px solid var(--separator);
|
||
}
|
||
.auth-tabs button {
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--text-secondary);
|
||
border-radius: 11px;
|
||
padding: 10px 12px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
font-family: inherit;
|
||
cursor: pointer;
|
||
transition: background 0.15s var(--ease), color 0.15s, box-shadow 0.15s;
|
||
min-height: 42px;
|
||
}
|
||
.auth-tabs button:hover:not(.active) {
|
||
color: var(--text);
|
||
}
|
||
.auth-tabs button.active {
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
font-weight: 600;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.auth-form {
|
||
text-align: left;
|
||
display: grid;
|
||
gap: 12px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.auth-form label {
|
||
display: grid;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
}
|
||
.auth-field {
|
||
position: relative;
|
||
}
|
||
.auth-field .field-icon {
|
||
position: absolute;
|
||
left: 12px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: var(--text-tertiary);
|
||
font-size: 15px;
|
||
pointer-events: none;
|
||
line-height: 1;
|
||
z-index: 1;
|
||
}
|
||
.auth-form input {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
border: 1px solid var(--separator);
|
||
border-radius: 12px;
|
||
padding: 12px 14px;
|
||
font-size: 16px;
|
||
background: var(--input-bg);
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
outline: none;
|
||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||
min-height: 46px;
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
}
|
||
.auth-field input {
|
||
padding-left: 40px;
|
||
}
|
||
.auth-field.has-toggle input {
|
||
padding-right: 44px;
|
||
}
|
||
.auth-form input:focus {
|
||
border-color: var(--focus-border);
|
||
box-shadow: 0 0 0 4px var(--ring);
|
||
background: var(--surface);
|
||
}
|
||
.auth-form input:read-only {
|
||
background: var(--surface-2);
|
||
color: var(--text);
|
||
cursor: default;
|
||
letter-spacing: 0.04em;
|
||
font-weight: 600;
|
||
}
|
||
.auth-form input:user-invalid:not(:placeholder-shown):not(:focus) {
|
||
border-color: color-mix(in srgb, var(--red) 55%, transparent);
|
||
}
|
||
.auth-form input::placeholder {
|
||
color: var(--text-tertiary);
|
||
font-weight: 400;
|
||
}
|
||
.auth-pw-toggle {
|
||
position: absolute;
|
||
right: 6px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 36px;
|
||
height: 36px;
|
||
border: none;
|
||
border-radius: 10px;
|
||
background: transparent;
|
||
color: var(--text-tertiary);
|
||
cursor: pointer;
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 15px;
|
||
padding: 0;
|
||
font-family: inherit;
|
||
transition: color 0.15s, background 0.15s;
|
||
}
|
||
.auth-pw-toggle:hover {
|
||
color: var(--text);
|
||
background: var(--chip);
|
||
}
|
||
.auth-pw-toggle:focus-visible {
|
||
outline: 2px solid var(--blue);
|
||
outline-offset: 1px;
|
||
}
|
||
.auth-hint {
|
||
margin: -4px 0 0;
|
||
font-size: 12px;
|
||
color: var(--text-tertiary);
|
||
line-height: 1.4;
|
||
}
|
||
.auth-form .btn {
|
||
width: 100%;
|
||
justify-content: center;
|
||
min-height: 48px;
|
||
font-size: 15px;
|
||
margin-top: 4px;
|
||
}
|
||
.auth-form .btn .bi {
|
||
margin-right: 2px;
|
||
}
|
||
.auth-divider {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin: 18px 0 14px;
|
||
color: var(--text-tertiary);
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
.auth-divider::before,
|
||
.auth-divider::after {
|
||
content: "";
|
||
flex: 1;
|
||
height: 1px;
|
||
background: var(--separator);
|
||
}
|
||
.auth-error {
|
||
color: var(--red);
|
||
font-size: 13px;
|
||
margin: 0 0 12px;
|
||
min-height: 0;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
background: var(--red-soft);
|
||
text-align: left;
|
||
line-height: 1.45;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
}
|
||
.auth-error[hidden] {
|
||
display: none !important;
|
||
}
|
||
.auth-error .auth-error-icon {
|
||
flex-shrink: 0;
|
||
font-size: 15px;
|
||
line-height: 1.35;
|
||
margin-top: 0.5px;
|
||
}
|
||
.auth-error .auth-error-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.auth-form .btn.is-loading {
|
||
pointer-events: none;
|
||
opacity: 0.72;
|
||
}
|
||
.auth-form .btn.is-loading .btn-label-idle { display: none; }
|
||
.auth-form .btn .btn-label-busy { display: none; }
|
||
.auth-form .btn.is-loading .btn-label-busy { display: inline-flex; align-items: center; gap: 6px; }
|
||
.auth-form .btn .spin {
|
||
display: inline-block;
|
||
width: 14px;
|
||
height: 14px;
|
||
border: 2px solid currentColor;
|
||
border-right-color: transparent;
|
||
border-radius: 50%;
|
||
animation: authSpin 0.7s linear infinite;
|
||
vertical-align: -2px;
|
||
}
|
||
@keyframes authSpin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
#loginBtn.btn-lg {
|
||
min-height: 48px;
|
||
font-size: 15px;
|
||
border-radius: var(--radius-pill);
|
||
}
|
||
.auth-footer-note {
|
||
margin: 16px 0 0;
|
||
font-size: 12px;
|
||
color: var(--text-tertiary);
|
||
line-height: 1.45;
|
||
}
|
||
.invite-quota {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
margin: 0 0 8px;
|
||
}
|
||
/* List containers — never qr-status (that is inline-flex for status lines) */
|
||
.list-panel {
|
||
display: block;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.invite-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
margin-top: 8px;
|
||
font-size: 13px;
|
||
color: var(--text);
|
||
}
|
||
.invite-list > .empty {
|
||
padding: 8px 0;
|
||
color: var(--text-secondary);
|
||
}
|
||
.invite-row {
|
||
display: flex;
|
||
flex-wrap: nowrap;
|
||
gap: 12px 16px;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 14px 0;
|
||
border-bottom: 1px solid var(--separator);
|
||
box-sizing: border-box;
|
||
}
|
||
.invite-row:last-child { border-bottom: none; }
|
||
.invite-row .invite-meta {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
}
|
||
.invite-row .invite-code-line {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 6px 8px;
|
||
margin-bottom: 6px;
|
||
line-height: 1.4;
|
||
}
|
||
.invite-row .invite-code-line strong {
|
||
flex-shrink: 0;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
.invite-row code {
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
word-break: break-all;
|
||
font-size: 12.5px;
|
||
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||
padding: 3px 8px;
|
||
border-radius: 6px;
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--separator);
|
||
line-height: 1.35;
|
||
}
|
||
.invite-row .invite-url {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
word-break: break-all;
|
||
overflow-wrap: anywhere;
|
||
line-height: 1.45;
|
||
margin: 0 0 4px;
|
||
}
|
||
.invite-row .invite-exp {
|
||
font-size: 12px;
|
||
color: var(--text-tertiary);
|
||
line-height: 1.4;
|
||
}
|
||
.invite-row .invite-actions {
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
padding-top: 1px;
|
||
}
|
||
|
||
/* QR panel */
|
||
.qr-panel {
|
||
--qr-size: 200px;
|
||
display: none;
|
||
margin-top: 8px;
|
||
padding: 20px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--separator);
|
||
text-align: center;
|
||
}
|
||
.qr-panel.show { display: block; animation: qrIn 0.25s var(--ease); }
|
||
@keyframes qrIn {
|
||
from { opacity: 0; transform: translateY(6px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
.qr-status {
|
||
margin: 0 0 12px;
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
max-width: 100%;
|
||
}
|
||
.qr-status .dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--blue);
|
||
flex-shrink: 0;
|
||
}
|
||
.qr-status.wait .dot { animation: qrPulse 1.4s ease infinite; }
|
||
.qr-status.ok .dot { background: var(--green); animation: none; }
|
||
.qr-status.warn .dot { background: var(--orange); animation: none; }
|
||
.qr-status.err .dot { background: var(--red); animation: none; }
|
||
@keyframes qrPulse {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.35; transform: scale(0.8); }
|
||
}
|
||
.qr-skel {
|
||
width: var(--qr-size);
|
||
max-width: 82vw;
|
||
aspect-ratio: 1;
|
||
height: auto;
|
||
border-radius: 12px;
|
||
margin: 0 auto;
|
||
}
|
||
.qr-panel img {
|
||
width: var(--qr-size);
|
||
max-width: 82vw;
|
||
aspect-ratio: 1;
|
||
height: auto;
|
||
border-radius: 12px;
|
||
box-shadow: var(--shadow-md);
|
||
background: #fff;
|
||
padding: 10px;
|
||
}
|
||
.qr-link {
|
||
margin-top: 12px;
|
||
font-size: 13px;
|
||
word-break: break-all;
|
||
color: var(--text-tertiary);
|
||
}
|
||
|
||
.toast {
|
||
position: fixed;
|
||
left: 50%;
|
||
bottom: calc(28px + var(--safe-b));
|
||
transform: translateX(-50%) translateY(20px);
|
||
background: var(--toast-bg);
|
||
color: #fff;
|
||
padding: 12px 20px;
|
||
border-radius: var(--radius-pill);
|
||
font-size: 14px;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.25s, transform 0.25s var(--ease);
|
||
z-index: 100;
|
||
max-width: min(92vw, 360px);
|
||
text-align: center;
|
||
/* Liquid glass baked into the element's own rule: toast() reassigns
|
||
className on every show, so the .glass/.glass-rim classes in the markup
|
||
cannot be relied on here. The dark --toast-bg tint is kept on purpose
|
||
so the white text stays readable in both themes. */
|
||
backdrop-filter: blur(var(--glass-blur)) saturate(180%);
|
||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(180%);
|
||
box-shadow:
|
||
inset 0 1px 0 0 var(--glass-hi),
|
||
inset 0 -1px 0 0 var(--glass-lo),
|
||
var(--glass-shadow);
|
||
}
|
||
/* The rim comes from .glass-rim — toast() re-applies that class on every
|
||
show, so no toast-specific copy of the rule is needed here. */
|
||
.toast.show {
|
||
opacity: 1;
|
||
transform: translateX(-50%) translateY(0);
|
||
}
|
||
.toast.err { background: var(--toast-err); }
|
||
|
||
.confirm-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 90;
|
||
background: var(--scrim);
|
||
backdrop-filter: blur(8px);
|
||
-webkit-backdrop-filter: blur(8px);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
padding-bottom: calc(20px + var(--safe-b));
|
||
}
|
||
.confirm-backdrop.show { display: flex; }
|
||
.confirm-card {
|
||
width: min(400px, 100%);
|
||
/* background / blur / edge come from .glass + .glass-strong + .glass-rim */
|
||
border-radius: var(--radius);
|
||
padding: 22px 20px 16px;
|
||
animation: confirmIn 0.2s var(--ease);
|
||
}
|
||
@keyframes confirmIn {
|
||
from { opacity: 0; transform: scale(0.96) translateY(8px); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
.confirm-card h3 {
|
||
margin: 0 0 8px;
|
||
font-size: 18px;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.confirm-card p {
|
||
margin: 0 0 18px;
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.5;
|
||
}
|
||
.confirm-card .row-actions { justify-content: flex-end; }
|
||
@media (max-width: 860px) {
|
||
.confirm-card .row-actions {
|
||
flex-direction: column-reverse;
|
||
align-items: stretch;
|
||
}
|
||
.confirm-card .row-actions .btn { width: 100%; min-height: 44px; }
|
||
}
|
||
|
||
.modal-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: var(--scrim);
|
||
backdrop-filter: blur(8px);
|
||
-webkit-backdrop-filter: blur(8px);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 80;
|
||
padding: 20px;
|
||
padding-left: max(20px, var(--safe-l));
|
||
padding-right: max(20px, var(--safe-r));
|
||
}
|
||
.modal-backdrop.show { display: flex; }
|
||
.modal-backdrop.show .modal { animation: modalIn 0.28s var(--ease); }
|
||
@keyframes modalIn {
|
||
from { opacity: 0; transform: translateY(18px) scale(0.985); }
|
||
to { opacity: 1; transform: none; }
|
||
}
|
||
.modal {
|
||
width: min(520px, 100%);
|
||
max-height: min(80vh, 720px);
|
||
overflow: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
/* background / blur / edge come from .glass + .glass-strong + .glass-rim */
|
||
border-radius: var(--radius-lg);
|
||
padding: 24px;
|
||
overscroll-behavior: contain;
|
||
}
|
||
.modal-handle {
|
||
display: none;
|
||
width: 36px;
|
||
height: 5px;
|
||
border-radius: 99px;
|
||
background: var(--separator);
|
||
margin: 0 auto 14px;
|
||
}
|
||
.modal h3 { margin: 0 0 8px; font-size: 20px; letter-spacing: -0.02em; }
|
||
.modal pre {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
background: var(--input-bg);
|
||
border-radius: 12px;
|
||
padding: 14px;
|
||
max-height: 280px;
|
||
overflow: auto;
|
||
color: var(--text-secondary);
|
||
margin: 12px 0 16px;
|
||
}
|
||
|
||
/* Try-chat thinking placeholder */
|
||
/* Try-chat modal layout (replaces heavy inline styles) */
|
||
.modal.try-chat-modal {
|
||
width: min(480px, 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: min(88vh, 720px);
|
||
}
|
||
.try-chat-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
}
|
||
.try-chat-head h3 { margin: 0 0 4px; }
|
||
.try-chat-head .desc {
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
margin: 0;
|
||
}
|
||
.try-chat-log {
|
||
flex: 1;
|
||
overflow: auto;
|
||
margin: 12px 0;
|
||
padding: 10px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--chip);
|
||
border: 1px solid var(--separator);
|
||
min-height: 200px;
|
||
max-height: 42vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.try-chat-quota {
|
||
margin: 0 0 8px;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.try-chat-compose {
|
||
margin: 0;
|
||
gap: 8px;
|
||
}
|
||
.try-chat-compose input { flex: 1; min-width: 0; }
|
||
.try-chat-actions {
|
||
margin-top: 12px;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.try-chat-bubble {
|
||
max-width: 88%;
|
||
padding: 8px 12px;
|
||
font-size: 14px;
|
||
line-height: 1.45;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
.try-chat-bubble.me {
|
||
align-self: flex-end;
|
||
border-radius: 14px 14px 4px 14px;
|
||
background: var(--blue);
|
||
color: #fff;
|
||
}
|
||
.try-chat-bubble.bot {
|
||
align-self: flex-start;
|
||
border-radius: 14px 14px 14px 4px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--separator);
|
||
color: var(--text);
|
||
}
|
||
.try-chat-thinking {
|
||
align-self: flex-start;
|
||
max-width: 88%;
|
||
padding: 8px 12px;
|
||
border-radius: 14px 14px 14px 4px;
|
||
background: var(--surface);
|
||
border: 1px solid var(--separator);
|
||
font-size: 14px;
|
||
line-height: 1.45;
|
||
color: var(--text-secondary);
|
||
display: inline-flex;
|
||
align-items: baseline;
|
||
gap: 0;
|
||
user-select: none;
|
||
}
|
||
/* Sticker card media row */
|
||
.sticker-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-start;
|
||
}
|
||
.sticker-thumb {
|
||
width: 56px;
|
||
height: 56px;
|
||
object-fit: contain;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--chip);
|
||
border: 1px solid var(--separator);
|
||
flex-shrink: 0;
|
||
}
|
||
.sticker-body { flex: 1; min-width: 0; }
|
||
.sticker-body .row-actions.between { justify-content: space-between; }
|
||
.sticker-body h4 { margin: 0; }
|
||
.sticker-body .desc { margin: 6px 0; }
|
||
.sticker-body .row-actions.actions { margin-top: 8px; }
|
||
.modal-meta {
|
||
margin: 8px 0 0;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.modal-meta + .modal-meta { margin-top: 4px; }
|
||
.modal .tag-row { margin-top: 10px; }
|
||
.modal .desc.sub {
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
margin: 0;
|
||
}
|
||
.try-chat-thinking .try-chat-dot {
|
||
display: inline-block;
|
||
opacity: 0.25;
|
||
animation: tryChatDot 1.2s ease-in-out infinite;
|
||
}
|
||
.try-chat-thinking .try-chat-dot:nth-child(2) { animation-delay: 0.2s; }
|
||
.try-chat-thinking .try-chat-dot:nth-child(3) { animation-delay: 0.4s; }
|
||
@keyframes tryChatDot {
|
||
0%, 80%, 100% { opacity: 0.25; }
|
||
40% { opacity: 1; }
|
||
}
|
||
|
||
/* Mobile list cards (bots / peers) */
|
||
.mobile-cards { display: none; }
|
||
.mcard {
|
||
background: var(--surface-2);
|
||
border: 1px solid var(--separator);
|
||
border-radius: 16px;
|
||
padding: 14px 16px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.mcard:last-child { margin-bottom: 0; }
|
||
.mcard .m-title {
|
||
margin: 0 0 6px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
letter-spacing: -0.02em;
|
||
word-break: break-word;
|
||
}
|
||
.mcard .m-meta {
|
||
margin: 0 0 10px;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.45;
|
||
word-break: break-all;
|
||
}
|
||
.mcard .m-meta code { font-size: 11px; }
|
||
.mcard .name-edit { width: 100%; }
|
||
.mcard .name-edit input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
max-width: none;
|
||
width: 100%;
|
||
}
|
||
.mcard .row-actions {
|
||
width: 100%;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.mcard .row-actions .btn { flex: 1 1 auto; min-width: 0; }
|
||
.mcard select {
|
||
min-width: 0;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.table-desktop { display: block; }
|
||
|
||
.fab-refresh {
|
||
display: none;
|
||
position: fixed;
|
||
right: max(16px, var(--safe-r));
|
||
bottom: calc(20px + var(--safe-b));
|
||
z-index: 40;
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 50%;
|
||
/* background / blur / edge come from .glass + .glass-rim */
|
||
border: none;
|
||
color: var(--text);
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
align-items: center;
|
||
justify-content: center;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.fab-refresh:active { transform: scale(0.96); }
|
||
.fab-refresh.spin { animation: fab-spin 0.6s var(--ease); }
|
||
@keyframes fab-spin {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.offline-banner {
|
||
display: none;
|
||
position: sticky;
|
||
top: calc(var(--nav-h) + var(--safe-t));
|
||
z-index: 45;
|
||
background: var(--orange);
|
||
color: #1d1d1f;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
padding: 9px 12px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.offline-banner.show { display: block; }
|
||
/* push the sticky tabs down while the banner is visible */
|
||
body.offline { --banner-h: 37px; }
|
||
|
||
@media (max-width: 900px) {
|
||
.form-fields {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
.form-fields .form-field:last-child {
|
||
grid-column: 1 / -1;
|
||
}
|
||
}
|
||
/* 吸顶胶囊的落影改由 .tabs.glass-dynamic 按滚动深度连续给出,
|
||
原来的 .tabs.scrolled 二段式静态阴影会盖住动态值,已移除。 */
|
||
|
||
@media (max-width: 860px) {
|
||
/* Compact nav — aligned with admin 860px breakpoint for tablet/phone */
|
||
:root { --nav-h: 52px; --control-h: 44px; --control-h-sm: 36px; }
|
||
.nav {
|
||
padding-left: max(12px, var(--safe-l));
|
||
padding-right: max(12px, var(--safe-r));
|
||
gap: 8px;
|
||
flex-wrap: nowrap;
|
||
align-items: center;
|
||
}
|
||
.brand {
|
||
flex-shrink: 0;
|
||
min-width: 0;
|
||
gap: 8px;
|
||
}
|
||
.brand span {
|
||
font-size: 14px;
|
||
max-width: 42vw;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.brand-mark { width: 26px; height: 26px; border-radius: 7px; }
|
||
.avatar-chip {
|
||
max-width: none;
|
||
padding: 2px;
|
||
gap: 0;
|
||
background: transparent;
|
||
}
|
||
.avatar-chip #userName {
|
||
display: none;
|
||
}
|
||
.avatar-dot {
|
||
width: 32px;
|
||
height: 32px;
|
||
font-size: 12px;
|
||
}
|
||
.nav-actions {
|
||
gap: 6px;
|
||
flex-wrap: nowrap;
|
||
flex-shrink: 0;
|
||
min-width: 0;
|
||
}
|
||
.nav-actions .btn {
|
||
padding: 0;
|
||
min-height: 36px;
|
||
min-width: 36px;
|
||
width: 36px;
|
||
height: 36px;
|
||
gap: 0;
|
||
border-radius: 50%;
|
||
}
|
||
.nav-actions .btn .btn-label { display: none; }
|
||
.nav-actions .btn .bi {
|
||
margin-right: 0;
|
||
font-size: 1rem;
|
||
}
|
||
.theme-toggle { width: 36px; height: 36px; flex-shrink: 0; }
|
||
#userBar {
|
||
align-items: center;
|
||
gap: 4px;
|
||
flex-wrap: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
#adminLink:not(.show-admin-mobile) { display: none !important; }
|
||
#adminLink.show-admin-mobile {
|
||
display: inline-flex !important;
|
||
}
|
||
.pro-open-btn .pro-label-full { display: none; }
|
||
.row-actions .pro-open-btn { flex: 1 1 auto; }
|
||
.card {
|
||
padding: 16px 14px;
|
||
border-radius: 16px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.card-head {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
margin-bottom: 14px;
|
||
}
|
||
.card h2 { font-size: 19px; }
|
||
/* extra bottom room for the refresh FAB (base padding comes from --gutter clamps) */
|
||
.wrap { padding-bottom: calc(88px + var(--safe-b)); }
|
||
.hero {
|
||
padding: 28px 18px 24px;
|
||
border-radius: 22px;
|
||
width: 100%;
|
||
}
|
||
.hero h1 { font-size: 22px; }
|
||
.hero .brand-mark {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 14px;
|
||
font-size: 24px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.hero > p,
|
||
.hero .hero-sub {
|
||
font-size: 13.5px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.auth-tabs {
|
||
margin-bottom: 14px;
|
||
}
|
||
.auth-tabs button {
|
||
min-height: 44px;
|
||
font-size: 14px;
|
||
padding: 10px 8px;
|
||
}
|
||
.auth-form {
|
||
gap: 11px;
|
||
}
|
||
.auth-form input,
|
||
.auth-field input {
|
||
min-height: 48px;
|
||
font-size: 16px; /* prevent iOS zoom */
|
||
}
|
||
.auth-form .btn,
|
||
#loginBtn.btn-lg {
|
||
min-height: 50px;
|
||
}
|
||
.invite-banner {
|
||
padding: 11px 12px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.invite-banner .invite-banner-icon {
|
||
width: 34px;
|
||
height: 34px;
|
||
font-size: 16px;
|
||
}
|
||
.invite-row {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 10px;
|
||
}
|
||
.invite-row .invite-actions {
|
||
justify-content: flex-start;
|
||
width: 100%;
|
||
}
|
||
.invite-row .invite-actions .btn {
|
||
min-height: 40px;
|
||
}
|
||
#loginGate {
|
||
min-height: calc(100dvh - var(--nav-h) - var(--safe-t));
|
||
padding: 20px max(14px, var(--safe-l)) calc(28px + var(--safe-b)) max(14px, var(--safe-r));
|
||
align-items: flex-start;
|
||
padding-top: max(28px, calc(12px + var(--safe-t)));
|
||
}
|
||
.tabs {
|
||
position: sticky;
|
||
top: calc(var(--nav-h) + var(--safe-t) + var(--banner-h, 0px));
|
||
z-index: 30;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
margin-left: 0;
|
||
margin-right: 0;
|
||
margin-bottom: 16px;
|
||
border-radius: 14px;
|
||
/* same glass recipe as the desktop pill — the .glass-rim ring replaces
|
||
the old 1px border so the edge is not drawn twice */
|
||
}
|
||
.tabs button {
|
||
flex: 1 1 auto;
|
||
text-align: center;
|
||
padding: 10px 8px;
|
||
font-size: 12px;
|
||
}
|
||
.tabs button .bi {
|
||
display: block;
|
||
margin: 0 auto 2px;
|
||
font-size: 1rem;
|
||
}
|
||
.toolbar {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 10px;
|
||
}
|
||
.toolbar input,
|
||
.toolbar select,
|
||
.toolbar .btn {
|
||
width: 100%;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
}
|
||
.toolbar .btn { min-height: 44px; }
|
||
input, select, textarea {
|
||
font-size: 16px; /* avoid iOS zoom on focus */
|
||
min-width: 0;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
}
|
||
.name-edit {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.name-edit input {
|
||
max-width: none;
|
||
width: 100%;
|
||
}
|
||
.row-actions .btn { min-height: 40px; }
|
||
.card, .qr-panel, .persona-card {
|
||
scroll-margin-top: calc(var(--nav-h) + var(--safe-t) + 68px);
|
||
}
|
||
.form-fields {
|
||
grid-template-columns: 1fr;
|
||
gap: 12px;
|
||
}
|
||
.form-footer {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 14px;
|
||
}
|
||
.vis-seg {
|
||
width: 100%;
|
||
display: flex;
|
||
}
|
||
.vis-seg label {
|
||
flex: 1;
|
||
text-align: center;
|
||
min-height: 40px;
|
||
padding: 8px 10px;
|
||
}
|
||
.form-actions {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.form-actions .btn { width: 100%; min-height: 44px; }
|
||
.persona-grid {
|
||
grid-template-columns: 1fr;
|
||
gap: 12px;
|
||
}
|
||
.persona-card {
|
||
padding: 16px;
|
||
}
|
||
.persona-card .row-actions {
|
||
gap: 8px;
|
||
}
|
||
.persona-card .row-actions .btn {
|
||
flex: 1 1 auto;
|
||
min-height: 40px;
|
||
}
|
||
.qr-panel {
|
||
--qr-size: min(240px, 72vw);
|
||
padding: 16px 12px;
|
||
margin-top: 4px;
|
||
}
|
||
#pubPrompt { font-size: 16px; } /* ID selector beats the generic 16px rule above */
|
||
.table-desktop { display: none !important; }
|
||
.mobile-cards { display: block; }
|
||
.modal-handle { display: block; }
|
||
.modal-backdrop {
|
||
padding: 0;
|
||
align-items: flex-end;
|
||
}
|
||
.modal {
|
||
width: 100%;
|
||
max-height: min(92dvh, 900px);
|
||
border-radius: var(--radius) var(--radius) 0 0;
|
||
padding: 10px 18px calc(18px + var(--safe-b));
|
||
border-bottom: none;
|
||
}
|
||
.modal.try-chat-modal {
|
||
max-height: min(92dvh, 900px);
|
||
}
|
||
.modal pre { max-height: 36vh; }
|
||
.modal .row-actions {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
position: sticky;
|
||
bottom: 0;
|
||
padding-top: 8px;
|
||
background: linear-gradient(transparent, var(--surface) 30%);
|
||
}
|
||
.modal .row-actions .btn { width: 100%; min-height: 44px; }
|
||
.fab-refresh { display: flex; }
|
||
/* keep theme-toggle at 36px to match other nav controls (avoid --nav-h drift) */
|
||
.theme-toggle { width: 36px; height: 36px; flex-shrink: 0; }
|
||
}
|
||
|
||
@media (max-width: 380px) {
|
||
.avatar-chip #userName { display: none; }
|
||
.avatar-chip { padding: 2px; }
|
||
.brand span { max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.hero { padding: 22px 14px 20px; border-radius: 18px; }
|
||
.hero h1 { font-size: 20px; }
|
||
.auth-tabs button { font-size: 13px; padding: 9px 6px; }
|
||
.invite-banner .invite-banner-title { font-size: 13px; }
|
||
.invite-banner .invite-banner-meta { font-size: 11.5px; }
|
||
}
|
||
|
||
@media (max-height: 500px) and (orientation: landscape) {
|
||
:root { --nav-h: 44px; }
|
||
.page-sub { display: none; }
|
||
.wrap { padding-top: 12px; padding-bottom: calc(56px + var(--safe-b)); }
|
||
#loginGate {
|
||
padding-top: 12px;
|
||
padding-bottom: 16px;
|
||
align-items: flex-start;
|
||
min-height: auto;
|
||
}
|
||
.hero {
|
||
padding: 18px 16px 16px;
|
||
border-radius: 16px;
|
||
}
|
||
.hero .brand-mark { width: 40px; height: 40px; margin-bottom: 8px; font-size: 20px; }
|
||
.hero h1 { font-size: 18px; margin-bottom: 4px; }
|
||
.hero > p,
|
||
.hero .hero-sub { margin-bottom: 10px; font-size: 13px; }
|
||
.auth-form { gap: 8px; }
|
||
.auth-form input,
|
||
.auth-field input { min-height: 42px; }
|
||
.auth-form .btn,
|
||
#loginBtn.btn-lg { min-height: 42px; }
|
||
.invite-banner { margin-bottom: 10px; padding: 8px 10px; }
|
||
.tabs { top: calc(var(--nav-h) + var(--safe-t)); }
|
||
.nav {
|
||
min-height: 44px;
|
||
padding-top: calc(4px + var(--safe-t));
|
||
padding-bottom: 4px;
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*, *::before, *::after {
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.01ms !important;
|
||
}
|
||
/* The block above zeroes durations but not delays; the stagger's
|
||
backwards fill would still hold cards at opacity 0 for up to 340ms. */
|
||
.stagger > * { animation: none !important; }
|
||
}
|
||
|
||
/* Higher-contrast separators & secondary text when requested */
|
||
@media (prefers-contrast: more) {
|
||
:root {
|
||
--separator: rgba(0, 0, 0, 0.24);
|
||
--text-secondary: #515154;
|
||
--text-tertiary: #6e6e73;
|
||
}
|
||
html[data-theme="dark"] {
|
||
--separator: rgba(255, 255, 255, 0.3);
|
||
--text-secondary: #c7c7cc;
|
||
--text-tertiary: #98989d;
|
||
}
|
||
.badge, .tag { font-weight: 600; }
|
||
}
|
||
|
||
/* Solid surfaces instead of glass when transparency is reduced */
|
||
@media (prefers-reduced-transparency: reduce) {
|
||
:root { --bg-elevated: #f5f5f7; --toast-bg: #1d1d1f; }
|
||
html[data-theme="dark"] { --bg-elevated: #1c1c1e; --toast-bg: #2c2c2e; }
|
||
.nav, .tabs, .toast, .modal-backdrop, .confirm-backdrop, .fab-refresh,
|
||
.modal, .confirm-card, .glass, .glass-dynamic {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
/* 边缘折射一并关掉:html.wa-lens 那条选择器更特异,不显式覆盖会漏网 */
|
||
html.wa-lens .glass,
|
||
html.wa-lens .glass-strong,
|
||
html.wa-lens .glass-dynamic {
|
||
backdrop-filter: none !important;
|
||
-webkit-backdrop-filter: none !important;
|
||
}
|
||
/* every glass surface falls back to an opaque fill; the tab pill also
|
||
gets a hairline back because its fill matches the page background */
|
||
.nav, .tabs, .fab-refresh { background: var(--bg-elevated); }
|
||
/* 动态阴影按滚动深度淡入,不透明模式下改回固定值:
|
||
.nav 拿回一条分隔线,胶囊靠下面的 1px 边框收边。
|
||
!important 是为了盖过 html[data-theme="dark"] .tabs.glass-dynamic。 */
|
||
.nav.glass-dynamic { box-shadow: 0 1px 0 0 var(--separator) !important; }
|
||
.tabs.glass-dynamic { box-shadow: none !important; }
|
||
.tabs, .fab-refresh { border: 1px solid var(--separator); }
|
||
.modal, .confirm-card { background: var(--surface); border: 1px solid var(--separator); }
|
||
/* specular rim reads as a stray outline without the blur behind it */
|
||
.glass-rim::before { display: none !important; }
|
||
}
|
||
|
||
@media (min-width: 861px) {
|
||
.table-desktop { display: block; }
|
||
.mobile-cards { display: none !important; }
|
||
.fab-refresh { display: none !important; }
|
||
.modal-handle { display: none; }
|
||
}
|
||
|
||
/* Bootstrap Icons */
|
||
.theme-toggle .bi { font-size: 1.1rem; line-height: 1; }
|
||
.fab-refresh .bi { font-size: 1.25rem; line-height: 1; }
|
||
.tabs button .bi {
|
||
margin-right: 6px;
|
||
font-size: 0.95rem;
|
||
vertical-align: -0.08em;
|
||
opacity: 0.9;
|
||
}
|
||
.tabs button.active .bi { opacity: 1; }
|
||
.btn .bi { margin-right: 4px; font-size: 0.95em; vertical-align: -0.05em; }
|
||
</style>
|
||
<noscript>
|
||
<style>
|
||
/* 无脚本时滞动进度永远为 0,吸顶栏会停在 0.3 底色 + 4px 模糊,
|
||
连分隔线都没有,正文会直接透过去。直接拉满,等同于改造前的静态玻璃。 */
|
||
:root { --glass-progress: 1; }
|
||
</style>
|
||
</noscript>
|
||
</head>
|
||
<body>
|
||
<svg class="wa-lens-defs" width="0" height="0" aria-hidden="true" focusable="false"><defs>
|
||
<!-- 边缘折射位移图:R 通道控制横向取样、G 通道控制纵向。中心为中性值 128 故不位移,
|
||
四边渐变让取样点向内偏移,形成玻璃厚度的透镜感。
|
||
color-interpolation-filters="sRGB" 必须保留:默认的 linearRGB 会把中性点 128
|
||
换算成 ~55,导致整个背景被均匀推歪。 -->
|
||
<filter id="waGlassLens" x="0%" y="0%" width="100%" height="100%" color-interpolation-filters="sRGB">
|
||
<feImage href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' preserveAspectRatio='none'><defs><linearGradient id='x' x1='0' x2='1'><stop offset='0' stop-color='rgb(255,0,0)'/><stop offset='0.25' stop-color='rgb(128,0,0)'/><stop offset='0.75' stop-color='rgb(128,0,0)'/><stop offset='1' stop-color='rgb(0,0,0)'/></linearGradient><linearGradient id='y' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='rgb(0,255,0)'/><stop offset='0.3' stop-color='rgb(0,128,0)'/><stop offset='0.7' stop-color='rgb(0,128,0)'/><stop offset='1' stop-color='rgb(0,0,0)'/></linearGradient></defs><rect width='100' height='100' fill='url(%23x)'/><rect width='100' height='100' fill='url(%23y)' style='mix-blend-mode:plus-lighter'/></svg>" preserveAspectRatio="none" result="wa-map"/>
|
||
<feDisplacementMap in="SourceGraphic" in2="wa-map" scale="14" xChannelSelector="R" yChannelSelector="G"/>
|
||
</filter>
|
||
</defs></svg>
|
||
<div class="load-bar" id="loadBar" aria-hidden="true"></div>
|
||
<header class="nav glass-dynamic">
|
||
<div class="brand">
|
||
<div class="brand-mark" aria-hidden="true"><i class="bi bi-robot"></i></div>
|
||
<span>WeChat-AI</span>
|
||
</div>
|
||
<div class="nav-actions">
|
||
<button class="theme-toggle" id="themeToggle" type="button" title="主题:浅色 / 深色 / 跟随系统" aria-label="切换主题">
|
||
<i class="moon bi bi-moon-stars" aria-hidden="true"></i>
|
||
<i class="sun bi bi-sun" aria-hidden="true"></i>
|
||
<i class="auto bi bi-circle-half" aria-hidden="true" style="display:none"></i>
|
||
</button>
|
||
<div id="userBar" style="display:none; align-items:center; gap:10px; flex-wrap:wrap">
|
||
<div class="avatar-chip">
|
||
<span class="avatar-dot" id="userInitial">U</span>
|
||
<span id="userName"></span>
|
||
</div>
|
||
<a class="btn btn-ghost" href="/docs" title="使用文档" aria-label="使用文档"><i class="bi bi-book" aria-hidden="true"></i><span class="btn-label">文档</span></a>
|
||
<a class="btn btn-ghost" href="/chatflow" title="Chatflow 流程编辑器" aria-label="Chatflow 流程编辑器"><i class="bi bi-diagram-3" aria-hidden="true"></i><span class="btn-label">Chatflow</span></a>
|
||
<a class="btn btn-ghost" href="/admin" id="adminLink" style="display:none" title="管理后台" aria-label="管理后台"><i class="bi bi-shield-lock" aria-hidden="true"></i><span class="btn-label">管理后台</span></a>
|
||
<button class="btn btn-secondary" id="logout" type="button" title="退出" aria-label="退出"><i class="bi bi-box-arrow-right" aria-hidden="true"></i><span class="btn-label">退出</span></button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="offline-banner" id="offlineBanner" role="status">网络已断开,部分操作可能失败</div>
|
||
|
||
<div id="bootSplash" aria-hidden="true">
|
||
<div class="brand-mark" aria-hidden="true"><i class="bi bi-robot"></i></div>
|
||
</div>
|
||
|
||
<div id="loginGate" style="display:none">
|
||
<div class="hero">
|
||
<div class="brand-mark" aria-hidden="true"><i class="bi bi-robot"></i></div>
|
||
<h1 id="loginGateTitle">管理你的微信机器人</h1>
|
||
<p class="hero-sub" id="loginGateSub">登录后添加机器人、批准好友、分配角色。本地注册需邀请码;LINUX DO 可直接登录。</p>
|
||
|
||
<div class="invite-banner" id="inviteBanner" role="status" aria-live="polite">
|
||
<div class="invite-banner-icon" aria-hidden="true"><i class="bi bi-envelope-check" id="inviteBannerIcon"></i></div>
|
||
<div class="invite-banner-body">
|
||
<p class="invite-banner-title" id="inviteBannerTitle">邀请注册</p>
|
||
<p class="invite-banner-meta" id="inviteBannerMeta"></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="auth-tabs" id="authTabs" role="tablist" aria-label="登录方式">
|
||
<button type="button" data-auth-tab="login" class="active" role="tab" aria-selected="true" id="authTabLogin">密码登录</button>
|
||
<button type="button" data-auth-tab="register" role="tab" aria-selected="false" id="authTabRegister">邀请注册</button>
|
||
</div>
|
||
<p class="auth-error" id="authError" hidden role="alert"></p>
|
||
<div id="authLoginPanel" role="tabpanel" aria-labelledby="authTabLogin">
|
||
<form class="auth-form" id="passwordLoginForm" autocomplete="on">
|
||
<label>用户名
|
||
<div class="auth-field">
|
||
<i class="bi bi-person field-icon" aria-hidden="true"></i>
|
||
<input id="loginUsername" name="username" autocomplete="username" required maxlength="32" placeholder="你的用户名" enterkeyhint="next" />
|
||
</div>
|
||
</label>
|
||
<label>密码
|
||
<div class="auth-field has-toggle">
|
||
<i class="bi bi-lock field-icon" aria-hidden="true"></i>
|
||
<input id="loginPassword" name="password" type="password" autocomplete="current-password" required maxlength="128" placeholder="登录密码" enterkeyhint="go" />
|
||
<button type="button" class="auth-pw-toggle" data-pw-toggle="loginPassword" aria-label="显示密码" title="显示/隐藏密码"><i class="bi bi-eye" aria-hidden="true"></i></button>
|
||
</div>
|
||
</label>
|
||
<button class="btn btn-primary" type="submit" id="passwordLoginSubmit">
|
||
<span class="btn-label-idle"><i class="bi bi-box-arrow-in-right" aria-hidden="true"></i>登录</span>
|
||
<span class="btn-label-busy"><span class="spin" aria-hidden="true"></span>登录中…</span>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
<div id="authRegisterPanel" style="display:none" role="tabpanel" aria-labelledby="authTabRegister">
|
||
<form class="auth-form" id="registerForm" autocomplete="on">
|
||
<label>邀请码
|
||
<div class="auth-field">
|
||
<i class="bi bi-ticket-perforated field-icon" aria-hidden="true"></i>
|
||
<input id="regInvite" name="invite" autocomplete="off" required maxlength="32" placeholder="好友分享的邀请码" spellcheck="false" autocapitalize="characters" enterkeyhint="next" />
|
||
</div>
|
||
</label>
|
||
<label>用户名
|
||
<div class="auth-field">
|
||
<i class="bi bi-at field-icon" aria-hidden="true"></i>
|
||
<input id="regUsername" name="username" autocomplete="username" required maxlength="32" minlength="3" pattern="[A-Za-z][A-Za-z0-9_]{2,31}" placeholder="字母开头,3–32 位" enterkeyhint="next" />
|
||
</div>
|
||
</label>
|
||
<p class="auth-hint">仅支持字母、数字、下划线,且以字母开头</p>
|
||
<label>显示名<span style="font-weight:400;color:var(--text-tertiary)">(可选)</span>
|
||
<div class="auth-field">
|
||
<i class="bi bi-card-text field-icon" aria-hidden="true"></i>
|
||
<input id="regName" name="name" autocomplete="nickname" maxlength="64" placeholder="展示给他人的名字" enterkeyhint="next" />
|
||
</div>
|
||
</label>
|
||
<label>密码
|
||
<div class="auth-field has-toggle">
|
||
<i class="bi bi-lock field-icon" aria-hidden="true"></i>
|
||
<input id="regPassword" name="password" type="password" autocomplete="new-password" required maxlength="128" minlength="8" placeholder="至少 8 位" enterkeyhint="next" />
|
||
<button type="button" class="auth-pw-toggle" data-pw-toggle="regPassword" aria-label="显示密码" title="显示/隐藏密码"><i class="bi bi-eye" aria-hidden="true"></i></button>
|
||
</div>
|
||
</label>
|
||
<label>确认密码
|
||
<div class="auth-field has-toggle">
|
||
<i class="bi bi-shield-lock field-icon" aria-hidden="true"></i>
|
||
<input id="regPassword2" name="password2" type="password" autocomplete="new-password" required maxlength="128" minlength="8" placeholder="再输入一次" enterkeyhint="go" />
|
||
<button type="button" class="auth-pw-toggle" data-pw-toggle="regPassword2" aria-label="显示密码" title="显示/隐藏密码"><i class="bi bi-eye" aria-hidden="true"></i></button>
|
||
</div>
|
||
</label>
|
||
<button class="btn btn-primary" type="submit" id="registerSubmit">
|
||
<span class="btn-label-idle"><i class="bi bi-person-plus" aria-hidden="true"></i>注册并登录</span>
|
||
<span class="btn-label-busy"><span class="spin" aria-hidden="true"></span>注册中…</span>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
<div class="auth-divider">或</div>
|
||
<button class="btn btn-secondary btn-lg" id="loginBtn" type="button" style="width:100%;justify-content:center"><i class="bi bi-box-arrow-in-right" aria-hidden="true"></i>使用 LINUX DO 登录</button>
|
||
<p class="auth-footer-note">LINUX DO 账号可直接登录,无需邀请码</p>
|
||
</div>
|
||
</div>
|
||
|
||
<main class="wrap" id="main" style="display:none">
|
||
<h1 class="page-title">用户中心</h1>
|
||
<p class="page-sub">机器人、用户对话、人设与表情,一站管理。</p>
|
||
|
||
<div class="tabs glass-dynamic glass-rim" id="mainTabs">
|
||
<button type="button" data-pane="bots" class="active"><i class="bi bi-robot" aria-hidden="true"></i>机器人</button>
|
||
<button type="button" data-pane="chat"><i class="bi bi-chat-dots" aria-hidden="true"></i>用户对话</button>
|
||
<button type="button" data-pane="invite"><i class="bi bi-person-plus" aria-hidden="true"></i>邀请</button>
|
||
<button type="button" data-pane="square"><i class="bi bi-shop" aria-hidden="true"></i>人设广场</button>
|
||
<button type="button" data-pane="stickers"><i class="bi bi-emoji-smile" aria-hidden="true"></i>表情包广场</button>
|
||
<button type="button" data-pane="mine"><i class="bi bi-person-vcard" aria-hidden="true"></i>我的人设</button>
|
||
<button type="button" data-pane="models"><i class="bi bi-hdd-network" aria-hidden="true"></i>我的模型</button>
|
||
</div>
|
||
|
||
<div class="pane active" id="pane-bots">
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>我的机器人</h2>
|
||
<p class="card-desc">扫码绑定微信;token 失效时点「重新扫码」可刷新凭证并保留好友/人设/记忆</p>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar">
|
||
<input id="botName" placeholder="机器人名字(可选,登录后可改)" />
|
||
<button class="btn btn-primary" id="addBot" type="button">扫码添加</button>
|
||
<button class="btn btn-secondary" id="cancelLogin" type="button" disabled>取消扫码</button>
|
||
</div>
|
||
<div id="loginBox" class="qr-panel">
|
||
<p class="qr-status" id="loginStatus">等待扫码…</p>
|
||
<div class="skeleton qr-skel" id="qrSkel" style="display:none" aria-hidden="true"></div>
|
||
<img id="qrImg" alt="登录二维码" width="200" height="200" style="display:none" />
|
||
<p class="qr-link" id="loginLinkWrap" style="display:none">
|
||
或打开链接:<a id="loginLink" target="_blank" rel="noopener"></a>
|
||
</p>
|
||
</div>
|
||
<div class="table-wrap table-desktop">
|
||
<table>
|
||
<thead>
|
||
<tr><th>机器人名字</th><th>ID</th><th>状态</th><th>操作</th></tr>
|
||
</thead>
|
||
<tbody id="botsBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="mobile-cards" id="botsCards" aria-live="polite"></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>机器人设置 · 微信用户</h2>
|
||
<p class="card-desc">批准私聊用户、分配人设;可对单个用户开启「主动联系」(空闲后智能体找对方聊天)</p>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar">
|
||
<select id="filterBot"><option value="">全部机器人</option></select>
|
||
<button class="btn btn-secondary" id="reloadPeers" type="button">刷新</button>
|
||
</div>
|
||
<div class="table-wrap table-desktop">
|
||
<table class="peers-table">
|
||
<thead>
|
||
<tr>
|
||
<th>微信用户</th>
|
||
<th>机器人</th>
|
||
<th>状态</th>
|
||
<th>人设</th>
|
||
<th>主动联系</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="peersBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="mobile-cards" id="peersCards" aria-live="polite"></div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="pane" id="pane-chat">
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>微信身份绑定(用户对话)</h2>
|
||
<p class="card-desc">绑定后可在微信发送 <code>@LINUX_DO用户名</code> 与其他已绑定用户对话。先生成绑定码,再给<strong>任意机器人</strong>发送 <code>/绑定 验证码</code>。</p>
|
||
</div>
|
||
</div>
|
||
<div id="wechatBindStatus" class="qr-status" style="margin-bottom:10px"><span class="skeleton" style="display:inline-block;width:9em;height:1em;vertical-align:-0.15em"></span></div>
|
||
<div class="toolbar">
|
||
<button class="btn btn-primary" id="genBindCode" type="button">生成绑定码</button>
|
||
<button class="btn btn-secondary" id="clearBind" type="button">解除绑定</button>
|
||
<button class="btn btn-ghost" id="refreshBind" type="button">刷新状态</button>
|
||
</div>
|
||
<div id="bindCodeBox" class="qr-panel" style="display:none">
|
||
<p class="qr-status" id="bindCodeText"></p>
|
||
<p class="muted" id="bindCodeHint" style="font-size:13px;opacity:.85"></p>
|
||
</div>
|
||
</section>
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>黑名单(用户对话)</h2>
|
||
<p class="card-desc">拉黑后双方无法再通过 <code>@用户名</code> 建立对话;进行中的会话会结束。也可在微信发送 <code>/拉黑 用户名</code>。</p>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar">
|
||
<input id="blockUsername" placeholder="LINUX DO 用户名" style="max-width:220px" />
|
||
<button class="btn btn-danger" id="addBlock" type="button">拉黑</button>
|
||
<button class="btn btn-ghost" id="refreshBlocks" type="button">刷新</button>
|
||
</div>
|
||
<div id="blockList" class="list-panel" style="margin-top:8px"><div class="sk-rows" aria-hidden="true"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div></div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="pane" id="pane-invite">
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>邀请好友注册</h2>
|
||
<p class="card-desc">生成一次性邀请码或链接,好友可用其注册<strong>用户名+密码</strong>账号。LINUX DO 登录无需邀请。生成受配额限制(每 X 小时 N 个)。</p>
|
||
</div>
|
||
</div>
|
||
<p class="invite-quota" id="inviteQuotaText">加载配额…</p>
|
||
<div class="toolbar">
|
||
<button class="btn btn-primary" id="genInvite" type="button">生成邀请链接</button>
|
||
<button class="btn btn-ghost" id="refreshInvites" type="button">刷新</button>
|
||
</div>
|
||
<div id="inviteList" class="invite-list"><div class="sk-rows" aria-hidden="true"><div class="skeleton"></div><div class="skeleton"></div><div class="skeleton"></div></div></div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="pane" id="pane-square">
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>人设广场</h2>
|
||
<p class="card-desc">浏览公开人设:试聊体验 → 加入库分配,或 Fork 改编成自己的版本</p>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar">
|
||
<input id="squareQ" placeholder="搜索名称、简介、标签…" style="min-width:220px;flex:1" />
|
||
<select id="squareSort" title="排序" aria-label="人设排序">
|
||
<option value="heat">热度</option>
|
||
<option value="use">最多收藏</option>
|
||
<option value="recent">最近更新</option>
|
||
<option value="name">名称</option>
|
||
</select>
|
||
<button class="btn btn-primary" id="squareSearch" type="button">搜索</button>
|
||
</div>
|
||
<div class="persona-grid stagger" id="squareGrid"></div>
|
||
<div class="pager" id="squarePager" hidden></div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="pane" id="pane-stickers">
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>表情包广场</h2>
|
||
<p class="card-desc">浏览已审核公开表情,添加到你的库后机器人对话可引用发图。公开投稿需管理员审核。</p>
|
||
</div>
|
||
</div>
|
||
<div class="toolbar">
|
||
<input id="stickerSquareQ" placeholder="搜索名称、标签…" style="min-width:220px;flex:1" />
|
||
<button class="btn btn-primary" id="stickerSquareSearch" type="button">搜索</button>
|
||
</div>
|
||
<div class="persona-grid stagger" id="stickerSquareGrid"></div>
|
||
<div class="pager" id="stickerSquarePager" hidden></div>
|
||
</section>
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>投稿 / 上传表情</h2>
|
||
<p class="card-desc">公开需审核;私有仅自己机器人可用。自动过滤 SVG / 脚本等多语言混合危险图。</p>
|
||
</div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<div class="form-fields">
|
||
<label class="form-field">名称
|
||
<input id="stPubName" placeholder="开心猫" autocomplete="off" />
|
||
</label>
|
||
<label class="form-field">Slug(可选,英文唯一)
|
||
<input id="stPubSlug" placeholder="happy-cat" autocomplete="off" />
|
||
</label>
|
||
<label class="form-field">简介
|
||
<input id="stPubDesc" placeholder="开心时用" autocomplete="off" />
|
||
</label>
|
||
<label class="form-field">标签
|
||
<input id="stPubTags" placeholder="开心, 撒娇" autocomplete="off" />
|
||
</label>
|
||
<label class="form-field">图片
|
||
<input type="file" id="stPubFile" accept="image/png,image/jpeg,image/webp,image/gif" />
|
||
</label>
|
||
</div>
|
||
<div class="form-footer">
|
||
<div class="vis-seg" role="radiogroup" aria-label="可见性">
|
||
<label title="公开到广场,需管理员审核">
|
||
<input type="radio" name="stPubVis" value="public" checked />
|
||
<span>公开到广场</span>
|
||
</label>
|
||
<label title="仅自己使用,无需广场审核">
|
||
<input type="radio" name="stPubVis" value="private" />
|
||
<span>仅自己使用</span>
|
||
</label>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button class="btn btn-primary" id="stPubSubmit" type="button">提交表情</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>我创建的表情</h2>
|
||
<p class="card-desc">审核状态与私有/公开</p>
|
||
</div>
|
||
</div>
|
||
<div class="persona-grid stagger" id="stickerCreatedGrid"></div>
|
||
</section>
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>我的表情库</h2>
|
||
<p class="card-desc">用于你的机器人对话回图</p>
|
||
</div>
|
||
</div>
|
||
<div class="persona-grid stagger" id="stickerLibGrid"></div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="pane" id="pane-mine">
|
||
<section class="card" id="personaEditorCard">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2 id="editorTitle">人设编辑器</h2>
|
||
<p class="card-desc">创作或修改人设;可用变量引用机器人名字,分配给不同机器人时自动替换</p>
|
||
</div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<input type="hidden" id="pubEditId" value="" />
|
||
<div class="form-fields">
|
||
<label class="form-field">人设名称
|
||
<input id="pubName" placeholder="例如:腹黑学姐" autocomplete="off" />
|
||
</label>
|
||
<label class="form-field">简介
|
||
<input id="pubDesc" placeholder="一句话介绍" autocomplete="off" />
|
||
</label>
|
||
<label class="form-field">标签
|
||
<input id="pubTags" placeholder="恋爱, 温柔(逗号分隔)" autocomplete="off" />
|
||
</label>
|
||
<label class="form-field">运行模式
|
||
<select id="pubMode" title="prompt=经典提示词;chatflow=可视化流程">
|
||
<option value="prompt">经典 Prompt</option>
|
||
<option value="chatflow">Chatflow 流程</option>
|
||
</select>
|
||
</label>
|
||
<label class="form-field">模型(对话出站)
|
||
<select id="pubLlmProvider" title="平台默认由管理员配置;自定义经 HF 工具服务发出">
|
||
<option value="">平台默认(管理员 LLM)</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label class="check-row">
|
||
<input type="checkbox" id="pubWebSearch" />
|
||
启用联网搜索
|
||
</label>
|
||
<p class="editor-help" id="pubChatflowHint" style="display:none">
|
||
Chatflow 模式下,请在发布后打开流程编辑器编排节点。LLM / 搜索节点只经工具网关出站。
|
||
<a href="/chatflow" id="pubChatflowLink" target="_blank" rel="noopener">打开 Chatflow 编辑器</a>
|
||
</p>
|
||
<div class="editor-block">
|
||
<span class="field-label">系统提示词(人设正文)</span>
|
||
<div class="var-toolbar">
|
||
<span class="hint">插入变量</span>
|
||
<button type="button" class="var-chip" data-insert="{{bot_name}}" title="运行时替换为当前机器人名字">{{bot_name}}</button>
|
||
<button type="button" class="var-chip" data-insert="{{机器人名字}}" title="同 bot_name,中文写法">{{机器人名字}}</button>
|
||
</div>
|
||
<textarea id="pubPrompt" placeholder="详细描述性格、说话方式、边界… 示例:你是「{{bot_name}}」,一只活泼的猫娘…"></textarea>
|
||
<p class="editor-help">
|
||
对话时系统会自动注入「智能体身份」段,并把
|
||
<code>{{bot_name}}</code> /
|
||
<code>{{机器人名字}}</code>
|
||
替换为该机器人的显示名。人设可复用到多个机器人,名字随绑定机器人变化。
|
||
</p>
|
||
</div>
|
||
<div class="form-footer">
|
||
<div class="vis-seg" role="radiogroup" aria-label="可见性">
|
||
<label title="发布到人设广场,其他人可浏览添加">
|
||
<input type="radio" name="pubVis" value="public" checked />
|
||
<span>公开到广场</span>
|
||
</label>
|
||
<label title="仅自己可见,不出现在广场">
|
||
<input type="radio" name="pubVis" value="private" />
|
||
<span>仅自己使用</span>
|
||
</label>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button class="btn btn-secondary" id="pubCancelEdit" type="button" style="display:none">取消编辑</button>
|
||
<button class="btn btn-primary" id="pubSubmit" type="button">发布人设</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>我创建的</h2>
|
||
<p class="card-desc">可改公开/私有,或删除</p>
|
||
</div>
|
||
</div>
|
||
<div class="persona-grid stagger" id="createdGrid"></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>我的库</h2>
|
||
<p class="card-desc">已添加的人设,可用于机器人分配</p>
|
||
</div>
|
||
</div>
|
||
<div class="persona-grid stagger" id="libGrid"></div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="pane" id="pane-models">
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>我的模型连接</h2>
|
||
<p class="card-desc">自定义 OpenAI 兼容 API。主站不会直连你的地址,请求经 HuggingFace 工具服务(TOOLS)代发;密钥加密存储,日志不打印明文。</p>
|
||
</div>
|
||
</div>
|
||
<p class="editor-help" id="llmProvGatewayNote">加载网关状态…</p>
|
||
<div class="form-grid" style="margin-top:12px">
|
||
<div class="form-fields" style="grid-template-columns:minmax(0,1fr) minmax(0,1.2fr)">
|
||
<label class="form-field">名称
|
||
<input id="llmProvName" placeholder="例如:我的中转" autocomplete="off" />
|
||
</label>
|
||
<label class="form-field">默认模型
|
||
<input id="llmProvModel" placeholder="gpt-4o-mini" autocomplete="off" />
|
||
</label>
|
||
</div>
|
||
<label class="form-field">Base URL(OpenAI 兼容,含 /v1)
|
||
<input id="llmProvBase" placeholder="https://api.example.com/v1" autocomplete="off" />
|
||
</label>
|
||
<label class="form-field">API Key
|
||
<input id="llmProvKey" type="password" placeholder="sk-…" autocomplete="off" />
|
||
</label>
|
||
<div class="form-footer">
|
||
<div class="form-actions">
|
||
<button class="btn btn-primary" id="llmProvSubmit" type="button">添加连接</button>
|
||
<button class="btn btn-ghost" id="llmProvRefresh" type="button">刷新</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="llm-prov-list" id="llmProvList" style="margin-top:16px"></div>
|
||
</section>
|
||
</div>
|
||
</main>
|
||
|
||
<div class="toast glass glass-rim" id="toast" role="status"></div>
|
||
<div class="confirm-backdrop" id="confirmBackdrop" role="alertdialog" aria-modal="true" aria-labelledby="confirmTitle" aria-describedby="confirmMsg">
|
||
<div class="confirm-card glass glass-strong glass-rim">
|
||
<h3 id="confirmTitle">确认操作</h3>
|
||
<p id="confirmMsg"></p>
|
||
<div class="row-actions">
|
||
<button class="btn btn-secondary" type="button" id="confirmCancel">取消</button>
|
||
<button class="btn btn-danger" type="button" id="confirmOk">确认</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button type="button" class="fab-refresh glass glass-rim" id="fabRefresh" title="刷新" aria-label="刷新当前页"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i></button>
|
||
<div class="modal-backdrop" id="personaModal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||
<div class="modal glass glass-strong glass-rim">
|
||
<div class="modal-handle" aria-hidden="true"></div>
|
||
<h3 id="modalTitle">人设详情</h3>
|
||
<p class="desc sub" id="modalDesc"></p>
|
||
<p class="modal-meta" id="modalUseCount"></p>
|
||
<p class="modal-meta" id="modalForkFrom"></p>
|
||
<div class="tag-row" id="modalTags"></div>
|
||
<pre id="modalPrompt"></pre>
|
||
<div class="row-actions">
|
||
<button class="btn btn-primary" type="button" id="modalTry"><i class="bi bi-chat-dots" aria-hidden="true"></i>试聊</button>
|
||
<button class="btn btn-secondary" type="button" id="modalFork"><i class="bi bi-shuffle" aria-hidden="true"></i>改编</button>
|
||
<button class="btn btn-primary" type="button" id="modalAdd"><i class="bi bi-plus-lg" aria-hidden="true"></i>添加到库</button>
|
||
<button class="btn btn-secondary" type="button" id="modalClose"><i class="bi bi-x-lg" aria-hidden="true"></i>关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 网页试聊弹层 -->
|
||
<div class="modal-backdrop" id="tryChatModal" role="dialog" aria-modal="true" aria-labelledby="tryChatTitle">
|
||
<div class="modal try-chat-modal glass glass-strong glass-rim">
|
||
<div class="modal-handle" aria-hidden="true"></div>
|
||
<div class="try-chat-head">
|
||
<div>
|
||
<h3 id="tryChatTitle">网页试聊</h3>
|
||
<p class="desc" id="tryChatSub">不写入微信 · 限量体验</p>
|
||
</div>
|
||
<button class="btn btn-ghost btn-sm" type="button" id="tryChatClose" aria-label="关闭"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
|
||
</div>
|
||
<div id="tryChatLog" class="try-chat-log"></div>
|
||
<p id="tryChatQuota" class="try-chat-quota"></p>
|
||
<div class="toolbar try-chat-compose">
|
||
<input id="tryChatInput" placeholder="说点什么…" maxlength="2000" />
|
||
<button class="btn btn-primary" type="button" id="tryChatSend">发送</button>
|
||
</div>
|
||
<div class="row-actions try-chat-actions">
|
||
<button class="btn btn-secondary" type="button" id="tryChatFork"><i class="bi bi-shuffle" aria-hidden="true"></i>改编</button>
|
||
<button class="btn btn-secondary" type="button" id="tryChatAdd"><i class="bi bi-plus-lg" aria-hidden="true"></i>加入库</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主动联系设置弹窗 -->
|
||
<div class="modal-backdrop" id="proactiveModal" role="dialog" aria-modal="true" aria-labelledby="proModalTitle">
|
||
<div class="modal glass glass-strong glass-rim" style="width:min(440px,100%)">
|
||
<div class="modal-handle" aria-hidden="true"></div>
|
||
<h3 id="proModalTitle" style="margin:0 0 4px">主动联系</h3>
|
||
<p class="desc" id="proModalSub" style="color:var(--text-secondary);font-size:13px;margin:0">配置空闲触发参数</p>
|
||
<div class="pro-modal-form">
|
||
<label class="pro-switch">
|
||
<input type="checkbox" id="proEn" />
|
||
开启主动联系
|
||
</label>
|
||
<div class="pro-grid">
|
||
<label>空闲小时<span class="hint">≥0.25,0.5=30分钟</span>
|
||
<input type="number" id="proIdle" min="0.25" max="720" step="0.25" inputmode="decimal" />
|
||
</label>
|
||
<label>最小间隔(时)<span class="hint">0=不限制</span>
|
||
<input type="number" id="proMin" min="0" max="720" step="0.25" inputmode="decimal" />
|
||
</label>
|
||
<label>每日上限<span class="hint">0=不限制</span>
|
||
<input type="number" id="proMax" min="0" max="9999" step="1" inputmode="numeric" />
|
||
</label>
|
||
<label>安静时段<span class="hint">如 0-8,留空关闭</span>
|
||
<input type="text" id="proQuiet" placeholder="0-8" autocomplete="off" />
|
||
</label>
|
||
</div>
|
||
<p class="pro-note" id="proModalNote">仅联系已在用户列表开启「允许主动」的好友;对方须曾聊过。</p>
|
||
</div>
|
||
<div class="row-actions" style="margin-top:16px;justify-content:flex-end">
|
||
<button class="btn btn-secondary" type="button" id="proModalCancel">取消</button>
|
||
<button class="btn btn-primary" type="button" id="proModalSave"><i class="bi bi-check-lg" aria-hidden="true"></i>保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const state = {
|
||
user: null,
|
||
bots: [],
|
||
personas: [],
|
||
loginSessionId: null,
|
||
timer: null,
|
||
editingPersonaId: null,
|
||
squarePage: 1,
|
||
stickerPage: 1,
|
||
};
|
||
const PAGE_SIZE = 20;
|
||
const PANE_IDS = ["bots", "chat", "invite", "square", "stickers", "mine", "models"];
|
||
const $ = (id) => document.getElementById(id);
|
||
/** Cached custom LLM providers for persona editor select */
|
||
let llmProvidersCache = [];
|
||
|
||
/** Insert text at cursor in a textarea */
|
||
function insertAtCursor(textarea, text) {
|
||
if (!textarea) return;
|
||
const start = textarea.selectionStart ?? textarea.value.length;
|
||
const end = textarea.selectionEnd ?? start;
|
||
const before = textarea.value.slice(0, start);
|
||
const after = textarea.value.slice(end);
|
||
textarea.value = before + text + after;
|
||
const pos = start + text.length;
|
||
textarea.focus();
|
||
textarea.setSelectionRange(pos, pos);
|
||
}
|
||
|
||
document.querySelectorAll("[data-insert]").forEach((btn) => {
|
||
btn.onclick = () => {
|
||
insertAtCursor($("pubPrompt"), btn.dataset.insert);
|
||
toast("已插入 " + btn.dataset.insert);
|
||
};
|
||
});
|
||
|
||
function activePane() {
|
||
return (
|
||
document.querySelector("#mainTabs button.active")?.dataset.pane || "bots"
|
||
);
|
||
}
|
||
function switchPane(pane, pushHash) {
|
||
if (!PANE_IDS.includes(pane)) pane = "bots";
|
||
document.querySelectorAll("#mainTabs button").forEach((b) => {
|
||
const on = b.dataset.pane === pane;
|
||
b.classList.toggle("active", on);
|
||
if (on) {
|
||
b.setAttribute("aria-current", "page");
|
||
try {
|
||
b.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
|
||
} catch (_) {}
|
||
} else {
|
||
b.removeAttribute("aria-current");
|
||
}
|
||
});
|
||
document.querySelectorAll(".pane").forEach((p) =>
|
||
p.classList.toggle("active", p.id === "pane-" + pane),
|
||
);
|
||
if (pushHash !== false) {
|
||
const next = "#" + pane;
|
||
if ((location.hash || "") !== next) {
|
||
try {
|
||
history.replaceState(null, "", next);
|
||
} catch (_) {
|
||
location.hash = pane;
|
||
}
|
||
}
|
||
}
|
||
const titles = {
|
||
bots: "机器人",
|
||
chat: "用户对话",
|
||
invite: "邀请",
|
||
square: "人设广场",
|
||
stickers: "表情包广场",
|
||
mine: "我的人设",
|
||
models: "我的模型",
|
||
};
|
||
document.title = `WeChat-AI · ${titles[pane] || "用户中心"}`;
|
||
if (pane === "chat") refreshChat().catch(() => {});
|
||
if (pane === "invite") loadInvites().catch((e) => toast(e.message || "加载失败", true));
|
||
if (pane === "square") loadSquare().catch(onSquareError);
|
||
if (pane === "stickers") loadStickerSquare().catch((e) => toast(e.message || "加载失败", true));
|
||
if (pane === "mine") {
|
||
fillPubLlmSelect().catch(() => {});
|
||
loadMine().catch(onMineError);
|
||
}
|
||
if (pane === "models") loadLlmProviders().catch((e) => toast(e.message || "加载失败", true));
|
||
try {
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
} catch (_) {}
|
||
}
|
||
document.querySelectorAll("#mainTabs button").forEach((btn) => {
|
||
btn.onclick = () => switchPane(btn.dataset.pane);
|
||
});
|
||
window.addEventListener("hashchange", () => {
|
||
const h = (location.hash || "").replace(/^#/, "");
|
||
if (PANE_IDS.includes(h)) switchPane(h, false);
|
||
});
|
||
|
||
// Horizontal swipe between tabs on main content
|
||
(function setupSwipePanes() {
|
||
const main = $("main");
|
||
if (!main) return;
|
||
let x0 = 0;
|
||
let y0 = 0;
|
||
let tracking = false;
|
||
main.addEventListener(
|
||
"touchstart",
|
||
(e) => {
|
||
if (window.innerWidth > 720) return;
|
||
if (!e.touches[0]) return;
|
||
if (e.target.closest("input, textarea, select, .modal, .qr-panel, .tabs"))
|
||
return;
|
||
x0 = e.touches[0].clientX;
|
||
y0 = e.touches[0].clientY;
|
||
tracking = true;
|
||
},
|
||
{ passive: true },
|
||
);
|
||
main.addEventListener(
|
||
"touchend",
|
||
(e) => {
|
||
if (!tracking) return;
|
||
tracking = false;
|
||
const t = e.changedTouches[0];
|
||
if (!t) return;
|
||
const dx = t.clientX - x0;
|
||
const dy = t.clientY - y0;
|
||
if (Math.abs(dx) < 64 || Math.abs(dx) < Math.abs(dy) * 1.2) return;
|
||
const i = PANE_IDS.indexOf(activePane());
|
||
if (i < 0) return;
|
||
if (dx < 0 && i < PANE_IDS.length - 1) switchPane(PANE_IDS[i + 1]);
|
||
else if (dx > 0 && i > 0) switchPane(PANE_IDS[i - 1]);
|
||
},
|
||
{ passive: true },
|
||
);
|
||
})();
|
||
|
||
(function setupModalSwipe() {
|
||
const bd = $("personaModal");
|
||
const modal = bd?.querySelector(".modal");
|
||
if (!bd || !modal) return;
|
||
let y0 = 0;
|
||
let pulling = false;
|
||
modal.addEventListener(
|
||
"touchstart",
|
||
(e) => {
|
||
if (window.innerWidth > 720) return;
|
||
if (modal.scrollTop > 2) return;
|
||
if (!e.touches[0]) return;
|
||
if (e.target.closest("input, textarea, pre, button, a")) return;
|
||
y0 = e.touches[0].clientY;
|
||
pulling = true;
|
||
modal.style.transition = "none";
|
||
},
|
||
{ passive: true },
|
||
);
|
||
modal.addEventListener(
|
||
"touchmove",
|
||
(e) => {
|
||
if (!pulling || !e.touches[0]) return;
|
||
const dy = e.touches[0].clientY - y0;
|
||
modal.style.transform = dy > 0 ? `translateY(${Math.min(dy, 280)}px)` : "";
|
||
},
|
||
{ passive: true },
|
||
);
|
||
modal.addEventListener(
|
||
"touchend",
|
||
(e) => {
|
||
if (!pulling) return;
|
||
pulling = false;
|
||
const dy = (e.changedTouches[0]?.clientY || 0) - y0;
|
||
modal.style.transition = "transform 0.2s var(--ease)";
|
||
if (dy > 110) {
|
||
modal.style.transform = "translateY(110%)";
|
||
setTimeout(() => {
|
||
closeModal();
|
||
modal.style.transform = "";
|
||
modal.style.transition = "";
|
||
}, 180);
|
||
} else {
|
||
modal.style.transform = "";
|
||
}
|
||
},
|
||
{ passive: true },
|
||
);
|
||
})();
|
||
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && $("personaModal")?.classList.contains("show")) {
|
||
closeModal();
|
||
}
|
||
});
|
||
|
||
function setOffline(off) {
|
||
$("offlineBanner")?.classList.toggle("show", off);
|
||
document.body.classList.toggle("offline", off);
|
||
}
|
||
window.addEventListener("online", () => setOffline(false));
|
||
window.addEventListener("offline", () => setOffline(true));
|
||
if (!navigator.onLine) setOffline(true);
|
||
|
||
window.addEventListener(
|
||
"scroll",
|
||
() => {
|
||
const y = window.scrollY > 4;
|
||
document.querySelector(".nav")?.classList.toggle("scrolled", y);
|
||
document.querySelector(".tabs")?.classList.toggle("scrolled", y);
|
||
},
|
||
{ passive: true },
|
||
);
|
||
|
||
$("fabRefresh")?.addEventListener("click", async () => {
|
||
const fab = $("fabRefresh");
|
||
if (fab) {
|
||
fab.classList.remove("spin");
|
||
void fab.offsetWidth;
|
||
fab.classList.add("spin");
|
||
}
|
||
try {
|
||
const pane = activePane();
|
||
if (pane === "square") await loadSquare();
|
||
else if (pane === "stickers") await loadStickerSquare();
|
||
else if (pane === "mine") await loadMine();
|
||
else if (pane === "chat") await refreshChat();
|
||
else await refreshBots();
|
||
toast("已刷新");
|
||
} catch (e) {
|
||
toast(e.message || "刷新失败", true);
|
||
}
|
||
});
|
||
|
||
const THEME_KEY = "wa_theme";
|
||
|
||
function currentTheme() {
|
||
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||
}
|
||
function themePref() {
|
||
try {
|
||
const p = localStorage.getItem(THEME_KEY);
|
||
if (p === "system" || p === "light" || p === "dark") return p;
|
||
} catch (_) {}
|
||
return document.documentElement.getAttribute("data-theme-pref") === "system"
|
||
? "system"
|
||
: currentTheme();
|
||
}
|
||
function resolveSystemTheme() {
|
||
try {
|
||
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||
} catch (_) {
|
||
return "light";
|
||
}
|
||
}
|
||
function applyTheme(theme) {
|
||
let pref = theme;
|
||
let t;
|
||
if (theme === "system") {
|
||
pref = "system";
|
||
t = resolveSystemTheme();
|
||
document.documentElement.setAttribute("data-theme-pref", "system");
|
||
} else {
|
||
t = theme === "dark" ? "dark" : "light";
|
||
pref = t;
|
||
document.documentElement.removeAttribute("data-theme-pref");
|
||
}
|
||
document.documentElement.setAttribute("data-theme", t);
|
||
try { localStorage.setItem(THEME_KEY, pref); } catch (_) {}
|
||
const meta = $("themeColor");
|
||
if (meta) meta.setAttribute("content", t === "dark" ? "#000000" : "#f5f5f7");
|
||
const btn = $("themeToggle");
|
||
if (btn) {
|
||
btn.title =
|
||
pref === "system"
|
||
? "主题:跟随系统(点击切换)"
|
||
: pref === "dark"
|
||
? "主题:深色(点击切换)"
|
||
: "主题:浅色(点击切换)";
|
||
}
|
||
}
|
||
|
||
$("themeToggle").onclick = () => {
|
||
const p = themePref();
|
||
if (p === "light") applyTheme("dark");
|
||
else if (p === "dark") applyTheme("system");
|
||
else applyTheme("light");
|
||
const labels = { light: "浅色", dark: "深色", system: "跟随系统" };
|
||
toast("主题:" + (labels[themePref()] || themePref()));
|
||
};
|
||
|
||
try {
|
||
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
|
||
let stored = null;
|
||
try { stored = localStorage.getItem(THEME_KEY); } catch (_) {}
|
||
if (stored === "system") applyTheme("system");
|
||
else if (!stored) {
|
||
const t = e.matches ? "dark" : "light";
|
||
document.documentElement.setAttribute("data-theme", t);
|
||
const meta = $("themeColor");
|
||
if (meta) meta.setAttribute("content", t === "dark" ? "#000000" : "#f5f5f7");
|
||
}
|
||
});
|
||
if (localStorage.getItem(THEME_KEY) === "system") applyTheme("system");
|
||
} catch (_) {}
|
||
// Sync status-bar color + toggle title with the resolved theme (no pref write)
|
||
(function () {
|
||
const meta = $("themeColor");
|
||
if (meta) meta.setAttribute("content", currentTheme() === "dark" ? "#000000" : "#f5f5f7");
|
||
const btn = $("themeToggle");
|
||
if (btn) {
|
||
const p = themePref();
|
||
btn.title =
|
||
p === "system"
|
||
? "主题:跟随系统(点击切换)"
|
||
: p === "dark"
|
||
? "主题:深色(点击切换)"
|
||
: "主题:浅色(点击切换)";
|
||
}
|
||
})();
|
||
|
||
function toast(msg, err) {
|
||
const el = $("toast");
|
||
el.textContent = msg;
|
||
// keep the glass material classes — a bare reassignment would strip them
|
||
// before the toast is ever painted
|
||
el.className = "toast glass glass-rim show" + (err ? " err" : "");
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => el.classList.remove("show"), 2800);
|
||
}
|
||
|
||
/** Apple-style confirm sheet; resolves true/false. Falls back to window.confirm. */
|
||
function confirmDialog(opts = {}) {
|
||
return new Promise((resolve) => {
|
||
const bd = $("confirmBackdrop");
|
||
if (!bd) {
|
||
resolve(window.confirm(opts.message || "确认?"));
|
||
return;
|
||
}
|
||
$("confirmTitle").textContent = opts.title || "确认操作";
|
||
$("confirmMsg").textContent = opts.message || "";
|
||
const okBtn = $("confirmOk");
|
||
okBtn.textContent = opts.okText || "确认";
|
||
okBtn.className = "btn " + (opts.danger === false ? "btn-primary" : "btn-danger");
|
||
bd.classList.add("show");
|
||
document.body.style.overflow = "hidden";
|
||
const done = (val) => {
|
||
bd.classList.remove("show");
|
||
document.body.style.overflow = "";
|
||
document.removeEventListener("keydown", onKey, true);
|
||
resolve(val);
|
||
};
|
||
const onKey = (e) => {
|
||
if (e.key === "Escape") {
|
||
e.stopPropagation();
|
||
done(false);
|
||
}
|
||
};
|
||
document.addEventListener("keydown", onKey, true);
|
||
okBtn.onclick = () => done(true);
|
||
$("confirmCancel").onclick = () => done(false);
|
||
bd.onclick = (e) => {
|
||
if (e.target === bd) done(false);
|
||
};
|
||
okBtn.focus({ preventScroll: true });
|
||
});
|
||
}
|
||
|
||
// Top progress bar: counts in-flight api() calls (never negative, never stuck)
|
||
let apiInflight = 0;
|
||
function setLoadBar(on) {
|
||
const bar = document.getElementById("loadBar");
|
||
if (bar) bar.classList.toggle("on", on);
|
||
}
|
||
const API_TIMEOUT_MS = 20000;
|
||
async function api(path, opts = {}) {
|
||
const headers = { ...(opts.headers || {}) };
|
||
if (opts.body && typeof opts.body === "object") {
|
||
headers["Content-Type"] = "application/json";
|
||
opts.body = JSON.stringify(opts.body);
|
||
}
|
||
// Without this a stalled request leaves the boot splash up forever with
|
||
// no error path. Callers can pass opts.timeoutMs (or 0 to opt out).
|
||
const timeoutMs = opts.timeoutMs === undefined ? API_TIMEOUT_MS : opts.timeoutMs;
|
||
let ctrl = null;
|
||
let timer = null;
|
||
if (timeoutMs > 0 && typeof AbortController !== "undefined" && !opts.signal) {
|
||
ctrl = new AbortController();
|
||
timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||
}
|
||
apiInflight++;
|
||
setLoadBar(true);
|
||
try {
|
||
const res = await fetch(path, {
|
||
...opts,
|
||
headers,
|
||
credentials: "include",
|
||
signal: ctrl ? ctrl.signal : opts.signal,
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(data.message || data.error || res.statusText);
|
||
return data;
|
||
} catch (err) {
|
||
if (ctrl && ctrl.signal.aborted) {
|
||
throw new Error("请求超时,请检查网络后重试");
|
||
}
|
||
throw err;
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
apiInflight = Math.max(0, apiInflight - 1);
|
||
if (apiInflight === 0) setLoadBar(false);
|
||
}
|
||
}
|
||
|
||
function skRows(n) {
|
||
return `<div class="sk-rows" aria-hidden="true">${'<div class="skeleton"></div>'.repeat(n)}</div>`;
|
||
}
|
||
function showListSkeleton(bodyId, cardsId, cols, rows) {
|
||
const body = $(bodyId);
|
||
if (body && !body.children.length) {
|
||
body.innerHTML = `<tr><td colspan="${cols}">${skRows(rows)}</td></tr>`;
|
||
}
|
||
const cards = $(cardsId);
|
||
if (cards && !cards.children.length) cards.innerHTML = skRows(rows);
|
||
}
|
||
function showGridSkeleton(gridId, n) {
|
||
const grid = $(gridId);
|
||
if (grid && !grid.children.length) {
|
||
grid.innerHTML =
|
||
`<div class="skeleton sk-tile" aria-hidden="true"></div>`.repeat(n);
|
||
}
|
||
}
|
||
function errorHtml(retryFn) {
|
||
return `<div class="empty">加载失败,请检查网络<br/>
|
||
<button class="btn btn-secondary btn-sm" type="button" onclick="${retryFn}()">重试</button></div>`;
|
||
}
|
||
function showListError(bodyId, cardsId, cols, retryFn) {
|
||
const body = $(bodyId);
|
||
if (body) body.innerHTML = `<tr><td colspan="${cols}">${errorHtml(retryFn)}</td></tr>`;
|
||
const cards = $(cardsId);
|
||
if (cards) cards.innerHTML = errorHtml(retryFn);
|
||
}
|
||
function onRefreshError(e) {
|
||
toast(e.message || "加载失败", true);
|
||
showListError("botsBody", "botsCards", 4, "retryRefresh");
|
||
showListError("peersBody", "peersCards", 5, "retryRefresh");
|
||
}
|
||
function retryRefresh() {
|
||
for (const id of ["botsBody", "botsCards", "peersBody", "peersCards"]) {
|
||
const el = $(id);
|
||
if (el) el.innerHTML = "";
|
||
}
|
||
showListSkeleton("botsBody", "botsCards", 4, 2);
|
||
showListSkeleton("peersBody", "peersCards", 5, 3);
|
||
refreshBots().catch(onRefreshError);
|
||
}
|
||
function onSquareError(e) {
|
||
toast(e.message || "加载失败", true);
|
||
const grid = $("squareGrid");
|
||
if (grid) grid.innerHTML = errorHtml("retrySquare");
|
||
}
|
||
function retrySquare() {
|
||
const grid = $("squareGrid");
|
||
if (grid) grid.innerHTML = "";
|
||
loadSquare().catch(onSquareError);
|
||
}
|
||
function onMineError(e) {
|
||
toast(e.message || "加载失败", true);
|
||
const created = $("createdGrid");
|
||
if (created) created.innerHTML = errorHtml("retryMine");
|
||
const lib = $("libGrid");
|
||
if (lib) lib.innerHTML = "";
|
||
}
|
||
function retryMine() {
|
||
const created = $("createdGrid");
|
||
if (created) created.innerHTML = "";
|
||
const lib = $("libGrid");
|
||
if (lib) lib.innerHTML = "";
|
||
loadMine().catch(onMineError);
|
||
}
|
||
|
||
function setUserAvatar(el, url, fallbackName) {
|
||
if (!el) return;
|
||
const initial = String(fallbackName || "U")
|
||
.trim()
|
||
.slice(0, 1)
|
||
.toUpperCase() || "U";
|
||
const safeUrl =
|
||
typeof url === "string" &&
|
||
(url.startsWith("https://") || url.startsWith("http://"))
|
||
? url
|
||
: "";
|
||
if (!safeUrl) {
|
||
el.classList.remove("has-img");
|
||
el.innerHTML = "";
|
||
el.textContent = initial;
|
||
return;
|
||
}
|
||
el.classList.add("has-img");
|
||
el.textContent = "";
|
||
const img = document.createElement("img");
|
||
img.src = safeUrl;
|
||
img.alt = "";
|
||
img.referrerPolicy = "no-referrer";
|
||
img.decoding = "async";
|
||
img.onerror = () => {
|
||
el.classList.remove("has-img");
|
||
el.innerHTML = "";
|
||
el.textContent = initial;
|
||
};
|
||
el.appendChild(img);
|
||
}
|
||
|
||
function showAuthError(msg) {
|
||
const el = $("authError");
|
||
if (!el) return;
|
||
if (!msg) {
|
||
el.hidden = true;
|
||
el.innerHTML = "";
|
||
return;
|
||
}
|
||
el.hidden = false;
|
||
el.innerHTML =
|
||
`<i class="bi bi-exclamation-circle auth-error-icon" aria-hidden="true"></i>` +
|
||
`<span class="auth-error-text">${esc(msg)}</span>`;
|
||
}
|
||
|
||
function setAuthBusy(btn, busy) {
|
||
if (!btn) return;
|
||
btn.classList.toggle("is-loading", !!busy);
|
||
btn.disabled = !!busy;
|
||
}
|
||
|
||
function setAuthTab(tab) {
|
||
document.querySelectorAll("#authTabs button").forEach((b) => {
|
||
const on = b.dataset.authTab === tab;
|
||
b.classList.toggle("active", on);
|
||
b.setAttribute("aria-selected", on ? "true" : "false");
|
||
});
|
||
if ($("authLoginPanel")) $("authLoginPanel").style.display = tab === "login" ? "" : "none";
|
||
if ($("authRegisterPanel")) $("authRegisterPanel").style.display = tab === "register" ? "" : "none";
|
||
showAuthError("");
|
||
const title = $("loginGateTitle");
|
||
const sub = $("loginGateSub");
|
||
const inviteOk = !!(sub && sub.dataset.inviteLocked === "1");
|
||
if (tab === "register") {
|
||
if (title) title.textContent = inviteOk ? "欢迎加入" : "创建账号";
|
||
if (sub) {
|
||
sub.textContent = inviteOk
|
||
? "使用下方表单完成注册,即可加入 WeChat-AI。"
|
||
: "填写邀请码与账号信息,完成后即可管理机器人与人设。";
|
||
}
|
||
} else {
|
||
if (title) title.textContent = "管理你的微信机器人";
|
||
if (sub) {
|
||
sub.textContent =
|
||
"登录后添加机器人、批准好友、分配角色。本地注册需邀请码;LINUX DO 可直接登录。";
|
||
}
|
||
}
|
||
}
|
||
|
||
function setInviteBanner(state) {
|
||
const banner = $("inviteBanner");
|
||
if (!banner) return;
|
||
const title = $("inviteBannerTitle");
|
||
const meta = $("inviteBannerMeta");
|
||
const icon = $("inviteBannerIcon");
|
||
if (!state) {
|
||
banner.classList.remove("show", "invalid");
|
||
return;
|
||
}
|
||
banner.classList.add("show");
|
||
banner.classList.toggle("invalid", !!state.invalid);
|
||
if (title) title.textContent = state.title || "邀请注册";
|
||
if (meta) {
|
||
if (state.metaHtml) meta.innerHTML = state.metaHtml;
|
||
else meta.textContent = state.meta || "";
|
||
}
|
||
if (icon) {
|
||
icon.className =
|
||
"bi " + (state.invalid ? "bi-exclamation-triangle" : "bi-envelope-check");
|
||
}
|
||
}
|
||
|
||
async function resolveInviteFromUrl() {
|
||
let inv = "";
|
||
try {
|
||
inv = (new URLSearchParams(location.search).get("invite") || "").trim();
|
||
} catch (_) {
|
||
inv = "";
|
||
}
|
||
if (!inv) {
|
||
setInviteBanner(null);
|
||
return;
|
||
}
|
||
const code = inv.toUpperCase();
|
||
if ($("regInvite")) $("regInvite").value = code;
|
||
setAuthTab("register");
|
||
setInviteBanner({
|
||
title: "正在校验邀请码…",
|
||
meta: code,
|
||
});
|
||
try {
|
||
const data = await api(
|
||
"/api/v1/auth/invite/" + encodeURIComponent(code),
|
||
);
|
||
if (data && data.valid) {
|
||
const who = data.inviterUsername
|
||
? `@${data.inviterUsername}`
|
||
: "好友";
|
||
const exp = data.expiresAt
|
||
? ` · 有效期至 ${String(data.expiresAt).slice(0, 16).replace("T", " ")}`
|
||
: "";
|
||
setInviteBanner({
|
||
title: "你收到了一份注册邀请",
|
||
metaHtml: `来自 <strong>${esc(who)}</strong> 的邀请码 <code>${esc(code)}</code>${esc(exp)}`,
|
||
});
|
||
const sub = $("loginGateSub");
|
||
if (sub) {
|
||
sub.textContent = `使用下方表单完成注册,即可加入 WeChat-AI。`;
|
||
sub.dataset.inviteLocked = "1";
|
||
}
|
||
const title = $("loginGateTitle");
|
||
if (title) title.textContent = "欢迎加入";
|
||
const invInput = $("regInvite");
|
||
if (invInput) {
|
||
invInput.readOnly = true;
|
||
invInput.title = "已从邀请链接自动填入";
|
||
invInput.style.letterSpacing = "0.06em";
|
||
invInput.style.fontWeight = "600";
|
||
}
|
||
} else {
|
||
setInviteBanner({
|
||
invalid: true,
|
||
title: "邀请码无效或已过期",
|
||
metaHtml: `码 <code>${esc(code)}</code> 无法使用。请向好友重新索取邀请链接,或改用 LINUX DO 登录。`,
|
||
});
|
||
}
|
||
} catch (_) {
|
||
// Soft-fail: still prefill code, don't block registration
|
||
setInviteBanner({
|
||
title: "邀请码已填入",
|
||
metaHtml: `已自动填入 <code>${esc(code)}</code>,请完善账号信息完成注册。`,
|
||
});
|
||
}
|
||
}
|
||
|
||
function bindPasswordToggles(root) {
|
||
(root || document).querySelectorAll("[data-pw-toggle]").forEach((btn) => {
|
||
if (btn.dataset.bound) return;
|
||
btn.dataset.bound = "1";
|
||
btn.onclick = () => {
|
||
const id = btn.getAttribute("data-pw-toggle");
|
||
const input = id ? $(id) : null;
|
||
if (!input) return;
|
||
const show = input.type === "password";
|
||
input.type = show ? "text" : "password";
|
||
btn.setAttribute("aria-label", show ? "隐藏密码" : "显示密码");
|
||
const i = btn.querySelector("i");
|
||
if (i) {
|
||
i.className = "bi " + (show ? "bi-eye-slash" : "bi-eye");
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
async function showLoginGate() {
|
||
$("bootSplash").style.display = "none";
|
||
$("loginGate").style.display = "flex";
|
||
$("main").style.display = "none";
|
||
bindPasswordToggles($("loginGate"));
|
||
await resolveInviteFromUrl();
|
||
try {
|
||
const cfg = await api("/api/v1/auth/config");
|
||
if (cfg) {
|
||
if (cfg.localAuthEnabled === false) {
|
||
setAuthTab("login");
|
||
if ($("authTabs")) $("authTabs").style.display = "none";
|
||
if ($("authLoginPanel")) $("authLoginPanel").style.display = "none";
|
||
if ($("authRegisterPanel")) $("authRegisterPanel").style.display = "none";
|
||
setInviteBanner(null);
|
||
}
|
||
if (cfg.oauthEnabled === false) {
|
||
const btn = $("loginBtn");
|
||
if (btn) btn.style.display = "none";
|
||
const divider = $("loginGate")?.querySelector(".auth-divider");
|
||
if (divider) divider.style.display = "none";
|
||
const note = $("loginGate")?.querySelector(".auth-footer-note");
|
||
if (note) note.style.display = "none";
|
||
}
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
async function boot() {
|
||
let me;
|
||
try {
|
||
me = await api("/api/v1/auth/me");
|
||
} catch (e) {
|
||
const msg = e && e.message ? String(e.message) : "";
|
||
await showLoginGate();
|
||
if (/banned|封禁/i.test(msg)) showAuthError(msg);
|
||
return;
|
||
}
|
||
state.user = me.user;
|
||
$("bootSplash").style.display = "none";
|
||
$("loginGate").style.display = "none";
|
||
$("main").style.display = "block";
|
||
$("userBar").style.display = "inline-flex";
|
||
$("userName").textContent = me.user.name || me.user.username;
|
||
setUserAvatar(
|
||
$("userInitial"),
|
||
me.user.avatarUrl,
|
||
me.user.name || me.user.username || "U",
|
||
);
|
||
if (me.user.isAdmin) {
|
||
const al = $("adminLink");
|
||
if (al) {
|
||
al.style.display = "inline-flex";
|
||
al.classList.add("show-admin-mobile");
|
||
al.title = "管理后台";
|
||
}
|
||
}
|
||
showListSkeleton("botsBody", "botsCards", 4, 2);
|
||
showListSkeleton("peersBody", "peersCards", 5, 3);
|
||
// Switch to the target pane immediately so its own loader starts in
|
||
// parallel with refresh() instead of queueing behind it.
|
||
const h = (location.hash || "").replace(/^#/, "");
|
||
if (PANE_IDS.includes(h)) switchPane(h, false);
|
||
else {
|
||
try {
|
||
history.replaceState(null, "", "#bots");
|
||
} catch (_) {}
|
||
switchPane("bots", false);
|
||
}
|
||
try {
|
||
await refresh();
|
||
} catch (e) {
|
||
onRefreshError(e);
|
||
}
|
||
}
|
||
|
||
document.querySelectorAll("#authTabs button").forEach((btn) => {
|
||
btn.onclick = () => setAuthTab(btn.dataset.authTab);
|
||
});
|
||
|
||
const regInviteInput = $("regInvite");
|
||
if (regInviteInput) {
|
||
regInviteInput.addEventListener("input", () => {
|
||
if (regInviteInput.readOnly) return;
|
||
const start = regInviteInput.selectionStart;
|
||
const end = regInviteInput.selectionEnd;
|
||
const up = regInviteInput.value.toUpperCase().replace(/[^A-Z0-9_-]/g, "");
|
||
if (up !== regInviteInput.value) {
|
||
regInviteInput.value = up;
|
||
try {
|
||
regInviteInput.setSelectionRange(start, end);
|
||
} catch (_) {}
|
||
}
|
||
});
|
||
}
|
||
|
||
const passwordLoginForm = $("passwordLoginForm");
|
||
if (passwordLoginForm) {
|
||
passwordLoginForm.onsubmit = async (ev) => {
|
||
ev.preventDefault();
|
||
showAuthError("");
|
||
const username = ($("loginUsername")?.value || "").trim();
|
||
const password = $("loginPassword")?.value || "";
|
||
if (!username || !password) {
|
||
showAuthError("请输入用户名和密码");
|
||
return;
|
||
}
|
||
const btn = $("passwordLoginSubmit");
|
||
setAuthBusy(btn, true);
|
||
try {
|
||
await api("/api/v1/auth/password-login", {
|
||
method: "POST",
|
||
body: { username, password },
|
||
});
|
||
location.href = "/app";
|
||
} catch (e) {
|
||
showAuthError(e.message || "登录失败");
|
||
setAuthBusy(btn, false);
|
||
}
|
||
};
|
||
}
|
||
|
||
const registerForm = $("registerForm");
|
||
if (registerForm) {
|
||
registerForm.onsubmit = async (ev) => {
|
||
ev.preventDefault();
|
||
showAuthError("");
|
||
const inviteCode = ($("regInvite")?.value || "").trim();
|
||
const username = ($("regUsername")?.value || "").trim();
|
||
const name = ($("regName")?.value || "").trim();
|
||
const password = $("regPassword")?.value || "";
|
||
const password2 = $("regPassword2")?.value || "";
|
||
if (!inviteCode) {
|
||
showAuthError("请填写邀请码");
|
||
return;
|
||
}
|
||
if (!username) {
|
||
showAuthError("请填写用户名");
|
||
return;
|
||
}
|
||
if (!/^[A-Za-z][A-Za-z0-9_]{2,31}$/.test(username)) {
|
||
showAuthError("用户名需字母开头,3–32 位,仅字母数字下划线");
|
||
return;
|
||
}
|
||
if (password.length < 8) {
|
||
showAuthError("密码至少 8 位");
|
||
return;
|
||
}
|
||
if (password !== password2) {
|
||
showAuthError("两次密码不一致");
|
||
return;
|
||
}
|
||
const btn = $("registerSubmit");
|
||
setAuthBusy(btn, true);
|
||
try {
|
||
await api("/api/v1/auth/register", {
|
||
method: "POST",
|
||
body: {
|
||
inviteCode,
|
||
username,
|
||
password,
|
||
name: name || undefined,
|
||
},
|
||
});
|
||
location.href = "/app";
|
||
} catch (e) {
|
||
showAuthError(e.message || "注册失败");
|
||
setAuthBusy(btn, false);
|
||
}
|
||
};
|
||
}
|
||
|
||
$("loginBtn").onclick = () => {
|
||
location.href = "/api/v1/auth/login?redirect=/app";
|
||
};
|
||
$("logout").onclick = async () => {
|
||
await api("/api/v1/auth/logout", { method: "POST", body: {} });
|
||
location.reload();
|
||
};
|
||
|
||
async function loadInvites() {
|
||
const list = $("inviteList");
|
||
const q = $("inviteQuotaText");
|
||
if (list) list.textContent = "加载中…";
|
||
try {
|
||
const data = await api("/api/v1/me/invites");
|
||
const maxLabel =
|
||
data.quota && data.quota.max != null ? data.quota.max : "不限";
|
||
const rem =
|
||
data.quota && data.quota.remaining != null
|
||
? data.quota.remaining
|
||
: "—";
|
||
if (q) {
|
||
q.textContent = `本周期已生成 ${data.quota?.used ?? 0}/${maxLabel} · 窗口 ${data.quota?.windowHours ?? "—"} 小时 · 剩余 ${rem}${
|
||
data.quota?.retryAfterSec
|
||
? ` · ${Math.ceil(data.quota.retryAfterSec / 3600)} 小时后可再生成`
|
||
: ""
|
||
}`;
|
||
}
|
||
const items = data.items || [];
|
||
if (!list) return;
|
||
if (!items.length) {
|
||
list.innerHTML = `<div class="empty">暂无未使用邀请码,点击上方生成</div>`;
|
||
return;
|
||
}
|
||
list.innerHTML = items
|
||
.map(
|
||
(it) => `<div class="invite-row">
|
||
<div class="invite-meta">
|
||
<div class="invite-code-line"><strong>码</strong> <code>${esc(it.code)}</code></div>
|
||
<div class="invite-url">${esc(it.inviteUrl)}</div>
|
||
<div class="invite-exp">过期 ${esc(it.expiresAt || "")}</div>
|
||
</div>
|
||
<div class="invite-actions">
|
||
<button class="btn btn-secondary btn-sm" type="button" data-copy-invite="${esc(it.inviteUrl)}">复制链接</button>
|
||
<button class="btn btn-ghost btn-sm" type="button" data-revoke-invite="${esc(it.code)}">撤销</button>
|
||
</div>
|
||
</div>`,
|
||
)
|
||
.join("");
|
||
list.querySelectorAll("[data-copy-invite]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(btn.dataset.copyInvite);
|
||
toast("已复制邀请链接");
|
||
} catch {
|
||
toast("复制失败", true);
|
||
}
|
||
};
|
||
});
|
||
list.querySelectorAll("[data-revoke-invite]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
const ok = await confirmDialog({
|
||
title: "撤销邀请码",
|
||
message: "撤销后该码将无法再用于注册。",
|
||
okText: "撤销",
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api("/api/v1/me/invites/" + encodeURIComponent(btn.dataset.revokeInvite), {
|
||
method: "DELETE",
|
||
});
|
||
toast("已撤销");
|
||
await loadInvites();
|
||
} catch (e) {
|
||
toast(e.message || "撤销失败", true);
|
||
}
|
||
};
|
||
});
|
||
} catch (e) {
|
||
if (list) list.innerHTML = `<div class="empty">${esc(e.message || "加载失败")}</div>`;
|
||
}
|
||
}
|
||
|
||
if ($("genInvite")) {
|
||
$("genInvite").onclick = async () => {
|
||
try {
|
||
const data = await api("/api/v1/me/invites", { method: "POST", body: {} });
|
||
toast("已生成邀请码");
|
||
try {
|
||
if (data.inviteUrl) await navigator.clipboard.writeText(data.inviteUrl);
|
||
toast("链接已复制");
|
||
} catch (_) {}
|
||
await loadInvites();
|
||
} catch (e) {
|
||
toast(e.message || "生成失败", true);
|
||
}
|
||
};
|
||
}
|
||
if ($("refreshInvites")) {
|
||
$("refreshInvites").onclick = () => loadInvites();
|
||
}
|
||
|
||
function botTokenBadge(b) {
|
||
return b.hasToken
|
||
? '<span class="badge ok">已绑定</span>'
|
||
: '<span class="badge warn">需重新扫码</span>';
|
||
}
|
||
function botNameEditHtml(b) {
|
||
return `<div class="name-edit">
|
||
<input type="text" data-name-input="${esc(b.id)}" value="${esc(b.displayName)}" maxlength="32" aria-label="机器人名字" autocomplete="off" />
|
||
<button class="btn btn-secondary btn-sm" type="button" data-rename="${esc(b.id)}">保存名字</button>
|
||
</div>`;
|
||
}
|
||
function botProactiveBtnHtml(b) {
|
||
const gOn = !!b.globalProactiveEnabled;
|
||
const on = !!b.proactiveEnabled;
|
||
const pill = on
|
||
? '<span class="pro-pill on">已开</span>'
|
||
: '<span class="pro-pill off">已关</span>';
|
||
const disabled = gOn ? "" : "disabled";
|
||
const cls = gOn ? "" : " is-disabled";
|
||
const title = gOn
|
||
? "打开主动联系设置"
|
||
: "服务端未开启 PROACTIVE_ENABLED";
|
||
return `<button class="btn btn-secondary btn-sm pro-open-btn${cls}" type="button" data-pro-open="${esc(b.id)}" title="${esc(title)}" ${disabled}>
|
||
<i class="bi bi-chat-dots" aria-hidden="true"></i>
|
||
<span class="pro-label-full">主动联系</span>${pill}
|
||
</button>`;
|
||
}
|
||
function botActionsHtml(b) {
|
||
return `<div class="row-actions">
|
||
${botProactiveBtnHtml(b)}
|
||
<button class="btn btn-primary btn-sm" type="button" data-rebind="${esc(b.id)}" title="刷新微信 token,保留好友与人设">重新扫码</button>
|
||
<button class="btn btn-danger btn-sm" type="button" data-del="${esc(b.id)}">删除</button>
|
||
</div>`;
|
||
}
|
||
|
||
let _proEditBotId = null;
|
||
function closeProactiveModal() {
|
||
const bd = $("proactiveModal");
|
||
if (bd) bd.classList.remove("show");
|
||
document.body.style.overflow = "";
|
||
_proEditBotId = null;
|
||
}
|
||
function openProactiveModal(botId) {
|
||
const b = (state.bots || []).find((x) => x.id === botId);
|
||
if (!b) {
|
||
toast("机器人不存在", true);
|
||
return;
|
||
}
|
||
const gOn = !!b.globalProactiveEnabled;
|
||
_proEditBotId = botId;
|
||
$("proModalTitle").textContent = "主动联系";
|
||
$("proModalSub").textContent = b.displayName
|
||
? `机器人「${b.displayName}」`
|
||
: botId;
|
||
const en = $("proEn");
|
||
en.checked = !!b.proactiveEnabled;
|
||
en.disabled = !gOn;
|
||
$("proIdle").value = String(b.proactiveIdleHours ?? 0.5);
|
||
$("proMin").value = String(b.proactiveMinIntervalHours ?? 0);
|
||
$("proMax").value = String(b.proactiveMaxPerDay ?? 0);
|
||
$("proQuiet").value = b.proactiveQuietHours ?? "0-8";
|
||
["proIdle", "proMin", "proMax", "proQuiet", "proModalSave"].forEach(
|
||
(id) => {
|
||
const el = $(id);
|
||
if (el) el.disabled = !gOn;
|
||
},
|
||
);
|
||
const note = $("proModalNote");
|
||
if (note) {
|
||
note.className = "pro-note" + (gOn ? "" : " warn");
|
||
note.textContent = gOn
|
||
? "仅联系已在用户列表开启「允许主动」的好友;对方须曾聊过。间隔/每日填 0 表示不限制。"
|
||
: "服务端未开启 PROACTIVE_ENABLED。请在 .env 设置 PROACTIVE_ENABLED=true 后重启。";
|
||
}
|
||
const bd = $("proactiveModal");
|
||
bd.classList.add("show");
|
||
document.body.style.overflow = "hidden";
|
||
setTimeout(() => {
|
||
try {
|
||
(gOn ? en : $("proModalCancel")).focus({ preventScroll: true });
|
||
} catch (_) {}
|
||
}, 30);
|
||
}
|
||
async function saveProactiveModal() {
|
||
const id = _proEditBotId;
|
||
if (!id) return;
|
||
const idleH = Number($("proIdle").value);
|
||
const minH = Number($("proMin").value);
|
||
const maxN = Math.floor(Number($("proMax").value));
|
||
const quiet = ($("proQuiet").value || "").trim();
|
||
if (!Number.isFinite(idleH) || idleH < 0.25 || idleH > 720) {
|
||
toast("空闲小时须在 0.25~720(0.5 = 30 分钟)", true);
|
||
$("proIdle").focus();
|
||
return;
|
||
}
|
||
if (!Number.isFinite(minH) || minH < 0 || minH > 720) {
|
||
toast("最小间隔须在 0~720 小时(0 = 不限制)", true);
|
||
$("proMin").focus();
|
||
return;
|
||
}
|
||
if (!Number.isFinite(maxN) || maxN < 0 || maxN > 9999) {
|
||
toast("每日上限须在 0~9999(0 = 不限制)", true);
|
||
$("proMax").focus();
|
||
return;
|
||
}
|
||
if (quiet && !/^\d{1,2}-\d{1,2}$/.test(quiet)) {
|
||
toast('安静时段格式如 "0-8",或不填', true);
|
||
$("proQuiet").focus();
|
||
return;
|
||
}
|
||
try {
|
||
await api("/api/v1/me/bots/" + id, {
|
||
method: "PATCH",
|
||
body: {
|
||
proactiveEnabled: !!$("proEn").checked,
|
||
proactiveIdleHours: idleH,
|
||
proactiveMinIntervalHours: minH,
|
||
proactiveMaxPerDay: maxN,
|
||
proactiveQuietHours: quiet,
|
||
},
|
||
});
|
||
toast("主动联系设置已保存");
|
||
closeProactiveModal();
|
||
refreshBots().catch(onRefreshError);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
}
|
||
function bindBotActions() {
|
||
document.querySelectorAll("[data-rename]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
const id = btn.dataset.rename;
|
||
const input =
|
||
btn.closest(".name-edit")?.querySelector("[data-name-input]") ||
|
||
document.querySelector(`[data-name-input="${CSS.escape(id)}"]`);
|
||
const displayName = (input?.value || "").trim();
|
||
if (!displayName) {
|
||
toast("名字不能为空", true);
|
||
return;
|
||
}
|
||
try {
|
||
await api("/api/v1/me/bots/" + id, {
|
||
method: "PATCH",
|
||
body: { displayName },
|
||
});
|
||
toast("机器人名字已更新(将用于对话身份)");
|
||
refreshBots().catch(onRefreshError);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-name-input]").forEach((input) => {
|
||
input.onkeydown = (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
input
|
||
.closest(".name-edit")
|
||
?.querySelector("[data-rename]")
|
||
?.click();
|
||
}
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-rebind]").forEach((btn) => {
|
||
btn.onclick = () => startBotLogin({ rebindBotId: btn.dataset.rebind });
|
||
});
|
||
document.querySelectorAll("[data-del]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
const ok = await confirmDialog({
|
||
title: "删除机器人?",
|
||
message:
|
||
"好友分配与记忆也会一并清除。若只是 token 失效,请改用「重新扫码」。",
|
||
okText: "删除",
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api("/api/v1/me/bots/" + btn.dataset.del, {
|
||
method: "DELETE",
|
||
});
|
||
toast("已删除");
|
||
refreshBots().catch(onRefreshError);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-pro-open]").forEach((btn) => {
|
||
btn.onclick = () => openProactiveModal(btn.dataset.proOpen);
|
||
});
|
||
}
|
||
|
||
// Proactive modal chrome (once)
|
||
(() => {
|
||
const bd = $("proactiveModal");
|
||
if (!bd) return;
|
||
$("proModalCancel")?.addEventListener("click", closeProactiveModal);
|
||
$("proModalSave")?.addEventListener("click", () => {
|
||
void saveProactiveModal();
|
||
});
|
||
bd.addEventListener("click", (e) => {
|
||
if (e.target === bd) closeProactiveModal();
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && bd.classList.contains("show")) {
|
||
e.preventDefault();
|
||
closeProactiveModal();
|
||
}
|
||
});
|
||
})();
|
||
async function refreshChat() {
|
||
// Independent endpoints — one wave, not two hops
|
||
await Promise.all([
|
||
loadWechatBind().catch(() => {}),
|
||
loadBlocks().catch(() => {}),
|
||
]);
|
||
}
|
||
async function refreshBots() {
|
||
// /me/bots and /me/personas do not depend on each other — fetch together.
|
||
// loadPeers() still runs after, it reads the rendered #filterBot value.
|
||
const personasP = api("/api/v1/me/personas");
|
||
const bots = await api("/api/v1/me/bots");
|
||
state.bots = bots.bots || [];
|
||
const botsEmptyHtml = `<div class="empty">还没有机器人<br/>
|
||
<button class="btn btn-primary btn-sm" type="button"
|
||
onclick="document.getElementById('botName').focus();document.getElementById('addBot').click()">扫码添加第一个机器人</button></div>`;
|
||
$("botsBody").innerHTML =
|
||
state.bots
|
||
.map((b) => {
|
||
const tokenBadge = botTokenBadge(b);
|
||
return `<tr>
|
||
<td>${botNameEditHtml(b)}</td>
|
||
<td><code>${esc(b.id)}</code></td>
|
||
<td>${tokenBadge} <span class="badge">${esc(b.status)}</span></td>
|
||
<td>${botActionsHtml(b)}</td>
|
||
</tr>`;
|
||
})
|
||
.join("") ||
|
||
`<tr><td colspan="4">${botsEmptyHtml}</td></tr>`;
|
||
|
||
const botsCards = $("botsCards");
|
||
if (botsCards) {
|
||
botsCards.innerHTML =
|
||
state.bots
|
||
.map((b) => {
|
||
const tokenBadge = botTokenBadge(b);
|
||
return `<article class="mcard">
|
||
<p class="m-title">${esc(b.displayName || "未命名机器人")}</p>
|
||
<p class="m-meta">${tokenBadge} <span class="badge">${esc(b.status)}</span><br/><code>${esc(b.id)}</code></p>
|
||
${botNameEditHtml(b)}
|
||
<div style="margin-top:10px">${botActionsHtml(b)}</div>
|
||
</article>`;
|
||
})
|
||
.join("") || botsEmptyHtml;
|
||
}
|
||
|
||
bindBotActions();
|
||
|
||
const fb = $("filterBot");
|
||
const prevSel = fb.value;
|
||
fb.innerHTML =
|
||
`<option value="">全部机器人</option>` +
|
||
state.bots
|
||
.map((b) => `<option value="${esc(b.id)}">${esc(b.displayName)}</option>`)
|
||
.join("");
|
||
// keep the user's filter across refreshes (rename/delete/rebind all call refresh)
|
||
if (prevSel && state.bots.some((b) => b.id === prevSel)) fb.value = prevSel;
|
||
|
||
const personas = await personasP;
|
||
state.personas = personas.library || [];
|
||
await loadPeers();
|
||
}
|
||
async function refresh() {
|
||
// The chat pane's own data loads when that pane is opened (switchPane),
|
||
// so boot no longer blocks on it.
|
||
await refreshBots();
|
||
}
|
||
|
||
function personaName(p) {
|
||
return p.displayName || p.display_name || p.slug || p.id;
|
||
}
|
||
|
||
function peerPersonaOpts(p) {
|
||
return (
|
||
state.personas
|
||
.map(
|
||
(x) =>
|
||
`<option value="${x.id}" ${p.personaId === x.id ? "selected" : ""}>${esc(personaName(x))}</option>`,
|
||
)
|
||
.join("") || '<option value="">请先添加人设</option>'
|
||
);
|
||
}
|
||
function peerStatusBadge(p) {
|
||
return p.approved
|
||
? '<span class="badge ok">已批准</span>'
|
||
: '<span class="badge warn">待批准</span>';
|
||
}
|
||
function peerProactiveHtml(p) {
|
||
const key = `${p.bot_account_id}|${p.peer_id}`;
|
||
const on = !!(p.proactiveEnabled || p.proactive_enabled);
|
||
const can = !!p.approved;
|
||
return `<label class="peer-pro ${can ? "" : "is-disabled"}" title="${
|
||
can ? "空闲后智能体可主动联系该用户" : "请先批准用户"
|
||
}">
|
||
<input type="checkbox" data-peer-pro="${esc(key)}" ${on ? "checked" : ""} ${
|
||
can ? "" : "disabled"
|
||
} />
|
||
<span class="peer-pro-txt">${on ? "开" : "关"}</span>
|
||
</label>`;
|
||
}
|
||
function peerPersonaSelectHtml(p) {
|
||
const key = `${p.bot_account_id}|${p.peer_id}`;
|
||
return `<select class="persona-select" data-asg="${esc(key)}" title="${esc(p.personaName || "选择人设")}">${peerPersonaOpts(p)}</select>`;
|
||
}
|
||
function peerActionsHtml(p) {
|
||
const key = `${p.bot_account_id}|${p.peer_id}`;
|
||
return `<div class="row-actions">
|
||
${
|
||
p.approved
|
||
? ""
|
||
: `<button class="btn btn-primary btn-sm" type="button" data-ap="${esc(key)}">批准</button>`
|
||
}
|
||
<button class="btn btn-secondary btn-sm" type="button" data-as="${esc(key)}">分配人设</button>
|
||
<button class="btn btn-ghost btn-sm" type="button" data-mem="${esc(key)}">记忆</button>
|
||
</div>`;
|
||
}
|
||
function bindPeerActions() {
|
||
document.querySelectorAll("[data-ap]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
const [botAccountId, peerId] = btn.dataset.ap.split("|");
|
||
try {
|
||
await api("/api/v1/me/peers/approve", {
|
||
method: "POST",
|
||
body: { botAccountId, peerId },
|
||
});
|
||
toast("已批准");
|
||
loadPeers();
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-as]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
const key = btn.dataset.as;
|
||
const [botAccountId, peerId] = key.split("|");
|
||
const sel = btn
|
||
.closest(".mcard, tr")
|
||
?.querySelector(`select[data-asg="${CSS.escape(key)}"]`);
|
||
if (!sel?.value) {
|
||
toast("请先在人设广场添加人设", true);
|
||
return;
|
||
}
|
||
try {
|
||
await api("/api/v1/me/assignments", {
|
||
method: "PUT",
|
||
body: { botAccountId, peerId, personaId: sel.value },
|
||
});
|
||
toast("人设已分配");
|
||
loadPeers();
|
||
} catch (e) {
|
||
toast(e.message || "分配失败", true);
|
||
}
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-mem]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
const [botAccountId, peerId] = btn.dataset.mem.split("|");
|
||
try {
|
||
const mem = await api(
|
||
"/api/v1/me/memories?botAccountId=" +
|
||
encodeURIComponent(botAccountId) +
|
||
"&peerId=" +
|
||
encodeURIComponent(peerId),
|
||
);
|
||
const groups = mem.groups || [];
|
||
const total = mem.total ?? groups.reduce((n, g) => n + (g.memories?.length || 0), 0);
|
||
if (!total) {
|
||
toast("该用户暂无长期记忆");
|
||
return;
|
||
}
|
||
const bodyHtml = groups
|
||
.map((g) => {
|
||
const items = (g.memories || [])
|
||
.map(
|
||
(m) =>
|
||
`<li class="mem-item" data-mid="${esc(m.id)}" data-pid="${esc(g.personaId)}">
|
||
<span class="mem-text">${esc(m.content)}</span>
|
||
<button type="button" class="btn btn-ghost btn-sm mem-del" data-del-mem="${esc(botAccountId)}|${esc(peerId)}|${esc(g.personaId)}|${esc(m.id)}">删除</button>
|
||
</li>`,
|
||
)
|
||
.join("");
|
||
return `<div class="mem-group"><h4>${esc(g.personaName || g.personaId)}</h4><ul class="mem-list">${items}</ul></div>`;
|
||
})
|
||
.join("");
|
||
const wrap = document.createElement("div");
|
||
wrap.innerHTML = `
|
||
<p class="muted" style="margin:0 0 12px">共 ${total} 条 · 删除后下一轮对话不再使用</p>
|
||
${bodyHtml}
|
||
<div style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap">
|
||
<button type="button" class="btn btn-danger btn-sm" id="memResetAll">清空全部记忆</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="memClose">关闭</button>
|
||
</div>`;
|
||
// Prefer confirmDialog-style sheet if openModal exists; else simple overlay
|
||
let host = document.getElementById("memSheet");
|
||
if (!host) {
|
||
host = document.createElement("div");
|
||
host.id = "memSheet";
|
||
host.setAttribute("role", "dialog");
|
||
host.style.cssText =
|
||
"position:fixed;inset:0;z-index:9999;display:flex;align-items:flex-end;justify-content:center;background:rgba(0,0,0,.35);padding:16px";
|
||
document.body.appendChild(host);
|
||
}
|
||
host.innerHTML = "";
|
||
const card = document.createElement("div");
|
||
card.style.cssText =
|
||
"background:var(--surface,#fff);color:var(--text,#111);border-radius:16px;max-width:520px;width:100%;max-height:80vh;overflow:auto;padding:18px 16px 16px;box-shadow:0 12px 40px rgba(0,0,0,.18)";
|
||
card.innerHTML = `<h3 style="margin:0 0 8px;font-size:17px">长期记忆</h3><p class="muted" style="margin:0 0 8px;font-size:12px;word-break:break-all">${esc(peerId)}</p>`;
|
||
card.appendChild(wrap);
|
||
host.appendChild(card);
|
||
host.style.display = "flex";
|
||
const close = () => {
|
||
host.style.display = "none";
|
||
host.innerHTML = "";
|
||
};
|
||
wrap.querySelector("#memClose")?.addEventListener("click", close);
|
||
host.onclick = (ev) => {
|
||
if (ev.target === host) close();
|
||
};
|
||
wrap.querySelector("#memResetAll")?.addEventListener("click", async () => {
|
||
const ok = window.confirm
|
||
? window.confirm("清除该用户全部人设下的长期记忆?不可恢复。")
|
||
: true;
|
||
if (!ok) return;
|
||
try {
|
||
await api("/api/v1/me/memories/reset", {
|
||
method: "POST",
|
||
body: { botAccountId, peerId },
|
||
});
|
||
toast("记忆已清空");
|
||
close();
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
});
|
||
wrap.querySelectorAll("[data-del-mem]").forEach((b) => {
|
||
b.onclick = async () => {
|
||
const [bid, pid, personaId, memoryId] = b.dataset.delMem.split("|");
|
||
try {
|
||
await api("/api/v1/me/memories", {
|
||
method: "DELETE",
|
||
body: { botAccountId: bid, peerId: pid, personaId, memoryId },
|
||
});
|
||
b.closest("li")?.remove();
|
||
toast("已删除");
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-peer-pro]").forEach((input) => {
|
||
input.onchange = async () => {
|
||
const key = input.dataset.peerPro;
|
||
const [botAccountId, peerId] = key.split("|");
|
||
const enabled = !!input.checked;
|
||
const txt = input
|
||
.closest("label.peer-pro")
|
||
?.querySelector(".peer-pro-txt");
|
||
if (txt) txt.textContent = enabled ? "开" : "关";
|
||
try {
|
||
await api("/api/v1/me/peers/proactive", {
|
||
method: "PATCH",
|
||
body: { botAccountId, peerId, enabled },
|
||
});
|
||
toast(enabled ? "已允许主动联系该用户" : "已关闭主动联系");
|
||
} catch (e) {
|
||
input.checked = !enabled;
|
||
if (txt) txt.textContent = enabled ? "关" : "开";
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
async function loadPeers() {
|
||
const botId = $("filterBot").value;
|
||
const q = botId ? "?botId=" + encodeURIComponent(botId) : "";
|
||
const data = await api("/api/v1/me/peers" + q);
|
||
const peers = data.peers || [];
|
||
const botLabel = (id) => {
|
||
const b = (state.bots || []).find((x) => x.id === id);
|
||
return b?.displayName || id;
|
||
};
|
||
const peersEmptyHtml = `<div class="empty">暂无私聊用户<br/>
|
||
<span style="font-size:13px">微信好友给机器人发消息后,会出现在这里等待批准</span></div>`;
|
||
$("peersBody").innerHTML =
|
||
peers
|
||
.map((p) => {
|
||
const botName = botLabel(p.bot_account_id);
|
||
return `<tr>
|
||
<td>
|
||
<div class="id-cell" title="${esc(p.peer_id)}">
|
||
<code>${esc(p.peer_id)}</code>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="id-cell" title="${esc(p.bot_account_id)}">
|
||
<span class="sub">${esc(botName)}</span>
|
||
<code>${esc(p.bot_account_id)}</code>
|
||
</div>
|
||
</td>
|
||
<td>${peerStatusBadge(p)}</td>
|
||
<td class="col-persona">${peerPersonaSelectHtml(p)}</td>
|
||
<td>${peerProactiveHtml(p)}</td>
|
||
<td class="col-actions">${peerActionsHtml(p)}</td>
|
||
</tr>`;
|
||
})
|
||
.join("") ||
|
||
`<tr><td colspan="6">${peersEmptyHtml}</td></tr>`;
|
||
|
||
const peersCards = $("peersCards");
|
||
if (peersCards) {
|
||
peersCards.innerHTML =
|
||
peers
|
||
.map((p) => {
|
||
return `<article class="mcard">
|
||
<p class="m-title">${esc(botLabel(p.bot_account_id))}</p>
|
||
<p class="m-meta">
|
||
${peerStatusBadge(p)}
|
||
· 当前人设 ${esc(p.personaName || "默认")}<br/>
|
||
<code title="${esc(p.peer_id)}">${esc(p.peer_id)}</code>
|
||
</p>
|
||
<div style="margin:10px 0 8px">${peerPersonaSelectHtml(p)}</div>
|
||
<div style="margin:0 0 10px">${peerProactiveHtml(p)}</div>
|
||
${peerActionsHtml(p)}
|
||
</article>`;
|
||
})
|
||
.join("") || peersEmptyHtml;
|
||
}
|
||
|
||
bindPeerActions();
|
||
}
|
||
|
||
function personaUseCount(p) {
|
||
const n = Number(p?.useCount ?? p?.use_count ?? 0);
|
||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||
}
|
||
function formatUseCount(n) {
|
||
return Number(n || 0).toLocaleString("zh-CN");
|
||
}
|
||
function personaHeatMeta(p) {
|
||
const heat = Number(p?.heatScore ?? p?.heat_score);
|
||
const use = personaUseCount(p);
|
||
const assign = Number(p?.assignCount ?? p?.assign_count ?? 0) || 0;
|
||
const fork = Number(p?.forkCount ?? p?.fork_count ?? 0) || 0;
|
||
const heatVal = Number.isFinite(heat)
|
||
? heat
|
||
: use * 2 + assign * 5 + fork * 3;
|
||
return {
|
||
heat: heatVal,
|
||
use,
|
||
assign,
|
||
fork,
|
||
text:
|
||
"热度 " +
|
||
formatUseCount(heatVal) +
|
||
" · 收藏 " +
|
||
formatUseCount(use) +
|
||
" · 分配 " +
|
||
formatUseCount(assign) +
|
||
" · 改编 " +
|
||
formatUseCount(fork),
|
||
};
|
||
}
|
||
function personaForkFromLabel(p) {
|
||
const f = p?.forkedFrom || p?.forked_from;
|
||
if (!f) return "";
|
||
const name = f.displayName || f.display_name || f.slug || "";
|
||
return name ? "改编自 " + name : "";
|
||
}
|
||
function forkBtnHtml(id) {
|
||
return `<button class="btn btn-fork" type="button" data-fork="${id}" title="复制为人设草稿,可自由编辑">
|
||
<i class="bi bi-shuffle" aria-hidden="true"></i>改编
|
||
</button>`;
|
||
}
|
||
function cardHtml(p, mode) {
|
||
const tags = (p.tags || [])
|
||
.filter((t) => String(t).toLowerCase() !== "fork")
|
||
.map((t) => `<span class="tag">${esc(t)}</span>`)
|
||
.join("");
|
||
const vis =
|
||
p.visibility === "private"
|
||
? '<span class="badge warn">私有</span>'
|
||
: '<span class="badge ok">公开</span>';
|
||
const modeBadge =
|
||
p.mode === "chatflow"
|
||
? '<span class="badge mode-cf">Chatflow</span>'
|
||
: "";
|
||
const searchBadge = p.webSearchEnabled
|
||
? '<span class="badge soft" title="联网搜索">搜索</span>'
|
||
: "";
|
||
let primary = "";
|
||
let links = "";
|
||
if (mode === "square") {
|
||
primary = `<button class="btn btn-primary" type="button" data-try="${p.id}"><i class="bi bi-chat-dots" aria-hidden="true"></i>试聊</button>`;
|
||
primary += p.inLibrary
|
||
? ` <button class="btn btn-secondary" type="button" disabled>已在库中</button>`
|
||
: ` <button class="btn btn-secondary" type="button" data-add="${p.id}">添加到库</button>`;
|
||
primary += ` ${forkBtnHtml(p.id)}`;
|
||
links = `<button class="btn btn-ghost" type="button" data-detail="${p.id}">详情</button>`;
|
||
} else if (mode === "created") {
|
||
const other = p.visibility === "private" ? "public" : "private";
|
||
primary = `
|
||
<button class="btn btn-primary" type="button" data-edit="${p.id}">编辑</button>
|
||
<button class="btn btn-secondary" type="button" data-vis="${p.id}|${other}">改为${other === "public" ? "公开" : "私有"}</button>
|
||
<button class="btn btn-danger" type="button" data-rm-created="${p.id}">删除</button>`;
|
||
links = `
|
||
<button class="btn btn-ghost" type="button" data-try="${p.id}">试聊</button>
|
||
${
|
||
p.mode === "chatflow"
|
||
? `<a class="btn btn-ghost" href="/chatflow?persona=${encodeURIComponent(p.id)}" target="_blank" rel="noopener">流程</a>`
|
||
: ""
|
||
}
|
||
<button class="btn btn-ghost" type="button" data-detail="${p.id}">详情</button>`;
|
||
} else if (mode === "lib") {
|
||
const system = p.ownerUserId === "system" || p.owner_user_id === "system";
|
||
primary = system
|
||
? `<span class="badge ok">官方</span>
|
||
<button class="btn btn-primary" type="button" data-try="${p.id}"><i class="bi bi-chat-dots" aria-hidden="true"></i>试聊</button>
|
||
${forkBtnHtml(p.id)}`
|
||
: `<button class="btn btn-primary" type="button" data-try="${p.id}"><i class="bi bi-chat-dots" aria-hidden="true"></i>试聊</button>
|
||
${forkBtnHtml(p.id)}
|
||
<button class="btn btn-secondary" type="button" data-rm-lib="${p.id}">从库移除</button>`;
|
||
links = `<button class="btn btn-ghost" type="button" data-detail="${p.id}">详情</button>`;
|
||
}
|
||
const meta = personaHeatMeta(p);
|
||
const forkLabel = personaForkFromLabel(p);
|
||
const useMeta =
|
||
mode === "square" || mode === "created"
|
||
? `<p class="meta-line" title="${esc(meta.text)}">${esc(meta.text)}</p>`
|
||
: "";
|
||
const forkMeta = forkLabel
|
||
? `<span class="badge-fork" title="${esc(forkLabel)}"><i class="bi bi-shuffle" aria-hidden="true"></i>${esc(forkLabel)}</span>`
|
||
: "";
|
||
return `<article class="persona-card">
|
||
<div class="row-actions" style="justify-content:space-between;align-items:flex-start;gap:8px">
|
||
<h4>${esc(personaName(p))}</h4>
|
||
<span style="display:flex;flex-wrap:wrap;gap:4px;justify-content:flex-end">${vis}${modeBadge}${searchBadge}</span>
|
||
</div>
|
||
<p class="desc">${esc(p.description || "暂无简介")}</p>
|
||
${useMeta}
|
||
${forkMeta}
|
||
<div class="tag-row">${tags}</div>
|
||
<div class="card-actions">${primary}</div>
|
||
${links ? `<div class="card-links">${links}</div>` : ""}
|
||
</article>`;
|
||
}
|
||
|
||
const tryChatState = {
|
||
sessionId: null,
|
||
personaId: null,
|
||
personaName: "",
|
||
inLibrary: false,
|
||
remainingToday: null,
|
||
remainingSession: null,
|
||
busy: false,
|
||
};
|
||
|
||
async function forkPersonaById(id, opts) {
|
||
const data = await api("/api/v1/square/personas/" + id + "/fork", {
|
||
method: "POST",
|
||
body: {},
|
||
});
|
||
toast("已创建私有改编,可在「我的人设」编辑");
|
||
if (opts?.closeTry) closeTryChat();
|
||
closeModal();
|
||
switchPane("mine");
|
||
if (data?.persona?.id) {
|
||
// best-effort open editor if loadMine supports edit
|
||
setTimeout(() => {
|
||
const btn = document.querySelector(
|
||
'[data-edit="' + data.persona.id + '"]',
|
||
);
|
||
if (btn) btn.click();
|
||
}, 400);
|
||
}
|
||
loadSquare().catch(onSquareError);
|
||
return data;
|
||
}
|
||
|
||
function appendTryBubble(role, text) {
|
||
const log = $("tryChatLog");
|
||
if (!log) return;
|
||
const div = document.createElement("div");
|
||
div.className =
|
||
"try-chat-bubble " + (role === "user" ? "me" : "bot");
|
||
div.textContent = text;
|
||
log.appendChild(div);
|
||
log.scrollTop = log.scrollHeight;
|
||
}
|
||
|
||
function showTryThinking() {
|
||
const log = $("tryChatLog");
|
||
if (!log) return;
|
||
let el = $("tryChatThinking");
|
||
if (!el) {
|
||
el = document.createElement("div");
|
||
el.id = "tryChatThinking";
|
||
el.className = "try-chat-thinking";
|
||
el.setAttribute("role", "status");
|
||
el.setAttribute("aria-live", "polite");
|
||
el.setAttribute("aria-label", "思考中");
|
||
el.innerHTML =
|
||
'思考中<span class="try-chat-dot" aria-hidden="true">.</span><span class="try-chat-dot" aria-hidden="true">.</span><span class="try-chat-dot" aria-hidden="true">.</span>';
|
||
log.appendChild(el);
|
||
}
|
||
log.scrollTop = log.scrollHeight;
|
||
}
|
||
|
||
function removeTryThinking() {
|
||
$("tryChatThinking")?.remove();
|
||
}
|
||
|
||
function updateTryQuota() {
|
||
const el = $("tryChatQuota");
|
||
if (!el) return;
|
||
const parts = [];
|
||
if (tryChatState.remainingToday != null) {
|
||
parts.push("今日剩余 " + tryChatState.remainingToday + " 条");
|
||
}
|
||
if (tryChatState.remainingSession != null) {
|
||
parts.push("本会话剩余 " + tryChatState.remainingSession + " 条");
|
||
}
|
||
el.textContent = parts.join(" · ") || "网页试聊 · 不写入微信";
|
||
}
|
||
|
||
async function openTryChat(id) {
|
||
try {
|
||
const data = await api("/api/v1/try-chat/sessions", {
|
||
method: "POST",
|
||
body: { personaId: id },
|
||
});
|
||
tryChatState.sessionId = data.sessionId;
|
||
tryChatState.personaId = data.persona?.id || id;
|
||
tryChatState.personaName =
|
||
data.persona?.displayName || data.persona?.display_name || "人设";
|
||
tryChatState.remainingToday = data.remainingToday;
|
||
tryChatState.remainingSession = null;
|
||
// inLibrary now comes back on the session response — no extra
|
||
// round trip before the try-chat panel can render.
|
||
tryChatState.inLibrary = !!(
|
||
data.inLibrary ?? data.persona?.inLibrary
|
||
);
|
||
if ($("tryChatTitle")) {
|
||
$("tryChatTitle").textContent = "试聊 · " + tryChatState.personaName;
|
||
}
|
||
if ($("tryChatSub")) {
|
||
$("tryChatSub").textContent = "网页试聊 · 不写入微信";
|
||
}
|
||
if ($("tryChatLog")) $("tryChatLog").innerHTML = "";
|
||
if ($("tryChatInput")) $("tryChatInput").value = "";
|
||
updateTryQuota();
|
||
const addBtn = $("tryChatAdd");
|
||
if (addBtn) {
|
||
addBtn.disabled = tryChatState.inLibrary;
|
||
addBtn.textContent = tryChatState.inLibrary ? "已在库中" : "加入库";
|
||
}
|
||
closeModal();
|
||
$("tryChatModal")?.classList.add("show");
|
||
document.body.style.overflow = "hidden";
|
||
$("tryChatInput")?.focus({ preventScroll: true });
|
||
} catch (e) {
|
||
toast(e.message || e.error || "无法开始试聊", true);
|
||
}
|
||
}
|
||
|
||
async function closeTryChat() {
|
||
const sid = tryChatState.sessionId;
|
||
tryChatState.sessionId = null;
|
||
$("tryChatModal")?.classList.remove("show");
|
||
if (!$("personaModal")?.classList.contains("show")) {
|
||
document.body.style.overflow = "";
|
||
}
|
||
if (sid) {
|
||
try {
|
||
await api("/api/v1/try-chat/sessions/" + sid, { method: "DELETE" });
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
async function sendTryChat() {
|
||
if (tryChatState.busy || !tryChatState.sessionId) return;
|
||
const input = $("tryChatInput");
|
||
const text = (input?.value || "").trim();
|
||
if (!text) return;
|
||
tryChatState.busy = true;
|
||
if ($("tryChatSend")) $("tryChatSend").disabled = true;
|
||
appendTryBubble("user", text);
|
||
if (input) input.value = "";
|
||
showTryThinking();
|
||
try {
|
||
const data = await api(
|
||
"/api/v1/try-chat/sessions/" + tryChatState.sessionId + "/messages",
|
||
{ method: "POST", body: { text } },
|
||
);
|
||
removeTryThinking();
|
||
const msgs = data.messages || [];
|
||
if (msgs.length) {
|
||
for (const m of msgs) {
|
||
if (m.type === "sticker") {
|
||
appendTryBubble("assistant", "[表情:" + (m.slug || "") + "]");
|
||
} else {
|
||
appendTryBubble("assistant", m.text || "");
|
||
}
|
||
}
|
||
} else if (data.displayText) {
|
||
appendTryBubble("assistant", data.displayText);
|
||
}
|
||
if (data.remainingToday != null) {
|
||
tryChatState.remainingToday = data.remainingToday;
|
||
}
|
||
if (data.remainingSession != null) {
|
||
tryChatState.remainingSession = data.remainingSession;
|
||
}
|
||
updateTryQuota();
|
||
} catch (e) {
|
||
removeTryThinking();
|
||
appendTryBubble("assistant", "(发送失败)" + (e.message || ""));
|
||
toast(e.message || "试聊失败", true);
|
||
} finally {
|
||
removeTryThinking();
|
||
tryChatState.busy = false;
|
||
if ($("tryChatSend")) $("tryChatSend").disabled = false;
|
||
$("tryChatInput")?.focus({ preventScroll: true });
|
||
}
|
||
}
|
||
|
||
if ($("tryChatClose")) $("tryChatClose").onclick = () => closeTryChat();
|
||
if ($("tryChatSend")) $("tryChatSend").onclick = () => sendTryChat();
|
||
if ($("tryChatInput")) {
|
||
$("tryChatInput").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendTryChat();
|
||
}
|
||
});
|
||
}
|
||
if ($("tryChatModal")) {
|
||
$("tryChatModal").onclick = (e) => {
|
||
if (e.target === $("tryChatModal")) closeTryChat();
|
||
};
|
||
}
|
||
if ($("tryChatFork")) {
|
||
$("tryChatFork").onclick = async () => {
|
||
if (!tryChatState.personaId) return;
|
||
try {
|
||
await forkPersonaById(tryChatState.personaId, { closeTry: true });
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
}
|
||
if ($("tryChatAdd")) {
|
||
$("tryChatAdd").onclick = async () => {
|
||
if (!tryChatState.personaId || tryChatState.inLibrary) return;
|
||
try {
|
||
await api(
|
||
"/api/v1/me/personas/" + tryChatState.personaId + "/add",
|
||
{ method: "POST", body: {} },
|
||
);
|
||
tryChatState.inLibrary = true;
|
||
$("tryChatAdd").disabled = true;
|
||
$("tryChatAdd").textContent = "已在库中";
|
||
toast("已添加到我的库");
|
||
loadSquare().catch(onSquareError);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
}
|
||
|
||
async function openDetail(id) {
|
||
try {
|
||
const data = await api("/api/v1/square/personas/" + id);
|
||
const p = data.persona;
|
||
$("modalTitle").textContent = personaName(p);
|
||
$("modalDesc").textContent = p.description || "暂无简介";
|
||
if ($("modalUseCount")) {
|
||
$("modalUseCount").textContent = personaHeatMeta(p).text;
|
||
}
|
||
if ($("modalForkFrom")) {
|
||
const label = personaForkFromLabel(p);
|
||
if (label) {
|
||
$("modalForkFrom").innerHTML =
|
||
'<span class="badge-fork"><i class="bi bi-shuffle" aria-hidden="true"></i>' +
|
||
esc(label) +
|
||
"</span>";
|
||
} else {
|
||
$("modalForkFrom").textContent = "";
|
||
}
|
||
}
|
||
$("modalTags").innerHTML = (p.tags || [])
|
||
.map((t) => `<span class="tag">${esc(t)}</span>`)
|
||
.join("");
|
||
$("modalPrompt").textContent =
|
||
p.systemPrompt || p.system_prompt || "(无提示词)";
|
||
const addBtn = $("modalAdd");
|
||
if (p.inLibrary) {
|
||
addBtn.disabled = true;
|
||
addBtn.textContent = "已在库中";
|
||
addBtn.onclick = null;
|
||
} else {
|
||
addBtn.disabled = false;
|
||
addBtn.innerHTML =
|
||
'<i class="bi bi-plus-lg" aria-hidden="true"></i>添加到库';
|
||
addBtn.onclick = async () => {
|
||
try {
|
||
await api("/api/v1/me/personas/" + id + "/add", {
|
||
method: "POST",
|
||
body: {},
|
||
});
|
||
toast("已添加到我的库");
|
||
closeModal();
|
||
loadSquare().catch(onSquareError);
|
||
refreshBots().catch(onRefreshError);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
}
|
||
if ($("modalTry")) {
|
||
$("modalTry").onclick = () => openTryChat(id);
|
||
}
|
||
if ($("modalFork")) {
|
||
$("modalFork").onclick = async () => {
|
||
try {
|
||
await forkPersonaById(id);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
}
|
||
$("personaModal").classList.add("show");
|
||
document.body.style.overflow = "hidden";
|
||
$("modalClose")?.focus({ preventScroll: true });
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
}
|
||
function closeModal() {
|
||
$("personaModal").classList.remove("show");
|
||
if (!$("tryChatModal")?.classList.contains("show")) {
|
||
document.body.style.overflow = "";
|
||
}
|
||
}
|
||
$("modalClose").onclick = closeModal;
|
||
$("personaModal").onclick = (e) => {
|
||
if (e.target === $("personaModal")) closeModal();
|
||
};
|
||
|
||
function bindDetailButtons(root) {
|
||
(root || document).querySelectorAll("[data-detail]").forEach((btn) => {
|
||
btn.onclick = () => openDetail(btn.dataset.detail);
|
||
});
|
||
}
|
||
|
||
function bindTryForkButtons(root) {
|
||
(root || document).querySelectorAll("[data-try]").forEach((btn) => {
|
||
btn.onclick = () => openTryChat(btn.dataset.try);
|
||
});
|
||
(root || document).querySelectorAll("[data-fork]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
try {
|
||
await forkPersonaById(btn.dataset.fork);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
function getSquareSort() {
|
||
const el = $("squareSort");
|
||
const v = el?.value || "heat";
|
||
return v === "recent" || v === "name" || v === "use" || v === "heat"
|
||
? v
|
||
: "heat";
|
||
}
|
||
/** Server-side pager for square lists. info: { page, limit, total } */
|
||
function renderSquarePager(containerId, info, onPageChange) {
|
||
const el = $(containerId);
|
||
if (!el) return;
|
||
const total = Number(info?.total) || 0;
|
||
const limit = Math.max(Number(info?.limit) || PAGE_SIZE, 1);
|
||
const pages = Math.max(1, Math.ceil(total / limit));
|
||
let page = Math.max(Number(info?.page) || 1, 1);
|
||
if (page > pages) page = pages;
|
||
if (!total) {
|
||
el.innerHTML = "";
|
||
el.hidden = true;
|
||
return;
|
||
}
|
||
el.hidden = false;
|
||
const single = pages <= 1;
|
||
el.innerHTML = `<span>第 ${page} / ${pages} 页 · 共 ${total} 条</span>
|
||
<div class="row-actions">
|
||
<button type="button" class="btn btn-secondary btn-sm" data-pg="first" ${single || page <= 1 ? "disabled" : ""}>首页</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-pg="prev" ${single || page <= 1 ? "disabled" : ""}>上一页</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-pg="next" ${single || page >= pages ? "disabled" : ""}>下一页</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-pg="last" ${single || page >= pages ? "disabled" : ""}>末页</button>
|
||
</div>`;
|
||
el.querySelectorAll("[data-pg]").forEach((btn) => {
|
||
btn.onclick = () => {
|
||
if (btn.disabled) return;
|
||
const a = btn.dataset.pg;
|
||
let next = page;
|
||
if (a === "first") next = 1;
|
||
if (a === "prev") next = Math.max(1, page - 1);
|
||
if (a === "next") next = Math.min(pages, page + 1);
|
||
if (a === "last") next = pages;
|
||
if (next === page) return;
|
||
onPageChange(next);
|
||
};
|
||
});
|
||
}
|
||
async function loadSquare() {
|
||
showGridSkeleton("squareGrid", 6);
|
||
const seq = (loadSquare._seq = (loadSquare._seq || 0) + 1);
|
||
const q = $("squareQ").value.trim();
|
||
const sort = getSquareSort();
|
||
let page = Math.max(state.squarePage || 1, 1);
|
||
const data = await api(
|
||
"/api/v1/square/personas?limit=" +
|
||
PAGE_SIZE +
|
||
"&page=" +
|
||
page +
|
||
"&sort=" +
|
||
encodeURIComponent(sort) +
|
||
(q ? "&q=" + encodeURIComponent(q) : ""),
|
||
);
|
||
if (seq !== loadSquare._seq) return;
|
||
const total = Number(data.total) || 0;
|
||
const limit = Number(data.limit) || PAGE_SIZE;
|
||
const pages = Math.max(1, Math.ceil(total / limit) || 1);
|
||
// If current page is empty after deletes/filter shrink, pull last valid page
|
||
if (total > 0 && page > pages) {
|
||
state.squarePage = pages;
|
||
return loadSquare();
|
||
}
|
||
if (data.page) state.squarePage = Math.max(Number(data.page) || page, 1);
|
||
const list = data.personas || [];
|
||
$("squareGrid").innerHTML =
|
||
list.map((p) => cardHtml(p, "square")).join("") ||
|
||
(q
|
||
? `<div class="empty compact">
|
||
<span class="empty-title">没有找到「${esc(q)}」相关人设</span>
|
||
试试其他关键词,或清除搜索
|
||
<br/>
|
||
<button class="btn btn-secondary btn-sm" type="button"
|
||
onclick="document.getElementById('squareQ').value='';document.getElementById('squareSearch').click()">清除搜索</button>
|
||
</div>`
|
||
: `<div class="empty compact">
|
||
<span class="empty-title">广场还没有公开人设</span>
|
||
成为第一个发布者,或到「我的人设」创作
|
||
<br/>
|
||
<button class="btn btn-primary btn-sm" type="button" onclick="switchPane('mine')">去创作第一个 <i class="bi bi-arrow-right" aria-hidden="true"></i></button>
|
||
</div>`);
|
||
renderSquarePager(
|
||
"squarePager",
|
||
{ page: state.squarePage, limit, total },
|
||
(next) => {
|
||
state.squarePage = next;
|
||
loadSquare().catch(onSquareError);
|
||
},
|
||
);
|
||
document.querySelectorAll("[data-add]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
try {
|
||
await api("/api/v1/me/personas/" + btn.dataset.add + "/add", {
|
||
method: "POST",
|
||
body: {},
|
||
});
|
||
toast("已添加到我的库");
|
||
loadSquare().catch(onSquareError);
|
||
refreshBots().catch(onRefreshError);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
bindDetailButtons($("squareGrid"));
|
||
bindTryForkButtons($("squareGrid"));
|
||
}
|
||
|
||
function stickerReviewLabel(s) {
|
||
const st = s.reviewStatus || s.review_status || "approved";
|
||
if (st === "pending") return '<span class="badge warn">待审核</span>';
|
||
if (st === "rejected") return '<span class="badge err">已拒绝</span>';
|
||
return '<span class="badge ok">已通过</span>';
|
||
}
|
||
function stickerCardHtml(s, mode) {
|
||
const name = s.displayName || s.display_name || s.slug;
|
||
const tags = (s.tags || []).map((t) => `<span class="tag">${esc(t)}</span>`).join("");
|
||
const vis =
|
||
(s.visibility || "public") === "private"
|
||
? '<span class="badge warn">私有</span>'
|
||
: '<span class="badge ok">公开</span>';
|
||
const img = s.imageUrl || `/api/v1/square/stickers/${s.id}/image`;
|
||
let actions = "";
|
||
if (mode === "square") {
|
||
actions = s.inLibrary
|
||
? `<button class="btn btn-secondary" type="button" disabled>已在库中</button>`
|
||
: `<button class="btn btn-primary" type="button" data-st-add="${s.id}">添加到库</button>`;
|
||
} else if (mode === "created") {
|
||
actions = `
|
||
${stickerReviewLabel(s)}
|
||
<button class="btn btn-danger" type="button" data-st-rm-created="${s.id}">删除</button>`;
|
||
} else if (mode === "lib") {
|
||
const system = s.ownerUserId === "system" || s.owner_user_id === "system";
|
||
actions = system
|
||
? `<span class="badge ok">官方</span>`
|
||
: `<button class="btn btn-secondary" type="button" data-st-rm-lib="${s.id}">从库移除</button>`;
|
||
}
|
||
return `<article class="persona-card">
|
||
<div class="sticker-row">
|
||
<img class="sticker-thumb" src="${esc(img)}" alt="" loading="lazy" />
|
||
<div class="sticker-body">
|
||
<div class="row-actions between">
|
||
<h4>${esc(name)}</h4>
|
||
${vis}
|
||
</div>
|
||
<p class="desc"><code>${esc(s.slug)}</code> · ${esc(s.description || "")}</p>
|
||
<div class="tag-row">${tags}</div>
|
||
<div class="row-actions actions">${actions}</div>
|
||
</div>
|
||
</div>
|
||
</article>`;
|
||
}
|
||
async function loadStickerSquare() {
|
||
showGridSkeleton("stickerSquareGrid", 4);
|
||
if ($("stickerCreatedGrid")) showGridSkeleton("stickerCreatedGrid", 2);
|
||
if ($("stickerLibGrid")) showGridSkeleton("stickerLibGrid", 2);
|
||
const seq = (loadStickerSquare._seq = (loadStickerSquare._seq || 0) + 1);
|
||
const q = ($("stickerSquareQ")?.value || "").trim();
|
||
let page = Math.max(state.stickerPage || 1, 1);
|
||
const [square, mine] = await Promise.all([
|
||
api(
|
||
"/api/v1/square/stickers?limit=" +
|
||
PAGE_SIZE +
|
||
"&page=" +
|
||
page +
|
||
(q ? "&q=" + encodeURIComponent(q) : ""),
|
||
),
|
||
api("/api/v1/me/stickers"),
|
||
]);
|
||
if (seq !== loadStickerSquare._seq) return;
|
||
const total = Number(square.total) || 0;
|
||
const limit = Number(square.limit) || PAGE_SIZE;
|
||
const pages = Math.max(1, Math.ceil(total / limit) || 1);
|
||
if (total > 0 && page > pages) {
|
||
state.stickerPage = pages;
|
||
return loadStickerSquare();
|
||
}
|
||
if (square.page) state.stickerPage = Math.max(Number(square.page) || page, 1);
|
||
const list = square.stickers || [];
|
||
$("stickerSquareGrid").innerHTML =
|
||
list.map((s) => stickerCardHtml(s, "square")).join("") ||
|
||
`<div class="empty compact"><span class="empty-title">暂无已审核表情</span>投稿后需管理员通过才会出现在此</div>`;
|
||
renderSquarePager(
|
||
"stickerSquarePager",
|
||
{ page: state.stickerPage, limit, total },
|
||
(next) => {
|
||
state.stickerPage = next;
|
||
loadStickerSquare().catch((e) => toast(e.message, true));
|
||
},
|
||
);
|
||
$("stickerCreatedGrid").innerHTML =
|
||
(mine.created || []).map((s) => stickerCardHtml(s, "created")).join("") ||
|
||
`<div class="empty compact">还没有投稿</div>`;
|
||
$("stickerLibGrid").innerHTML =
|
||
(mine.library || []).map((s) => stickerCardHtml(s, "lib")).join("") ||
|
||
`<div class="empty compact">库为空,从广场添加</div>`;
|
||
|
||
document.querySelectorAll("[data-st-add]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
try {
|
||
await api("/api/v1/me/stickers/" + btn.dataset.stAdd + "/add", {
|
||
method: "POST",
|
||
body: {},
|
||
});
|
||
toast("已添加到表情库");
|
||
loadStickerSquare().catch((e) => toast(e.message, true));
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-st-rm-lib]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
try {
|
||
await api("/api/v1/me/stickers/" + btn.dataset.stRmLib, {
|
||
method: "DELETE",
|
||
});
|
||
toast("已从库移除");
|
||
loadStickerSquare().catch((e) => toast(e.message, true));
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-st-rm-created]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
if (!confirm("删除该表情?")) return;
|
||
try {
|
||
await api("/api/v1/square/stickers/" + btn.dataset.stRmCreated, {
|
||
method: "DELETE",
|
||
});
|
||
toast("已删除");
|
||
loadStickerSquare().catch((e) => toast(e.message, true));
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
}
|
||
$("stickerSquareSearch")?.addEventListener("click", () => {
|
||
state.stickerPage = 1;
|
||
loadStickerSquare().catch((e) => toast(e.message, true));
|
||
});
|
||
$("stickerSquareQ")?.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
state.stickerPage = 1;
|
||
loadStickerSquare().catch((er) => toast(er.message, true));
|
||
}
|
||
});
|
||
let _stPubB64 = null;
|
||
let _stPubMime = null;
|
||
$("stPubFile")?.addEventListener("change", () => {
|
||
const f = $("stPubFile").files?.[0];
|
||
if (!f) {
|
||
_stPubB64 = null;
|
||
_stPubMime = null;
|
||
return;
|
||
}
|
||
if (f.size > 2.5 * 1024 * 1024) {
|
||
toast("图片过大(≤2MB)", true);
|
||
$("stPubFile").value = "";
|
||
return;
|
||
}
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const dataUrl = String(reader.result || "");
|
||
const m = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||
if (!m) {
|
||
toast("读取失败", true);
|
||
return;
|
||
}
|
||
_stPubMime = m[1];
|
||
_stPubB64 = m[2];
|
||
};
|
||
reader.readAsDataURL(f);
|
||
});
|
||
$("stPubSubmit")?.addEventListener("click", async () => {
|
||
const displayName = ($("stPubName")?.value || "").trim();
|
||
const slug = ($("stPubSlug")?.value || "").trim().toLowerCase();
|
||
const description = ($("stPubDesc")?.value || "").trim();
|
||
const tags = ($("stPubTags")?.value || "")
|
||
.split(/[,,]/)
|
||
.map((t) => t.trim())
|
||
.filter(Boolean);
|
||
const visibility =
|
||
document.querySelector('input[name="stPubVis"]:checked')?.value ||
|
||
"public";
|
||
if (!displayName || !_stPubB64) {
|
||
toast("请填写名称并选择图片", true);
|
||
return;
|
||
}
|
||
try {
|
||
const res = await api("/api/v1/square/stickers", {
|
||
method: "POST",
|
||
body: {
|
||
displayName,
|
||
slug: slug || undefined,
|
||
description,
|
||
tags,
|
||
visibility,
|
||
mime: _stPubMime,
|
||
dataBase64: _stPubB64,
|
||
},
|
||
});
|
||
const st = res.sticker?.reviewStatus || res.sticker?.review_status;
|
||
toast(
|
||
visibility === "public"
|
||
? st === "pending"
|
||
? "已提交,等待管理员审核"
|
||
: "已提交"
|
||
: "私有表情已保存",
|
||
);
|
||
$("stPubName").value = "";
|
||
$("stPubSlug").value = "";
|
||
$("stPubDesc").value = "";
|
||
$("stPubTags").value = "";
|
||
$("stPubFile").value = "";
|
||
_stPubB64 = null;
|
||
_stPubMime = null;
|
||
loadStickerSquare().catch((e) => toast(e.message, true));
|
||
} catch (e) {
|
||
toast(e.message || "提交失败", true);
|
||
}
|
||
});
|
||
|
||
async function loadMine() {
|
||
showGridSkeleton("createdGrid", 2);
|
||
showGridSkeleton("libGrid", 2);
|
||
const data = await api("/api/v1/me/personas");
|
||
const created = data.created || [];
|
||
const library = data.library || [];
|
||
$("createdGrid").innerHTML =
|
||
created.map((p) => cardHtml(p, "created")).join("") ||
|
||
`<div class="empty compact">
|
||
<span class="empty-title">还没有发布人设</span>
|
||
在上方填写名称与提示词,一键发布
|
||
<br/>
|
||
<button class="btn btn-primary btn-sm" type="button"
|
||
onclick="document.getElementById('pubName').focus();document.getElementById('personaEditorCard').scrollIntoView({behavior:'smooth',block:'start'})">用上方编辑器创作 <i class="bi bi-arrow-up" aria-hidden="true"></i></button>
|
||
</div>`;
|
||
$("libGrid").innerHTML =
|
||
library.map((p) => cardHtml(p, "lib")).join("") ||
|
||
`<div class="empty compact">
|
||
<span class="empty-title">库为空</span>
|
||
从广场添加后,可分配给机器人用户
|
||
<br/>
|
||
<button class="btn btn-primary btn-sm" type="button" onclick="switchPane('square')">去人设广场逛逛 <i class="bi bi-arrow-right" aria-hidden="true"></i></button>
|
||
</div>`;
|
||
|
||
document.querySelectorAll("[data-vis]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
const [id, visibility] = btn.dataset.vis.split("|");
|
||
try {
|
||
await api("/api/v1/square/personas/" + id, {
|
||
method: "PUT",
|
||
body: { visibility },
|
||
});
|
||
toast("已更新可见性");
|
||
loadMine().catch(onMineError);
|
||
loadSquare().catch(onSquareError);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-edit]").forEach((btn) => {
|
||
btn.onclick = () => startEditPersona(btn.dataset.edit);
|
||
});
|
||
document.querySelectorAll("[data-rm-created]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
const ok = await confirmDialog({
|
||
title: "删除人设?",
|
||
message: "删除后他人无法再使用(软删除,管理员可恢复)。",
|
||
okText: "删除",
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api("/api/v1/square/personas/" + btn.dataset.rmCreated, {
|
||
method: "DELETE",
|
||
});
|
||
toast("已删除");
|
||
if (state.editingPersonaId === btn.dataset.rmCreated) resetEditor();
|
||
loadMine().catch(onMineError);
|
||
refreshBots().catch(onRefreshError);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
document.querySelectorAll("[data-rm-lib]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
try {
|
||
await api("/api/v1/me/personas/" + btn.dataset.rmLib, {
|
||
method: "DELETE",
|
||
});
|
||
toast("已从库移除");
|
||
loadMine().catch(onMineError);
|
||
refreshBots().catch(onRefreshError);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
bindDetailButtons($("createdGrid"));
|
||
bindDetailButtons($("libGrid"));
|
||
bindTryForkButtons($("createdGrid"));
|
||
bindTryForkButtons($("libGrid"));
|
||
}
|
||
|
||
function syncChatflowHint() {
|
||
const mode = $("pubMode")?.value || "prompt";
|
||
const hint = $("pubChatflowHint");
|
||
if (hint) hint.style.display = mode === "chatflow" ? "block" : "none";
|
||
const link = $("pubChatflowLink");
|
||
if (link && state.editingPersonaId) {
|
||
link.href = "/chatflow?persona=" + encodeURIComponent(state.editingPersonaId);
|
||
} else if (link) {
|
||
link.href = "/chatflow";
|
||
}
|
||
}
|
||
|
||
async function fillPubLlmSelect(selectedId) {
|
||
const sel = $("pubLlmProvider");
|
||
if (!sel) return;
|
||
try {
|
||
const data = await api("/api/v1/me/llm-providers");
|
||
llmProvidersCache = data.providers || [];
|
||
} catch (_) {
|
||
/* keep cache */
|
||
}
|
||
const cur = selectedId !== undefined ? selectedId : sel.value;
|
||
sel.innerHTML =
|
||
'<option value="">平台默认(管理员 LLM)</option>' +
|
||
llmProvidersCache
|
||
.filter((p) => p.enabled !== false)
|
||
.map(
|
||
(p) =>
|
||
`<option value="${esc(p.id)}">${esc(p.name)} · ${esc(p.defaultModel || "")}</option>`,
|
||
)
|
||
.join("");
|
||
if (cur && [...sel.options].some((o) => o.value === cur)) {
|
||
sel.value = cur;
|
||
} else {
|
||
sel.value = "";
|
||
}
|
||
}
|
||
|
||
function resetEditor() {
|
||
state.editingPersonaId = null;
|
||
$("pubEditId").value = "";
|
||
$("pubName").value = "";
|
||
$("pubDesc").value = "";
|
||
$("pubTags").value = "";
|
||
$("pubPrompt").value = "";
|
||
if ($("pubMode")) $("pubMode").value = "prompt";
|
||
if ($("pubWebSearch")) $("pubWebSearch").checked = false;
|
||
if ($("pubLlmProvider")) $("pubLlmProvider").value = "";
|
||
const pubRadio = document.querySelector('input[name="pubVis"][value="public"]');
|
||
if (pubRadio) pubRadio.checked = true;
|
||
$("editorTitle").textContent = "人设编辑器";
|
||
$("pubSubmit").textContent = "发布人设";
|
||
$("pubCancelEdit").style.display = "none";
|
||
syncChatflowHint();
|
||
}
|
||
|
||
async function startEditPersona(id) {
|
||
try {
|
||
const data = await api("/api/v1/square/personas/" + id);
|
||
const p = data.persona;
|
||
state.editingPersonaId = id;
|
||
$("pubEditId").value = id;
|
||
$("pubName").value = personaName(p);
|
||
$("pubDesc").value = p.description || "";
|
||
$("pubTags").value = (p.tags || []).join(", ");
|
||
$("pubPrompt").value = p.systemPrompt || "";
|
||
const vis = p.visibility === "private" ? "private" : "public";
|
||
const radio = document.querySelector(`input[name="pubVis"][value="${vis}"]`);
|
||
if (radio) radio.checked = true;
|
||
if ($("pubMode")) {
|
||
$("pubMode").value = p.mode === "chatflow" ? "chatflow" : "prompt";
|
||
}
|
||
if ($("pubWebSearch")) {
|
||
$("pubWebSearch").checked = Boolean(p.webSearchEnabled);
|
||
}
|
||
await fillPubLlmSelect(p.llmProviderId || "");
|
||
$("editorTitle").textContent = "编辑人设";
|
||
$("pubSubmit").textContent = "保存修改";
|
||
$("pubCancelEdit").style.display = "inline-flex";
|
||
syncChatflowHint();
|
||
$("personaEditorCard")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
toast("已载入人设,可插入 {{bot_name}} 变量后保存");
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
}
|
||
|
||
async function loadLlmProviders() {
|
||
const list = $("llmProvList");
|
||
const note = $("llmProvGatewayNote");
|
||
if (list) list.innerHTML = '<p class="muted">加载中…</p>';
|
||
try {
|
||
const data = await api("/api/v1/me/llm-providers");
|
||
llmProvidersCache = data.providers || [];
|
||
if (note) {
|
||
if (!data.configured) {
|
||
note.textContent =
|
||
"服务端未配置 LLM_PROVIDER_SECRET,无法保存自定义密钥。";
|
||
} else if (!data.toolsGateway) {
|
||
note.textContent =
|
||
"未配置 TOOLS_BASE_URL:自定义模型不可用。请先部署 huggingface/wechat-ai-tools。";
|
||
} else {
|
||
note.textContent =
|
||
"工具网关已配置。自定义 API 由 HF 工具服务代发;" +
|
||
(data.webSearchEnabled
|
||
? "联网搜索已开启。"
|
||
: "联网搜索总开关关闭(WEB_SEARCH_ENABLED)。");
|
||
}
|
||
}
|
||
if (!list) return;
|
||
if (!llmProvidersCache.length) {
|
||
list.innerHTML =
|
||
'<div class="empty compact"><span class="empty-title">还没有模型连接</span>填写上方表单添加 OpenAI 兼容 API</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = llmProvidersCache
|
||
.map((p) => {
|
||
const en = p.enabled !== false;
|
||
return `<div class="llm-prov-card" data-prov="${esc(p.id)}">
|
||
<div class="row">
|
||
<h4>${esc(p.name)}</h4>
|
||
<span class="badge ${en ? "ok" : "warn"}">${en ? "启用" : "停用"}</span>
|
||
</div>
|
||
<div class="meta">${esc(p.baseUrl)} · 模型 ${esc(p.defaultModel || "—")} · Key ${esc(p.apiKeyMasked || "****")}</div>
|
||
<div class="row-actions">
|
||
<button class="btn btn-secondary btn-sm" type="button" data-prov-toggle="${esc(p.id)}|${en ? "0" : "1"}">${en ? "停用" : "启用"}</button>
|
||
<button class="btn btn-danger btn-sm" type="button" data-prov-del="${esc(p.id)}">删除</button>
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
list.querySelectorAll("[data-prov-del]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
const ok = await confirmDialog({
|
||
title: "删除模型连接?",
|
||
message: "使用该连接的人设将回退到平台默认模型。",
|
||
okText: "删除",
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api("/api/v1/me/llm-providers/" + btn.dataset.provDel, {
|
||
method: "DELETE",
|
||
});
|
||
toast("已删除");
|
||
loadLlmProviders().catch((e) => toast(e.message, true));
|
||
fillPubLlmSelect().catch(() => {});
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
list.querySelectorAll("[data-prov-toggle]").forEach((btn) => {
|
||
btn.onclick = async () => {
|
||
const [id, en] = (btn.dataset.provToggle || "").split("|");
|
||
try {
|
||
await api("/api/v1/me/llm-providers/" + id, {
|
||
method: "PATCH",
|
||
body: { enabled: en === "1" },
|
||
});
|
||
toast(en === "1" ? "已启用" : "已停用");
|
||
loadLlmProviders().catch((e) => toast(e.message, true));
|
||
fillPubLlmSelect().catch(() => {});
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
});
|
||
} catch (e) {
|
||
if (list) list.innerHTML = "";
|
||
if (note) note.textContent = e.message || "加载失败";
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
$("pubCancelEdit").onclick = () => {
|
||
resetEditor();
|
||
toast("已取消编辑");
|
||
};
|
||
|
||
$("squareSearch").onclick = () => {
|
||
state.squarePage = 1;
|
||
loadSquare().catch(onSquareError);
|
||
};
|
||
$("squareQ").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
state.squarePage = 1;
|
||
loadSquare().catch(onSquareError);
|
||
}
|
||
});
|
||
$("squareQ").addEventListener("input", () => {
|
||
clearTimeout(loadSquare._deb);
|
||
loadSquare._deb = setTimeout(() => {
|
||
state.squarePage = 1;
|
||
loadSquare().catch(onSquareError);
|
||
}, 350);
|
||
});
|
||
if ($("squareSort")) {
|
||
try {
|
||
const saved = localStorage.getItem("wa_app_square_sort");
|
||
if (
|
||
saved === "use" ||
|
||
saved === "recent" ||
|
||
saved === "name" ||
|
||
saved === "heat"
|
||
) {
|
||
$("squareSort").value = saved;
|
||
} else {
|
||
$("squareSort").value = "heat";
|
||
}
|
||
} catch (_) {}
|
||
$("squareSort").onchange = () => {
|
||
try {
|
||
localStorage.setItem("wa_app_square_sort", getSquareSort());
|
||
} catch (_) {}
|
||
state.squarePage = 1;
|
||
loadSquare().catch(onSquareError);
|
||
};
|
||
}
|
||
|
||
$("pubMode")?.addEventListener("change", () => syncChatflowHint());
|
||
|
||
$("pubSubmit").onclick = async () => {
|
||
const displayName = $("pubName").value.trim();
|
||
const systemPrompt = $("pubPrompt").value.trim();
|
||
if (!displayName || !systemPrompt) {
|
||
toast("请填写名称和提示词", true);
|
||
return;
|
||
}
|
||
const visibility =
|
||
document.querySelector('input[name="pubVis"]:checked')?.value ||
|
||
"public";
|
||
const tags = $("pubTags").value
|
||
.split(/[,,]/)
|
||
.map((t) => t.trim())
|
||
.filter(Boolean);
|
||
const mode = $("pubMode")?.value === "chatflow" ? "chatflow" : "prompt";
|
||
const llmProviderId = ($("pubLlmProvider")?.value || "").trim() || null;
|
||
const webSearchEnabled = Boolean($("pubWebSearch")?.checked);
|
||
const body = {
|
||
displayName,
|
||
description: $("pubDesc").value.trim(),
|
||
systemPrompt,
|
||
visibility,
|
||
tags,
|
||
mode,
|
||
llmProviderId,
|
||
webSearchEnabled,
|
||
};
|
||
try {
|
||
if (state.editingPersonaId) {
|
||
await api("/api/v1/square/personas/" + state.editingPersonaId, {
|
||
method: "PUT",
|
||
body,
|
||
});
|
||
toast("人设已保存");
|
||
resetEditor();
|
||
} else {
|
||
await api("/api/v1/square/personas", {
|
||
method: "POST",
|
||
body,
|
||
});
|
||
toast("发布成功");
|
||
resetEditor();
|
||
}
|
||
loadMine().catch(onMineError);
|
||
if (visibility === "public") loadSquare().catch(onSquareError);
|
||
refreshBots().catch(onRefreshError);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
}
|
||
};
|
||
|
||
$("llmProvSubmit")?.addEventListener("click", async () => {
|
||
const name = ($("llmProvName")?.value || "").trim();
|
||
const baseUrl = ($("llmProvBase")?.value || "").trim();
|
||
const apiKey = ($("llmProvKey")?.value || "").trim();
|
||
const defaultModel = ($("llmProvModel")?.value || "").trim();
|
||
if (!name || !baseUrl || !apiKey || !defaultModel) {
|
||
toast("请填写名称、Base URL、API Key 与模型", true);
|
||
return;
|
||
}
|
||
try {
|
||
await api("/api/v1/me/llm-providers", {
|
||
method: "POST",
|
||
body: { name, baseUrl, apiKey, defaultModel },
|
||
});
|
||
toast("已添加模型连接");
|
||
if ($("llmProvName")) $("llmProvName").value = "";
|
||
if ($("llmProvBase")) $("llmProvBase").value = "";
|
||
if ($("llmProvKey")) $("llmProvKey").value = "";
|
||
if ($("llmProvModel")) $("llmProvModel").value = "";
|
||
loadLlmProviders().catch((e) => toast(e.message, true));
|
||
fillPubLlmSelect().catch(() => {});
|
||
} catch (e) {
|
||
toast(e.message || "添加失败", true);
|
||
}
|
||
});
|
||
$("llmProvRefresh")?.addEventListener("click", () => {
|
||
loadLlmProviders().catch((e) => toast(e.message, true));
|
||
});
|
||
|
||
$("reloadPeers").onclick = () => loadPeers().catch((e) => toast(e.message, true));
|
||
$("filterBot").onchange = () => loadPeers().catch((e) => toast(e.message, true));
|
||
|
||
function stopPoll() {
|
||
if (state.timer) {
|
||
clearInterval(state.timer);
|
||
state.timer = null;
|
||
}
|
||
}
|
||
|
||
async function startBotLogin(opts) {
|
||
const rebindBotId = opts && opts.rebindBotId;
|
||
try {
|
||
stopPoll();
|
||
clearTimeout(showLogin._hideT);
|
||
clearTimeout(collapseLoginBox._t);
|
||
$("loginBox").style.cssText = "";
|
||
$("cancelLogin").disabled = false;
|
||
$("loginBox").classList.add("show");
|
||
$("qrImg").style.display = "none";
|
||
$("qrImg").dataset.src = "";
|
||
$("loginLinkWrap").style.display = "none";
|
||
$("qrSkel").style.display = "block";
|
||
try {
|
||
$("loginBox").scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
} catch (_) {}
|
||
setLoginStatus(
|
||
"wait",
|
||
rebindBotId ? "正在发起重新绑定…" : "正在获取二维码…",
|
||
);
|
||
const data = rebindBotId
|
||
? await api("/api/v1/me/bots/" + rebindBotId + "/relogin/start", {
|
||
method: "POST",
|
||
body: {},
|
||
})
|
||
: await api("/api/v1/me/bots/login/start", {
|
||
method: "POST",
|
||
body: { displayName: $("botName").value.trim() || undefined },
|
||
});
|
||
state.loginSessionId = data.session.sessionId;
|
||
state.loginMode = data.session.mode || (rebindBotId ? "rebind" : "create");
|
||
showLogin(data.session);
|
||
state.pollInFlight = false;
|
||
state.timer = setInterval(async () => {
|
||
// Don't stack requests on a slow link, and don't keep polling a
|
||
// backgrounded tab (server-side session TTL is 10 min, so pausing
|
||
// here never discards a still-valid session).
|
||
if (state.pollInFlight) return;
|
||
if (typeof document !== "undefined" && document.hidden) return;
|
||
state.pollInFlight = true;
|
||
try {
|
||
const r = await api("/api/v1/me/bots/login/" + state.loginSessionId);
|
||
showLogin(r.session);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
stopPoll();
|
||
} finally {
|
||
state.pollInFlight = false;
|
||
}
|
||
}, 1500);
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
setLoginStatus("err", e.message || "发起失败");
|
||
$("qrSkel").style.display = "none";
|
||
$("cancelLogin").disabled = true;
|
||
}
|
||
}
|
||
|
||
$("addBot").onclick = () => startBotLogin({});
|
||
$("botName").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
$("addBot").click();
|
||
}
|
||
});
|
||
|
||
async function loadWechatBind() {
|
||
const el = $("wechatBindStatus");
|
||
const box = $("bindCodeBox");
|
||
if (!el) return;
|
||
try {
|
||
const data = await api("/api/v1/me/wechat-bind");
|
||
if (data.enabled === false) {
|
||
el.textContent = "用户对话功能已关闭(P2P_ENABLED=false)";
|
||
if (box) box.style.display = "none";
|
||
return;
|
||
}
|
||
if (!data.bound) {
|
||
el.innerHTML =
|
||
"状态:<strong>未绑定</strong>。生成绑定码后,用微信给机器人发送 <code>/绑定 验证码</code>。";
|
||
} else {
|
||
const reach = data.reachable
|
||
? "可达(已有会话凭证)"
|
||
: "暂不可达——请先用该微信与机器人聊一句,对方才能 @ 到你";
|
||
el.innerHTML =
|
||
`状态:已绑定为 <strong>@${esc(data.username || "")}</strong><br/>` +
|
||
`<span class="muted" style="font-size:13px">Bot <code>${esc(data.botId || "")}</code> · peer <code>${esc(data.peerId || "")}</code> · ${esc(reach)}</span>`;
|
||
}
|
||
} catch (e) {
|
||
el.textContent = "无法加载绑定状态:" + (e.message || "error");
|
||
}
|
||
}
|
||
|
||
$("genBindCode").onclick = async () => {
|
||
try {
|
||
const data = await api("/api/v1/me/wechat-bind/code", {
|
||
method: "POST",
|
||
body: {},
|
||
});
|
||
$("bindCodeBox").style.display = "block";
|
||
$("bindCodeText").innerHTML =
|
||
`绑定码:<strong style="font-size:1.4em;letter-spacing:.12em">${esc(data.code)}</strong>` +
|
||
`(约 ${Math.round((data.expiresInSec || 600) / 60)} 分钟有效)`;
|
||
$("bindCodeHint").textContent =
|
||
data.instruction ||
|
||
`在微信中给机器人发送:/绑定 ${data.code}`;
|
||
toast("绑定码已生成");
|
||
await loadWechatBind();
|
||
} catch (e) {
|
||
toast(e.message || "生成失败", true);
|
||
}
|
||
};
|
||
|
||
$("clearBind").onclick = async () => {
|
||
const ok = await confirmDialog({
|
||
title: "解除微信绑定",
|
||
message: "解绑后无法被他人 @,进行中的用户对话也会结束。确定?",
|
||
okText: "解除",
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await api("/api/v1/me/wechat-bind", { method: "DELETE" });
|
||
$("bindCodeBox").style.display = "none";
|
||
toast("已解除绑定");
|
||
await loadWechatBind();
|
||
} catch (e) {
|
||
toast(e.message || "解绑失败", true);
|
||
}
|
||
};
|
||
|
||
$("refreshBind").onclick = () => {
|
||
loadWechatBind().catch((e) => toast(e.message || "刷新失败", true));
|
||
};
|
||
|
||
async function loadBlocks() {
|
||
const el = $("blockList");
|
||
if (!el) return;
|
||
try {
|
||
const data = await api("/api/v1/me/blocks");
|
||
if (data.enabled === false) {
|
||
el.textContent = "用户对话功能已关闭,黑名单不可用。";
|
||
return;
|
||
}
|
||
const blocks = data.blocks || [];
|
||
if (!blocks.length) {
|
||
el.innerHTML = '<span class="muted">黑名单为空</span>';
|
||
return;
|
||
}
|
||
el.innerHTML =
|
||
`<div class="table-wrap"><table><thead><tr><th>用户名</th><th>操作</th></tr></thead><tbody>` +
|
||
blocks
|
||
.map(
|
||
(b) =>
|
||
`<tr>
|
||
<td><strong>@${esc(b.username || b.userId || "")}</strong>${
|
||
b.name && b.name !== b.username
|
||
? ` <span class="muted">(${esc(b.name)})</span>`
|
||
: ""
|
||
}</td>
|
||
<td><button class="btn btn-secondary btn-sm" type="button" data-unblock="${esc(
|
||
b.userId,
|
||
)}">取消拉黑</button></td>
|
||
</tr>`,
|
||
)
|
||
.join("") +
|
||
`</tbody></table></div>`;
|
||
el.querySelectorAll("[data-unblock]").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
const id = btn.getAttribute("data-unblock");
|
||
if (!id) return;
|
||
try {
|
||
await api("/api/v1/me/blocks/" + encodeURIComponent(id), {
|
||
method: "DELETE",
|
||
});
|
||
toast("已取消拉黑");
|
||
await loadBlocks();
|
||
} catch (e) {
|
||
toast(e.message || "操作失败", true);
|
||
}
|
||
});
|
||
});
|
||
} catch (e) {
|
||
el.textContent = "无法加载黑名单:" + (e.message || "error");
|
||
}
|
||
}
|
||
|
||
$("addBlock").onclick = async () => {
|
||
const username = ($("blockUsername").value || "").trim().replace(/^@/, "");
|
||
if (!username) {
|
||
toast("请输入用户名", true);
|
||
return;
|
||
}
|
||
try {
|
||
const r = await api("/api/v1/me/blocks", {
|
||
method: "POST",
|
||
body: { username },
|
||
});
|
||
$("blockUsername").value = "";
|
||
toast(
|
||
r.already
|
||
? `@${r.blocked?.username || username} 已在黑名单`
|
||
: `已拉黑 @${r.blocked?.username || username}`,
|
||
);
|
||
await loadBlocks();
|
||
} catch (e) {
|
||
toast(e.message || "拉黑失败", true);
|
||
}
|
||
};
|
||
|
||
$("blockUsername").addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
$("addBlock").click();
|
||
}
|
||
});
|
||
|
||
$("refreshBlocks").onclick = () => {
|
||
loadBlocks().catch((e) => toast(e.message || "刷新失败", true));
|
||
};
|
||
|
||
$("cancelLogin").onclick = async () => {
|
||
if (!state.loginSessionId) return;
|
||
try {
|
||
await api("/api/v1/me/bots/login/" + state.loginSessionId + "/cancel", {
|
||
method: "POST",
|
||
body: {},
|
||
});
|
||
} catch (_) { /* ignore */ }
|
||
stopPoll();
|
||
$("cancelLogin").disabled = true;
|
||
$("qrSkel").style.display = "none";
|
||
setLoginStatus("warn", "已取消扫码");
|
||
toast("已取消扫码");
|
||
};
|
||
|
||
const LOGIN_STATUS_META = {
|
||
pending: { cls: "wait", label: "正在准备二维码…" },
|
||
wait_scan: { cls: "wait", label: "等待微信扫码…" },
|
||
scanned: { cls: "wait", label: "已扫码,请在手机上确认登录" },
|
||
confirmed: { cls: "ok", label: "登录成功" },
|
||
expired: { cls: "warn", label: "二维码已过期,请重新发起" },
|
||
error: { cls: "err", label: "出错了" },
|
||
};
|
||
|
||
function setLoginStatus(cls, text) {
|
||
const st = $("loginStatus");
|
||
if (!st) return;
|
||
// the 1.5s poll calls this repeatedly — only touch the DOM on change,
|
||
// otherwise the pulsing dot animation restarts every tick
|
||
const next = "qr-status " + cls;
|
||
let label = st.querySelector(".qr-label");
|
||
if (!label) {
|
||
st.textContent = "";
|
||
const dot = document.createElement("span");
|
||
dot.className = "dot";
|
||
dot.setAttribute("aria-hidden", "true");
|
||
label = document.createElement("span");
|
||
label.className = "qr-label";
|
||
st.append(dot, label);
|
||
}
|
||
if (st.className !== next) st.className = next;
|
||
if (label.textContent !== text) label.textContent = text;
|
||
}
|
||
|
||
function collapseLoginBox() {
|
||
const box = $("loginBox");
|
||
if (!box || !box.classList.contains("show")) return;
|
||
const h = box.offsetHeight;
|
||
box.style.height = h + "px";
|
||
box.style.overflow = "hidden";
|
||
requestAnimationFrame(() => {
|
||
box.style.transition =
|
||
"height 0.28s var(--ease), opacity 0.28s var(--ease), padding 0.28s var(--ease), margin 0.28s var(--ease)";
|
||
box.style.height = "0px";
|
||
box.style.opacity = "0";
|
||
box.style.paddingTop = "0px";
|
||
box.style.paddingBottom = "0px";
|
||
box.style.marginTop = "0px";
|
||
collapseLoginBox._t = setTimeout(() => {
|
||
box.classList.remove("show");
|
||
box.style.cssText = "";
|
||
}, 300);
|
||
});
|
||
}
|
||
|
||
function showLogin(s) {
|
||
$("loginBox").classList.add("show");
|
||
const meta = LOGIN_STATUS_META[s.status] || { cls: "wait", label: s.status || "" };
|
||
const modeLabel = s.mode === "rebind" ? "重新绑定 · " : "";
|
||
setLoginStatus(meta.cls, modeLabel + (s.message || meta.label));
|
||
if (s.openUrl) {
|
||
$("loginLinkWrap").style.display = "block";
|
||
$("loginLink").href = s.openUrl;
|
||
$("loginLink").textContent = s.openUrl;
|
||
const img = $("qrImg");
|
||
/* 二维码由本站渲染。openUrl 里带着登录 ticket,早先的实现把它当查询参数
|
||
发给 api.qrserver.com 换取图片,等于把凭证抄送给第三方。现在只请求本站
|
||
同源接口(<img> 自带 cookie),ticket 不出本机与浏览器。 */
|
||
const url =
|
||
"/api/v1/me/bots/login/" +
|
||
encodeURIComponent(s.sessionId) +
|
||
"/qr.svg";
|
||
// the poll repeats the same session — only (re)load and swap on change,
|
||
// and keep the skeleton until the image has actually decoded
|
||
if (img.dataset.src !== url) {
|
||
img.dataset.src = url;
|
||
img.onload = () => {
|
||
$("qrSkel").style.display = "none";
|
||
img.style.display = "inline-block";
|
||
};
|
||
img.onerror = () => {
|
||
$("qrSkel").style.display = "none";
|
||
setLoginStatus("warn", "二维码图片加载失败,请用下方链接打开");
|
||
};
|
||
img.src = url;
|
||
}
|
||
}
|
||
if (s.status === "confirmed") {
|
||
stopPoll();
|
||
$("cancelLogin").disabled = true;
|
||
$("qrSkel").style.display = "none";
|
||
$("qrImg").style.display = "none";
|
||
$("loginLinkWrap").style.display = "none";
|
||
toast(
|
||
s.mode === "rebind"
|
||
? "重新绑定成功,好友与人设已保留"
|
||
: "机器人添加成功",
|
||
);
|
||
refreshBots().catch(onRefreshError);
|
||
clearTimeout(showLogin._hideT);
|
||
showLogin._hideT = setTimeout(collapseLoginBox, 2000);
|
||
}
|
||
if (s.status === "expired" || s.status === "error") {
|
||
stopPoll();
|
||
$("cancelLogin").disabled = true;
|
||
$("qrSkel").style.display = "none";
|
||
toast(s.message || s.status, true);
|
||
}
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s ?? "").replace(/[&<>"']/g, (c) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]),
|
||
);
|
||
}
|
||
|
||
boot();
|
||
|
||
/* 只有 Chromium 会真正渲染 backdrop-filter 里的 SVG 滤镜引用;Safari / Firefox
|
||
语法能解析但渲染为空,会把整条 backdrop-filter 作废——必须运行时判定而不是 @supports。
|
||
navigator.userAgentData 目前仅 Chromium 实现,用作引擎判定。
|
||
再要求 pointer:fine:位移滤镜在滚动时每帧重算,中低端手机上代价太高。 */
|
||
(function () {
|
||
try {
|
||
var fine = matchMedia("(pointer: fine)").matches;
|
||
var chromium = !!navigator.userAgentData;
|
||
if (chromium && fine && CSS.supports("backdrop-filter", "url(#x)")) {
|
||
document.documentElement.classList.add("wa-lens");
|
||
}
|
||
} catch (e) {}
|
||
})();
|
||
|
||
/* 把滚动深度写成 0..1 的进度值,驱动吸顶层的模糊与底色。rAF 节流,passive 监听。 */
|
||
(function () {
|
||
var root = document.documentElement;
|
||
var ticking = false;
|
||
function apply() {
|
||
ticking = false;
|
||
var y = window.scrollY || root.scrollTop || 0;
|
||
/* 下界必须夹到 0:Safari / iOS 回弹时 scrollY 会是负数,负进度会让
|
||
--glass-blur-live 算出负长度,blur(-8.5px) 非法 → 整条 backdrop-filter
|
||
作废(模糊直接消失),同时 --glass-a 掉到 0.01 附近近乎全透明。 */
|
||
var p = y / 80;
|
||
p = p < 0 ? 0 : p > 1 ? 1 : p;
|
||
root.style.setProperty("--glass-progress", p.toFixed(3));
|
||
}
|
||
addEventListener(
|
||
"scroll",
|
||
function () {
|
||
if (!ticking) {
|
||
ticking = true;
|
||
requestAnimationFrame(apply);
|
||
}
|
||
},
|
||
{ passive: true },
|
||
);
|
||
apply();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|