mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-13 15:10:05 +00:00
Merge pull request #945 from Jasonzhu1207/refactor/ui-rebuild
Refactor/UI rebuild
This commit is contained in:
@@ -1001,6 +1001,8 @@ function createAgreementWindow() {
|
||||
*/
|
||||
function createSplashWindow(): BrowserWindow {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const splashThemeId = configService?.get('themeId') || 'cloud-dancer'
|
||||
const splashThemeMode = configService?.get('theme') || 'system'
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../public/icon.ico')
|
||||
: (process.platform === 'darwin'
|
||||
@@ -1008,8 +1010,8 @@ function createSplashWindow(): BrowserWindow {
|
||||
: join(process.resourcesPath, 'icon.ico'))
|
||||
|
||||
splashWindow = new BrowserWindow({
|
||||
width: 856,
|
||||
height: 540,
|
||||
width: 680,
|
||||
height: 460,
|
||||
resizable: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
@@ -1027,9 +1029,17 @@ function createSplashWindow(): BrowserWindow {
|
||||
})
|
||||
|
||||
if (isDev) {
|
||||
splashWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}splash.html`)
|
||||
const splashUrl = new URL('splash.html', process.env.VITE_DEV_SERVER_URL)
|
||||
splashUrl.searchParams.set('themeId', splashThemeId)
|
||||
splashUrl.searchParams.set('themeMode', splashThemeMode)
|
||||
splashWindow.loadURL(splashUrl.toString())
|
||||
} else {
|
||||
splashWindow.loadFile(join(__dirname, '../dist/splash.html'))
|
||||
splashWindow.loadFile(join(__dirname, '../dist/splash.html'), {
|
||||
query: {
|
||||
themeId: splashThemeId,
|
||||
themeMode: splashThemeMode
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
splashWindow.once('ready-to-show', () => {
|
||||
|
||||
@@ -4,23 +4,55 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WeFlow</title>
|
||||
<script>
|
||||
(function initSplashMode() {
|
||||
var params = new URLSearchParams(window.location.search || "");
|
||||
var mode = params.get("themeMode") || params.get("mode") || "system";
|
||||
var themeId = params.get("themeId") || "cloud-dancer";
|
||||
var mq = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
|
||||
var resolved = mode === "dark" || (mode === "system" && mq && mq.matches) ? "dark" : "light";
|
||||
|
||||
document.documentElement.setAttribute("data-theme", themeId);
|
||||
document.documentElement.setAttribute("data-theme-mode", mode);
|
||||
document.documentElement.setAttribute("data-mode", resolved);
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #8b7355;
|
||||
--primary-rgb: 139, 115, 85;
|
||||
--on-primary: #ffffff;
|
||||
--window-bg: transparent;
|
||||
--card-bg: rgba(255, 255, 255, 0.96);
|
||||
--card-bg-solid: #ffffff;
|
||||
--card-border: rgba(0, 0, 0, 0.06);
|
||||
--text-primary: #171717;
|
||||
--text-secondary: #6f6f6f;
|
||||
--text-tertiary: #9a9a9a;
|
||||
--track-bg: rgba(0, 0, 0, 0.06);
|
||||
--logo-surface: #f4f4f5;
|
||||
--card-gloss-start: rgba(255, 255, 255, 0.62);
|
||||
--card-gloss-end: rgba(255, 255, 255, 0);
|
||||
--shadow: none;
|
||||
--surface-start: #ffffff;
|
||||
--surface-end: #f8f9fc;
|
||||
--accent: #5b6abf;
|
||||
--accent-rgb: 91, 106, 191;
|
||||
--ambient-glow: rgba(91, 106, 191, 0.08);
|
||||
--text: #1a1b1e;
|
||||
--text-muted: #5f6368;
|
||||
--text-faint: #9aa0a6;
|
||||
--border-subtle: rgba(0, 0, 0, 0.05);
|
||||
--loader-track: rgba(0, 0, 0, 0.06);
|
||||
--shadow-window:
|
||||
0 24px 60px rgba(23, 27, 38, 0.10),
|
||||
0 4px 12px rgba(23, 27, 38, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
--radius-window: 24px;
|
||||
--ease-ambient: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--font: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
[data-mode="dark"] {
|
||||
--surface-start: #14171d;
|
||||
--surface-end: #0b0d10;
|
||||
--accent: #7c8deb;
|
||||
--accent-rgb: 124, 141, 235;
|
||||
--ambient-glow: rgba(124, 141, 235, 0.08);
|
||||
--text: #f0f0f0;
|
||||
--text-muted: #8b92a5;
|
||||
--text-faint: #4e5569;
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--loader-track: rgba(255, 255, 255, 0.09);
|
||||
--shadow-window:
|
||||
0 24px 80px rgba(0, 0, 0, 0.60),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
--radius-window: 20px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -34,9 +66,9 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--window-bg);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -48,100 +80,131 @@
|
||||
}
|
||||
|
||||
.splash-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 600px;
|
||||
height: 380px;
|
||||
max-width: calc(100vw - 64px);
|
||||
max-height: calc(100vh - 64px);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background:
|
||||
linear-gradient(180deg, var(--card-gloss-start) 0%, var(--card-gloss-end) 48%),
|
||||
var(--card-bg);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius-window);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: linear-gradient(145deg, var(--surface-start), var(--surface-end));
|
||||
box-shadow: var(--shadow-window);
|
||||
isolation: isolate;
|
||||
animation: shellIn 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
animation: windowAppear 800ms var(--ease-ambient) both;
|
||||
}
|
||||
|
||||
.splash-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
left: -50%;
|
||||
top: -50%;
|
||||
background: radial-gradient(circle at 50% 40%, var(--ambient-glow) 0%, transparent 44%);
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(90deg, transparent 0%, rgba(var(--primary-rgb), 0.035) 48%, transparent 100%),
|
||||
linear-gradient(180deg, rgba(var(--primary-rgb), 0.045) 0%, transparent 32%);
|
||||
opacity: 0.9;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.brand-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding-bottom: 42px;
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: -20px;
|
||||
text-align: center;
|
||||
animation: contentIn 460ms cubic-bezier(0.22, 1, 0.36, 1) 80ms both;
|
||||
animation: contentIn 560ms var(--ease-ambient) 90ms both;
|
||||
}
|
||||
|
||||
.logo-tile {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
.logo-core {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: var(--logo-surface);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 10px 24px rgba(0, 0, 0, 0.04);
|
||||
margin-bottom: 25px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
object-fit: contain;
|
||||
border-radius: 14px;
|
||||
.logo-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
border-radius: 20px;
|
||||
animation: logoBreathe 3200ms ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 25px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
font-size: 24px;
|
||||
line-height: 1.18;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
[data-mode="dark"] .app-name {
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.50);
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
[data-mode="dark"] .app-desc {
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
position: absolute;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
bottom: 26px;
|
||||
bottom: 24px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
gap: 18px;
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
font-variant-numeric: tabular-nums;
|
||||
animation: contentIn 460ms cubic-bezier(0.22, 1, 0.36, 1) 160ms both;
|
||||
animation: contentIn 560ms var(--ease-ambient) 170ms both;
|
||||
}
|
||||
|
||||
.progress-text-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-mode="dark"] .progress-text-wrap {
|
||||
color: var(--text-faint);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px rgba(var(--accent-rgb), 0.42);
|
||||
animation: dotPulse 1700ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
@@ -149,13 +212,20 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.version {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-faint);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.82;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
[data-mode="dark"] .version {
|
||||
opacity: 0.50;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
@@ -163,88 +233,142 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
height: 3px;
|
||||
background: var(--track-bg);
|
||||
background: var(--loader-track);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-mode="dark"] .progress-track {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 0%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
min-width: 0;
|
||||
border-radius: 0 999px 999px 0;
|
||||
box-shadow: 0 0 18px rgba(var(--primary-rgb), 0.34);
|
||||
transition: width 440ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
position: relative;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.34);
|
||||
overflow: hidden;
|
||||
transition: width 440ms var(--ease-ambient);
|
||||
}
|
||||
|
||||
.progress-fill::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -18px;
|
||||
width: 44px;
|
||||
height: 15px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--accent-rgb), 0.34);
|
||||
filter: blur(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.42), transparent);
|
||||
inset: -1px 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.54), transparent);
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
animation: sweep 1200ms ease-out;
|
||||
animation: spectralGlide 1200ms ease-out;
|
||||
}
|
||||
|
||||
.progress-fill.waiting::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -10px;
|
||||
width: 22px;
|
||||
height: 11px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--primary-rgb), 0.7);
|
||||
filter: blur(5px);
|
||||
animation: waitingPulse 1300ms ease-in-out infinite;
|
||||
opacity: 0.65;
|
||||
animation: leadingGlow 1300ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.splash-shell,
|
||||
.brand-lockup,
|
||||
.brand-stage,
|
||||
.status-row,
|
||||
.logo-image,
|
||||
.status-dot,
|
||||
.progress-fill,
|
||||
.progress-fill::after,
|
||||
.progress-fill.waiting::before {
|
||||
.progress-fill::before,
|
||||
.progress-fill::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
left: 0 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shellIn {
|
||||
from {
|
||||
@keyframes windowAppear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
transform: scale(0.97) translateY(12px);
|
||||
}
|
||||
to {
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contentIn {
|
||||
from {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sweep {
|
||||
@keyframes logoBreathe {
|
||||
0% {
|
||||
opacity: 0.94;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.38;
|
||||
transform: scale(0.84);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes leadingGlow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.38;
|
||||
transform: scaleX(0.78);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.86;
|
||||
transform: scaleX(1.28);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spectralGlide {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
20%,
|
||||
70% {
|
||||
opacity: 1;
|
||||
22%,
|
||||
66% {
|
||||
opacity: 0.58;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
@@ -252,33 +376,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes waitingPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.32;
|
||||
transform: scaleX(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.92;
|
||||
transform: scaleX(1.45);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="splash-shell" id="splash" role="status" aria-live="polite">
|
||||
<section class="brand-stage">
|
||||
<div class="brand-lockup">
|
||||
<div class="logo-tile" aria-hidden="true">
|
||||
<img class="logo" src="./logo.png" alt="">
|
||||
<section class="brand-stage" aria-label="WeFlow">
|
||||
<div class="logo-core" aria-hidden="true">
|
||||
<img class="logo-image" src="./logo.png" alt="">
|
||||
</div>
|
||||
|
||||
<h1 class="app-name">WeFlow</h1>
|
||||
<p class="app-desc">微信聊天记录管理工具</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="progress-text" id="progressText">正在启动...</div>
|
||||
<div class="progress-text-wrap">
|
||||
<div class="status-dot" aria-hidden="true"></div>
|
||||
<div class="progress-text" id="progressText">正在预加载会话逻辑...</div>
|
||||
</div>
|
||||
<div class="version" id="versionText"></div>
|
||||
</div>
|
||||
|
||||
@@ -288,82 +403,45 @@
|
||||
</main>
|
||||
|
||||
<script>
|
||||
var themePalettes = {
|
||||
"cloud-dancer": {
|
||||
light: { primary: "#8B7355", rgb: "139, 115, 85", onPrimary: "#ffffff" },
|
||||
dark: { primary: "#C9A86C", rgb: "201, 168, 108", onPrimary: "#111111" }
|
||||
},
|
||||
"blossom-dream": {
|
||||
light: { primary: "#D4849A", rgb: "212, 132, 154", onPrimary: "#ffffff" },
|
||||
dark: { primary: "#D19EBB", rgb: "209, 158, 187", onPrimary: "#111111" }
|
||||
},
|
||||
"corundum-blue": {
|
||||
light: { primary: "#4A6670", rgb: "74, 102, 112", onPrimary: "#ffffff" },
|
||||
dark: { primary: "#6A9AAA", rgb: "106, 154, 170", onPrimary: "#111111" }
|
||||
},
|
||||
"kiwi-green": {
|
||||
light: { primary: "#7A9A5C", rgb: "122, 154, 92", onPrimary: "#ffffff" },
|
||||
dark: { primary: "#9ABA7C", rgb: "154, 186, 124", onPrimary: "#111111" }
|
||||
},
|
||||
"spicy-red": {
|
||||
light: { primary: "#8B4049", rgb: "139, 64, 73", onPrimary: "#ffffff" },
|
||||
dark: { primary: "#C06068", rgb: "192, 96, 104", onPrimary: "#ffffff" }
|
||||
},
|
||||
"teal-water": {
|
||||
light: { primary: "#5A8A8A", rgb: "90, 138, 138", onPrimary: "#ffffff" },
|
||||
dark: { primary: "#7ABAAA", rgb: "122, 186, 170", onPrimary: "#111111" }
|
||||
},
|
||||
"geist": {
|
||||
light: { primary: "#444444", rgb: "68, 68, 68", onPrimary: "#ffffff" },
|
||||
dark: { primary: "#ededed", rgb: "237, 237, 237", onPrimary: "#111111" }
|
||||
}
|
||||
};
|
||||
|
||||
function setVar(name, value) {
|
||||
document.documentElement.style.setProperty(name, value);
|
||||
}
|
||||
var themeModeQuery = null;
|
||||
var systemModeQuery = null;
|
||||
|
||||
function resolveMode(mode) {
|
||||
if (mode === "dark" || mode === "light") return mode;
|
||||
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(themeId, mode) {
|
||||
var palette = themePalettes[themeId] || themePalettes["cloud-dancer"];
|
||||
var resolvedMode = resolveMode(mode);
|
||||
var accent = palette[resolvedMode] || palette.light;
|
||||
var isDark = resolvedMode === "dark";
|
||||
function syncSystemModeListener(mode) {
|
||||
if (!window.matchMedia) return;
|
||||
var nextQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
setVar("--primary", accent.primary);
|
||||
setVar("--primary-rgb", accent.rgb);
|
||||
setVar("--on-primary", accent.onPrimary);
|
||||
setVar("--window-bg", "transparent");
|
||||
|
||||
if (isDark) {
|
||||
setVar("--card-bg", "rgba(31, 31, 31, 0.96)");
|
||||
setVar("--card-bg-solid", "#1f1f1f");
|
||||
setVar("--card-border", "rgba(255, 255, 255, 0.08)");
|
||||
setVar("--text-primary", "#f1f1f1");
|
||||
setVar("--text-secondary", "#b8b8b8");
|
||||
setVar("--text-tertiary", "#858585");
|
||||
setVar("--track-bg", "rgba(255, 255, 255, 0.09)");
|
||||
setVar("--logo-surface", "#2b2b2b");
|
||||
setVar("--card-gloss-start", "rgba(255, 255, 255, 0.035)");
|
||||
setVar("--card-gloss-end", "rgba(255, 255, 255, 0)");
|
||||
setVar("--shadow", "none");
|
||||
} else {
|
||||
setVar("--card-bg", "rgba(255, 255, 255, 0.96)");
|
||||
setVar("--card-bg-solid", "#ffffff");
|
||||
setVar("--card-border", "rgba(0, 0, 0, 0.06)");
|
||||
setVar("--text-primary", "#171717");
|
||||
setVar("--text-secondary", "#6f6f6f");
|
||||
setVar("--text-tertiary", "#9a9a9a");
|
||||
setVar("--track-bg", "rgba(0, 0, 0, 0.06)");
|
||||
setVar("--logo-surface", "#f4f4f5");
|
||||
setVar("--card-gloss-start", "rgba(255, 255, 255, 0.62)");
|
||||
setVar("--card-gloss-end", "rgba(255, 255, 255, 0)");
|
||||
setVar("--shadow", "none");
|
||||
if (systemModeQuery && systemModeQuery !== nextQuery && systemModeQuery.removeEventListener) {
|
||||
systemModeQuery.removeEventListener("change", handleSystemModeChange);
|
||||
}
|
||||
|
||||
systemModeQuery = nextQuery;
|
||||
themeModeQuery = mode;
|
||||
|
||||
if (mode === "system" && nextQuery.addEventListener) {
|
||||
nextQuery.addEventListener("change", handleSystemModeChange);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSystemModeChange() {
|
||||
if (themeModeQuery === "system") {
|
||||
document.documentElement.setAttribute("data-mode", resolveMode("system"));
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(themeId, mode) {
|
||||
var safeThemeId = String(themeId || "cloud-dancer");
|
||||
var safeMode = mode === "light" || mode === "dark" || mode === "system" ? mode : "system";
|
||||
var resolvedMode = resolveMode(safeMode);
|
||||
|
||||
document.documentElement.setAttribute("data-theme", safeThemeId);
|
||||
document.documentElement.setAttribute("data-theme-mode", safeMode);
|
||||
document.documentElement.setAttribute("data-mode", resolvedMode);
|
||||
syncSystemModeListener(safeMode);
|
||||
}
|
||||
|
||||
function updateProgress(percent, text, waiting) {
|
||||
@@ -393,7 +471,11 @@
|
||||
el.textContent = text ? "v" + text.replace(/^v/i, "") : "";
|
||||
}
|
||||
|
||||
applyTheme("cloud-dancer", "light");
|
||||
(function bootstrapSplash() {
|
||||
var params = new URLSearchParams(window.location.search || "");
|
||||
applyTheme(params.get("themeId") || "cloud-dancer", params.get("themeMode") || "system");
|
||||
updateProgress(0, "", false);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ChatMessageBubbleProps {
|
||||
isSystem: boolean
|
||||
isEmoji?: boolean
|
||||
isImage?: boolean
|
||||
isVideo?: boolean
|
||||
isVoice?: boolean
|
||||
emojiHasAsset?: boolean
|
||||
emojiError?: boolean
|
||||
@@ -45,6 +46,7 @@ function ChatMessageBubble({
|
||||
isSystem,
|
||||
isEmoji,
|
||||
isImage,
|
||||
isVideo,
|
||||
isVoice,
|
||||
emojiHasAsset,
|
||||
emojiError,
|
||||
@@ -82,7 +84,7 @@ function ChatMessageBubble({
|
||||
{isSelectionMode && !isSent && <SelectionCheckbox checked={isSelected} side="left" />}
|
||||
|
||||
<div
|
||||
className={`message-bubble ${bubbleClass} ${isEmoji && emojiHasAsset && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVoice ? 'voice' : ''}`}
|
||||
className={`message-bubble ${bubbleClass} ${isEmoji && emojiHasAsset && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVideo ? 'video' : ''} ${isVoice ? 'voice' : ''}`}
|
||||
onContextMenu={(event) => onContextMenu?.(event, message)}
|
||||
>
|
||||
<div className="bubble-avatar">
|
||||
@@ -118,6 +120,7 @@ function areEqual(prev: ChatMessageBubbleProps, next: ChatMessageBubbleProps) {
|
||||
prev.isSystem === next.isSystem &&
|
||||
prev.isEmoji === next.isEmoji &&
|
||||
prev.isImage === next.isImage &&
|
||||
prev.isVideo === next.isVideo &&
|
||||
prev.isVoice === next.isVoice &&
|
||||
prev.emojiHasAsset === next.emojiHasAsset &&
|
||||
prev.emojiError === next.emojiError &&
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.chat-page {
|
||||
.chat-page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
@@ -666,7 +666,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.emoji {
|
||||
&.emoji,
|
||||
&.image,
|
||||
&.video {
|
||||
.bubble-content {
|
||||
background: transparent !important;
|
||||
padding: 0;
|
||||
@@ -2012,6 +2014,15 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.voice {
|
||||
.bubble-content {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bubble-avatar {
|
||||
@@ -2067,7 +2078,12 @@
|
||||
.message-bubble .bubble-content:has(> .solitaire-message),
|
||||
.message-bubble .bubble-content:has(> .official-message),
|
||||
.message-bubble .bubble-content:has(> .channel-video-card),
|
||||
.message-bubble .bubble-content:has(> .location-message) {
|
||||
.message-bubble .bubble-content:has(> .location-message),
|
||||
.message-bubble .bubble-content:has(> .voice-stack),
|
||||
.message-bubble .bubble-content:has(> .video-thumb-wrapper),
|
||||
.message-bubble .bubble-content:has(> .video-placeholder),
|
||||
.message-bubble .bubble-content:has(> .video-loading),
|
||||
.message-bubble .bubble-content:has(> .video-unavailable) {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
@@ -2465,13 +2481,202 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 引用消息样式
|
||||
// ═══════════════════════════════════════════
|
||||
// Ambient Reply System — "Spectral Thread"
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
// Wrapper for the entire ambient reply UI
|
||||
.ambient-reply-wrapper {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
|
||||
// Reply anchor — always visible, minimal
|
||||
.reply-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px 2px 4px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
opacity: 0.42;
|
||||
transition: opacity 0.35s ease, background-color 0.35s ease;
|
||||
user-select: none;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&.jumpable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 45%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .reply-anchor {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--primary) 8%, transparent);
|
||||
}
|
||||
|
||||
.reply-anchor-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
color: var(--primary);
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover .reply-anchor-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.reply-anchor-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
letter-spacing: 0.01em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-anchor-sep {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-anchor-excerpt {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.7;
|
||||
|
||||
// Hide inline emoji images in anchor excerpt
|
||||
.inline-emoji {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Ghost preview — appears on hover, frosted glass
|
||||
.reply-ghost {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
max-width: 320px;
|
||||
min-width: 160px;
|
||||
padding: 8px 12px 12px 12px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bg-primary) 75%, transparent);
|
||||
backdrop-filter: blur(20px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.3);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 50;
|
||||
|
||||
// Gradient accent bar on left edge
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2.5px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to bottom, color-mix(in srgb, var(--primary) 40%, transparent), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .reply-ghost {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.reply-ghost-sender {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
opacity: 0.75;
|
||||
margin-bottom: 3px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.reply-ghost-text {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.82;
|
||||
padding-left: 6px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
.inline-emoji {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sent message — adjust ghost position to right
|
||||
.message-bubble.sent .ambient-reply-wrapper {
|
||||
.reply-ghost {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.reply-anchor-name {
|
||||
color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
|
||||
}
|
||||
|
||||
.reply-anchor-excerpt {
|
||||
color: color-mix(in srgb, var(--on-primary) 72%, var(--primary));
|
||||
}
|
||||
|
||||
.reply-anchor-sep {
|
||||
color: color-mix(in srgb, var(--on-primary) 50%, var(--primary));
|
||||
}
|
||||
|
||||
.reply-anchor-icon {
|
||||
color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
|
||||
}
|
||||
|
||||
&:hover .reply-anchor {
|
||||
background: color-mix(in srgb, var(--on-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.reply-ghost {
|
||||
background: color-mix(in srgb, var(--bg-primary) 80%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
// Received message — ghost to left
|
||||
.message-bubble.received .ambient-reply-wrapper {
|
||||
.reply-ghost {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
// Legacy .quoted-message — used by SettingsPage quote-layout preview widget
|
||||
.quoted-message {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-left: 2px solid var(--primary);
|
||||
padding: 6px 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
|
||||
.quoted-sender {
|
||||
color: var(--primary);
|
||||
@@ -2485,38 +2690,9 @@
|
||||
|
||||
.quoted-text {
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
|
||||
.quoted-type-label {
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.quoted-emoji-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
vertical-align: middle;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自己发送的消息中的引用样式
|
||||
.message-bubble.sent .quoted-message {
|
||||
background: color-mix(in srgb, var(--on-primary) 12%, var(--primary));
|
||||
border-left-color: color-mix(in srgb, var(--on-primary) 36%, var(--primary));
|
||||
|
||||
.quoted-sender {
|
||||
color: color-mix(in srgb, var(--on-primary) 92%, var(--primary));
|
||||
}
|
||||
|
||||
.quoted-text {
|
||||
color: color-mix(in srgb, var(--on-primary) 80%, var(--primary));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 气泡内容区域(包含名字和内容)
|
||||
.bubble-body {
|
||||
display: flex;
|
||||
@@ -2531,14 +2707,6 @@
|
||||
|
||||
.bubble-content {
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&.quote-layout-top .quoted-message {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&.quote-layout-bottom .quoted-message {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// 时间分隔
|
||||
@@ -5589,7 +5757,8 @@
|
||||
}
|
||||
|
||||
&.emoji,
|
||||
&.image {
|
||||
&.image,
|
||||
&.video {
|
||||
max-width: min(82%, 760px);
|
||||
|
||||
.bubble-content {
|
||||
@@ -5602,6 +5771,13 @@
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.voice .bubble-content {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.message-bubble .bubble-content:has(> .link-message),
|
||||
@@ -5615,7 +5791,12 @@
|
||||
.message-bubble .bubble-content:has(> .transfer-message),
|
||||
.message-bubble .bubble-content:has(> .gift-message),
|
||||
.message-bubble .bubble-content:has(> .miniapp-message),
|
||||
.message-bubble .bubble-content:has(> .file-message) {
|
||||
.message-bubble .bubble-content:has(> .file-message),
|
||||
.message-bubble .bubble-content:has(> .voice-stack),
|
||||
.message-bubble .bubble-content:has(> .video-thumb-wrapper),
|
||||
.message-bubble .bubble-content:has(> .video-placeholder),
|
||||
.message-bubble .bubble-content:has(> .video-loading),
|
||||
.message-bubble .bubble-content:has(> .video-unavailable) {
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
@@ -5628,23 +5809,7 @@
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.quoted-message {
|
||||
background: transparent;
|
||||
border-left: 2px solid color-mix(in srgb, var(--primary) 62%, var(--text-tertiary));
|
||||
border-radius: 0;
|
||||
padding: 2px 0 2px 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.message-bubble.sent .quoted-message {
|
||||
background: color-mix(in srgb, var(--on-primary) 12%, transparent);
|
||||
border-left-color: color-mix(in srgb, var(--on-primary) 62%, var(--primary));
|
||||
|
||||
.quoted-sender,
|
||||
.quoted-text {
|
||||
color: color-mix(in srgb, var(--on-primary) 82%, var(--primary));
|
||||
}
|
||||
}
|
||||
// Ambient Reply dark mode / alternate adjustments handled via CSS variables
|
||||
|
||||
.link-message,
|
||||
.card-message,
|
||||
|
||||
@@ -55,6 +55,17 @@ interface PendingFootprintJumpPayload {
|
||||
createTime: number
|
||||
}
|
||||
|
||||
interface QuotedMessageJumpTarget {
|
||||
sourceMessageKey: string
|
||||
sourceCreateTime: number
|
||||
sessionId: string
|
||||
localId?: number
|
||||
serverId?: string
|
||||
createTime?: number
|
||||
senderUsername?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done'
|
||||
type GlobalMsgSearchResult = Message & { sessionId: string }
|
||||
|
||||
@@ -64,7 +75,7 @@ interface GlobalMsgPrefixCacheEntry {
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
type QuoteLayout = configService.QuoteLayout
|
||||
|
||||
|
||||
const GLOBAL_MSG_PER_SESSION_LIMIT = 10
|
||||
const GLOBAL_MSG_SEED_LIMIT = 120
|
||||
@@ -676,6 +687,26 @@ function cleanMessageContent(content: string): string {
|
||||
return content.trim()
|
||||
}
|
||||
|
||||
function normalizeMessageIdToken(value: unknown): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return ''
|
||||
if (!/^\d+$/.test(raw)) return raw
|
||||
return raw.replace(/^0+(?=\d)/, '')
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: unknown): number | undefined {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return undefined
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return undefined
|
||||
return Math.floor(parsed)
|
||||
}
|
||||
|
||||
function normalizeQuotedComparableText(value: unknown): string {
|
||||
const text = cleanMessageContent(String(value ?? '')).replace(/\s+/g, ' ').trim()
|
||||
return text.length > 160 ? text.slice(0, 160) : text
|
||||
}
|
||||
|
||||
const CHAT_SESSION_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||
const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||
const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30
|
||||
@@ -1114,6 +1145,15 @@ interface LoadMessagesOptions {
|
||||
inSessionJumpRequestSeq?: number
|
||||
}
|
||||
|
||||
type LoadMessagesFn = (
|
||||
sessionId: string,
|
||||
offset?: number,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
ascending?: boolean,
|
||||
options?: LoadMessagesOptions
|
||||
) => Promise<void>
|
||||
|
||||
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
@@ -1572,6 +1612,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
const [globalMsgSearchError, setGlobalMsgSearchError] = useState<string | null>(null)
|
||||
const pendingInSessionSearchRef = useRef<PendingInSessionSearchPayload | null>(null)
|
||||
const pendingFootprintJumpRef = useRef<PendingFootprintJumpPayload | null>(null)
|
||||
const pendingQuotedMessageJumpRef = useRef<QuotedMessageJumpTarget | null>(null)
|
||||
const loadMessagesRef = useRef<LoadMessagesFn | null>(null)
|
||||
const pendingGlobalMsgSearchReplayRef = useRef<string | null>(null)
|
||||
const globalMsgPrefixCacheRef = useRef<GlobalMsgPrefixCacheEntry | null>(null)
|
||||
|
||||
@@ -3772,6 +3814,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
loadMessagesRef.current = loadMessages
|
||||
|
||||
const handleJumpDateSelect = useCallback((date: Date, options: { sessionId?: string; switchRequestSeq?: number } = {}) => {
|
||||
const targetSessionId = String(options.sessionId || currentSessionRef.current || currentSessionId || '').trim()
|
||||
if (!targetSessionId) return
|
||||
@@ -4881,6 +4925,136 @@ function ChatPage(props: ChatPageProps) {
|
||||
}, 2500)
|
||||
}, [])
|
||||
|
||||
const findQuotedTargetInMessages = useCallback((target: QuotedMessageJumpTarget): { index: number; message: Message } | null => {
|
||||
if (messages.length === 0) return null
|
||||
|
||||
const targetServerId = normalizeMessageIdToken(target.serverId)
|
||||
const targetLocalId = typeof target.localId === 'number' && target.localId > 0 ? target.localId : undefined
|
||||
const targetCreateTime = typeof target.createTime === 'number' && target.createTime > 0 ? target.createTime : undefined
|
||||
const targetSender = String(target.senderUsername || '').trim()
|
||||
const targetContent = normalizeQuotedComparableText(target.content)
|
||||
const sourceIndex = target.sourceMessageKey
|
||||
? messages.findIndex((item) => getMessageKey(item) === target.sourceMessageKey)
|
||||
: -1
|
||||
|
||||
const orderedIndices: number[] = []
|
||||
const usedIndices = new Set<number>()
|
||||
const pushIndex = (index: number) => {
|
||||
if (index < 0 || index >= messages.length || usedIndices.has(index)) return
|
||||
usedIndices.add(index)
|
||||
orderedIndices.push(index)
|
||||
}
|
||||
|
||||
if (sourceIndex > 0) {
|
||||
for (let index = sourceIndex - 1; index >= 0; index--) {
|
||||
pushIndex(index)
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
pushIndex(index)
|
||||
}
|
||||
|
||||
let best: { index: number; message: Message; score: number } | null = null
|
||||
for (const index of orderedIndices) {
|
||||
const item = messages[index]
|
||||
const itemKey = getMessageKey(item)
|
||||
if (itemKey === target.sourceMessageKey) continue
|
||||
|
||||
const itemServerId = normalizeMessageIdToken(item.serverIdRaw ?? item.serverId)
|
||||
const serverMatch = Boolean(targetServerId && itemServerId && itemServerId === targetServerId)
|
||||
const localIdMatch = Boolean(targetLocalId && Number(item.localId || 0) === targetLocalId)
|
||||
const itemCreateTime = Number(item.createTime || 0)
|
||||
const timeDelta = targetCreateTime ? Math.abs(itemCreateTime - targetCreateTime) : Number.POSITIVE_INFINITY
|
||||
const exactTimeMatch = Boolean(targetCreateTime && timeDelta <= 1)
|
||||
const nearTimeMatch = Boolean(targetCreateTime && timeDelta <= 300)
|
||||
const senderMatch = Boolean(targetSender && String(item.senderUsername || '').trim() === targetSender)
|
||||
const itemText = targetContent
|
||||
? normalizeQuotedComparableText(item.parsedContent || item.rawContent || item.content || '')
|
||||
: ''
|
||||
const contentMatch = Boolean(
|
||||
targetContent &&
|
||||
itemText &&
|
||||
(itemText.includes(targetContent) || targetContent.includes(itemText))
|
||||
)
|
||||
|
||||
const strongMatch = Boolean(
|
||||
serverMatch ||
|
||||
localIdMatch ||
|
||||
(exactTimeMatch && (senderMatch || contentMatch)) ||
|
||||
(exactTimeMatch && !targetSender && !targetContent)
|
||||
)
|
||||
if (!strongMatch) continue
|
||||
|
||||
const score =
|
||||
(localIdMatch ? 100 : 0) +
|
||||
(serverMatch ? 90 : 0) +
|
||||
(exactTimeMatch ? 35 : (nearTimeMatch ? 8 : 0)) +
|
||||
(senderMatch ? 12 : 0) +
|
||||
(contentMatch ? 12 : 0)
|
||||
|
||||
if (!best || score > best.score) {
|
||||
best = { index, message: item, score }
|
||||
if (score >= 125) break
|
||||
}
|
||||
}
|
||||
|
||||
return best ? { index: best.index, message: best.message } : null
|
||||
}, [messages, getMessageKey])
|
||||
|
||||
const scrollToResolvedMessage = useCallback((resolved: { index: number; message: Message }, behavior: 'auto' | 'smooth' = 'smooth') => {
|
||||
const key = getMessageKey(resolved.message)
|
||||
flashNewMessages([key])
|
||||
requestAnimationFrame(() => {
|
||||
if (messageVirtuosoRef.current) {
|
||||
messageVirtuosoRef.current.scrollToIndex({
|
||||
index: resolved.index,
|
||||
align: 'center',
|
||||
behavior
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [flashNewMessages, getMessageKey])
|
||||
|
||||
const handleJumpToQuotedMessage = useCallback((target: QuotedMessageJumpTarget) => {
|
||||
const targetSessionId = String(currentSessionRef.current || currentSessionId || target.sessionId || '').trim()
|
||||
if (!targetSessionId) return
|
||||
|
||||
const normalizedTarget: QuotedMessageJumpTarget = {
|
||||
...target,
|
||||
sessionId: targetSessionId
|
||||
}
|
||||
const resolved = findQuotedTargetInMessages(normalizedTarget)
|
||||
if (resolved) {
|
||||
pendingQuotedMessageJumpRef.current = null
|
||||
scrollToResolvedMessage(resolved)
|
||||
return
|
||||
}
|
||||
|
||||
pendingQuotedMessageJumpRef.current = normalizedTarget
|
||||
const targetTime = Number(normalizedTarget.createTime || 0)
|
||||
if (!targetTime) return
|
||||
|
||||
const requestSeq = inSessionResultJumpRequestSeqRef.current + 1
|
||||
inSessionResultJumpRequestSeqRef.current = requestSeq
|
||||
setCurrentOffset(0)
|
||||
setJumpStartTime(0)
|
||||
setJumpEndTime(targetTime + 1)
|
||||
suppressAutoLoadLaterRef.current = true
|
||||
void loadMessagesRef.current?.(targetSessionId, 0, 0, targetTime + 1, false, {
|
||||
forceInitialLimit: 120,
|
||||
inSessionJumpRequestSeq: requestSeq
|
||||
})
|
||||
}, [currentSessionId, findQuotedTargetInMessages, scrollToResolvedMessage])
|
||||
|
||||
useEffect(() => {
|
||||
const pending = pendingQuotedMessageJumpRef.current
|
||||
if (!pending) return
|
||||
const resolved = findQuotedTargetInMessages(pending)
|
||||
if (!resolved) return
|
||||
pendingQuotedMessageJumpRef.current = null
|
||||
scrollToResolvedMessage(resolved, 'auto')
|
||||
}, [messages, findQuotedTargetInMessages, scrollToResolvedMessage])
|
||||
|
||||
const handleInSessionResultJump = useCallback((msg: Message) => {
|
||||
const targetTime = Number(msg.createTime || 0)
|
||||
const targetSessionId = String(currentSessionRef.current || currentSessionId || '').trim()
|
||||
@@ -6679,6 +6853,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
autoTranscribeVoiceEnabled={autoTranscribeVoiceEnabled}
|
||||
onRequireModelDownload={handleRequireModelDownload}
|
||||
onContextMenu={handleContextMenu}
|
||||
onJumpToQuotedMessage={handleJumpToQuotedMessage}
|
||||
isSelectionMode={isSelectionMode}
|
||||
messageKey={messageKey}
|
||||
isSelected={selectedMessages.has(messageKey)}
|
||||
@@ -6698,6 +6873,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
autoTranscribeVoiceEnabled,
|
||||
handleRequireModelDownload,
|
||||
handleContextMenu,
|
||||
handleJumpToQuotedMessage,
|
||||
isSelectionMode,
|
||||
selectedMessages,
|
||||
handleToggleSelection
|
||||
@@ -8261,6 +8437,7 @@ function MessageBubble({
|
||||
autoTranscribeVoiceEnabled,
|
||||
onRequireModelDownload,
|
||||
onContextMenu,
|
||||
onJumpToQuotedMessage,
|
||||
isSelectionMode,
|
||||
isSelected,
|
||||
onToggleSelection
|
||||
@@ -8275,6 +8452,7 @@ function MessageBubble({
|
||||
autoTranscribeVoiceEnabled?: boolean;
|
||||
onRequireModelDownload?: (sessionId: string, messageId: string) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, message: Message) => void;
|
||||
onJumpToQuotedMessage?: (target: QuotedMessageJumpTarget) => void;
|
||||
isSelectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelection?: (messageKey: string, isShiftKey?: boolean) => void;
|
||||
@@ -8291,7 +8469,6 @@ function MessageBubble({
|
||||
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
|
||||
const [senderName, setSenderName] = useState<string | undefined>(undefined)
|
||||
const [quotedSenderName, setQuotedSenderName] = useState<string | undefined>(undefined)
|
||||
const [quoteLayout, setQuoteLayout] = useState<QuoteLayout>('quote-top')
|
||||
const [solitaireExpanded, setSolitaireExpanded] = useState(false)
|
||||
const senderProfileRequestSeqRef = useRef(0)
|
||||
const [emojiError, setEmojiError] = useState(false)
|
||||
@@ -9464,17 +9641,7 @@ function MessageBubble({
|
||||
myWxid
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void configService.getQuoteLayout().then((layout) => {
|
||||
if (!cancelled) setQuoteLayout(layout)
|
||||
}).catch(() => {
|
||||
if (!cancelled) setQuoteLayout('quote-top')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
// quoteLayout config removed - Ambient Reply uses a single fixed layout
|
||||
|
||||
const locationMessageMeta = useMemo(() => {
|
||||
if (message.localType !== 48) return null
|
||||
@@ -9511,31 +9678,90 @@ function MessageBubble({
|
||||
// 是否有引用消息
|
||||
const hasQuote = quotedContent.length > 0
|
||||
const displayQuotedSenderName = quotedSenderName || quotedSenderFallbackName
|
||||
const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => {
|
||||
const quoteFirst = quoteLayout !== 'quote-bottom'
|
||||
return (
|
||||
<div className={`bubble-content ${quoteFirst ? 'quote-layout-top' : 'quote-layout-bottom'}`}>
|
||||
{quoteFirst ? (
|
||||
<>
|
||||
{quotedNode}
|
||||
{messageNode}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{messageNode}
|
||||
{quotedNode}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}, [quoteLayout])
|
||||
const quotedJumpTarget = useMemo<QuotedMessageJumpTarget | null>(() => {
|
||||
if (!hasQuote) return null
|
||||
|
||||
const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => (
|
||||
<div className="quoted-message">
|
||||
{displayQuotedSenderName && <span className="quoted-sender">{displayQuotedSenderName}</span>}
|
||||
<span className="quoted-text">{contentNode}</span>
|
||||
const quotedServerId = normalizeMessageIdToken(
|
||||
queryAppMsgText('refermsg > svrid') ||
|
||||
queryAppMsgText('refermsg > msgsvrid') ||
|
||||
queryAppMsgText('refermsg > newmsgid') ||
|
||||
queryAppMsgText('refermsg > msgid')
|
||||
)
|
||||
const quotedCreateTime = parsePositiveInteger(
|
||||
queryAppMsgText('refermsg > createtime') ||
|
||||
queryAppMsgText('refermsg > create_time') ||
|
||||
queryAppMsgText('refermsg > createTime')
|
||||
)
|
||||
const quotedLocalId = parsePositiveInteger(
|
||||
queryAppMsgText('refermsg > localid') ||
|
||||
queryAppMsgText('refermsg > local_id') ||
|
||||
queryAppMsgText('refermsg > localId')
|
||||
)
|
||||
const normalizedQuotedContent = normalizeQuotedComparableText(quotedContent)
|
||||
|
||||
if (!quotedServerId && !quotedCreateTime && !quotedLocalId && !normalizedQuotedContent) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
sourceMessageKey: messageKey,
|
||||
sourceCreateTime: Number(message.createTime || 0),
|
||||
sessionId: session.username,
|
||||
localId: quotedLocalId,
|
||||
serverId: quotedServerId || undefined,
|
||||
createTime: quotedCreateTime,
|
||||
senderUsername: quotedSenderUsername || undefined,
|
||||
content: normalizedQuotedContent || undefined
|
||||
}
|
||||
}, [hasQuote, message.createTime, messageKey, queryAppMsgText, quotedContent, quotedSenderUsername, session.username])
|
||||
const handleQuotedJumpClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isSelectionMode) return
|
||||
if (!quotedJumpTarget || !onJumpToQuotedMessage) return
|
||||
event.stopPropagation()
|
||||
onJumpToQuotedMessage(quotedJumpTarget)
|
||||
}, [isSelectionMode, onJumpToQuotedMessage, quotedJumpTarget])
|
||||
const handleQuotedJumpKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
if (isSelectionMode) return
|
||||
if (!quotedJumpTarget || !onJumpToQuotedMessage) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onJumpToQuotedMessage(quotedJumpTarget)
|
||||
}, [isSelectionMode, onJumpToQuotedMessage, quotedJumpTarget])
|
||||
// Ambient Reply: single fixed layout (anchor above, message below)
|
||||
const renderBubbleWithQuote = useCallback((quotedNode: React.ReactNode, messageNode: React.ReactNode) => (
|
||||
<div className="bubble-content">
|
||||
{quotedNode}
|
||||
{messageNode}
|
||||
</div>
|
||||
), [displayQuotedSenderName])
|
||||
), [])
|
||||
|
||||
// Ambient Reply: render reply-anchor + ghost preview
|
||||
const renderQuotedMessageBlock = useCallback((contentNode: React.ReactNode) => (
|
||||
<div className="ambient-reply-wrapper">
|
||||
{/* Reply anchor - always visible, subtle */}
|
||||
<div
|
||||
className={`reply-anchor ${quotedJumpTarget ? 'jumpable' : ''}`}
|
||||
role={quotedJumpTarget && !isSelectionMode ? 'button' : undefined}
|
||||
tabIndex={quotedJumpTarget && !isSelectionMode ? 0 : undefined}
|
||||
onClick={handleQuotedJumpClick}
|
||||
onKeyDown={handleQuotedJumpKeyDown}
|
||||
>
|
||||
<svg className="reply-anchor-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 14 4 9 9 4" />
|
||||
<path d="M20 20v-7a4 4 0 0 0-4-4H4" />
|
||||
</svg>
|
||||
{displayQuotedSenderName && <span className="reply-anchor-name">{displayQuotedSenderName}</span>}
|
||||
<span className="reply-anchor-sep">·</span>
|
||||
<span className="reply-anchor-excerpt">{contentNode}</span>
|
||||
</div>
|
||||
{/* Ghost preview - appears on hover */}
|
||||
<div className="reply-ghost">
|
||||
{displayQuotedSenderName && <div className="reply-ghost-sender">{displayQuotedSenderName}</div>}
|
||||
<div className="reply-ghost-text">{contentNode}</div>
|
||||
</div>
|
||||
</div>
|
||||
), [displayQuotedSenderName, handleQuotedJumpClick, handleQuotedJumpKeyDown, isSelectionMode, quotedJumpTarget])
|
||||
|
||||
const handlePlayVideo = useCallback(async () => {
|
||||
if (!videoInfo?.videoUrl) return
|
||||
@@ -10773,6 +10999,7 @@ function MessageBubble({
|
||||
isSystem={isSystem}
|
||||
isEmoji={isEmoji}
|
||||
isImage={isImage}
|
||||
isVideo={isVideo}
|
||||
isVoice={isVoice}
|
||||
emojiHasAsset={Boolean(message.emojiCdnUrl || message.emojiLocalPath)}
|
||||
emojiError={emojiError}
|
||||
@@ -10802,6 +11029,7 @@ const MemoMessageBubble = React.memo(MessageBubble, (prevProps, nextProps) => {
|
||||
if (prevProps.isSelected !== nextProps.isSelected) return false
|
||||
if (prevProps.onRequireModelDownload !== nextProps.onRequireModelDownload) return false
|
||||
if (prevProps.onContextMenu !== nextProps.onContextMenu) return false
|
||||
if (prevProps.onJumpToQuotedMessage !== nextProps.onJumpToQuotedMessage) return false
|
||||
if (prevProps.onToggleSelection !== nextProps.onToggleSelection) return false
|
||||
|
||||
return (
|
||||
|
||||
@@ -793,7 +793,7 @@ function ContactsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const getContactTypeName = (type: string) => {
|
||||
function getContactTypeName(type: string) {
|
||||
switch (type) {
|
||||
case 'friend': return '好友'
|
||||
case 'group': return '群聊'
|
||||
|
||||
Reference in New Issue
Block a user