Анализ сайта mnspeak.com
Основное Готовность: 100%
Домен
mnspeak.com
Состояние доменного имени
?
Проверяем корректность доменного имени и наличие технических проблем на уровне домена.
Домен второго уровня идеален для продвижения.
Отличный запоминающийся домен.
Ответ сервера
200 Успешный ответ
HTTP-код ответа и цепочка редиректов
?
Код 200 — страница доступна. Коды 3xx — редиректы (цепочки замедляют загрузку и размывают ссылочный вес). Коды 4xx/5xx — ошибки, поисковик не сможет проиндексировать страницу.
Сервер настроен корректно.
Цепочка редиректов:
http://mnspeak.com
301 MovedPermanently
https://mnspeak.com/
200 OK
Безопасность
Сайт безопасен
Использование HTTPS и SSL-сертификат
?
HTTPS — обязательный стандарт. Google и Яндекс отдают предпочтение защищённым сайтам. Отсутствие SSL или просроченный сертификат ведут к предупреждениям в браузере и снижению позиций.
Не настроен HSTS (Strict-Transport-Security) — рекомендуется включить.
На сайте работает защищенный протокол ssl и сайт открывается по https.
Ssl-сертификат действителен до 16.11.2026 17:50:00.
HTTP автоматически перенаправляется на HTTPS.
Поздравляем! Сайт не содержится в реестре РКН.
Кодировка
UTF-8
Кодировка символов страницы
?
Стандарт — UTF-8. Неправильная кодировка вызывает нечитаемые символы и мешает поисковику корректно распознать текст страницы.
Указана кодировка на странице UTF-8.
Язык
zh-CN
Атрибут lang в HTML-теге
?
Атрибут lang (<html lang="ru">) сообщает поисковикам и браузерам, на каком языке написана страница. Помогает при ранжировании в региональном поиске.
Язык документа указан явно: zh-CN.
Скорость загрузки
~1,86сек
Время отклика сервера (TTFB)
?
Time To First Byte — время до получения первого байта от сервера. Норма до 200 мс. Медленный отклик ухудшает пользовательский опыт и ранжирование: Яндекс и Google учитывают скорость страниц.
Скорость загрузки сайта 1,86сек превышает 1 секунду. Желательно улучшить работу сайта!
Объем документа
734Кб
Размер HTML-кода страницы
?
Слишком большой HTML замедляет парсинг браузером и сканирование поисковым роботом. Рекомендуется не более 200 Кб.
Объем html-документа 734Кб оптимален.
Структура html-документа корректна.
Ресурсы
Ресурсы: 5
Внешние ресурсы страницы (CSS, JS, изображения)
?
Количество и тип подключённых ресурсов влияют на скорость загрузки. Большое число запросов увеличивает время рендеринга страницы.
Кол-во файлов ресурсов 5 достаточно.
Показать полный список ресурсов
| Тип | Название | Значение |
|---|---|---|
| js | /libs/tailwind.js | |
| js | /libs/react.min.js | |
| js | /libs/react-dom.min.js | |
| js | /libs/babel.min.js | |
| js | /libs/socket.io.min.js |
Серверные заголовки
Кол-во: 8
HTTP-заголовки ответа сервера
?
Заголовки сервера передают браузеру и поисковику служебную информацию: кеширование, безопасность (CSP, HSTS), сжатие (gzip). Правильная настройка ускоряет загрузку и повышает защищённость.
Найдены серверные заголовки 8шт. Подробнее про серверные заголовки.
Показать полный список серверных заголовков
| Ключ | Значение |
|---|---|
| Server | nginx/1.18.0 |
| Date | Fri, 21 Aug 2026 22:05:36 GMT |
| Connection | keep-alive |
| Vary | Accept-Encoding |
| ETag | "6a82c6f6-c25c7" |
| Cache-Control | no-store, must-revalidate, no-cache |
| Pragma | no-cache |
| Accept-Ranges | bytes |
CMS
Система управления сайтом (движок)
?
CMS — это движок, на котором работает сайт (WordPress, 1C-Bitrix, Tilda и др.). Знание CMS помогает понять возможности SEO-оптимизации и подобрать подходящие инструменты. «Не определена» — вероятно, самописный сайт или нестандартная сборка.
Сайт работает на CMS Tilda.
Веб-сервер
Программное обеспечение сервера
?
Веб-сервер — это ПО, которое отдаёт страницы посетителям (nginx, Apache, IIS, LiteSpeed и др.). Определяется по серверным заголовкам ответа (Server, X-Powered-By и т.п.). «Не определён» — сервер намеренно скрывает эти заголовки, это нормальная практика безопасности.
Сайт работает на веб-сервере nginx.
Мета-теги Готовность: 48%
Title
ABC娱乐
Заголовок страницы в браузере и поисковой выдаче
?
Title — главный SEO-заголовок страницы. Влияет на CTR в поиске и ранжирование. Оптимальная длина: 50–70 символов. Ключевые слова — ближе к началу.
Необходимо увеличить число символов в title (текущее значение мало: 5, минимум: 25, оптимально: от 40 до 45)
Дублей словоформ в title не найдено.
Description
Описание страницы в поисковой выдаче (сниппет)
?
Meta Description — текст под заголовком в выдаче. Напрямую на позиции не влияет, но влияет на CTR. Оптимальная длина: 120–160 символов.
Установите мета-тег description!
Keywords
Список ключевых слов страницы (устаревший тег)
?
Meta Keywords не учитывается Яндексом и Google для ранжирования с 2009–2012 годов. Заполнение не обязательно, но не вредит. Конкурент может использовать содержимое для анализа.
Установите мета-тег keywords!
Канонический Url
Указывает поисковику основную версию страницы
?
Canonical (rel=canonical) предотвращает проблему дублей страниц. Должен точно совпадать с URL проверяемой страницы. Неправильный canonical может передать ссылочный вес на другую страницу.
Рекомендуем прописать канонический Url.
Robots
Ошибок нет
Директивы для поисковых роботов на уровне страницы
?
Meta Robots управляет индексацией конкретной страницы: index/noindex — индексировать ли, follow/nofollow — следовать ли по ссылкам. Noindex полностью исключает страницу из поиска.
Meta-тег robots не указан. Страница свободна для индексации.
Адаптивность
width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no
Настройка масштабирования на мобильных устройствах
?
Тег viewport (<meta name="viewport">) сообщает браузеру, как масштабировать страницу на мобильных. Стандарт: width=device-width, initial-scale=1. Отсутствие — признак отсутствия мобильной версии.
Meta-тег viewport со значением-константой width=device-width задаёт ширину страницы в соответствии с размером экрана.
Meta-тег viewport со значением initial-scale=1.0 определяет масштаб 1:1, т.е. «не масштабировать».
Meta-тег viewport со значением user-scalable определяет доступность масштабирования страницы пользователем.
Meta-тег viewport со значением maximum-scale определяет максимальный масштаб области просмотра.
Разметка OpenGraph
Кол-во: 5
Мета-теги для красивых превью в соцсетях
?
OpenGraph (og:title, og:description, og:image) управляет тем, как страница выглядит при репосте в социальных сетях и мессенджерах. Отсутствие OG-тегов — невзрачный превью при шеринге.
Разметка OpenGraph задана. Страница оптимизирована под социальные сети.
Показать полный список og мета-тегов
| Тип | Значение |
|---|---|
| og:title | ABC |
| og:description | 免费建群,红包接龙/扫雷,无跑包无封号,点击就能玩 |
| og:image | https://4949group.top/abc-logo.jpg |
| og:url | https://4949group.top |
| og:type | website |
Все мета-теги
Кол-во: 15
Полный список мета-тегов страницы
?
Таблица всех meta-тегов, включая нестандартные. Позволяет найти опечатки, дубли и лишние теги.
Найдены мета-теги 15шт. Мета-теги не видимы для человека и предназначены для обмена информацией между веб-страницей и поисковыми системами, браузерами и другими веб-службами. С ними роботы 🤖 и устройства ведут себя более ожидаемо.
Показать полный список мета-тегов
| Тип | Название | Значение |
|---|---|---|
| name | viewport | width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no |
| name | theme-color | #f5f5f5 |
| name | apple-mobile-web-app-capable | yes |
| name | apple-mobile-web-app-status-bar-style | default |
| name | apple-mobile-web-app-title | 生肖竞猜 |
| name | mobile-web-app-capable | yes |
| name | twitter:card | summary_large_image |
| name | twitter:title | ABC |
| name | twitter:description | 免费建群,红包接龙/扫雷,无跑包无封号,点击就能玩 |
| name | twitter:image | https://4949group.top/abc-logo.jpg |
| property | og:title | ABC |
| property | og:description | 免费建群,红包接龙/扫雷,无跑包无封号,点击就能玩 |
| property | og:image | https://4949group.top/abc-logo.jpg |
| property | og:url | https://4949group.top |
| property | og:type | website |
Оптимизация Готовность: 0%
Структура
Ошибок нет
Семантические HTML-элементы страницы
?
Проверяет наличие основных структурных элементов: nav, header, footer, main. Корректная семантическая структура помогает поисковику понять архитектуру страницы.
Структура документа корректна (теги <html> и <body> присутствуют в единственном экземпляре).
Контент
Есть ошибки
Объём и качество текстового содержимого
?
Анализирует объём полезного текста на странице. Слишком мало — страница может считаться малополезной. Слишком много — ухудшается читаемость и восприятие.
Слова из title 0 встречаются в тексте редко. Добавьте в контент страницы слова из тега <title>!
Абзацев с текстом 1 слишком мало. Добавьте больше абзацев с текстом (тег <p>)!
Среднее число слов в абзаце 1 слишком мало. Сделайте контент более читаемым!
Кол-во знаков контента 6 слишком мало. Добавьте больше текста!
Кол-во слов 1 слишком мало. Добавьте больше текста (минимум 400 слов)!
Заголовки
Есть ошибки
Иерархия заголовков H1–H6
?
H1 должен быть один и содержать ключевой запрос. H2–H6 описывают подразделы. Пропуск уровней (H1 → H3) и несколько H1 — типичные ошибки, снижающие понятность страницы для поисковика.
Не обнаружено заголовков на странице. Добавьте заголовки (<h1>, <h2> или <h3>)
Тошнота
Не удалось произвести расчет
Насколько одно слово доминирует в тексте
?
Классическая тошнота = √(частота самого повторяющегося слова). Норма до 7–8: текст воспринимается естественно. Выше — поисковик может счесть страницу переспамленной.
Тошнота не рассчитана. Измените текст страницы!
Академич. тошнота
Нет данных для расчета
Насколько текст перенасыщен ключевыми словами
?
Академическая тошнота = (частота слова / общее количество слов) × 100%. Показывает долю конкретного слова в тексте. Норма 5–15%.
Академическая тошнота не рассчитана. Измените текст страницы!
Семантическое ядро
Не удалось собрать
Наиболее часто встречающиеся слова на странице
?
Топ слов по частоте использования. Показывает, какие слова доминируют в тексте с точки зрения поисковика.
Контент страницы не содержит осмысленного текста. Добавьте текст на страницу!
Индексация Готовность: 20%
Индексирование
Есть ошибки
Разрешено ли индексирование страницы
?
Проверяет, не закрыта ли страница от индексации через robots.txt, meta robots или X-Robots-Tag. Страница, закрытая от индексации, не появится в поисковой выдаче.
Анкоров на странице не обнаружено. Велика вероятность, что сайт - одностраничник, такой сайт продвинуть крайне затруднительно!
Robots.txt
Найден корректный robots.txt
Файл управления сканированием сайта роботами
?
Robots.txt указывает поисковым роботам, какие страницы сканировать, а какие — нет. Ошибки в файле могут случайно закрыть важные разделы от индексации.
Robots.txt настроен корректно. Размер файла: 752159 байт. Загружен за: 2сек.
Проверяемая страница не запрещена в robots.txt.
Robots.txt доступен по постоянному адресу
Цепочка редиректов для файла robots.txt:
http://mnspeak.com/robots.txt
301 MovedPermanently
https://mnspeak.com/robots.txt
200 OK
Показать содержимое robots.txt
<!DOCTYPE html>
<!-- VERSION: 20260606-AUTO-UPDATE -->
<!-- 【自动更新机制】每次部署更新版本号,APP端加载时自动检测并强制刷新 -->
<script>
(function(){
var APP_VERSION='20260606-v2';
try{
var stored=localStorage.getItem('_app_ver');
if(stored&&stored!==APP_VERSION){
localStorage.setItem('_app_ver',APP_VERSION);
if('caches' in window){caches.keys().then(function(ks){ks.forEach(function(k){caches.delete(k);});});}
if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then(function(rs){rs.forEach(function(r){r.unregister();});});}
location.replace(location.href.split('?')[0]+'?v='+APP_VERSION+'&t='+Date.now());
return;
}
if(!stored) localStorage.setItem('_app_ver',APP_VERSION);
}catch(e){}
})();
</script>
<!-- 域名跳转防封:主域名访问时自动跳转到备用域名 -->
<script>
(function() {
// APP端(Median WebView)不跳转,直接在4949group.top运行
if (navigator.userAgent.indexOf('median') > -1) return;
// 主域名(仅用于分享传播,访问时自动跳转)
const mainDomains = ['https://4949group.top', 'https://www.4949group.top'];
// 备用域名(实际运行域名)
const backupDomains = [
'https://wwcpcc.top'
];
const currentDomain = window.location.origin;
// 如果当前是主域名,立即跳转到备用域名
if (mainDomains.includes(currentDomain)) {
const randomBackup = backupDomains[Math.floor(Math.random() * backupDomains.length)];
// 保留完整路径和参数(ref等)
window.location.href = randomBackup + window.location.pathname + window.location.search;
}
})();
</script>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#f5f5f5">
<!-- 【CRASH-GUARD-v1】2026/05/30 禁用 webview 缓存,强制拉取最新 index.html,避免旧版本闪退 -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="生肖竞猜">
<meta name="mobile-web-app-capable" content="yes">
<title>ABC娱乐</title>
<!-- Open Graph / 微信分享元标签 -->
<meta property="og:title" content="ABC">
<meta property="og:description" content="免费建群,红包接龙/扫雷,无跑包无封号,点击就能玩">
<meta property="og:image" content="https://4949group.top/abc-logo.jpg">
<meta property="og:url" content="https://4949group.top">
<meta property="og:type" content="website">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="ABC">
<meta name="twitter:description" content="免费建群,红包接龙/扫雷,无跑包无封号,点击就能玩">
<meta name="twitter:image" content="https://4949group.top/abc-logo.jpg">
<!-- 本地加载脚本(避免CDN延迟) -->
<script src="/libs/tailwind.js"></script>
<script src="/libs/react.min.js"></script>
<script src="/libs/react-dom.min.js"></script>
<script src="/libs/babel.min.js"></script>
<script src="/libs/socket.io.min.js"></script>
<style>
* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
body { font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif; background:#8B0000; min-height:100vh; overflow-x:hidden; }
#root { min-height:100vh; max-width:500px; margin:0 auto; position:relative; }
::-webkit-scrollbar { width:0; }
/* 主色调 */
.bg-main { background:linear-gradient(180deg,#8B0000 0%,#6B0000 100%); }
.bg-gold { background:linear-gradient(135deg,#FFD700 0%,#DAA520 100%); }
.bg-card { background:linear-gradient(145deg,#A52A2A 0%,#8B1A1A 100%); border:1px solid rgba(255,215,0,0.3); border-radius:12px; }
.bg-dark-card { background:rgba(0,0,0,0.3); border-radius:12px; border:1px solid rgba(255,215,0,0.2); }
.text-gold { color:#FFD700; }
.text-gold2 { color:#DAA520; }
.border-gold { border-color:#FFD700; }
/* 按钮 */
.btn-red { background:linear-gradient(145deg,#C41E3A 0%,#8B0000 100%); color:#fff; border:2px solid #FFD700; border-radius:25px; padding:10px 24px; font-weight:bold; font-size:15px; cursor:pointer; }
.btn-red:active { transform:scale(0.96); }
.btn-gold { background:linear-gradient(135deg,#FFD700 0%,#DAA520 100%); color:#5B0000; border:none; border-radius:25px; padding:10px 24px; font-weight:bold; font-size:15px; cursor:pointer; }
.btn-gold:active { transform:scale(0.96); }
/* 输入框 */
.input-main { width:100%; padding:12px 16px; border-radius:8px; border:1px solid rgba(255,215,0,0.3); background:rgba(255,255,255,0.1); color:#fff; font-size:15px; outline:none; }
.input-main::placeholder { color:rgba(255,255,255,0.5); }
.input-main:focus { border-color:#FFD700; }
/* 底部导航 */
.bottom-nav { position:fixed; bottom:0; left:50%; transform:translateX(-50%); width:100%; max-width:500px; display:flex; align-items:flex-end; justify-content:space-around; padding:6px 0 env(safe-area-inset-bottom,8px); background:#f5f5f5; border-top:1px solid #e0e0e0; z-index:100; }
.nav-item { display:flex; flex-direction:column; align-items:center; gap:2px; color:#999; font-size:11px; cursor:pointer; padding:4px 0; flex:1; }
.nav-item.active { color:#07C160; }
.nav-item .icon { font-size:22px; }
.nav-center { position:relative; top:-16px; width:56px; height:56px; border-radius:50%; background:linear-gradient(135deg,#FFD700,#DAA520); display:flex; align-items:center; justify-content:center; font-size:28px; border:3px solid #5B0000; box-shadow:0 0 15px rgba(255,215,0,0.5); cursor:pointer; }
/* 滚动公告 */
.marquee { overflow:hidden; white-space:nowrap; }
.marquee-inner { display:inline-block; animation:marquee 15s linear infinite; }
@keyframes marquee { 0%{transform:translateX(100%)} 100%{transform:translateX(-100%)} }
@keyframes marqueeScroll { 0%{transform:translateX(100vw)} 100%{transform:translateX(-100%)} }
@keyframes bounce { 0%,80%,100%{transform:scale(0)} 40%{transform:scale(1)} }
@keyframes slideInLeft { from{transform:translateX(-100%);opacity:0.8} to{transform:translateX(0);opacity:1} }
@keyframes slideInRight { from{transform:translateX(30%);opacity:0.9} to{transform:translateX(0);opacity:1} }
@keyframes slideOutRight { from{transform:translateX(0);opacity:1} to{transform:translateX(30%);opacity:0.9} }
/* 图片轮播 */
.carousel-container { position:relative; overflow:hidden; border-radius:12px; border:1px solid rgba(255,215,0,0.3); }
.carousel-track { display:flex; transition:transform 0.5s ease-in-out; }
.carousel-slide { min-width:100%; cursor:pointer; }
.carousel-slide img { width:100%; height:100px; object-fit:contain; display:block; }
.carousel-dots { position:absolute; bottom:8px; left:50%; transform:translateX(-50%); display:flex; gap:6px; }
.carousel-dot { width:8px; height:8px; border-radius:50%; background:rgba(255,255,255,0.5); cursor:pointer; }
.carousel-dot.active { background:#FFD700; }
/* 生肖格子 */
.zodiac-cell { display:flex; flex-direction:column; align-items:center; justify-content:center; border-radius:12px; padding:8px 4px; cursor:pointer; transition:all 0.2s; border:2px solid transparent; position:relative; }
.zodiac-cell.selected { border-color:#FFD700; background:rgba(255,215,0,0.15); box-shadow:0 0 12px rgba(255,215,0,0.4); }
.zodiac-cell .emoji { font-size:36px; }
.zodiac-cell .name { font-size:13px; font-weight:bold; color:#FFD700; background:rgba(0,0,0,0.4); padding:2px 12px; border-radius:4px; margin-top:4px; }
.zodiac-cell .my-bet { position:absolute; top:2px; right:2px; font-size:10px; color:#FFD700; background:rgba(0,0,0,0.6); padding:1px 4px; border-radius:4px; }
/* 弹窗 */
.modal-mask { position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:1000; display:flex; align-items:center; justify-content:center; }
.modal-box { background:linear-gradient(145deg,#5B0000,#3A0000); border:2px solid #FFD700; border-radius:16px; padding:24px; width:90%; max-width:380px; max-height:80vh; overflow-y:auto; }
/* 列表项 */
.list-item { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-bottom:1px solid rgba(255,215,0,0.1); }
.list-item:last-child { border-bottom:none; }
/* 倒计时 */
.countdown { font-size:28px; font-weight:bold; color:#FFD700; font-family:'Courier New',monospace; text-shadow:0 0 10px rgba(255,215,0,0.5); }
/* 头像 */
.avatar { width:44px; height:44px; border-radius:50%; border:none; display:flex; align-items:center; justify-content:center; font-size:24px; background:linear-gradient(135deg,#5B0000,#3A0000); }
/* 标签页 */
.tab-btn { padding:8px 16px; border-radius:20px; font-size:13px; cursor:pointer; color:#ccc; background:transparent; border:1px solid rgba(255,215,0,0.2); }
.tab-btn.active { color:#5B0000; background:linear-gradient(135deg,#FFD700,#DAA520); border-color:#FFD700; font-weight:bold; }
</style>
</head>
<body>
<div id="_loading_" style="position:fixed;top:0;left:0;width:100%;height:100%;background:#8B0000;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:99999;">
<div style="width:40px;height:40px;border:3px solid rgba(255,255,255,0.3);border-top:3px solid #FFD700;border-radius:50%;animation:_spin_ 0.8s linear infinite;"></div>
<p style="color:#FFD700;margin-top:16px;font-size:14px;">加载中...</p>
<style>@keyframes _spin_{to{transform:rotate(360deg)}}</style>
</div>
<script>
// 兜底保护:如果JS执行失败,5秒后强制隐藏加载动画并显示错误提示
(function(){
var fallbackTimer = setTimeout(function(){
var ld = document.getElementById('_loading_');
if (ld) {
ld.innerHTML = '<div style="text-align:center;padding:20px;"><p style="color:#FFD700;font-size:18px;margin-bottom:16px;">⚠️ 加载超时</p><p style="color:#DAA520;font-size:14px;margin-bottom:20px;">请检查网络连接后重试</p><button onclick="location.reload()" style="background:linear-gradient(135deg,#FFD700,#DAA520);color:#5B0000;border:none;border-radius:25px;padding:12px 32px;font-size:16px;font-weight:bold;cursor:pointer;">重新加载</button></div>';
}
}, 8000);
// 如果正常加载完成,清除兜底定时器
window._clearLoadingFallback = function(){ clearTimeout(fallbackTimer); };
})();
</script>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useCallback, useImperativeHandle } = React;
const API = '/api';
// 自适应高度文本输入框(支持全屏编辑)
const AutoResizeTextarea = React.forwardRef(function AutoResizeTextarea({ value, onChange, onSend, placeholder, maxHeight, style, onKeyDown, onFocus }, ref) {
const [fullScreen, setFullScreen] = useState(false);
const innerRef = useRef(null);
useImperativeHandle(ref, () => innerRef.current);
useEffect(() => {
const el = innerRef.current;
if (el && !fullScreen) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, maxHeight) + 'px';
}
}, [value, fullScreen, maxHeight]);
const showExpand = value && value.includes('\n');
return (
<>
<div style={{flex:1,position:'relative',display:'flex',alignItems:'center'}}>
<textarea ref={innerRef} value={value} onChange={onChange} placeholder={placeholder} rows={1}
style={{...style, height:'auto', minHeight:'40px', maxHeight: maxHeight + 'px'}}
onKeyDown={onKeyDown}
onFocus={onFocus}
/>
{showExpand && (
<button type="button" onClick={()=>setFullScreen(true)} style={{position:'absolute',right:'6px',top:'4px',background:'rgba(255,255,255,0.8)',border:'none',borderRadius:'4px',fontSize:'14px',color:'#666',cursor:'pointer',padding:'2px 4px',zIndex:2,lineHeight:1}}>⛶</button>
)}
</div>
{fullScreen && (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,zIndex:10000,background:'#fff',display:'flex',flexDirection:'column'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'12px 16px',borderBottom:'1px solid #eee',background:'#f5f5f5'}}>
<button type="button" style={{background:'none',border:'none',fontSize:'15px',color:'#333',cursor:'pointer'}} onClick={()=>setFullScreen(false)}>取消</button>
<span style={{fontSize:'16px',fontWeight:'bold',color:'#333'}}>编辑消息</span>
<button type="button" style={{background:'none',border:'none',fontSize:'15px',color:'#07C160',cursor:'pointer',fontWeight:'bold'}} onClick={()=>{onSend();setFullScreen(false);}}>发送</button>
</div>
<textarea value={value} onChange={onChange} placeholder={placeholder} autoFocus
style={{flex:1,width:'100%',padding:'16px',fontSize:'16px',border:'none',outline:'none',resize:'none',lineHeight:'1.5'}}
/>
</div>
)}
</>
);
});
// 红包详情全屏页
function RedPacketDetailPage({ detail, onClose, user, onClaim }) {
const onCloseRef = React.useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
// 【彻底修复】不操作 pushState/back,仅用全局标志让群聊/私聊 popstate 拦截返回键
window.__redPacketDetailActive = true;
window.__closeRedPacketDetail = () => { onCloseRef.current(); };
return () => {
window.__redPacketDetailActive = false;
window.__closeRedPacketDetail = null;
};
}, []);
const packet = detail.packet || {};
const claims = detail.claims || [];
const myClaim = detail.myClaim;
const totalCount = packet.totalCount || packet.count || 1;
const claimedCount = claims.length;
const isExpired = packet.status === 'expired';
const isFinished = packet.status === 'finished' || claimedCount >= totalCount;
const totalAmount = parseFloat(packet.totalAmount || packet.amount || 0).toFixed(2);
const firstClaim = claims[0];
const lastClaim = claims[claims.length - 1];
let durationText = '';
if (firstClaim && lastClaim) {
const diff = new Date(lastClaim.createdAt).getTime() - new Date(firstClaim.createdAt).getTime();
const minutes = Math.max(1, Math.round(diff / 60000));
durationText = minutes + '分钟';
}
const isSender = packet.senderId === user.id;
return (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,zIndex:10002,background:'#fff',display:'flex',flexDirection:'column',overflow:'hidden'}}>
{/* 顶部红色区域 */}
<div style={{background:'#C74B38',paddingTop:'env(safe-area-inset-top)',position:'relative',flexShrink:0}}>
{/* 顶部栏 */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',padding:'10px 12px',height:'44px'}}>
<button style={{background:'none',border:'none',padding:'6px',cursor:'pointer',display:'flex',alignItems:'center'}} onClick={onClose}>
<span style={{display:'inline-block',width:'12px',height:'12px',borderLeft:'2px solid #fff',borderBottom:'2px solid #fff',transform:'rotate(45deg)'}} />
</button>
<span style={{color:'#fff',fontSize:'17px',fontWeight:'bold'}}>红包详情</span>
<button style={{background:'none',border:'none',padding:'6px',cursor:'pointer',display:'flex',alignItems:'center',gap:'2px'}} onClick={onClose}>
<span style={{width:'4px',height:'4px',borderRadius:'50%',background:'#fff'}} />
<span style={{width:'4px',height:'4px',borderRadius:'50%',background:'#fff'}} />
<span style={{width:'4px',height:'4px',borderRadius:'50%',background:'#fff'}} />
</button>
</div>
{/* 红包标题信息 */}
<div style={{textAlign:'center',padding:'10px 20px 40px'}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'center',gap:'8px',marginBottom:'10px'}}>
<div style={{width:'40px',height:'40px',borderRadius:'50%',overflow:'hidden',background:'#eee',display:'flex',alignItems:'center',justifyContent:'center'}}>
{packet.senderAvatar ? <img src={packet.senderAvatar} style={{width:'100%',height:'100%',objectFit:'cover'}} /> : <span style={{fontSize:'20px'}}>👤</span>}
</div>
<span style={{fontSize:'17px',color:'#fff',fontWeight:'500'}}>{packet.senderName || '未知用户'}的红包</span>
</div>
<div style={{fontSize:'15px',color:'#FFE4B5',marginBottom:'16px'}}>{packet.message || '恭喜发财,大吉大利'}</div>
{isExpired && <div style={{fontSize:'14px',color:'#FFE4B5',opacity:0.9}}>已过期</div>}
{myClaim && (
<div style={{marginTop:'4px'}}>
<span style={{fontSize:'48px',fontWeight:'bold',color:'#FFD700'}}>{parseFloat(myClaim.amount).toFixed(2)}</span>
<span style={{fontSize:'18px',color:'#FFD700',marginLeft:'4px'}}>元</span>
</div>
)}
{myClaim && <div style={{fontSize:'13px',color:'#FFE4B5',marginTop:'8px'}}>已存入零钱,可直接提现 ></div>}
{!myClaim && isSender && <div style={{fontSize:'14px',color:'#FFE4B5',marginTop:'16px'}}>你发出的红包</div>}
{!myClaim && !isSender && (isFinished || isExpired) && <div style={{fontSize:'14px',color:'#FFE4B5',marginTop:'16px'}}>{isExpired ? '红包已过期' : '红包已领完'}</div>}
</div>
{/* 底部圆弧 */}
<div style={{position:'absolute',bottom:0,left:0,right:0,height:'20px',background:'#fff',borderRadius:'20px 20px 0 0'}} />
</div>
{/* 中间内容 */}
<div style={{flex:1,overflowY:'auto',padding:'0 16px 20px'}}>
{myClaim && (
<div style={{textAlign:'center',margin:'16px 0'}}>
<button style={{background:'#fff5e6',border:'1px solid #ffe0b2',borderRadius:'20px',padding:'8px 24px',fontSize:'14px',color:'#c78000',cursor:'pointer'}} onClick={onClose}>😊 回复表情到聊天</button>
</div>
)}
<div style={{fontSize:'13px',color:'#999',padding:'12px 0',borderBottom:'1px solid #f0f0f0'}}>
{isFinished ? `${totalCount}个红包,${durationText || '已被抢光'}` : `已领取 ${claimedCount}/${totalCount} 个`}
</div>
{claims.map((c,idx) => (
<div key={idx} style={{display:'flex',alignItems:'center',justifyContent:'space-between',padding:'12px 0',borderBottom:'1px solid #f5f5f5'}}>
<div style={{display:'flex',alignItems:'center',gap:'10px'}}>
<div style={{width:'40px',height:'40px',borderRadius:'50%',overflow:'hidden',background:'#eee',display:'flex',alignItems:'center',justifyContent:'center'}}>
{c.userAvatar ? <img src={c.userAvatar} style={{width:'100%',height:'100%',objectFit:'cover'}} /> : <span style={{fontSize:'16px'}}>👤</span>}
</div>
<div>
<div style={{fontSize:'15px',color:'#333'}}>{c.userName || c.userId}</div>
<div style={{fontSize:'12px',color:'#999',marginTop:'2px'}}>{c.createdAt ? new Date(c.createdAt).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : ''}</div>
</div>
</div>
<div style={{textAlign:'right'}}>
<div style={{fontSize:'16px',fontWeight:'bold',color:'#333'}}>{parseFloat(c.amount).toFixed(2)}元</div>
<div style={{fontSize:'11px',color:'#FF9800',marginTop:'2px'}}>
{c.tags && c.tags.includes('运气王') && '👑 手气最佳'}
{c.tags && c.tags.includes('最小') && '最小'}
{c.mineHit && '💣'}
</div>
</div>
</div>
))}
{claims.length === 0 && <div style={{textAlign:'center',padding:'40px 0',color:'#999',fontSize:'14px'}}>暂无人领取</div>}
</div>
</div>
);
}
// 开红包弹窗页(非全屏,浮层卡片)
function RedPacketOpenPage({ detail, onClose, user, onClaim, title }) {
const onCloseRef = React.useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
// 【修复】不再 pushState,避免历史栈残留导致多次查看红包详情时返回导航异常
// 通过全局标志让群聊/私聊的 popstate 处理器拦截硬件返回键
window.__redPacketOpenActive = true;
window.__closeRedPacketOpen = () => { onCloseRef.current(); };
return () => {
window.__redPacketOpenActive = false;
window.__closeRedPacketOpen = null;
};
}, []);
const packet = detail.packet || {};
const [opening, setOpening] = useState(false);
const mountedRef = React.useRef(true);
useEffect(() => { return () => { mountedRef.current = false; }; }, []);
const handleOpen = async () => {
if (opening) return;
setOpening(true);
await onClaim(packet.id);
if (mountedRef.current) setOpening(false);
};
return (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,zIndex:10003,background:'rgba(0,0,0,0.55)',display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',padding:'20px',overflow:'hidden'}} onClick={onClose}>
{/* 红包卡片 */}
<div style={{width:'82vw',maxWidth:'340px',background:'#C74B38',borderRadius:'18px',padding:'44px 24px 36px',textAlign:'center',position:'relative',boxShadow:'0 20px 60px rgba(0,0,0,0.4)'}} onClick={(e)=>e.stopPropagation()}>
<div style={{display:'flex',alignItems:'center',justifyContent:'center',gap:'10px',marginBottom:'16px'}}>
<div style={{width:'48px',height:'48px',borderRadius:'50%',overflow:'hidden',background:'rgba(255,255,255,0.2)',display:'flex',alignItems:'center',justifyContent:'center'}}>
{packet.senderAvatar ? <img src={packet.senderAvatar} style={{width:'100%',height:'100%',objectFit:'cover'}} /> : <span style={{fontSize:'24px'}}>👤</span>}
</div>
<span style={{fontSize:'18px',color:'#fff',fontWeight:'500'}}>{packet.senderName || '未知用户'}的红包</span>
</div>
<div style={{fontSize:'20px',color:'#FFE4B5',marginBottom:'8px'}}>{packet.message || '恭喜发财,大吉大利'}</div>
{packet.mineNumber != null && <div style={{fontSize:'14px',color:'#FFD700',marginBottom:'8px'}}>雷点:<span style={{fontSize:'24px',fontWeight:'bold',color:'#fff',background:'rgba(255,0,0,0.5)',padding:'2px 12px',borderRadius:'8px'}}>{packet.mineNumber}</span></div>}
<button disabled={opening} onClick={handleOpen} style={{width:'90px',height:'90px',borderRadius:'50%',background:'linear-gradient(135deg,#F5D76E,#DAA520)',border:'4px solid #F0E68C',display:'flex',alignItems:'center',justifyContent:'center',margin:'36px auto 0',cursor:'pointer',boxShadow:'0 6px 20px rgba(0,0,0,0.3)',transition:'transform 0.2s',transform:opening?'scale(0.95)':'scale(1)'}}>
<span style={{fontSize:'36px',fontWeight:'bold',color:'#8B4513'}}>{opening ? '...' : '開'}</span>
</button>
</div>
{/* 关闭按钮在卡片下方 */}
<button style={{width:'44px',height:'44px',borderRadius:'50%',border:'2px solid rgba(255,255,255,0.55)',background:'transparent',cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center',marginTop:'28px'}} onClick={(e)=>{e.stopPropagation();onClose();}}>
<span style={{fontSize:'22px',color:'rgba(255,255,255,0.8)'}}>✕</span>
</button>
</div>
);
}
// 自定义Toast提示(不显示网址)
function showToast(msg, duration = 2500) {
let el = document.getElementById('_custom_toast_');
if (!el) {
el = document.createElement('div');
el.id = '_custom_toast_';
el.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.8);color:#fff;padding:14px 28px;border-radius:12px;font-size:14px;z-index:99999;text-align:center;max-width:80vw;word-break:break-all;pointer-events:none;transition:opacity 0.3s;';
document.body.appendChild(el);
}
el.textContent = msg;
el.style.opacity = '1';
el.style.display = 'block';
clearTimeout(el._timer);
el._timer = setTimeout(() => { el.style.opacity = '0'; setTimeout(() => { el.style.display = 'none'; }, 300); }, duration);
}
// 替代alert的确认弹窗(不显示网址)
function showAlert(msg) {
showToast(msg, 3000);
}
const ZODIAC_EMOJIS = {1:'🐭',2:'🐮',3:'🐯',4:'🐰',5:'🐲',6:'🐍',7:'🐴',8:'🐑',9:'🐵',10:'🐔',11:'🐶',12:'🐷'};
const ZODIAC_NAMES = {1:'鼠',2:'牛',3:'虎',4:'兔',5:'龙',6:'蛇',7:'马',8:'羊',9:'猴',10:'鸡',11:'狗',12:'猪'};
const ZODIAC_IMAGES = {1:'/images/zodiac/rat.jpg',2:'/images/zodiac/ox.jpg',3:'/images/zodiac/tiger.jpg',4:'/images/zodiac/rabbit.jpg',5:'/images/zodiac/dragon.jpg',6:'/images/zodiac/snake.jpg',7:'/images/zodiac/horse.jpg',8:'/images/zodiac/goat.jpg',9:'/images/zodiac/monkey.jpg',10:'/images/zodiac/rooster.jpg',11:'/images/zodiac/dog.jpg',12:'/images/zodiac/pig.jpg'};
// 六合彩号码→生肖映射
const NUMBER_TO_ZODIAC = {
1:'马',2:'蛇',3:'龙',4:'兔',5:'虎',6:'牛',7:'鼠',8:'猪',9:'狗',10:'鸡',
11:'猴',12:'羊',13:'马',14:'蛇',15:'龙',16:'兔',17:'虎',18:'牛',19:'鼠',20:'猪',
21:'狗',22:'鸡',23:'猴',24:'羊',25:'马',26:'蛇',27:'龙',28:'兔',29:'虎',30:'牛',
31:'鼠',32:'猪',33:'狗',34:'鸡',35:'猴',36:'羊',37:'马',38:'蛇',39:'龙',40:'兔',
41:'虎',42:'牛',43:'鼠',44:'猪',45:'狗',46:'鸡',47:'猴',48:'羊',49:'马'
};
// 六合彩号码→波色映射
const NUMBER_TO_COLOR = {
1:'红波',2:'红波',7:'红波',8:'红波',12:'红波',13:'红波',18:'红波',19:'红波',23:'红波',24:'红波',
29:'红波',30:'红波',34:'红波',35:'红波',40:'红波',45:'红波',46:'红波',
3:'蓝波',4:'蓝波',9:'蓝波',10:'蓝波',14:'蓝波',15:'蓝波',20:'蓝波',25:'蓝波',26:'蓝波',31:'蓝波',
36:'蓝波',37:'蓝波',41:'蓝波',42:'蓝波',47:'蓝波',48:'蓝波',
5:'绿波',6:'绿波',11:'绿波',16:'绿波',17:'绿波',21:'绿波',22:'绿波',27:'绿波',28:'绿波',32:'绿波',
33:'绿波',38:'绿波',39:'绿波',43:'绿波',44:'绿波',49:'绿波'
};
// 波色背景样式
const COLOR_BG_MAP = {
'红波': 'linear-gradient(135deg, #D32F2F, #B71C1C)',
'蓝波': 'linear-gradient(135deg, #1976D2, #0D47A1)',
'绿波': 'linear-gradient(135deg, #388E3C, #1B5E20)',
};
// ============ 六合彩前端解析函数(7种玩法) ============
const MARK_SIX_DATA = {
zodiac: { '马': [1,13,25,37,49], '蛇': [2,14,26,38], '龙': [3,15,27,39], '兔': [4,16,28,40], '虎': [5,17,29,41], '牛': [6,18,30,42], '鼠': [7,19,31,43], '猪': [8,20,32,44], '狗': [9,21,33,45], '鸡': [10,22,34,46], '猴': [11,23,35,47], '羊': [12,24,36,48] },
element: { '金': [4,5,12,13,26,27,34,35,42,43], '木': [8,9,16,17,24,25,38,39,46,47], '水': [1,14,15,22,23,30,31,44,45], '火': [2,3,10,11,18,19,32,33,40,41,48,49], '土': [6,7,20,21,28,29,36,37] },
color: { '红波': [1,2,7,8,12,13,18,19,23,24,29,30,34,35,40,45,46], '蓝波': [3,4,9,10,14,15,20,25,26,31,36,37,41,42,47,48], '绿波': [5,6,11,16,17,21,22,27,28,32,33,38,39,43,44,49] },
size: { '大': [25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49], '小': [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24] },
head: { '0头': [1,2,3,4,5,6,7,8,9], '1头': [10,11,12,13,14,15,16,17,18,19], '2头': [20,21,22,23,24,25,26,27,28,29], '3头': [30,31,32,33,34,35,36,37,38,39], '4头': [40,41,42,43,44,45,46,47,48,49] },
tail: { '0尾': [10,20,30,40], '1尾': [1,11,21,31,41], '2尾': [2,12,22,32,42], '3尾': [3,13,23,33,43], '4尾': [4,14,24,34,44], '5尾': [5,15,25,35,45], '6尾': [6,16,26,36,46], '7尾': [7,17,27,37,47], '8尾': [8,18,28,38,48], '9尾': [9,19,29,39,49] },
category: { '家禽': [6,18,30,42,1,13,25,37,49,10,22,34,46,9,21,33,45,8,20,32,44], '野兽': [7,19,31,43,5,17,29,41,4,16,28,40,3,15,27,39,2,14,26,38,11,23,35,47] },
parity: { '单': [1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,37,39,41,43,45,47,49], '双': [2,4,6,8,10,12,14,16,18,20,22,24,26,28,30,32,34,36,38,40,42,44,46,48] },
};
const ALL_TYPES_MAP = {};
for (const category of Object.values(MARK_SIX_DATA)) { Object.assign(ALL_TYPES_MAP, category); }
const ALL_ZODIACS = Object.keys(MARK_SIX_DATA.zodiac);
const NUMBER_TO_SIZE = { 25:'大',26:'大',27:'大',28:'大',29:'大',30:'大',31:'大',32:'大',33:'大',34:'大',35:'大',36:'大',37:'大',38:'大',39:'大',40:'大',41:'大',42:'大',43:'大',44:'大',45:'大',46:'大',47:'大',48:'大',49:'大',1:'小',2:'小',3:'小',4:'小',5:'小',6:'小',7:'小',8:'小',9:'小',10:'小',11:'小',12:'小',13:'小',14:'小',15:'小',16:'小',17:'小',18:'小',19:'小',20:'小',21:'小',22:'小',23:'小',24:'小' };
const NUMBER_TO_PARITY = { 1:'单',3:'单',5:'单',7:'单',9:'单',11:'单',13:'单',15:'单',17:'单',19:'单',21:'单',23:'单',25:'单',27:'单',29:'单',31:'单',33:'单',35:'单',37:'单',39:'单',41:'单',43:'单',45:'单',47:'单',49:'单',2:'双',4:'双',6:'双',8:'双',10:'双',12:'双',14:'双',16:'双',18:'双',20:'双',22:'双',24:'双',26:'双',28:'双',30:'双',32:'双',34:'双',36:'双',38:'双',40:'双',42:'双',44:'双',46:'双',48:'双' };
function getNumbersByType(typeName) {
if (ALL_TYPES_MAP[typeName]) return [...ALL_TYPES_MAP[typeName]];
const normalizedTypeName = normalizeTypeAliases(typeName);
if (normalizedTypeName !== typeName && ALL_TYPES_MAP[normalizedTypeName]) return [...ALL_TYPES_MAP[normalizedTypeName]];
const comboMap = { '红波单': [1,7,13,19,23,29,35,45], '红波双': [2,8,12,18,24,30,34,40,46], '红波大': [29,30,34,35,40,45,46], '红波小': [1,2,7,8,12,13,18,19,23,24], '蓝波单': [3,9,15,25,31,37,41,47], '蓝波双': [4,10,14,20,26,36,42,48], '蓝波大': [25,26,31,36,37,41,42,47,48], '蓝波小': [3,4,9,10,14,15,20], '绿波单': [5,11,17,27,33,39,43,49], '绿波双': [6,16,22,28,32,38,44], '绿波大': [27,28,32,33,38,39,43,44,49], '绿波小': [5,6,11,16,17,21,22], '大单': [25,27,29,31,33,35,37,39,41,43,45,47,49], '大双': [26,28,30,32,34,36,38,40,42,44,46,48], '小单': [1,3,5,7,9,11,13,15,17,19,21,23], '小双': [2,4,6,8,10,12,14,16,18,20,22,24] };
if (comboMap[normalizedTypeName]) return comboMap[normalizedTypeName];
const aliases = { '红波单数':'红波单','红波双数':'红波双','红波大数':'红波大','红波小数':'红波小','蓝波单数':'蓝波单','蓝波双数':'蓝波双','蓝波大数':'蓝波大','蓝波小数':'蓝波小','绿波单数':'绿波单','绿波双数':'绿波双','绿波大数':'绿波大','绿波小数':'绿波小','红单':'红波单','红双':'红波双','蓝单':'蓝波单','蓝双':'蓝波双','绿单':'绿波单','绿双':'绿波双' };
if (aliases[typeName] && comboMap[aliases[typeName]]) return comboMap[aliases[typeName]];
// 从头尾复合映射(来自后台配置)查找
if (billTypeMappings && billTypeMappings.length > 0) {
for (const m of billTypeMappings) {
if (m.name === typeName) {
const result = new Set();
for (const t of (m.targets || [])) {
const ns = getNumbersByType(t);
if (ns) ns.forEach(n => result.add(n));
}
if (result.size > 0) return [...result];
}
}
}
for (const t1 of Object.keys(ALL_TYPES_MAP)) { if (normalizedTypeName.startsWith(t1)) { const r = normalizedTypeName.slice(t1.length); if (ALL_TYPES_MAP[r]) { const setB = new Set(ALL_TYPES_MAP[r]); return ALL_TYPES_MAP[t1].filter(x => setB.has(x)); } } }
return null;
}
function normalizeTypeAliases(text) {
if (!text || typeof text !== 'string') return text;
return text.replace(/^(澳门|澳|港|新)\s*/, '').replace(/大数/g, '大').replace(/小数/g, '小').replace(/单数/g, '单').replace(/双数/g, '双');
}
// 通用筛选链解析:将"红绿单"按筛选逻辑拆解为(红波∩单)∪(绿波∩单)
function resolveTypeFilters(str) {
if (!str) return null;
// 1) 头尾复合: "23头"→2头+3头, "12尾"→1尾+2尾, "1.2尾"→1尾+2尾, "123头"→1头+2头+3头
const htMatch = str.match(/^[\d\.\/\,,\s]+(头|尾)$/);
if (htMatch) {
const suffix = htMatch[1];
// 提取所有数字(支持连写、点、斜杠、逗号分隔)
const digits = str.slice(0, -1).split(/[^\d]+/).map(s => s.split('')).flat().filter(s => s);
const result = new Set();
for (const ch of digits) {
const key = ch + suffix;
if (ALL_TYPES_MAP[key]) ALL_TYPES_MAP[key].forEach(n => result.add(n));
}
if (result.size > 0) return [...result].sort((a,b)=>a-b);
return null;
}
// 单字符颜色简名→全名
const SHORT_MAP = { '红':'红波','蓝':'蓝波','绿':'绿波' };
// 类型→分类映射
const CAT_MAP = {};
for (const k of ['红波','蓝波','绿波']) CAT_MAP[k]='color';
for (const k of ['大','小']) CAT_MAP[k]='size';
for (const k of ['单','双']) CAT_MAP[k]='parity';
for (const k of ['家禽','野兽']) CAT_MAP[k]='category';
for (const k of ['金','木','水','火','土']) CAT_MAP[k]='element';
for (const z of ALL_ZODIACS) CAT_MAP[z]='zodiac';
for (let i=0;i<=4;i++) CAT_MAP[i+'头']='head';
for (let i=0;i<=9;i++) CAT_MAP[i+'尾']='tail';
// 2) 逐个字节识别类型标记(先3字→2字→1字)
const types = [];
let rem = str;
while (rem.length > 0) {
let found = false;
// 3字: 红波 蓝波 绿波 0头-4头 0尾-9尾 家禽 野兽
if (rem.length >= 3) {
const k = rem.slice(0,3);
if (ALL_TYPES_MAP[k]) { types.push(k); rem = rem.slice(3); found = true; continue; }
}
// 2字: 红波等已识别, 补充检查
if (rem.length >= 2) {
const k = rem.slice(0,2);
const full = SHORT_MAP[k] || null;
if (ALL_TYPES_MAP[k]) { types.push(k); rem = rem.slice(2); found = true; continue; }
if (full) { types.push(full); rem = rem.slice(2); found = true; continue; }
}
// 1字: 大小单双 金木水火土 生肖 红蓝绿
if (rem.length >= 1) {
const k = rem.slice(0,1);
const full = SHORT_MAP[k] || null;
if (ALL_TYPES_MAP[k]) { types.push(k); rem = rem.slice(1); found = true; continue; }
if (full) { types.push(full); rem = rem.slice(1); found = true; continue; }
}
if (!found) break;
}
if (rem.length > 0 || types.length < 2) return null;
// 3) 按分类分组
const groups = {};
for (const t of types) {
const cat = CAT_MAP[t];
if (!cat) continue;
if (!groups[cat]) groups[cat] = [];
if (!groups[cat].includes(t)) groups[cat].push(t);
}
if (Object.keys(groups).length === 0) return null;
// 4) 笛卡尔积:同分类多选→多路径,每路径取交集→并集
const catArrays = Object.keys(groups).map(cat => groups[cat]);
function cartesian(arrs) {
if (arrs.length === 0) return [[]];
const [first, ...rest] = arrs;
const restProd = cartesian(rest);
const r = [];
for (const item of first) for (const combo of restProd) r.push([item, ...combo]);
return r;
}
const combos = cartesian(catArrays);
const finalSet = new Set();
for (const combo of combos) {
let intersect = null;
for (const token of combo) {
const ns = ALL_TYPES_MAP[token];
if (!ns || ns.length === 0) { intersect = null; break; }
if (intersect === null) intersect = new Set(ns);
else intersect = new Set(ns.filter(n => intersect.has(n)));
}
if (intersect !== null) intersect.forEach(n => finalSet.add(n));
}
return finalSet.size > 0 ? [...finalSet].sort((a,b)=>a-b) : null;
}
function extractZodiacs(str) {
const result = [];
for (const ch of str) { if (ALL_ZODIACS.includes(ch) && !result.includes(ch)) result.push(ch); }
return result;
}
// 可配置账单格式 - 从后台加载
let billFormatsConfig = [];
let billTypeMappings = [];
async function loadBillFormats() {
try {
const res = await fetch('/api/admin/bill-formats');
const data = await res.json();
if (data.code === 200) {
if (Array.isArray(data.data)) {
billFormatsConfig = data.data;
billTypeMappings = [];
} else {
billFormatsConfig = data.data.playTypes || [];
billTypeMappings = data.data.typeMappings || [];
}
} else {
billFormatsConfig = []; billTypeMappings = [];
}
} catch(e) {
billFormatsConfig = []; billTypeMappings = [];
}
}
if (typeof loadBillFormats === 'function' && !window._billFormatsLoaded) {
window._billFormatsLoaded = true;
loadBillFormats();
}
// ===== 前端 parseLine(与mark-six.js逻辑一致) =====
function parseLineFront(trimmed) {
if (!trimmed) return null;
trimmed = normalizeTypeAliases(trimmed);
// 应用类型映射配置(如"天肖" → "兔马猴猪牛龙")
if (billTypeMappings && billTypeMappings.length > 0) {
for (const mapping of billTypeMappings) {
const { name, targets } = mapping;
if (name && targets && targets.length > 0) {
// 数字targets用逗号分隔(如大数:25,26,27...),文字targets直接拼接(如家肖:牛马羊...)
const isNumberArray = typeof targets[0] === 'number';
const targetStr = isNumberArray ? targets.join(',') : targets.join('');
const regex = new RegExp(name + '(?=(各号|各肖|各|每组|每|号|个|$))', 'g');
trimmed = trimmed.replace(regex, targetStr);
}
}
}
// 【BILL-PARSE-v17】2026/05/30 特号专用早期识别(支持“斤”等单位):号码列表+各号/各/号+金额+任意单位/符号
const tehaoEarlyMatch = trimmed.match(/^\s*([^\u4e00-\u9fff]+?)\s*(各号|各肖|各|每组|每|号|买|个)\s*(\d+)\s*(?:[\u5143\u5757\u94b1\u5706\u89d2\u5206\u5200\u65a4\u7c73#!\uff01\uff03$\u00a5\s]*)$/);
if (tehaoEarlyMatch) {
const numStr = tehaoEarlyMatch[1];
const suffix = tehaoEarlyMatch[2];
const amt = parseInt(tehaoEarlyMatch[3]);
if (!isNaN(amt) && amt > 0) {
const segs = numStr.split(/[^\d]+/).filter(s => s);
const nums = segs.map(s => parseInt(s)).filter(n => !isNaN(n) && n >= 1 && n <= 49);
// 需要至少一个有效号码,且所有段都是合法号码(1-49)
if (nums.length > 0 && nums.length === segs.length) {
return [{
playType: 'tehao',
type: nums.map(n => String(n).padStart(2, '0')).join(','),
count: nums.length,
amount: amt,
total: nums.length * amt,
numbers: nums,
zodiacs: [],
color: null,
size: null,
parity: null,
lianCount: null
}];
}
}
}
// 【BILL-PARSE-v13】2026/05/30 金额后面任何符号或单位全部忽略;同时清理开头可能残留的单位字
let cleanTrimmed = trimmed.replace(/^[元块钱圆米\s#!!#$¥]+/, '');
const numMatch = cleanTrimmed.match(/(\d+)\D*$/);
if (!numMatch) return null;
const amount = parseInt(numMatch[1]);
if (isNaN(amount) || amount <= 0) return null;
const beforeAmount = cleanTrimmed.slice(0, -numMatch[0].length);
// 独立匹配后缀(长前缀优先)
const suffixMatch = beforeAmount.match(/(各号|各肖|各|每组|每|号|买|个)$/);
const amountMatch = [numMatch[0], suffixMatch ? suffixMatch[1] : undefined, numMatch[1]];
// 从 beforeAmount 中去掉后缀得到号码部分
const withoutAmount = suffixMatch
? beforeAmount.slice(0, -suffixMatch[0].length).trim()
: beforeAmount.trim();
// amountMatch[1] 已匹配后缀→作为 geSuffix
let content, geSuffix;
content = withoutAmount;
geSuffix = amountMatch[1] || '';
// 号码间用任意符号(.、/、-、:、空格等)分隔,统一替换为","
content = content.replace(/[.\/\-:\s]+/g, ',').replace(/,+$/, '');
content = content.replace(/[::\-\s,,、]+$/, '');
// 【修复】“十点”是澳门2的期号前缀,不属于玩法内容,需在决策树前剥离
if (content.startsWith('十点')) {
content = content.slice(2).trim();
}
const hasGeHao = geSuffix === '各号' || geSuffix === '号';
const hasGeXiao = geSuffix === '各肖';
const hasGe = geSuffix === '各' || geSuffix === '每组' || geSuffix === '每' || geSuffix === '买' || geSuffix === '个';
// 使用可配置格式匹配玩法类型
const formats = (billFormatsConfig || []).filter(f => f.enabled).sort((a, b) => a.priority - b.priority);
// 强制:平特玩法优先级最高,任何包含"平特"的必须先匹配
if (content.startsWith('平特') && formats.length > 0) {
const pingteFmt = formats.find(f => f.playType === 'pingte');
if (pingteFmt) {
return parsePingTeFront(content.replace(/^平特/, '').trim(), amount);
}
}
if (formats.length === 0) {
// 格式未加载时使用默认逻辑
return parseLineFrontLegacy(content, amount, hasGeHao, hasGeXiao, hasGe);
}
for (const fmt of formats) {
const patterns = fmt.patterns || [];
let matched = false;
switch (fmt.playType) {
case 'baobo':
matched = patterns.some(p => content.startsWith(p));
break;
case 'baodaxiao':
case 'baodanshuang':
matched = patterns.some(p => content.startsWith(p));
break;
case 'texiao':
matched = patterns.some(p => content.includes(p));
if (!matched && hasGeXiao) matched = true;
break;
case 'lianxiao2':
case 'lianxiao3':
case 'lianxiao4':
case 'lianxiao5':
matched = patterns.some(p => content.startsWith(p));
break;
case 'pingte':
matched = patterns.some(p => content.startsWith(p));
break;
case 'tehao':
// 排除连肖/平特/特肖前缀
if (content.startsWith('二连肖') || content.startsWith('三连肖') ||
content.startsWith('四连肖') || content.startsWith('五连肖') ||
content.startsWith('平特') || content.startsWith('特肖') ||
content.startsWith('包肖') || content.includes('各肖')) {
matched = false;
} else {
matched = (hasGeHao && patterns.includes('各号')) ||
(hasGe && patterns.includes('各')) ||
/^\d/.test(content) ||
(getNumbersByType(content) && getNumbersByType(content).length > 0);
}
break;
default:
matched = patterns.some(p => content.includes(p));
}
if (!matched) continue;
switch (fmt.playType) {
case 'lianxiao2': { const m2 = content.match(/^(二)连肖(.+)$/); if (m2) return parseLianXiaoFront(m2, amount); return null; }
case 'lianxiao3': { const m2 = content.match(/^(三)连肖(.+)$/); if (m2) return parseLianXiaoFront(m2, amount); return null; }
case 'lianxiao4': { const m2 = content.match(/^(四)连肖(.+)$/); if (m2) return parseLianXiaoFront(m2, amount); return null; }
case 'lianxiao5': { const m2 = content.match(/^(五)连肖(.+)$/); if (m2) return parseLianXiaoFront(m2, amount); return null; }
case 'pingte':
return parsePingTeFront(content.replace(/^平特/, '').trim(), amount);
case 'texiao': {
const hasPrefix = patterns.some(p => content.startsWith(p));
const cleaned = hasPrefix ? content.replace(/^(特肖|包肖|各肖)/, '').trim() : content;
return parseTeXiaoFront(cleaned, amount);
}
case 'baobo': {
const matchedPattern = patterns.find(p => content.startsWith(p));
if (matchedPattern) {
const colorType = matchedPattern.replace('包', '');
return [{ playType: 'baobo', type: content, count: 1, amount, total: amount, numbers: [], zodiacs: [], color: colorType, size: null, parity: null, lianCount: null }];
}
return null;
}
case 'baodaxiao': {
const matchedPattern = patterns.find(p => content.startsWith(p));
if (matchedPattern) {
const sizeType = matchedPattern.replace('包', '');
return [{ playType: 'baodaxiao', type: content, count: 1, amount, total: amount, numbers: [], zodiacs: [], color: null, size: sizeType, parity: null, lianCount: null }];
}
return null;
}
case 'baodanshuang': {
const matchedPattern = patterns.find(p => content.startsWith(p));
if (matchedPattern) {
const parityType = matchedPattern.replace('包', '');
return [{ playType: 'baodanshuang', type: content, count: 1, amount, total: amount, numbers: [], zodiacs: [], color: null, size: null, parity: parityType, lianCount: null }];
}
return null;
}
case 'tehao':
return buildTehaoItemsFront(content, amount);
default:
return null;
}
}
return null;
}
// 默认解析逻辑(格式未加载时的降级)
function parseLineFrontLegacy(content, amount, hasGeHao, hasGeXiao, hasGe) {
// 1.连肖
const lianMatch = content.match(/^(二|三|四|五)连肖(.+)$/);
if (lianMatch) return parseLianXiaoFront(lianMatch, amount);
// 2.平特(区分平特尾和平特肖)
if (content.startsWith('平特')) {
const rest = content.replace(/^平特/, '').trim();
// 检查是否是平特尾格式(数字+尾)
if (/^[\d\.\/\,,\s]+尾$/.test(rest)) {
return parsePingTeWeiFront(rest, amount);
}
// 否则是平特肖
return parsePingTeFront(rest, amount);
}
// 3.特肖/包肖前缀
if (content.startsWith('特肖')||content.startsWith('包肖')) return parseTeXiaoFront(content.replace(/^(特肖|包肖)/,'').trim(), amount);
// 4.各肖→特肖
if (hasGeXiao) return parseTeXiaoFront(content, amount);
// 5.包波
const baoBoMatch=content.match(/^包(红波|蓝波|绿波)$/);
if (baoBoMatch) return [{ playType:'baobo', type:content, count:1, amount, total:amount, numbers:[], zodiacs:[], color:baoBoMatch[1], size:null, parity:null, lianCount:null }];
// 6.包大小
if (content==='包大'||content==='包小') return [{ playType:'baodaxiao', type:content, count:1, amount, total:amount, numbers:[], zodiacs:[], color:null, size:content.replace('包',''), parity:null, lianCount:null }];
// 7.包单双
if (content==='包单'||content==='包双') return [{ playType:'baodanshuang', type:content, count:1, amount, total:amount, numbers:[], zodiacs:[], color:null, size:null, parity:content.replace('包',''), lianCount:null }];
// 8.特号(有各号/各 或 纯号码)
if (hasGeHao||hasGe||/^\d/.test(content)) return buildTehaoItemsFront(content, amount);
// 9.无标记生肖→特肖(没有"各号"/"各"的纯生肖按特肖统计)
const zds=extractZodiacs(content); if(zds.length>0) return parseTeXiaoFront(content, amount);
// 10.纯属性无标记→特号
const an=getNumbersByType(content); if(an&&an.length>0) return buildTehaoItemsFront(content, amount);
return null;
}
function buildTehaoItemsFront(content, amount) {
const results=[]; const cc=content.replace(/(各号|号|各|买)$/,'').trim();
// 检查是否有3位及以上数字(如012),为账单出错
if(/\d{3,}/.test(cc)) return results;
// 检测头尾复合:"23头"→2头+3头, "12尾"→1尾+2尾
const htBuild=cc.match(/^(\d+)(头|尾)$/);
if(htBuild){const digits=htBuild[1],suffix=htBuild[2];const allNums=new Set();for(const ch of digits){const key=ch+suffix;if(ALL_TYPES_MAP[key])ALL_TYPES_MAP[key].forEach(n=>allNums.add(n));}if(allNums.size>0){const numArr=[...allNums].sort((a,b)=>a-b);results.push({playType:'tehao',type:cc,count:numArr.length,amount,total:numArr.length*amount,numbers:numArr,zodiacs:[],color:null,size:null,parity:null,lianCount:null});return results;}}
// 提取所有数字段,号码之间任意符号均可(包括空格)
const segments=cc.split(/[^\d]+/).filter(s=>s.length>0);
if(segments.length>0){
const nums=segments.map(s=>parseInt(s)).filter(n=>!isNaN(n)&&n>=1&&n<=49);
if(nums.length>0)results.push({playType:'tehao',type:nums.map(n=>String(n).padStart(2,'0')).join(','),count:nums.length,amount,total:nums.length*amount,numbers:nums,zodiacs:[],color:null,size:null,parity:null,lianCount:null});return results;}
if(ALL_ZODIACS.includes(cc)){const nums=[...MARK_SIX_DATA.zodiac[cc]];results.push({playType:'tehao',type:cc,count:nums.length,amount,total:nums.length*amount,numbers:nums,zodiacs:[cc],color:null,size:null,parity:null,lianCount:null});return results;}
const zds=extractZodiacs(cc);if(zds.length>0){const allNums=[];for(const z of zds){const ns=MARK_SIX_DATA.zodiac[z];if(ns)allNums.push(...ns);}if(allNums.length>0)results.push({playType:'tehao',type:zds.join(','),count:allNums.length,amount,total:allNums.length*amount,numbers:[...new Set(allNums)],zodiacs:zds,color:null,size:null,parity:null,lianCount:null});return results;}
const nums=getNumbersByType(cc);if(nums&&nums.length>0){results.push({playType:'tehao',type:cc,count:nums.length,amount,total:nums.length*amount,numbers:nums,zodiacs:[],color:null,size:null,parity:null,lianCount:null});return results;}
// 通用筛选链兜底
const filterNums=resolveTypeFilters(cc);if(filterNums&&filterNums.length>0){results.push({playType:'tehao',type:cc,count:filterNums.length,amount,total:filterNums.length*amount,numbers:filterNums,zodiacs:[],color:null,size:null,parity:null,lianCount:null});return results;}
const types=[];let rem=cc;while(rem.length>0){let found=false;if(rem.length>=3){const tc=rem.slice(0,3);if(getNumbersByType(tc)){types.push(tc);rem=rem.slice(3);found=true;continue;}}if(rem.length>=2){const tw=rem.slice(0,2);if(getNumbersByType(tw)){types.push(tw);rem=rem.slice(2);found=true;continue;}}const oc=rem.slice(0,1);if(getNumbersByType(oc)){types.push(oc);rem=rem.slice(1);found=true;continue;}if(!found)rem=rem.slice(1);}
if(types.length>0){const allNums=new Set();for(const t of types){const n=getNumbersByType(t);if(n)n.forEach(x=>allNums.add(x));}const numArr=[...allNums];results.push({playType:'tehao',type:cc,count:numArr.length,amount,total:numArr.length*amount,numbers:numArr,zodiacs:[],color:null,size:null,parity:null,lianCount:null});}
return results;
}
function parsePingTeFront(content,amount){const zds=extractZodiacs(content);return zds.map(z=>({playType:'pingte',type:'平特-'+z,count:1,amount,total:amount,numbers:[],zodiacs:[z],color:null,size:null,parity:null,lianCount:null}));}
function parsePingTeWeiFront(content,amount){const results=[];const weiMatch=content.match(/^([\d\.\/\,,\s]+)尾$/);if(!weiMatch)return results;const digits=weiMatch[1].split(/[^\d]+/).map(s=>s.split('')).flat().filter(s=>s);for(const d of digits){const num=parseInt(d);if(!isNaN(num)&&num>=0&&num<=9){results.push({playType:'pingtewei',type:'平特'+num+'尾',count:1,amount,total:amount,numbers:[num],zodiacs:[],color:null,size:null,parity:null,lianCount:null});}}return results;}
function parseTeXiaoFront(content,amount){const zds=extractZodiacs(content);return zds.map(z=>({playType:'texiao',type:'特肖-'+z,count:1,amount,total:amount,numbers:[],zodiacs:[z],color:null,size:null,parity:null,lianCount:null}));}
function parseLianXiaoFront(match,amount){const lm={'二':2,'三':3,'四':4,'五':5};const lc=lm[match[1]];const zp=match[2];if(!zp) return [];const pk='lianxiao'+lc;const results=[];if(zp.includes('/')){const groups=zp.split('/').map(g=>g.trim()).filter(g=>g);for(const g of groups){const zds=extractZodiacs(g);if(zds.length===lc)results.push({playType:pk,type:lc+'连肖-'+zds.join(''),count:1,amount,total:amount,numbers:[],zodiacs:zds,color:null,size:null,parity:null,lianCount:lc});}}else{const zds=extractZodiacs(zp);if(zds.length===lc)results.push({playType:pk,type:lc+'连肖-'+zds.join(''),count:1,amount,total:amount,numbers:[],zodiacs:zds,color:null,size:null,parity:null,lianCount:lc});}return results;}
function parseBillText(text) {
if (!text || typeof text !== 'string') return [];
text = normalizeTypeAliases(text);
// 【恢复】先检查是否是“多个生肖各X”的格式(如:龙、虎、龙、兔各30),但排除连肖/平特/特肖前缀
const geMatch = text.match(/^(.+?)各(\d+)$/);
if (geMatch && !text.startsWith('二连肖') && !text.startsWith('三连肖') &&
!text.startsWith('四连肖') && !text.startsWith('五连肖') &&
!text.startsWith('平特') && !text.startsWith('特肖') &&
!text.startsWith('包肖') && !text.includes('各肖')) {
const zodiacPart = geMatch[1].trim();
const amount = parseInt(geMatch[2]);
if (!isNaN(amount) && amount > 0) {
const zodiacs = zodiacPart.split(/[,,、\s]+/).filter(s => s.trim());
if (zodiacs.length > 0) {
const allNums = [];
for (const z of zodiacs) {
const trimmed = z.trim();
if (ALL_ZODIACS.includes(trimmed)) {
const nums = MARK_SIX_DATA.zodiac[trimmed];
if (nums) allNums.push(...nums);
}
}
if (allNums.length > 0) {
return [{
playType: 'tehao',
type: zodiacs.join(','),
count: allNums.length,
amount,
total: allNums.length * amount,
numbers: [...new Set(allNums)],
zodiacs: zodiacs,
color: null,
size: null,
parity: null,
lianCount: null
}];
}
}
}
}
// 【恢复】连肖玩法:整体解析,不按逗号拆分
if (/^[二三四五]连肖/.test(text)) {
const normalized = text.replace(/[,,、]/g, '/');
const parsed = parseLineFront(normalized.trim());
if (parsed) { if (Array.isArray(parsed)) return parsed; else return [parsed]; }
return [];
}
// 【优化】通用预处理:按金额单位边界拆分(处理全连接单行和多行混合情况)
const normalizedText = text
.replace(/([#!!#]+)\s*[,,、;;]?\s*(?=\d)/g, '$1\n')
.replace(/([元块钱圆角分刀斤米¥$])\s*[,,、;;]\s*(?=\d)/g, '$1\n')
.replace(/([元块钱圆角分刀斤米¥$#!!#])\s*(?=[\u4e00-\u9fff\d])/g, '$1\n');
let lines = normalizedText.split('\n').filter(s => s.trim());
// 如果仍只有一行,尝试 multi-各 regex 进一步拆分
if (lines.length === 1) {
const multiNumberGeRe = /[^\u4e00-\u9fff]+?(?:各号|各肖|各|每组|每|号|买|个)\s*\d+/g;
const multiNumberMatches = text.match(multiNumberGeRe);
if (multiNumberMatches && multiNumberMatches.length >= 2) {
lines = multiNumberMatches.map(s => s.trim()).filter(s => s);
}
}
const results = [];
for (const line of lines) {
const parsed = parseLineFront(line.trim());
if (parsed) { if (Array.isArray(parsed)) results.push(...parsed); else results.push(parsed); }
}
return results;
}
function isBillLikeText(text) {
if (!text || typeof text !== 'string') return false;
// 使用可配置格式检测
const formats = billFormatsConfig || [];
if (formats.length > 0) {
for (const fmt of formats) {
if (fmt.patterns.some(p => text.includes(p))) return true;
}
}
// 降级:硬编码检测
if (/各号|各肖|生肖|平特|特肖|包肖|连肖|包红波|包蓝波|包绿波|包大|包小|包单|包双/.test(text)) return true;
const typePattern = /(马|蛇|龙|兔|虎|牛|鼠|猪|狗|鸡|猴|羊|红波|蓝波|绿波|大|小|单|双|金|木|水|火|土|家禽|野兽|\d头|\d尾).*?\d+/;
if (typePattern.test(text)) return true;
if (/^\d+\D+\d+$/.test(text.trim())) return true;
return false;
}
function calculateTotalBill(items) { return items.reduce((sum, item) => sum + item.total, 0); }
function getChatSettings(userId) {
try { return JSON.parse(localStorage.getItem('chatSettings_'+userId) || '{}'); } catch(e) { return {}; }
}
// 根据开奖号码计算单个账单项的赔付金额(基于 playType + 固定赔率)
function calcPayout(item, drawNums) {
if (!drawNums) return 0;
// 过滤有效号码(允许部分号码已输入,逐步计算)
const nums = drawNums.map(n => {
if (n === null || n === '' || n === undefined) return null;
const p = parseInt(n);
return !isNaN(p) && p >= 1 && p <= 49 ? p : null;
});
const validNums = nums.filter(n => n !== null);
if (validNums.length === 0) return 0;
const specialNum = nums[6]; // 特码(第7个)
const drawZodiacs = validNums.map(n => NUMBER_TO_ZODIAC[n]);
const specialZodiac = specialNum ? NUMBER_TO_ZODIAC[specialNum] : null;
const specialColor = specialNum ? NUMBER_TO_COLOR[specialNum] : null;
const specialSize = specialNum ? NUMBER_TO_SIZE[specialNum] : null;
const specialParity = specialNum ? NUMBER_TO_PARITY[specialNum] : null;
const pt = item.playType;
const total = item.total || 0;
// 特号:命中的号码 × amount × 47(单号码赔付)
if (pt === 'tehao') {
if (specialNum && item.numbers && item.numbers.includes(specialNum)) {
return (item.amount || 0) * 47;
}
return 0;
}
// 平特:生肖在已输入的号码生肖中 → total × 2(马 1.7)
if (pt === 'pingte') {
const z = item.zodiacs[0];
if (z && drawZodiacs.includes(z)) return total * (z === '马' ? 1.7 : 2);
return 0;
}
// 平特尾:个位数在7个开奖号码的个位数中出现 → total × 1.75
if (pt === 'pingtewei') {
const targetDigit = item.numbers && item.numbers[0]; // 平特尾的目标个位数
if (targetDigit !== undefined && targetDigit !== null) {
// 检查7个开奖号码的个位数是否包含目标数字
const hasMatch = validNums.some(n => (n % 10) === targetDigit);
if (hasMatch) return total * 1.75;
}
return 0;
}
// 特肖:最后一个号码生肖匹配 → total × 11(马 10)
if (pt === 'texiao') {
const z = item.zodiacs[0];
if (z && specialZodiac === z) return total * (z === '马' ? 10 : 11);
return 0;
}
// 连肖:组内所有生肖都出现在已输入的号码生肖中 → total × 对应倍数
if (pt === 'lianxiao2' || pt === 'lianxiao3' || pt === 'lianxiao4' || pt === 'lianxiao5') {
const odds = { lianxiao2: 4, lianxiao3: 10, lianxiao4: 30, lianxiao5: 60 };
if (item.zodiacs && item.zodiacs.every(z => drawZodiacs.includes(z))) {
return total * (odds[pt] || 0);
}
return 0;
}
// 包波:特码波色匹配 → total × 2.5(红波 2.4)
if (pt === 'baobo') {
if (item.color && specialColor === item.color) {
return total * (item.color === '红波' ? 2.4 : 2.5);
}
return 0;
}
// 包大小:特码大小匹配 → total × 1.8
if (pt === 'baodaxiao') {
if (item.size && specialSize === item.size) return total * 1.8;
return 0;
}
// 包单双:特码单双匹配 → total × 1.8
if (pt === 'baodanshuang') {
if (item.parity && specialParity === item.parity) return total * 1.8;
return 0;
}
return 0;
}
// 【修复】获取北京时间日期(YYYY-MM-DD),确保前后端日期一致
function getBeijingDate(d) {
const date = d || new Date();
// 直接用本地时间方法,浏览器已在用户时区(北京时间)
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// 判断一条消息是否属于澳门2(22:30场):21:35后发送 或 含"十点"关键字
function isMacau2Bill(createdAt, content) {
if (content && /十点/.test(content)) return true;
try {
const d = new Date(createdAt);
if (isNaN(d.getTime())) return false;
const cutoff = new Date(d);
cutoff.setHours(21, 35, 0, 0);
return d >= cutoff;
} catch(e) { return false; }
}
// 【新增】后端统计 API 调用(第二阶段:前端改为调用后端 API)
// 从后端获取统计数据,替代前端的 calcSpeedCalcStats
async function fetchSpeedCalcStatsFromServer(userFilter = 'all', lottery = 'macau1', drawNums = null, targetDate = null, chatFriendId = null) {
try {
const token = localStorage.getItem('token');
if (!token) {
console.warn('[统计] 未登录,使用本地计算');
console.warn('[统计] 后端不可用,返回空统计'); return { total:0, count:0, out:0, types:{}, numberStats:{}, billList:[], userStats:{}, playStats:{} };
}
const date = targetDate || getBeijingDate();
// 【修复】聊天窗口场景:传递 receiverId 按接收方过滤
let url = API + '/speedcalc/stats?lottery=' + lottery + '&date=' + date + '&userFilter=' + encodeURIComponent(userFilter);
if (chatFriendId) {
url += '&receiverId=' + encodeURIComponent(chatFriendId);
}
const res = await fetch(url, {
headers: { 'Authorization': 'Bearer ' + token }
});
if (!res.ok) {
console.warn('[统计] 后端API失败,使用本地计算');
console.warn('[统计] 后端不可用,返回空统计'); return { total:0, count:0, out:0, types:{}, numberStats:{}, billList:[], userStats:{}, playStats:{} };
}
const data = await res.json();
console.log('[统计] 后端返回:', data);
if (data.code !== 200) {
console.warn('[统计] 后端返回非200:', data.code, data.message);
console.warn('[统计] 后端不可用,返回空统计'); return { total:0, count:0, out:0, types:{}, numberStats:{}, billList:[], userStats:{}, playStats:{} };
}
// 【修复】只要后端返回了code 200,就直接使用后端数据
if (!data.data) {
console.warn('[统计] 后端data为空,返回空统计');
return { total: 0, count: 0, out: 0, types: {}, numberStats: {}, billList: [], userStats: {}, playStats: makePlayStats() };
}
console.log('[统计] 使用后端数据, total:', data.data.total, 'count:', data.data.count);
return data.data;
} catch(e) {
console.error('[统计] 后端API异常,使用本地计算:', e);
// 降级方案:后端失败时使用本地计算
console.warn('[统计] 后端不可用,返回空统计'); return { total:0, count:0, out:0, types:{}, numberStats:{}, billList:[], userStats:{}, playStats:{} };
}
}
// 【已删除】calcSpeedCalcStats - 全部改为后端统一计算
// 前端不再从localStorage计算账单,强制使用后端API
function buildBillStats(content) {
if (!content || typeof content !== 'string') return null;
if (content.includes('redpacket') || content.includes('transfer') || content.startsWith('data:image')) return null;
// 【BILL-SYNC-LOCAL】同步本地解析,避免 async/await 缺失导致 Promise 对象被误用
const items = parseBillText(content);
if (!items || items.length === 0) return null;
const total = calculateTotalBill(items);
const summary = items.map(i => i.type + (i.count > 1 ? '×' + i.count : '') + '=' + i.total).join('\n');
return { total, count: items.length, items, summary };
}
// 停止下注工具函数(全局)
const getChatStopTime = (chatKey) => {
try {
const val = localStorage.getItem('chatStopTime_' + chatKey);
if (val) return parseInt(val);
// 私聊时尝试读取反向 key(对方视角设置的停止时间)
const parts = chatKey.split('_');
if (parts.length >= 3 && parts[0] === 'chat') {
const reversedKey = 'chat_' + parts[2] + '_' + parts[1];
const reversedVal = localStorage.getItem('chatStopTime_' + reversedKey);
if (reversedVal) return parseInt(reversedVal);
}
return 0;
} catch(e) { return 0; }
};
// 获取活动结束时间和活动开始时间
// 【修复v6】getActivityTimes 在外层作用域、访问不到 React state 里的 messages!必须调用方传入 msgList。
// 参数:(chatType, chatKey, msgList?) → msgList 可选,传入则使用传入的数组;不传则尝试读外部变量(可能为 undefined)。
const getActivityTimes = (chatType, chatKey, msgList) => {
try {
let endTime = 0;
let startTime = 0;
// 主源:调用方传入的 messages/groupMessages【权威】
let list = msgList;
if (!Array.isArray(list)) {
// fallback:外层作用域可能读不到 React state,这里仅作补充
try { list = chatType === 'group' ? (typeof groupMessages !== 'undefined' ? groupMessages : null) : (typeof messages !== 'undefined' ? messages : null); } catch(e) { list = null; }
}
if (Array.isArray(list)) {
for (let i = 0; i < list.length; i++) {
const m = list[i];
if (!m) continue;
const content = m.content || '';
const t = new Date(m.createdAt).getTime();
if (!t || isNaN(t)) continue;
if (content.includes('活动结束') || content.includes('🛑')) {
if (t > endTime) endTime = t;
} else if (content.includes('活动开始') || content.includes('🟢')) {
if (t > startTime) startTime = t;
}
}
}
// 辅助源 1:localStorage user_activityTimes,与 messages 取最大值
try {
const userActivityTimes = localStorage.getItem('user_activityTimes');
if (userActivityTimes) {
const timesObj = JSON.parse(userActivityTimes);
if (timesObj && timesObj[chatKey]) {
const lsEnd = timesObj[chatKey].endTime || 0;
const lsStart = timesObj[chatKey].startTime || 0;
if (lsEnd > endTime) endTime = lsEnd;
if (lsStart > startTime) startTime = lsStart;
}
}
} catch(e) {}
// 辅助源 2:chatStopTime_/chatResumeTime_ 两向都检查
try {
const lsStopRaw = localStorage.getItem('chatStopTime_' + chatKey);
const lsResumeRaw = localStorage.getItem('chatResumeTime_' + chatKey);
const lsStop = lsStopRaw ? parseInt(lsStopRaw) : 0;
const lsResume = lsResumeRaw ? parseInt(lsResumeRaw) : 0;
if (lsStop && lsStop > endTime) endTime = lsStop;
if (lsResume && lsResume > startTime) startTime = lsResume;
const parts = (chatKey || '').split('_');
if (parts.length >= 3 && parts[0] === 'chat') {
const reversedKey = 'chat_' + parts[2] + '_' + parts[1];
const rsRaw = localStorage.getItem('chatStopTime_' + reversedKey);
const rrRaw = localStorage.getItem('chatResumeTime_' + reversedKey);
const rs = rsRaw ? parseInt(rsRaw) : 0;
const rr = rrRaw ? parseInt(rrRaw) : 0;
if (rs && rs > endTime) endTime = rs;
if (rr && rr > startTime) startTime = rr;
}
} catch(e) {}
return { endTime, startTime };
} catch(e) { return { endTime: 0, startTime: 0 }; }
};
const setChatStopTime = (chatKey, time) => {
try {
localStorage.setItem('chatStopTime_' + chatKey, String(time));
// 同时设置 stoppedPeriodTime 作为分割线(停止期间及之后的消息都无效)
localStorage.setItem('chatStoppedPeriod_' + chatKey, String(time));
// 私聊时同时设置反向 key,确保对方界面也能读取到停止状态
const parts = chatKey.split('_');
if (parts.length >= 3 && parts[0] === 'chat') {
const reversedKey = 'chat_' + parts[2] + '_' + parts[1];
localStorage.setItem('chatStopTime_' + reversedKey, String(time));
localStorage.setItem('chatStoppedPeriod_' + reversedKey, String(time));
}
} catch(e) {}
};
// 获取停时期记录(恢复后统计仍排除停时段的账单)
const getStoppedPeriodTime = (chatKey) => {
try {
const val = localStorage.getItem('chatStoppedPeriod_' + chatKey);
if (val) return parseInt(val);
// 私聊时检查反向 key
const parts = chatKey.split('_');
if (parts.length >= 3 && parts[0] === 'chat') {
const reversedKey = 'chat_' + parts[2] + '_' + parts[1];
const reversedVal = localStorage.getItem('chatStoppedPeriod_' + reversedKey);
if (reversedVal) return parseInt(reversedVal);
}
return 0;
} catch(e) { return 0; };
};
// 【新增】获取恢复时间
const getChatResumeTime = (chatKey) => {
try {
const val = localStorage.getItem('chatResumeTime_' + chatKey);
if (val) return parseInt(val);
// 私聊时检查反向 key
const parts = chatKey.split('_');
if (parts.length >= 3 && parts[0] === 'chat') {
const reversedKey = 'chat_' + parts[2] + '_' + parts[1];
const reversedVal = localStorage.getItem('chatResumeTime_' + reversedKey);
if (reversedVal) return parseInt(reversedVal);
}
return 0;
} catch(e) { return 0; }
};
const isChatStopped = (chatKey) => {
return getChatStopTime(chatKey) > 0;
};
// 【AUTO-CLOSE-V1】前端同步共享成员截止配置:仅用于显示/菜单,后端仍是权威
const _timeToMinutesFront = (hhmm, fallback) => {
const text = /^([01]\d|2[0-3]):[0-5]\d$/.test(String(hhmm || '')) ? String(hhmm) : fallback;
const parts = text.split(':').map(n => parseInt(n, 10));
return parts[0] * 60 + parts[1];
};
const _beijingMinutesFront = (dateInput) => {
const d = dateInput ? new Date(dateInput) : new Date();
const bj = new Date(d.getTime() + 8 * 3600000);
return bj.getUTCHours() * 60 + bj.getUTCMinutes();
};
const getMemberAutoCloseConfigFront = (friendId) => {
try {
const list = JSON.parse(localStorage.getItem('speedCalcSharedMembers_' + (JSON.parse(localStorage.getItem('user') || '{}').id || '')) || '[]');
const m = list.find(x => x.friendId === friendId) || {};
return { macau1HongkongEndTime: m.macau1HongkongEndTime || m.closeTimes?.macau1HongkongEndTime || '21:30', macau2EndTime: m.macau2EndTime || m.closeTimes?.macau2EndTime || '23:59' };
} catch(e) { return { macau1HongkongEndTime:'21:30', macau2EndTime:'23:59' }; }
};
const isBillClosedByAutoTimeFront = (msg, friendId) => {
if (!msg || !msg.createdAt || !friendId) return false;
const cfg = getMemberAutoCloseConfigFront(friendId);
const billDate = getBeijingDate(new Date(msg.createdAt));
const today = getBeijingDate();
if (billDate < today) return true;
const content = msg.content || '';
const minutesNow = _beijingMinutesFront(new Date());
const firstEnd = _timeToMinutesFront(cfg.macau1HongkongEndTime, '21:30');
const macau2End = _timeToMinutesFront(cfg.macau2EndTime, '23:59');
const msgMinutes = _beijingMinutesFront(msg.createdAt);
const lottery = msgMinutes >= firstEnd ? 'macau2' : (content.includes('香港') ? 'hongkong' : 'macau1');
return ((lottery === 'macau1' || lottery === 'hongkong') && minutesNow >= firstEnd) || (lottery === 'macau2' && minutesNow >= macau2End);
};
const canEditDeleteBill = (msg, chatKey, chatType, user, msgList) => {
if (!msg.billStats || msg.billStats.error) return false;
// 【权限检查】只有账单的发送者才能修改/删除
if (msg.senderId !== user.id) return false;
const partsForAutoClose = (chatKey || '').split('_');
const autoCloseFriendId = partsForAutoClose.length >= 3 ? partsForAutoClose[2] : null;
if (isBillClosedByAutoTimeFront(msg, autoCloseFriendId)) {
console.log('[AUTO-CLOSE-V1][FRONT-BLOCK-MENU]', 'msgId:', msg.id, 'friend:', autoCloseFriendId);
return false;
}
// 【DEBUG-账单限制v6】必须传入 msgList(外层作用域读不到 React state)
const { endTime, startTime } = getActivityTimes(chatType || 'private', chatKey, msgList);
const billTime = new Date(msg.createdAt).getTime();
if (endTime > 0 && billTime <= endTime) {
if (typeof window !== 'undefined' && window.__BILL_DBG_v6 !== msg.id) {
window.__BILL_DBG_v6 = msg.id;
console.log('[BILL-CAN-v6][BLOCK]', 'msgId:', msg.id, 'billTime:', billTime, 'endTime:', endTime, 'startTime:', startTime, 'listLen:', (msgList||[]).length);
}
return false;
}
if (typeof window !== 'undefined' && window.__BILL_DBG_v6_pass !== msg.id) {
window.__BILL_DBG_v6_pass = msg.id;
console.log('[BILL-CAN-v6][PASS]', 'msgId:', msg.id, 'billTime:', billTime, 'endTime:', endTime, 'startTime:', startTime, 'listLen:', (msgList||[]).length);
}
return true;
};
// API请求封装
const request = async (url, options = {}) => {
const token = localStorage.getItem('token');
const headers = { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
try {
const res = await fetch(API + url, { ...options, headers });
const data = await res.json();
if (data.code === 401) { localStorage.removeItem('token'); localStorage.removeItem('user'); window.location.reload(); }
// 【FREEZE-CHECK-V1】403=冻结/封禁,强制退出
if (data.code === 403) { alert('网络异常'); localStorage.removeItem('token'); localStorage.removeItem('user'); window.location.reload(); }
return data;
} catch (e) { return { code: 500, message: '网络错误' }; }
};
const get = (url) => request(url);
const post = (url, body) => request(url, { method: 'POST', body: JSON.stringify(body) });
const put = (url, body) => request(url, { method: 'PUT', body: JSON.stringify(body) });
// 【IMG-SAVE-V1】保存图片到相册:base64先上传服务器获取HTTP URL,再调用原生方法
const savePreviewImage = async (imgData) => {
let imageUrl = imgData;
// 如果是base64,先上传服务器转为HTTP URL
if (imgData && imgData.startsWith('data:')) {
try {
showToast('正在准备图片...');
const res = await post('/upload-temp', { imageData: imgData });
if (res.code === 200 && res.data && res.data.url) {
imageUrl = res.data.url;
} else {
showToast('准备图片失败');
return;
}
} catch(e) {
showToast('网络错误');
return;
}
}
// 调用原生保存方法
if (window.AndroidNotify && typeof window.AndroidNotify.saveImageToGallery === 'function') {
window.AndroidNotify.saveImageToGallery(imageUrl);
} else {
// 浏览器回退:通过URL直接下载
const a = document.createElement('a');
a.href = imageUrl;
a.download = 'image_' + Date.now() + '.jpg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
showToast('图片已开始下载');
}
};
// 【VOICE-V1】录音函数已移入 FriendsPage 组件内部
let pttModeRef_global = false;
// PTT functions moved inside FriendsPage component (need access to state setters)
// ============ 未注册用户官方群预览 ============
function GuestOfficialGroupPreview({ onRegister }) {
const [loading, setLoading] = useState(true);
const [groupMessages, setGroupMessages] = useState([]);
const messagesEndRef = React.useRef(null); // 消息列表底部引用
// 自动滚动到最新消息
const scrollToBottom = (smooth = false) => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({
behavior: smooth ? 'smooth' : 'auto',
block: 'end'
});
}
};
// 加载官方群消息
useEffect(() => {
let mounted = true;
const loadMessages = async () => {
try {
// 从公开API获取官方群消息
const res = await fetch('/api/groups/public-messages?groupId=official_group_abc&limit=50');
if (res.ok) {
const data = await res.json();
if (data.code === 200 && data.data && mounted) {
setGroupMessages(data.data);
setLoading(false);
// 加载完成后直接跳转到底部(无动画)
setTimeout(() => scrollToBottom(false), 100);
return;
}
}
// 如果API失败,尝试从localStorage读取
const user = JSON.parse(localStorage.getItem('user') || '{}');
const gKey = 'groupMsgs_' + (user.id || 'guest') + '_official_group_abc';
const msgs = JSON.parse(localStorage.getItem(gKey) || '[]');
if (mounted) {
setGroupMessages(msgs.slice(-50));
setLoading(false);
setTimeout(() => scrollToBottom(false), 100);
}
} catch(e) {
console.error('Load messages error:', e);
if (mounted) setLoading(false);
}
};
loadMessages();
// 每3秒轮询新消息,实现实时同步
const interval = setInterval(() => {
loadMessages().then(() => {
// 新消息加载后直接跳转到底部(无动画)
setTimeout(() => scrollToBottom(false), 100);
});
}, 3000);
return () => {
mounted = false;
clearInterval(interval);
};
}, []);
// 消息更新时自动滚动
useEffect(() => {
if (groupMessages.length > 0) {
scrollToBottom(false); // 无动画直接跳转
}
}, [groupMessages]);
// 操作提示
const showRegisterTip = () => {
if (confirm('请先注册账号才能使用此功能\n\n点击确定立即注册')) {
onRegister();
}
};
if (loading) {
return (
<div style={{position:'fixed',top:0,bottom:0,left:'50%',transform:'translateX(-50%)',width:'100%',maxWidth:'500px',zIndex:50,display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',background:'#EDEDED'}}>
<div style={{fontSize:'24px',marginBottom:'12px'}}>🔄</div>
<div style={{color:'#666'}}>加载中...</div>
</div>
);
}
return (
<div style={{position:'fixed',top:0,bottom:0,left:'50%',transform:'translateX(-50%)',width:'100%',maxWidth:'500px',zIndex:50,display:'flex',flexDirection:'column',background:'#EDEDED'}}>
{/* 顶部栏 */}
<div style={{background:'#3A0000',padding:'12px 16px',display:'flex',alignItems:'center',justifyContent:'space-between'}}>
<div style={{display:'flex',alignItems:'center',gap:'10px'}}>
<button style={{background:'none',border:'none',color:'#FFD700',fontSize:'20px',cursor:'pointer'}} onClick={() => {
window.location.href = '/';
}}>←</button>
<div>
<div style={{color:'#fff',fontWeight:'bold',fontSize:'16px'}}>暂时展示官方群</div>
<div style={{color:'#FFD700',fontSize:'12px'}}>100名成员</div>
</div>
</div>
<div style={{background:'rgba(255,215,0,0.15)',border:'1px solid rgba(255,215,0,0.4)',color:'#FFD700',padding:'4px 12px',borderRadius:'14px',fontSize:'12px'}}>
👀 预览模式
</div>
</div>
{/* 消息区域 */}
<div style={{flex:1,overflowY:'auto',padding:'12px',display:'flex',flexDirection:'column',gap:'10px'}}>
{groupMessages.length === 0 ? (
<div style={{textAlign:'center',padding:'40px 20px',color:'#999'}}>
<div style={{fontSize:'48px',marginBottom:'12px'}}>🎉</div>
<div style={{fontSize:'14px',fontWeight:'bold',color:'#666'}}>官方群</div>
<div style={{fontSize:'12px',marginTop:'8px',color:'#999'}}>注册后即可查看群消息和红包</div>
</div>
) : (
<>
{groupMessages.map((m) => {
// 【GUEST-PREVIEW-FIX-v1】2026/05/30 修复:后端返回字段为 msgType(不是 messageType);红包消息 content 是 JSON 字符串需要解析
const msgType = m.msgType || m.messageType || m.type;
// 解析红包数据(如果 content 是 JSON 字符串)
let rpData = null;
if (msgType === 'redpacket' && typeof m.content === 'string' && m.content.startsWith('{')) {
try { rpData = JSON.parse(m.content); } catch(e) { rpData = null; }
}
if (msgType === 'system') {
return <div key={m.id} style={{textAlign:'center',fontSize:'11px',color:'#999',background:'rgba(0,0,0,0.05)',padding:'4px 12px',borderRadius:'10px',alignSelf:'center',maxWidth:'80%'}}>{m.displayContent || m.content}</div>;
}
if (msgType === 'recalled') {
return <div key={m.id} style={{textAlign:'center',fontSize:'11px',color:'#999',fontStyle:'italic',padding:'4px 12px'}}>消息已撤回</div>;
}
return (
<div key={m.id} style={{display:'flex',justifyContent:'flex-start',alignItems:'flex-start',gap:'8px'}}>
<div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:'2px',flexShrink:0}}>
<div style={{width:'36px',height:'36px',borderRadius:'50%',overflow:'hidden',background:'#ddd',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'14px'}}>
{m.senderAvatar ? <img src={m.senderAvatar} style={{width:'100%',height:'100%',objectFit:'cover'}} /> : ''}
</div>
<span style={{fontSize:'10px',color:'#999',maxWidth:'50px',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{m.senderName}</span>
</div>
<div style={{padding:'10px 14px',borderRadius:'4px 12px 12px 12px',maxWidth:'65%',fontSize:'15px',lineHeight:'1.5',wordBreak:'break-all',background:'#fff',color:'#333',boxShadow:'0 1px 1px rgba(0,0,0,0.08)'}}>
{msgType === 'image' ? (
<div style={{textAlign:'center'}}>
<img src={m.content} style={{maxWidth:'200px',borderRadius:'8px'}} />
<div style={{fontSize:'11px',color:'#999',marginTop:'4px'}}>[图片]</div>
</div>
) : msgType === 'redpacket' ? (
<div
style={{
display:'flex',
alignItems:'center',
padding:'12px',
background:'linear-gradient(135deg,#FF8C42 0%,#FF6B35 100%)',
borderRadius:'8px',
color:'#fff',
cursor:'pointer',
boxShadow:'0 2px 8px rgba(255,107,53,0.3)',
minWidth:'240px',
maxWidth:'280px'
}}
onClick={showRegisterTip}
>
{/* 左侧红色¥图标区域 */}
<div style={{
width:'48px', height:'48px',
background:'#E74C3C',
borderRadius:'6px',
display:'flex', alignItems:'center', justifyContent:'center',
fontSize:'24px', fontWeight:'bold',
marginRight:'12px', flexShrink:0
}}>¥</div>
{/* 右侧内容区域 */}
<div style={{flex:1, minWidth:0}}>
{/* 【GUEST-PREVIEW-FIX-v1】祝福语文本:优先 rpData.message,兑底用默认值 */}
<div style={{
fontSize:'15px', fontWeight:'bold',
marginBottom:'6px', color:'#fff',
textShadow:'0 1px 2px rgba(0,0,0,0.1)'
}}>
{(rpData && rpData.message) || m.displayContent || '恭喜发财,大吉大利'}
</div>
{/* 【GUEST-PREVIEW-FIX-v1】红包类型和金额:读解析后的 rpData */}
{rpData && (
<div style={{fontSize:'12px', opacity:0.95}}>
{rpData.redPacketType === 'relay' && rpData.relayMode === 'lucky' && <span>👑 运气王接龙红包</span>}
{rpData.redPacketType === 'relay' && rpData.relayMode === 'min' && <span>🔻 最小接龙红包</span>}
{rpData.redPacketType === 'mine' && <span>💣 扫雷红包</span>}
{rpData.redPacketType === 'normal' && <span>🧧 普通红包</span>}
{rpData.redPacketType === 'relay' && rpData.relayMode !== 'lucky' && rpData.relayMode !== 'min' && <span>🐉 接龙红包</span>}
{rpData.totalAmount != null && <span> {rpData.totalAmount}元</span>}
{rpData.totalCount != null && <span>/{rpData.totalCount}个</span>}
{rpData.mineNumber != null && <span> 雷点{rpData.mineNumber}</span>}
</div>
)}
{/* 底部提示 */}
<div style={{
fontSize:'11px', marginTop:'6px',
paddingTop:'6px',
borderTop:'1px solid rgba(255,255,255,0.3)',
opacity:0.85
}}>注册后可领取</div>
</div>
</div>
) : (
<div>{m.displayContent || m.content}</div>
)}
</div>
</div>
);
})}
{/* 滚动目标元素 */}
<div ref={messagesEndRef} style={{height:'1px'}} />
</>
)}
</div>
{/* 底部操作栏 */}
<div style={{background:'#fff',padding:'12px 16px',borderTop:'1px solid #ddd'}}>
<div style={{display:'flex',gap:'8px',marginBottom:'8px'}}>
<button
style={{flex:1,padding:'12px',borderRadius:'8px',border:'1px solid #ddd',background:'#f5f5f5',color:'#999',fontSize:'14px',cursor:'not-allowed'}}
onClick={showRegisterTip}
>
💬 发消息
</button>
<button
style={{flex:1,padding:'12px',borderRadius:'8px',border:'1px solid #ddd',background:'#f5f5f5',color:'#999',fontSize:'14px',cursor:'not-allowed'}}
onClick={showRegisterTip}
>
🧧 发红包
</button>
</div>
<button
style={{width:'100%',padding:'14px',borderRadius:'12px',border:'none',background:'linear-gradient(135deg,#FFD700,#FFA500)',color:'#5B0000',fontWeight:'bold',fontSize:'16px',cursor:'pointer'}}
onClick={onRegister}
>
🎉 立即注册,参与互动
</button>
</div>
</div>
);
}
// ============ 登录页 ============
function LoginPage({ onLogin, onSwitch }) {
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (!phone || !password) return showToast('请填写手机号和密码');
setLoading(true);
const res = await post('/auth/login', { phone, password });
setLoading(false);
if (res.code === 200) { localStorage.setItem('token', res.data.token); localStorage.setItem('user', JSON.stringify(res.data.user)); onLogin(res.data); }
else showToast(res.message);
};
return (
<div className="min-h-screen bg-main flex flex-col items-center justify-center px-6">
<div className="text-center mb-8">
<div className="text-6xl mb-4">🐲</div>
<h1 className="text-3xl font-bold text-gold mb-2" style={{textShadow:'0 0 20px rgba(255,215,0,0.5)'}}>ABC</h1>
<p className="text-gold2 text-sm">公平透明 · 无上限赔率 · 纯玩家博弈</p>
</div>
<div className="w-full max-w-sm space-y-4">
<input className="input-main" placeholder="手机号" value={phone} onChange={e=>setPhone(e.target.value)} maxLength={11} />
<input className="input-main" type="password" placeholder="密码" value={password} onChange={e=>setPassword(e.target.value)} />
<button className="btn-red w-full text-lg py-3" onClick={handleSubmit} disabled={loading}>{loading ? '登录中...' : '立即登录'}</button>
<p className="text-center text-sm" style={{color:'rgba(255,255,255,0.6)'}}>还没有账号?<span className="text-gold cursor-pointer" onClick={onSwitch}>立即注册</span></p>
</div>
</div>
);
}
// ============ 注册页 ============
function RegisterPage({ onRegister, onSwitch }) {
const [phone, setPhone] = useState('');
const [password, setPassword] = useState('');
const [nickname, setNickname] = useState('');
// 【REF-SYNC-FIX-V1】同步读取URL中的ref参数作为初始值,避免useEffect异步导致表单提交时邀请码为空
const [inviteCode, setInviteCode] = useState(() => {
try { const p = new URLSearchParams(window.location.search); const r = p.get('ref'); return r ? r.toUpperCase() : ''; } catch(e) { return ''; }
});
const [loading, setLoading] = useState(false);
// 检查是否为分享链接(同步读取,与inviteCode初始值保持一致)
const isSharedLink = (() => {
try { const p = new URLSearchParams(window.location.search); return !!p.get('ref'); } catch(e) { return false; }
})();
const handleSubmit = async () => {
if (!phone || !password) return showToast('请填写手机号和密码');
// 非分享链接必须填写邀请码
if (!isSharedLink && !inviteCode.trim()) {
return showToast('请填写邀请码\n\n请与邀请人获取邀请码');
}
setLoading(true);
const res = await post('/auth/register', { phone, password, nickname, inviteCode });
setLoading(false);
if (res.code === 200) { localStorage.setItem('token', res.data.token); localStorage.setItem('user', JSON.stringify(res.data.user)); onRegister(res.data); }
else showToast(res.message);
};
return (
<div className="min-h-screen bg-main flex flex-col items-center justify-center px-6">
<div className="text-center mb-6">
<div className="text-5xl mb-3">🎊</div>
<h1 className="text-2xl font-bold text-gold mb-1">注册新账号</h1>
<p className="text-gold2 text-sm">注册即送500体验筹码</p>
</div>
<div className="w-full max-w-sm space-y-3">
<input className="input-main" placeholder="手机号" value={phone} onChange={e=>setPhone(e.target.value)} maxLength={11} />
<input className="input-main" type="password" placeholder="密码(6-16位数字+字母)" value={password} onChange={e=>setPassword(e.target.value)} />
<input className="input-main" placeholder="昵称(可选,2-8位)" value={nickname} onChange={e=>setNickname(e.target.value)} maxLength={8} />
{/* 邀请码输入框 */}
<div>
<input
className="input-main"
placeholder={isSharedLink ? "邀请码(自动填充)" : "邀请码(必填)"}
value={inviteCode}
onChange={e=>setInviteCode(e.target.value.toUpperCase())}
maxLength={6}
style={!isSharedLink && !inviteCode ? {border: '2px solid #FF6B6B'} : {}}
/>
{!isSharedLink && !inviteCode && (
<div style={{color: '#FF6B6B', fontSize: '12px', marginTop: '4px', textAlign: 'center'}}>
⚠️ 请与邀请人获取邀请码
</div>
)}
</div>
<button className="btn-red w-full text-lg py-3" onClick={handleSubmit} disabled={loading}>{loading ? '注册中...' : '立即注册'}</button>
<p className="text-center text-sm" style={{color:'rgba(255,255,255,0.6)'}}>已有账号?<span className="text-gold cursor-pointer" onClick={onSwitch}>立即登录</span></p>
</div>
</div>
);
}
// ============ 大厅页 ============
function LobbyPage({ user, onEnterRoom, onRefresh, onNavigate }) {
const [lobbyData, setLobbyData] = useState(null);
const [showRules, setShowRules] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState('');
const [createCD, setCreateCD] = useState(60);
const [createPwd, setCreatePwd] = useState('');
const [countdown, setCountdown] = useState('00:00:00');
const [roundNo, setRoundNo] = useState(1);
const [carouselIndex, setCarouselIndex] = useState(0);
const [slides, setSlides] = useState([]);
const [installPrompt, setInstallPrompt] = useState(null);
const [isIOS, setIsIOS] = useState(false);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const timerRef = useRef(null);
const carouselTimerRef = useRef(null);
// 捕获 PWA 安装提示事件
useEffect(() => {
const ua = navigator.userAgent;
setIsIOS(/iPad|iPhone|iPod/.test(ua) && !window.MSStream);
const handler = (e) => { e.preventDefault(); setInstallPrompt(e); };
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const handleInstallClick = async () => {
if (installPrompt) {
installPrompt.prompt();
const result = await installPrompt.userChoice;
setInstallPrompt(null);
if (result.outcome === 'accepted') showToast('添加成功!');
} else {
setShowInstallGuide(true);
}
};
const getInstallGuide = () => {
const ua = navigator.userAgent;
if (/iPad|iPhone|iPod/.test(ua)) return { browser: 'Safari', steps: ['点击底部中间分享图标(□↑)', '滑动菜单找到「添加到主屏幕」', '点击右上角「添加」确认'] };
if (/HarmonyOS|HMSCore/.test(ua)) return { browser: '华为浏览器', steps: ['点击底部「⋮」菜单', '选择「添加到桌面」', '确认添加即可'] };
if (/MiuiBrowser|XiaoMi/.test(ua)) return { browser: '小米浏览器', steps: ['点击底部「⋯」菜单', '选择「添加到桌面」', '确认添加'] };
if (/OPPO|HeyTapBrowser/.test(ua)) return { browser: 'OPPO浏览器', steps: ['点击底部「⋯」菜单', '选择「添加到桌面」', '确认添加'] };
if (/VivoBrowser/.test(ua)) return { browser: 'vivo浏览器', steps: ['点击底部「⋯」菜单', '选择「添加到桌面」', '确认添加'] };
if (/EdgA/.test(ua)) return { browser: 'Edge', steps: ['点击底部「⋯」菜单', '选择「添加到手机」或「安装应用」', '确认添加'] };
if (/CriOS|Chrome/.test(ua)) return { browser: 'Chrome', steps: ['点击右上角「⋮」菜单', '选择「添加到主屏幕」或「安装应用」', '确认添加'] };
if (/QQBrowser/.test(ua)) return { browser: 'QQ浏览器', steps: ['点击底部「≡」菜单', '选择「添加到桌面」', '确认添加'] };
if (/UCBrowser/.test(ua)) return { browser: 'UC浏览器', steps: ['点击底部「≡」菜单', '选择「添加到桌面」', '确认添加'] };
return { browser: '浏览器', steps: ['打开浏览器菜单(⋮ 或 ≡)', '找到「添加到桌面」或「添加到主屏幕」', '确认添加即可'] };
};
const fetchLobby = async () => {
const res = await get('/game/lobby');
if (res.code === 200) {
setLobbyData(res.data);
if (res.data.currentRound) {
const end = new Date(res.data.currentRound.countdownEnd);
startTimer(end);
setRoundNo(parseInt(res.data.currentRound.roundNo?.replace(/\D/g,'').slice(-4)) || 1);
}
}
};
// 获取轮播图
const fetchCarousel = async () => {
try {
const res = await get('/carousel/list');
if (res.code === 200 && res.data && res.data.length > 0) {
setSlides(res.data);
}
} catch(e) {
console.error('获取轮播图失败:', e);
}
};
useEffect(() => {
fetchLobby();
fetchCarousel();
const interval = setInterval(fetchLobby, 5000);
return () => {
clearInterval(interval);
if (carouselTimerRef.current) clearInterval(carouselTimerRef.current);
};
}, []);
// 自动轮播
useEffect(() => {
if (slides.length > 1) {
carouselTimerRef.current = setInterval(() => {
setCarouselIndex(prev => (prev + 1) % slides.length);
}, 3000);
}
return () => {
if (carouselTimerRef.current) clearInterval(carouselTimerRef.current);
};
}, [slides]);
const startTimer = (end) => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
const diff = Math.max(0, end - Date.now());
if (diff <= 0) { clearInterval(timerRef.current); fetchLobby(); return; }
const h = String(Math.floor(diff/3600000)).padStart(2,'0');
const m = String(Math.floor(diff%3600000/60000)).padStart(2,'0');
const s = String(Math.floor(diff%60000/1000)).padStart(2,'0');
setCountdown(`${h}:${m}:${s}`);
}, 1000);
};
useEffect(() => { fetchLobby(); const iv = setInterval(fetchLobby, 10000); return () => { clearInterval(iv); clearInterval(timerRef.current); }; }, []);
const handleCreate = async () => {
const res = await post('/game/create-room', { name: createName, countdown: createCD, password: createPwd });
if (res.code === 200) { setShowCreate(false); onEnterRoom(res.data.roomId); } else showToast(res.message);
};
const enterPublicRoom = () => {
if (lobbyData?.publicRooms?.[0]) onEnterRoom(lobbyData.publicRooms[0].id);
};
const handleSlideClick = (url) => {
if (url) window.open(url, '_blank');
};
return (
<div className="pb-20" style={{backgroundImage: 'url(/lobby-icons/lobby-bg.png?v=2)', backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat', minHeight: '100vh'}}>
{/* 顶部栏 */}
<div className="px-4 pt-3 pb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="avatar" style={{overflow:'hidden'}}>{user?.avatar ? <img src={user.avatar} style={{width:'100%',height:'100%',objectFit:'cover',borderRadius:'50%'}} /> : <span>🐲</span>}</div>
<div>
<div className="text-white font-bold text-sm">{user?.nickname || '玩家'}</div>
<div className="text-gold2 text-xs">用户昵称</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 bg-dark-card px-3 py-1">
<span className="text-sm">🏆</span>
<span className="text-gold text-sm font-bold">{((user?.chips || 0) + (user?.bonusChips || 0)).toLocaleString()}</span>
{(user?.bonusChips || 0) > 0 && <span className="text-xs text-orange-400 ml-1" title={`含赠送筹码${user.bonusChips}(仅限系统群红包)`}>🎁</span>}
</div>
<div className="flex items-center gap-1 bg-dark-card px-3 py-1">
<span className="text-sm">💰</span>
<span className="text-gold text-sm font-bold">{(user?.commissionBalance || 0).toLocaleString()}</span>
</div>
</div>
</div>
{/* 图片轮播 */}
{slides.length > 0 ? (
<div className="mx-4 mb-3 carousel-container">
<div className="carousel-track" style={{transform: `translateX(-${carouselIndex * 100}%)`}}>
{slides.map((slide, idx) => (
<div key={slide.id} className="carousel-slide" onClick={() => handleSlideClick(slide.linkUrl)}>
<img src={slide.imageUrl} alt="活动" />
</div>
))}
</div>
{slides.length > 1 && (
<div className="carousel-dots">
{slides.map((_, idx) => (
<div key={idx} className={`carousel-dot ${idx === carouselIndex ? 'active' : ''}`} onClick={() => setCarouselIndex(idx)} />
))}
</div>
)}
</div>
) : (
<div className="mx-4 mb-3 bg-dark-card px-4 py-2 flex items-center gap-2" style={{borderRadius:'20px',border:'1px solid #DAA520'}}>
<span>📢</span>
<div className="marquee flex-1"><span className="marquee-inner text-gold2 text-sm">今日活动:注册送500筹码</span></div>
</div>
)}
{/* 宫格游戏入口 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px', padding: '0 16px', marginBottom: '16px' }}>
{/* 创建房间 */}
<div onClick={() => setShowCreate(true)} style={{ borderRadius: '12px', overflow: 'hidden', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,215,0,0.2)', cursor: 'pointer' }}>
<img src="/lobby-icons/create-room.jpg" alt="创建房间" style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', display: 'block' }} />
<div style={{ textAlign: 'center', padding: '8px', color: '#ffd700', fontSize: '14px', fontWeight: 'bold' }}>创建房间</div>
</div>
{/* 无庄十分彩 */}
<div onClick={() => { if (onNavigate) onNavigate('grid49'); }} style={{ borderRadius: '12px', overflow: 'hidden', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,215,0,0.2)', cursor: 'pointer' }}>
<img src="/lobby-icons/grid49.jpg" alt="无庄十分彩" style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', display: 'block' }} />
<div style={{ textAlign: 'center', padding: '8px', color: '#ffd700', fontSize: '14px', fontWeight: 'bold' }}>无庄十分彩</div>
</div>
{/* 幸运六合 */}
<div onClick={() => window.open('https://lkjbn-54lkmn-8541jhgvcx.xyz/p/AAA', '_blank')} style={{ borderRadius: '12px', overflow: 'hidden', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,215,0,0.2)', cursor: 'pointer' }}>
<img src="/lobby-icons/mark-six.jpg" alt="幸运六合" style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', display: 'block' }} />
<div style={{ textAlign: 'center', padding: '8px', color: '#ffd700', fontSize: '14px', fontWeight: 'bold' }}>幸运六合</div>
</div>
{/* 幸运十二生肖 */}
<div onClick={() => enterPublicRoom()} style={{ borderRadius: '12px', overflow: 'hidden', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,215,0,0.2)', cursor: 'pointer' }}>
<img src="/lobby-icons/zodiac.jpg" alt="幸运十二生肖" style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', display: 'block' }} />
<div style={{ textAlign: 'center', padding: '8px', color: '#ffd700', fontSize: '14px', fontWeight: 'bold' }}>幸运十二生肖</div>
</div>
{/* 红包接龙扫雷 */}
<div onClick={() => { if (onNavigate) onNavigate('friends'); setTimeout(() => { const el = document.querySelector('[data-official-group]'); if (el) el.click(); }, 300); }} style={{ borderRadius: '12px', overflow: 'hidden', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,215,0,0.2)', cursor: 'pointer' }}>
<img src="/lobby-icons/redpacket.jpg" alt="红包接龙扫雷" style={{ width: '100%', aspectRatio: '1', objectFit: 'cover', display: 'block' }} />
<div style={{ textAlign: 'center', padding: '8px', color: '#ffd700', fontSize: '14px', fontWeight: 'bold' }}>红包接龙扫雷</div>
</div>
</div>
{/* 规则弹窗 */}
{showRules && (
<div className="modal-mask" onClick={()=>setShowRules(false)}>
<div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold text-lg font-bold text-center mb-4">🐲 生肖下注规则</h3>
<div className="text-sm space-y-2" style={{color:'rgba(255,255,255,0.85)'}}>
<p>1. 每局12生肖自由下注,选肖无限制,金额无上限</p>
<p>2. <strong className="text-gold">全场下注总额最高的生肖即为中奖生肖</strong></p>
<p>3. 多个生肖并列最高,全部判定为中奖</p>
<p>4. 奖金 = 个人中奖下注额 ÷ 全部中奖下注额 × 可分配奖池</p>
<p>5. 开奖前所有下注数据全程保密,盲选博弈</p>
<p>6. 赔率无上限,纯玩家奖池瓜分,公平透明</p>
<p>7. 下注确认后不可撤销,请仔细核对</p>
</div>
<button className="btn-gold w-full mt-4" onClick={()=>setShowRules(false)}>我知道了</button>
</div>
</div>
)}
{/* 创建房间弹窗 */}
{showCreate && (
<div className="modal-mask" onClick={()=>setShowCreate(false)}>
<div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold text-lg font-bold text-center mb-4">创建房间</h3>
<div className="space-y-3">
<input className="input-main" placeholder="房间名称(8字内)" value={createName} onChange={e=>setCreateName(e.target.value)} maxLength={8} />
<select className="input-main" value={createCD} onChange={e=>setCreateCD(+e.target.value)}>
<option value={30}>30秒</option><option value={60}>60秒</option><option value={90}>90秒</option>
<option value={120}>120秒</option><option value={180}>3分钟</option><option value={300}>5分钟</option>
</select>
<input className="input-main" placeholder="房间密码(可选)" value={createPwd} onChange={e=>setCreatePwd(e.target.value)} />
</div>
<div className="flex gap-3 mt-4">
<button className="flex-1 py-3 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setShowCreate(false)}>取消</button>
<button className="btn-red flex-1" onClick={handleCreate}>创建</button>
</div>
</div>
</div>
)}
</div>
);
}
// ============ 图片压缩工具 ============
const readFileAsBase64 = (file) => new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(file);
});
const compressImage = (file, maxWidth = 500, quality = 0.5) => new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let w = img.width, h = img.height;
if (w > maxWidth) { h = Math.round(h * maxWidth / w); w = maxWidth; }
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', quality));
URL.revokeObjectURL(img.src);
};
img.onerror = () => { readFileAsBase64(file).then(resolve); };
img.src = URL.createObjectURL(file);
});
// ============ 清空记录工具函数(全局) ============
const getClearedKeyGlobal = (userId, fid) => 'cleared_' + userId + '_' + fid;
const markClearedGlobal = (userId, fid) => { try { localStorage.setItem(getClearedKeyGlobal(userId, fid), String(Date.now())); } catch(e){} };
const getClearedTimeGlobal = (userId, fid) => { try { const v = localStorage.getItem(getClearedKeyGlobal(userId, fid)); return v ? parseInt(v) : 0; } catch(e){ return 0; } };
const isClearedGlobal = (userId, fid) => getClearedTimeGlobal(userId, fid) > 0;
const unmarkClearedGlobal = (userId, fid) => { try { localStorage.removeItem(getClearedKeyGlobal(userId, fid)); } catch(e){} };
// ============ 智能消息时间格式化 ============
// 今天:只显示时间(如 14:30)
// 昨天:显示"昨天 14:30"
// 更早:显示日期+时间(如 05-20 14:30)
// 将文本中的URL转换为可点击链接
const renderTextWithLinks = (text) => {
if (!text || typeof text !== 'string') return text;
const urlRegex = /(https?:\/\/[^\s<]+)/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = urlRegex.exec(text)) !== null) {
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
parts.push(<a key={match.index} href={match[1]} target="_blank" rel="noopener noreferrer" style={{color:'#1E90FF',textDecoration:'underline',wordBreak:'break-all',cursor:'pointer',position:'relative',zIndex:10}} onTouchEnd={(e)=>{e.stopPropagation();}} onClick={(e)=>{e.stopPropagation();}}>{match[1]}</a>);
lastIndex = match.index + match[1].length;
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts.length > 0 ? parts : text;
};
const formatMsgTime = (dateStr) => {
if (!dateStr) return '';
try {
const msgDate = new Date(dateStr);
const now = new Date();
const msgDay = new Date(msgDate.getFullYear(), msgDate.getMonth(), msgDate.getDate());
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const timeStr = msgDate.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
if (msgDay.getTime() === today.getTime()) {
// 今天:只显示时间
return timeStr;
} else if (msgDay.getTime() === yesterday.getTime()) {
// 昨天:显示"昨天 14:30"
return '昨天 ' + timeStr;
} else {
// 更早:显示日期+时间(如 05-20 14:30)
const month = String(msgDate.getMonth() + 1).padStart(2, '0');
const day = String(msgDate.getDate()).padStart(2, '0');
return month + '-' + day + ' ' + timeStr;
}
} catch(e) {
return dateStr;
}
};
// 联系人列表时间:今天→时间,本周→星期,更早→月/日
const formatRelativeTime = (dateStr) => {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.floor((today.getTime() - dDay.getTime()) / 86400000);
if (diffDays === 0) {
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
} else if (diffDays < 7 && diffDays > 0) {
return ['周日','周一','周二','周三','周四','周五','周六'][d.getDay()];
} else {
return (d.getMonth()+1) + '/' + d.getDate();
}
} catch(e) { return dateStr; }
};
// ============ 消息提示音系统 ============
const _notifySounds = {
'default': { name: '默认提示', freq: [800], dur: [150] },
'dingdong': { name: '叮咚', freq: [880, 660], dur: [120, 180] },
'triple': { name: '三连音', freq: [600, 800, 1000], dur: [80, 80, 120] },
'bubble': { name: '气泡', freq: [400, 600], dur: [60, 100] },
'crystal': { name: '水晶', freq: [1200, 900, 1400], dur: [60, 60, 100] },
'whistle': { name: '口哨', freq: [500, 700, 500], dur: [100, 150, 100] },
};
let _audioCtx = null;
let _audioReady = false;
function _getAudioCtx() {
if (!_audioCtx) try { _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } catch(e) {}
return _audioCtx;
}
// 用户首次交互时激活音频上下文
function _activateAudio() {
if (_audioReady) return;
const ctx = _getAudioCtx();
if (ctx) {
if (ctx.state === 'suspended') ctx.resume();
// 播放一段静音来解锁音频
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
gain.gain.value = 0;
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.01);
} catch(e){}
_audioReady = true;
}
}
document.addEventListener('touchstart', _activateAudio, { once: false, capture: true });
document.addEventListener('click', _activateAudio, { once: false, capture: true });
function playNotifySound(soundKey) {
if (localStorage.getItem('notifySound') === 'off') return;
const key = soundKey || localStorage.getItem('notifySoundType') || 'default';
const snd = _notifySounds[key] || _notifySounds['default'];
const ctx = _getAudioCtx();
if (!ctx) return;
if (ctx.state === 'suspended') ctx.resume();
let t = ctx.currentTime;
for (let i = 0; i < snd.freq.length; i++) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = snd.freq[i];
osc.type = 'sine';
gain.gain.setValueAtTime(0.3, t);
gain.gain.exponentialRampToValueAtTime(0.01, t + snd.dur[i] / 1000);
osc.start(t);
osc.stop(t + snd.dur[i] / 1000);
t += snd.dur[i] / 1000 + 0.02;
}
}
// ============ 全局图片预览 ============
let _imgPreviewCb = null;
let _imgPreviewOpen = 0;
const _imgMap = {};
let _imgIdCounter = 0;
function _regImg(src) { const id = 'img_' + (++_imgIdCounter); _imgMap[id] = src; return id; }
document.addEventListener('touchend', function(e) {
var el = e.target;
if (el && el.tagName === 'IMG' && el.parentNode && el.parentNode.getAttribute('data-pid')) {
var pid = el.parentNode.getAttribute('data-pid');
var src = _imgMap[pid];
if (src && _imgPreviewCb) {
_imgPreviewOpen = Date.now();
_imgPreviewCb(src);
}
}
}, true);
document.addEventListener('click', function(e) {
var el = e.target;
if (el && el.tagName === 'IMG' && el.parentNode && el.parentNode.getAttribute('data-pid')) {
var pid = el.parentNode.getAttribute('data-pid');
var src = _imgMap[pid];
if (src && _imgPreviewCb) {
_imgPreviewOpen = Date.now();
_imgPreviewCb(src);
}
}
}, true);
document.addEventListener('touchstart', function(e) {
var el = e.target;
while (el && el !== document) {
if (el.hasAttribute && el.hasAttribute('data-img-overlay')) {
if (Date.now() - _imgPreviewOpen > 300 && _imgPreviewCb) { _imgPreviewCb(null); }
return;
}
el = el.parentNode;
}
}, true);
document.addEventListener('click', function(e) {
var el = e.target;
while (el && el !== document) {
if (el.hasAttribute && el.hasAttribute('data-img-overlay')) {
if (Date.now() - _imgPreviewOpen > 300 && _imgPreviewCb) { _imgPreviewCb(null); }
return;
}
el = el.parentNode;
}
}, false);
// ============ 49号码宫格游戏页 ============
function Grid49Page({ user, onUpdateUser }) {
const [lobbyData, setLobbyData] = useState(null);
const [selectedNumbers, setSelectedNumbers] = useState([]); // 多选号码
const [betAmount, setBetAmount] = useState('');
const [odds, setOdds] = useState({});
const [totalBet, setTotalBet] = useState(0);
const [netPool, setNetPool] = useState(0);
const [countdown, setCountdown] = useState(60);
const countdownRef = useRef(60); // 用ref跟踪countdown,避免闭包问题
const [gameStatus, setGameStatus] = useState('betting'); // betting/sealed/preparing/drawing/settled
const [lastResult, setLastResult] = useState(null);
const [drawAnimNum, setDrawAnimNum] = useState(null); // 开奖动画当前号码
const [prepareCountdown, setPrepareCountdown] = useState(0); // 准备开奖倒计时
const [drawCountdown, setDrawCountdown] = useState(0); // 开奖动画倒计时
const [betLoading, setBetLoading] = useState(false);
const [roundNo, setRoundNo] = useState(''); // 当前期号
const oddsTimerRef = useRef(null);
const countdownTimerRef = useRef(null);
const drawAnimRef = useRef(null);
const prepareTimerRef = useRef(null);
const pollTimerRef = useRef(null); // 轮询兜底定时器
const currentRoundIdRef = useRef(null);
const betTokenRef = useRef(0);
const gameStatusRef = useRef('betting'); // 用ref跟踪gameStatus,避免闭包问题
const loadLobbyRef = useRef(null); // 用ref跟踪最新loadLobby,避免闭包问题
const [showHistory, setShowHistory] = useState(false);
const [historyData, setHistoryData] = useState([]);
const [historyPage, setHistoryPage] = useState(1);
const [historyTotal, setHistoryTotal] = useState(0);
const [myBets, setMyBets] = useState([]); // 本局下注记录
const [showMyBets, setShowMyBets] = useState(false); // 本局下注记录折叠
const [showBetHistory, setShowBetHistory] = useState(false); // 投注记录弹窗
const [betHistoryData, setBetHistoryData] = useState(null); // 投注记录数据
const [betHistoryDate, setBetHistoryDate] = useState(new Date().toISOString().split('T')[0]); // 查询日期
const [betHistoryLoading, setBetHistoryLoading] = useState(false);
const [announcement, setAnnouncement] = useState({ text: '预估倍率仅供参考,封盘后以最终结算倍率为准', titleColor: '#ffd700', contentColor: '#888' }); // 滚动公告
const [showQuickSelect, setShowQuickSelect] = useState(false); // 快捷选号面板
const [activeFilters, setActiveFilters] = useState({ head: null, tail: null, size: null, parity: null }); // 快捷选号激活的过滤器
const [showGameIntro, setShowGameIntro] = useState(false); // 玩法介绍弹窗
// 包装setCountdown,同时更新ref
const updateCountdown = (val) => {
countdownRef.current = val;
setCountdown(val);
};
// 包装setGameStatus,同时更新ref
const updateGameStatus = (status) => {
gameStatusRef.current = status;
setGameStatus(status);
};
// 加载大厅数据
const loadLobby = async () => {
try {
const res = await fetch('/api/grid49/lobby', { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } });
const json = await res.json();
if (json.code === 200) {
setLobbyData(json.data);
// 设置公告
if (json.data.announcement) {
setAnnouncement(json.data.announcement);
}
// 设置上一期开奖结果(从历史记录第一条获取)
if (json.data.history && json.data.history.length > 0) {
const last = json.data.history[0];
if (last.drawNumber > 0) {
setLastResult({
roundNo: last.roundNo,
drawNumber: last.drawNumber,
actualMultiplier: last.actualMultiplier,
totalBet: last.totalBet,
winnerCount: last.winnerCount || 0
});
}
}
if (json.data.currentRound) {
// 有新局:更新所有状态
currentRoundIdRef.current = json.data.currentRound.id;
setTotalBet(json.data.currentRound.totalBet || 0);
updateGameStatus(json.data.currentRound.status || 'betting');
const endTime = new Date(json.data.currentRound.endTime);
const remaining = Math.max(0, Math.floor((endTime - Date.now()) / 1000));
updateCountdown(remaining);
// 提取期号
const rn = json.data.currentRound.roundNo || '';
setRoundNo(rn);
} else if (gameStatusRef.current === 'betting') {
// 无新局但当前是投注状态:说明已封盘,切换到封盘状态
updateGameStatus('sealed');
updateCountdown(0);
}
}
} catch(e) { console.error('Grid49 loadLobby error:', e); }
};
loadLobbyRef.current = loadLobby; // 始终保持ref指向最新函数
const loadHistory = async (page) => {
try {
const res = await fetch('/api/grid49/history?page=' + (page||1) + '&limit=20', { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } });
const json = await res.json();
if (json.code === 200) { setHistoryData(json.data.history || []); setHistoryTotal(json.data.total || 0); setHistoryPage(page || 1); }
} catch(e) { console.error('loadHistory error:', e); }
};
// 获取赔率
const loadOdds = async () => {
if (!currentRoundIdRef.current || gameStatusRef.current !== 'betting') return;
try {
const res = await fetch(`/api/grid49/odds?roundId=${currentRoundIdRef.current}`, {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
});
const json = await res.json();
if (json.code === 200) {
setOdds(json.data.odds || {});
setTotalBet(json.data.totalBet || 0);
setNetPool(json.data.netPool || 0);
}
} catch(e) {}
};
// 加载本局下注记录
const loadMyBets = async () => {
if (!currentRoundIdRef.current) return;
try {
const res = await fetch(`/api/grid49/my-bets/${currentRoundIdRef.current}`, {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
});
const json = await res.json();
if (json.code === 200) setMyBets(json.data || []);
} catch(e) {}
};
// 加载投注历史(按日期)
const loadBetHistory = async (date) => {
const d = date || betHistoryDate;
setBetHistoryLoading(true);
try {
const res = await fetch(`/api/grid49/bet-history?date=${d}`, {
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }
});
const json = await res.json();
if (json.code === 200) setBetHistoryData(json.data);
else setBetHistoryData(null);
} catch(e) { setBetHistoryData(null); }
finally { setBetHistoryLoading(false); }
};
// 启动开奖动画(号码快速滚动10秒)
const startDrawAnimation = () => {
updateGameStatus('drawing');
setDrawCountdown(10);
let ticks = 0;
const maxTicks = 100; // 10秒,每100ms一次
drawAnimRef.current = setInterval(() => {
setDrawAnimNum(Math.floor(Math.random() * 49) + 1);
ticks++;
// 每秒更新倒计时
if (ticks % 10 === 0) {
setDrawCountdown(prev => Math.max(0, prev - 1));
}
if (ticks >= maxTicks) {
clearInterval(drawAnimRef.current);
setDrawAnimNum(null);
// 动画结束,显示赔付中
updateGameStatus('settling');
}
}, 100);
};
useEffect(() => {
loadLobby();
loadOdds();
loadMyBets();
oddsTimerRef.current = setInterval(loadOdds, 2000);
// 倒计时
countdownTimerRef.current = setInterval(() => {
updateCountdown(prev => {
if (prev <= 1) {
// 倒计时到0,主动切换到封盘状态
if (gameStatusRef.current === 'betting') {
updateGameStatus('sealed');
}
return 0;
}
return prev - 1;
});
}, 1000);
// 轮询兜底:用ref调用最新loadLobby,避免闭包问题(始终轮询以获取最新公告等数据)
pollTimerRef.current = setInterval(() => {
if (loadLobbyRef.current) loadLobbyRef.current();
}, 5000);
return () => {
clearInterval(oddsTimerRef.current);
clearInterval(countdownTimerRef.current);
clearInterval(pollTimerRef.current);
if (drawAnimRef.current) clearInterval(drawAnimRef.current);
if (prepareTimerRef.current) clearTimeout(prepareTimerRef.current);
};
}, []);
// WebSocket监听
useEffect(() => {
// 确保 socket 存在
const ensureSocket = () => {
if (typeof io === 'undefined') { setTimeout(ensureSocket, 300); return; }
if (!window._socket || !window._socket.connected) {
const sock = io({ transports: ['websocket', 'polling'] });
sock.on('connect', () => {
sock.emit('login', { userId: user?.id, nickname: user?.nickname, avatar: user?.avatar });
});
window._socket = sock;
setupHandlers(sock);
} else {
setupHandlers(window._socket);
}
};
const setupHandlers = (socket) => {
const onSealed = (data) => {
updateGameStatus('sealed');
updateCountdown(0);
if (data.roundNo) {
const rn = data.roundNo;
setRoundNo(rn);
}
};
const onDrawing = (data) => {
startDrawAnimation();
};
const onResult = (data) => {
if (drawAnimRef.current) clearInterval(drawAnimRef.current);
setDrawAnimNum(null);
updateGameStatus('settling');
setLastResult(data);
if (data.roundNo) {
const rn = data.roundNo;
setRoundNo(rn);
}
setTimeout(() => { if (loadLobbyRef.current) loadLobbyRef.current(); }, 500);
};
const onNewRound = (data) => {
currentRoundIdRef.current = data.roundId;
updateGameStatus('betting');
setSelectedNumbers([]);
setMyBets([]);
setBetAmount('');
setOdds({});
setTotalBet(0);
const rn = data.roundNo || '';
setRoundNo(rn);
const endTime = new Date(data.endTime);
const remaining = Math.max(0, Math.floor((endTime - Date.now()) / 1000));
updateCountdown(remaining);
loadOdds();
loadLobby();
loadMyBets();
};
socket.on('grid49_round_sealed', onSealed);
socket.on('grid49_drawing', onDrawing);
socket.on('grid49_round_result', onResult);
socket.on('grid49_new_round', onNewRound);
socket.on('grid49_round_void', (data) => {
updateGameStatus('settling');
setLastResult({
roundNo: data.roundNo || roundNo,
drawNumber: 0,
actualMultiplier: 0,
totalBet: 0,
winnerCount: 0,
isVoid: true
});
if (data.roundNo) {
const rn = data.roundNo;
setRoundNo(rn);
}
});
return () => {
socket.off('grid49_round_sealed', onSealed);
socket.off('grid49_drawing', onDrawing);
socket.off('grid49_round_result', onResult);
socket.off('grid49_new_round', onNewRound);
socket.off('grid49_round_void');
};
};
ensureSocket();
}, []);
// 倍率颜色
const getOddsColor = (m) => {
if (!m || m === 0) return '#888';
if (m <= 10) return '#00e676';
if (m <= 100) return '#ffeb3b';
if (m <= 1000) return '#ff9800';
return '#ff1744';
};
// 格式化倍率
const formatOdds = (m) => {
if (!m || m === 0) return '--';
if (m >= 10000) return (m / 10000).toFixed(1) + '万';
if (m >= 1000) return m.toFixed(0);
if (m >= 100) return m.toFixed(1);
if (m >= 10) return m.toFixed(1);
return m.toFixed(2);
};
// 下注(支持多选)
const handleBet = async () => {
if (selectedNumbers.length === 0) { showToast('请先选择号码'); return; }
const amt = parseInt(betAmount);
if (!amt || amt <= 0) { showToast('请输入投注金额'); return; }
if (gameStatus !== 'betting') { showToast('当前不在投注期'); return; }
setBetLoading(true);
betTokenRef.current++;
const token = 'bet_' + Date.now() + '_' + betTokenRef.current;
try {
const res = await fetch('/api/grid49/place-bet', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token'),
'X-Bet-Token': token
},
body: JSON.stringify({
roundId: currentRoundIdRef.current,
numbers: selectedNumbers,
amount: amt,
betToken: token
})
});
const json = await res.json();
if (json.code === 200) {
const numsStr = selectedNumbers.map(n => String(n).padStart(2, '0')).join(',');
showToast(`投注成功!${numsStr} 每注${amt}筹码`);
setBetAmount('');
setSelectedNumbers([]);
setShowQuickSelect(false);
setActiveFilters({ head: null, tail: null, size: null, parity: null });
// 更新赔率
if (json.data.newOdds) setOdds(json.data.newOdds);
if (json.data.totalBet) setTotalBet(json.data.totalBet);
// 更新净奖池
const feeRate = 0.08;
setNetPool(Math.floor((json.data.totalBet || 0) * (1 - feeRate)));
// 更新用户筹码
if (onUpdateUser) {
const userRes = await fetch('/api/user/info', { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } });
const userJson = await userRes.json();
if (userJson.code === 200) onUpdateUser(userJson.data);
}
// 刷新下注记录
loadMyBets();
} else {
showToast(json.message || '下注失败');
}
} catch(e) {
showToast('网络错误');
} finally {
setBetLoading(false);
}
};
// 快捷金额
const quickAmounts = [10, 50, 100, 500, 1000, 2000];
return (
<div style={{ minHeight: '100vh', background: 'linear-gradient(180deg, #0a1628 0%, #1a2a4a 50%, #0a1628 100%)', color: '#fff', paddingBottom: '120px' }}>
{/* 滚动公告 */}
<div style={{ overflow: 'hidden', background: 'rgba(0,0,0,0.2)', padding: '8px 12px' }}>
<div style={{ display: 'inline-block', whiteSpace: 'nowrap', animation: 'marqueeScroll 20s linear infinite' }}>
<span style={{ color: announcement.titleColor, fontWeight: 'bold', marginRight: '8px' }}>📢 公告:</span>
<span style={{ color: announcement.contentColor, fontSize: '12px' }}>{announcement.text}</span>
</div>
</div>
{/* 状态窗口:上期结果 + 倒计时/开奖动画 */}
<div style={{ margin: '8px', padding: '12px', background: 'linear-gradient(135deg, rgba(255,215,0,0.12), rgba(255,165,0,0.06))', borderRadius: '12px', border: '2px solid rgba(255,215,0,0.4)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{/* 左侧:上期结果 */}
<div style={{ flex: 1, textAlign: 'center' }}>
{lastResult ? (
<div>
<div style={{ fontSize: '11px', color: '#ffd700', marginBottom: '4px' }}>第{lastResult.roundNo || roundNo}期开奖结果</div>
<div style={{ fontSize: '32px', fontWeight: 'bold', color: '#fff', textShadow: '0 0 10px rgba(255,215,0,0.6)' }}>{String(lastResult.drawNumber).padStart(2, '0')}</div>
<div style={{ fontSize: '11px', color: '#00e676', marginTop: '2px' }}>{lastResult.actualMultiplier}倍 · {lastResult.winnerCount || 0}人中奖</div>
</div>
) : (
<div>
<div style={{ fontSize: '11px', color: '#888' }}>暂无开奖记录</div>
</div>
)}
</div>
{/* 分隔线 */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', margin: '0 12px' }}>
<div style={{ width: '1px', height: '50px', background: 'rgba(255,255,255,0.15)' }}></div>
<div style={{ fontSize: '10px', color: '#ffd700', marginTop: '4px', whiteSpace: 'nowrap' }}>💰 {netPool.toLocaleString()}</div>
</div>
{/* 右侧:倒计时 / 准备开奖 / 开奖动画 */}
<div style={{ flex: 1, textAlign: 'center' }}>
{gameStatus === 'drawing' ? (
<div>
<div style={{ fontSize: '11px', color: '#ffd700', marginBottom: '4px' }}>开奖中...</div>
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#fff', textShadow: '0 0 20px rgba(255,215,0,0.8)', animation: 'pulse 0.3s ease-in-out infinite' }}>
{drawAnimNum ? String(drawAnimNum).padStart(2, '0') : '--'}
</div>
</div>
) : gameStatus === 'sealed' ? (
<div>
<div style={{ fontSize: '11px', color: '#ffa500', marginBottom: '4px' }}>准备开奖</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: '6px', marginTop: '8px' }}>
{[0, 1, 2].map(i => (
<div key={i} style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#ffa500', animation: `bounce 1.4s ease-in-out ${i * 0.16}s infinite both` }}></div>
))}
</div>
</div>
) : (
<div>
<div style={{ fontSize: '11px', color: '#aaa', marginBottom: '4px' }}>第{roundNo}期倒计时</div>
<div style={{ fontSize: '28px', fontWeight: 'bold', color: '#ff1744', fontVariantNumeric: 'tabular-nums' }}>
{(() => {
const h = Math.floor(countdown / 3600);
const m = Math.floor((countdown % 3600) / 60);
const s = countdown % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
})()}
</div>
<div style={{ fontSize: '11px', color: gameStatus === 'betting' ? '#00e676' : gameStatus === 'sealed' ? '#ff9800' : '#888', marginTop: '4px' }}>
{gameStatus === 'betting' ? '投注中' : gameStatus === 'sealed' ? '封盘中停止下注' : '结算中'}
</div>
</div>
)}
</div>
</div>
</div>
{/* 7x7宫格 */}
<div style={{ padding: '8px', display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '4px' }}>
{Array.from({ length: 49 }, (_, i) => i + 1).sort((a, b) => (odds[b] || 0) - (odds[a] || 0)).map(num => {
const numOdds = odds[num] || 0;
const isSelected = selectedNumbers.includes(num);
return (
<div
key={num}
onClick={() => {
if (gameStatus !== 'betting') return;
setSelectedNumbers(prev =>
prev.includes(num) ? prev.filter(n => n !== num) : [...prev, num]
);
}}
style={{
background: isSelected ? 'rgba(0,230,255,0.2)' : 'rgba(255,255,255,0.05)',
border: isSelected ? '2px solid #00e6ff' : '1px solid rgba(255,255,255,0.15)',
borderRadius: '8px',
padding: '6px 2px',
textAlign: 'center',
cursor: gameStatus === 'betting' ? 'pointer' : 'default',
boxShadow: isSelected ? '0 0 10px rgba(0,230,255,0.5)' : 'none',
transition: 'all 0.2s'
}}
>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: '#fff' }}>{String(num).padStart(2, '0')}</div>
<div style={{ fontSize: '10px', color: getOddsColor(numOdds), fontWeight: numOdds > 1000 ? 'bold' : 'normal' }}>
{formatOdds(numOdds)}{numOdds > 0 ? '倍' : ''}
</div>
</div>
);
})}
</div>
{/* 功能按钮行 */}
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', padding: '8px' }}>
<button onClick={() => setShowGameIntro(true)} style={{ padding: '6px 16px', borderRadius: '8px', border: '1px solid rgba(0,230,118,0.4)', background: 'rgba(0,230,118,0.12)', color: '#00e676', fontSize: '13px', fontWeight: 'bold', cursor: 'pointer' }}>📖 玩法介绍</button>
<button onClick={() => { setShowBetHistory(true); loadBetHistory(); }} style={{ padding: '6px 16px', borderRadius: '8px', border: '1px solid rgba(0,230,255,0.4)', background: 'rgba(0,230,255,0.12)', color: '#00e6ff', fontSize: '13px', fontWeight: 'bold', cursor: 'pointer' }}>📋 投注记录</button>
<button onClick={() => { setShowHistory(true); loadHistory(1); }} style={{ padding: '6px 16px', borderRadius: '8px', border: '1px solid rgba(255,215,0,0.4)', background: 'rgba(255,215,0,0.12)', color: '#ffd700', fontSize: '13px', fontWeight: 'bold', cursor: 'pointer' }}>🏆 开奖记录</button>
</div>
{/* 投注面板 */}
<div style={{ margin: '8px', padding: '12px', background: 'rgba(255,255,255,0.08)', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ fontSize: '14px', color: '#aaa', marginBottom: '8px' }}>
已选号码:{selectedNumbers.length > 0 ? (
<span style={{ color: '#00e6ff', fontWeight: 'bold', fontSize: '16px' }}>
{selectedNumbers.sort((a,b)=>a-b).map(n => String(n).padStart(2,'0')).join('、')}
</span>
) : <span style={{ color: '#666' }}>--</span>}
{selectedNumbers.length > 0 && betAmount && (
<span style={{ color: '#888', fontSize: '12px', marginLeft: '8px' }}>共{selectedNumbers.length}注,合计{selectedNumbers.length * parseInt(betAmount)}筹码</span>
)}
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="number"
value={betAmount}
onChange={(e) => setBetAmount(e.target.value)}
placeholder="输入金额"
disabled={gameStatus !== 'betting'}
style={{
flex: 1, padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(0,0,0,0.3)', color: '#fff', fontSize: '16px', textAlign: 'center',
outline: 'none'
}}
/>
<button
onClick={handleBet}
disabled={gameStatus !== 'betting' || betLoading}
style={{
padding: '10px 20px', borderRadius: '8px', border: 'none',
background: gameStatus === 'betting' ? 'linear-gradient(135deg, #0066ff, #0099ff)' : '#555',
color: '#fff', fontSize: '14px', fontWeight: 'bold', cursor: gameStatus === 'betting' ? 'pointer' : 'not-allowed',
whiteSpace: 'nowrap'
}}
>
{betLoading ? '...' : '确认投注'}
</button>
</div>
{/* 快捷金额 */}
<div style={{ display: 'flex', gap: '6px', marginTop: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
{quickAmounts.map(amt => (
<button
key={amt}
onClick={() => setBetAmount(String(amt))}
disabled={gameStatus !== 'betting'}
style={{
padding: '6px 12px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.1)', color: '#fff', fontSize: '12px',
cursor: gameStatus === 'betting' ? 'pointer' : 'not-allowed'
}}
>
{amt}
</button>
))}
</div>
{/* 快捷选号 + 账户余额 */}
<div style={{ display: 'flex', gap: '6px', marginTop: '8px', alignItems: 'center' }}>
{/* 账户余额 */}
<div style={{ fontSize: '12px', color: '#ffd700', fontWeight: 'bold' }}>
💰 {(user?.chips || 0).toLocaleString()}
</div>
<button
onClick={() => setShowQuickSelect(!showQuickSelect)}
style={{
padding: '6px 12px', borderRadius: '6px', border: '1px solid rgba(0,230,255,0.4)',
background: showQuickSelect ? 'rgba(0,230,255,0.2)' : 'rgba(0,230,255,0.1)',
color: '#00e6ff', fontSize: '12px', fontWeight: 'bold', cursor: 'pointer',
marginLeft: 'auto'
}}
>
{showQuickSelect ? '▲ 收起选号' : '▼ 快捷选号'}
</button>
</div>
{/* 快捷选号面板 */}
{showQuickSelect && (() => {
// 根据 activeFilters 计算交集号码
const allNums = Array.from({length: 49}, (_, i) => i + 1);
// computeFiltered 接收 filters 参数,避免闭包读到旧状态
const computeFiltered = (filters) => {
let result = [...allNums];
if (filters.head !== null) result = result.filter(n => Math.floor(n / 10) === filters.head);
if (filters.tail !== null) result = result.filter(n => n % 10 === filters.tail);
if (filters.size !== null) result = result.filter(n => filters.size === 'big' ? n >= 25 : n <= 24);
if (filters.parity !== null) result = result.filter(n => filters.parity === 'odd' ? n % 2 === 1 : n % 2 === 0);
return result;
};
const filteredNums = computeFiltered(activeFilters);
const hasAnyFilter = activeFilters.head !== null || activeFilters.tail !== null || activeFilters.size !== null || activeFilters.parity !== null;
const btnBase = { padding: '4px 10px', borderRadius: '4px', fontSize: '11px', cursor: 'pointer', border: '1px solid' };
const activeStyle = (color) => ({ ...btnBase, background: color, color: '#fff', fontWeight: 'bold', borderColor: color });
const inactiveStyle = (color) => ({ ...btnBase, background: color + '22', color: color, borderColor: color + '66' });
return (
<div style={{ marginTop: '12px', padding: '12px', background: 'rgba(0,0,0,0.2)', borderRadius: '8px' }}>
{/* 头 */}
<div style={{ marginBottom: '8px' }}>
<div style={{ fontSize: '11px', color: '#888', marginBottom: '4px' }}>头</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{['0头', '1头', '2头', '3头', '4头'].map(label => {
const headNum = parseInt(label);
const isActive = activeFilters.head === headNum;
return (
<button key={label} onClick={() => {
const newHead = activeFilters.head === headNum ? null : headNum;
const newFilters = { ...activeFilters, head: newHead };
setActiveFilters(newFilters);
setSelectedNumbers(computeFiltered(newFilters));
}} style={isActive ? activeStyle('#ffd700') : inactiveStyle('#ffd700')}>
{label}
</button>
);
})}
</div>
</div>
{/* 尾 */}
<div style={{ marginBottom: '8px' }}>
<div style={{ fontSize: '11px', color: '#888', marginBottom: '4px' }}>尾</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{Array.from({length: 10}, (_, i) => i).map(tail => {
const isActive = activeFilters.tail === tail;
return (
<button key={tail} onClick={() => {
const newTail = activeFilters.tail === tail ? null : tail;
const newFilters = { ...activeFilters, tail: newTail };
setActiveFilters(newFilters);
setSelectedNumbers(computeFiltered(newFilters));
}} style={isActive ? activeStyle('#00e676') : inactiveStyle('#00e676')}>
{tail}尾
</button>
);
})}
</div>
</div>
{/* 大小单双 */}
<div>
<div style={{ fontSize: '11px', color: '#888', marginBottom: '4px' }}>大小单双</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{[
{ label: '大', key: 'size', val: 'big', color: '#ff5252' },
{ label: '小', key: 'size', val: 'small', color: '#448aff' },
{ label: '单', key: 'parity', val: 'odd', color: '#ff9800' },
{ label: '双', key: 'parity', val: 'even', color: '#ab47bc' }
].map(item => {
const isActive = activeFilters[item.key] === item.val;
return (
<button key={item.label} onClick={() => {
const newVal = activeFilters[item.key] === item.val ? null : item.val;
const newFilters = { ...activeFilters, [item.key]: newVal };
setActiveFilters(newFilters);
setSelectedNumbers(computeFiltered(newFilters));
}} style={isActive ? activeStyle(item.color) : inactiveStyle(item.color)}>
{item.label}
</button>
);
})}
</div>
</div>
{/* 清除筛选 */}
{hasAnyFilter && (
<div style={{ marginTop: '8px', textAlign: 'right' }}>
<button onClick={() => { const empty = { head: null, tail: null, size: null, parity: null }; setActiveFilters(empty); setSelectedNumbers([]); }} style={{ padding: '4px 12px', borderRadius: '4px', border: '1px solid rgba(255,255,255,0.3)', background: 'rgba(255,255,255,0.1)', color: '#aaa', fontSize: '11px', cursor: 'pointer' }}>
清除筛选
</button>
<span style={{ fontSize: '11px', color: '#00e6ff', marginLeft: '8px' }}>已选 {filteredNums.length} 个号码</span>
</div>
)}
</div>
);
})()}
</div>
{/* 本局下注记录 */}
{myBets.length > 0 && (
<div style={{ margin: '8px', padding: '12px', background: 'rgba(255,255,255,0.05)', borderRadius: '12px', border: '1px solid rgba(255,255,255,0.08)' }}>
<div onClick={() => setShowMyBets(!showMyBets)} style={{ fontSize: '14px', color: '#ffd700', marginBottom: showMyBets ? '8px' : 0, fontWeight: 'bold', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span> 本局下注记录</span>
<span style={{ fontSize: '12px', color: '#888' }}>{showMyBets ? '▲ 收起' : '▼ 展开'}</span>
</div>
{showMyBets && (
<>
{myBets.map((b, i) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 8px', marginBottom: '4px', background: 'rgba(0,0,0,0.2)', borderRadius: '6px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#00e6ff' }}>{String(b.number).padStart(2,'0')}</span>
<span style={{ fontSize: '12px', color: '#aaa' }}>{b.amount}筹码</span>
</div>
<div style={{ fontSize: '12px', color: b.isWinner ? '#00e676' : '#888' }}>
{b.status === 'settled' ? (b.isWinner ? `✅ 中奖 +${b.winAmount}` : '❌ 未中') : ' 待开奖'}
</div>
</div>
))}
<div style={{ fontSize: '12px', color: '#888', marginTop: '8px', textAlign: 'right' }}>
合计投注 {myBets.reduce((s, b) => s + b.amount, 0)}筹码
</div>
</>
)}
</div>
)}
{/* 开奖记录弹窗 */}
{showHistory && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: '#fff', zIndex: 9999, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 16px', background: '#f5f5f5', borderBottom: '1px solid #e0e0e0' }}>
<span style={{ fontSize: '16px', fontWeight: 'bold', color: '#333' }}> 开奖记录</span>
<button onClick={() => setShowHistory(false)} style={{ background: 'none', border: 'none', color: '#666', fontSize: '20px', cursor: 'pointer', padding: '4px 8px' }}>✕</button>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '8px' }}>
{historyData.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>暂无记录</div>
) : (
historyData.map((h, i) => {
const isVoid = h.drawNumber === 0;
const rn = h.roundNo || '';
return (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 12px', marginBottom: '6px', background: isVoid ? '#f5f5f5' : '#fff', borderRadius: '8px', border: '1px solid #e0e0e0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ color: '#666', fontSize: '12px', minWidth: '50px' }}>{rn}期</span>
{isVoid ? (
<span style={{ color: '#888', fontWeight: 'bold' }}>轮空</span>
) : (
<>
<span style={{ fontSize: '20px', fontWeight: 'bold', color: '#007bff' }}>{String(h.drawNumber).padStart(2, '0')}</span>
<span style={{ color: '#ff9800', fontSize: '13px' }}>{h.actualMultiplier}倍</span>
</>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '11px', color: '#666' }}>总投注 {(h.totalBet||0).toLocaleString()}</div>
<div style={{ fontSize: '11px', color: '#666' }}>中奖 {h.winnerCount||0}人</div>
</div>
</div>
);
})
)}
</div>
{historyTotal > 20 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', padding: '10px', background: '#f5f5f5', borderTop: '1px solid #e0e0e0' }}>
<button onClick={() => loadHistory(historyPage - 1)} disabled={historyPage <= 1} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #ddd', background: historyPage <= 1 ? '#eee' : '#007bff', color: historyPage <= 1 ? '#999' : '#fff', cursor: historyPage <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
<span style={{ color: '#666', fontSize: '13px', lineHeight: '28px' }}>{historyPage} / {Math.ceil(historyTotal / 20)}</span>
<button onClick={() => loadHistory(historyPage + 1)} disabled={historyPage >= Math.ceil(historyTotal / 20)} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #ddd', background: historyPage >= Math.ceil(historyTotal / 20) ? '#eee' : '#007bff', color: historyPage >= Math.ceil(historyTotal / 20) ? '#999' : '#fff', cursor: historyPage >= Math.ceil(historyTotal / 20) ? 'not-allowed' : 'pointer' }}>下一页</button>
</div>
)}
</div>
)}
{/* 投注记录弹窗 */}
{showBetHistory && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: '#fff', zIndex: 9999, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 16px', background: '#f5f5f5', borderBottom: '1px solid #e0e0e0' }}>
<span style={{ fontSize: '16px', fontWeight: 'bold', color: '#333' }}> 投注记录</span>
<button onClick={() => setShowBetHistory(false)} style={{ background: 'none', border: 'none', color: '#666', fontSize: '20px', cursor: 'pointer', padding: '4px 8px' }}>✕</button>
</div>
{/* 日期选择 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '10px 16px', background: '#fafafa', borderBottom: '1px solid #e0e0e0' }}>
<span style={{ fontSize: '13px', color: '#666' }}>日期:</span>
<input type="date" value={betHistoryDate} onChange={(e) => setBetHistoryDate(e.target.value)} style={{ padding: '6px 10px', borderRadius: '6px', border: '1px solid #ddd', background: '#fff', color: '#333', fontSize: '13px', outline: 'none' }} />
<button onClick={() => loadBetHistory()} disabled={betHistoryLoading} style={{ padding: '6px 14px', borderRadius: '6px', border: 'none', background: betHistoryLoading ? '#ccc' : '#007bff', color: '#fff', fontSize: '13px', fontWeight: 'bold', cursor: betHistoryLoading ? 'not-allowed' : 'pointer' }}>{betHistoryLoading ? '加载中...' : '查询'}</button>
</div>
{/* 汇总 */}
{betHistoryData && betHistoryData.summary && (
<div style={{ display: 'flex', justifyContent: 'space-around', padding: '10px 16px', background: '#f8f9fa', borderBottom: '1px solid #e0e0e0' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '11px', color: '#888' }}>投注期数</div>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: '#333' }}>{betHistoryData.summary.totalRounds}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '11px', color: '#888' }}>总投注</div>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: '#ff9800' }}>{betHistoryData.summary.totalBet.toLocaleString()}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '11px', color: '#888' }}>总中奖</div>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: '#00e676' }}>{betHistoryData.summary.totalWin.toLocaleString()}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '11px', color: '#888' }}>盈亏</div>
<div style={{ fontSize: '16px', fontWeight: 'bold', color: betHistoryData.summary.profit >= 0 ? '#00e676' : '#ff1744' }}>{betHistoryData.summary.profit >= 0 ? '+' : ''}{betHistoryData.summary.profit.toLocaleString()}</div>
</div>
</div>
)}
{/* 每期记录 */}
<div style={{ flex: 1, overflow: 'auto', padding: '8px' }}>
{betHistoryLoading ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>加载中...</div>
) : !betHistoryData || !betHistoryData.rounds || betHistoryData.rounds.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>该日期暂无投注记录</div>
) : (
betHistoryData.rounds.map((round, ri) => {
// 按号码分组合计
const betMap = {};
round.bets.forEach(b => {
if (!betMap[b.number]) betMap[b.number] = { number: b.number, amount: 0, isWinner: false, winAmount: 0, status: b.status };
betMap[b.number].amount += b.amount;
if (b.isWinner) {
betMap[b.number].isWinner = true;
betMap[b.number].winAmount += b.winAmount;
}
});
const groupedBets = Object.values(betMap).sort((a, b) => a.number - b.number);
return (
<div key={ri} style={{ marginBottom: '12px', background: '#fff', borderRadius: '8px', border: '1px solid #e0e0e0', overflow: 'hidden' }}>
{/* 期号头部 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 12px', background: '#f5f5f5', borderBottom: '1px solid #e0e0e0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ color: '#333', fontSize: '14px', fontWeight: 'bold' }}>第{round.roundNo}期</span>
{round.status === 'void' ? (
<span style={{ fontSize: '11px', color: '#888', background: '#e0e0e0', padding: '2px 8px', borderRadius: '4px' }}>轮空</span>
) : (
<>
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#007bff' }}>{String(round.drawNumber).padStart(2, '0')}</span>
<span style={{ fontSize: '12px', color: '#ff9800' }}>{round.actualMultiplier}倍</span>
</>
)}
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '12px', color: round.profit >= 0 ? '#00e676' : '#ff1744', fontWeight: 'bold' }}>{round.profit >= 0 ? '+' : ''}{round.profit.toLocaleString()}</div>
<div style={{ fontSize: '10px', color: '#888' }}>投注{round.betTotal} 中奖{round.winTotal}</div>
</div>
</div>
{/* 投注明细 - 每号码一行 */}
<div style={{ padding: '4px 0' }}>
{groupedBets.map((bet, bi) => (
<div key={bi} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderBottom: bi < groupedBets.length - 1 ? '1px solid #f0f0f0' : 'none', background: bet.isWinner ? '#f0fff0' : '#fff' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '14px', fontWeight: 'bold', color: bet.isWinner ? '#007bff' : '#333', minWidth: '28px' }}>{String(bet.number).padStart(2, '0')}</span>
<span style={{ fontSize: '13px', color: '#666' }}>{bet.amount}筹码</span>
</div>
<div style={{ fontSize: '13px', fontWeight: 'bold', color: bet.isWinner ? '#00e676' : '#999' }}>
{bet.isWinner ? `+${bet.winAmount}` : bet.status === 'settled' ? '未中' : '待开奖'}
</div>
</div>
))}
</div>
</div>
);
})
)}
</div>
</div>
)}
{/* 玩法介绍弹窗 */}
{showGameIntro && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '16px' }} onClick={() => setShowGameIntro(false)}>
<div style={{ background: 'linear-gradient(135deg, #1a2a4a, #0a1628)', borderRadius: '16px', border: '2px solid rgba(255,215,0,0.4)', maxWidth: '500px', width: '100%', maxHeight: '85vh', display: 'flex', flexDirection: 'column' }} onClick={e => e.stopPropagation()}>
{/* 固定标题栏 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '20px', borderBottom: '1px solid rgba(255,215,0,0.2)', flexShrink: 0 }}>
<h2 style={{ fontSize: '20px', color: '#ffd700', fontWeight: 'bold', margin: 0 }}>📖 玩法介绍</h2>
<button onClick={() => setShowGameIntro(false)} style={{ background: 'none', border: 'none', color: '#fff', fontSize: '24px', cursor: 'pointer', padding: '0 8px' }}>✕</button>
</div>
{/* 可滚动内容区 */}
<div style={{ overflowY: 'auto', padding: '20px', fontSize: '14px', lineHeight: '1.6', color: '#ddd' }}>
{/* 游戏简介 */}
<div style={{ marginBottom: '16px' }}>
<h3 style={{ fontSize: '16px', color: '#00e6ff', marginBottom: '8px' }}>🎯 游戏简介</h3>
<p style={{ margin: '4px 0', color: '#ccc' }}>49号码宫格是一款数字竞猜游戏,从1-49个号码中选择心仪号码投注,系统每期随机开出1个中奖号码,根据投注情况动态计算赔率进行赔付。</p>
</div>
{/* 游戏流程 */}
<div style={{ marginBottom: '16px' }}>
<h3 style={{ fontSize: '16px', color: '#00e6ff', marginBottom: '8px' }}>️ 游戏流程</h3>
<div style={{ background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '6px' }}>
<span style={{ color: '#00e676' }}>投注期</span>
<span style={{ color: '#fff' }}>60秒(可下注)</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '6px' }}>
<span style={{ color: '#ff9800' }}>封盘期</span>
<span style={{ color: '#fff' }}>40秒(停止下注)</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '6px' }}>
<span style={{ color: '#ffd700' }}>开奖动画</span>
<span style={{ color: '#fff' }}>10秒</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#ff5252' }}>赔付展示</span>
<span style={{ color: '#fff' }}>10秒</span>
</div>
</div>
<p style={{ margin: '8px 0 0 0', fontSize: '12px', color: '#888' }}>每期约2分钟,自动流转进入下一期</p>
</div>
{/* 投注方式 */}
<div style={{ marginBottom: '16px' }}>
<h3 style={{ fontSize: '16px', color: '#00e6ff', marginBottom: '8px' }}> 投注方式</h3>
<ul style={{ margin: '4px 0', paddingLeft: '20px', color: '#ccc' }}>
<li style={{ marginBottom: '4px' }}>点击号码直接选中,支持多选</li>
<li style={{ marginBottom: '4px' }}>输入投注金额,确认投注</li>
<li style={{ marginBottom: '4px' }}>单局投注上限:2000筹码</li>
</ul>
</div>
{/* 快捷选号 */}
<div style={{ marginBottom: '16px' }}>
<h3 style={{ fontSize: '16px', color: '#00e6ff', marginBottom: '8px' }}> 快捷选号</h3>
<p style={{ margin: '4px 0', color: '#ccc' }}>支持多维度筛选,快速选中符合条件的号码:</p>
<div style={{ background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '10px', marginTop: '8px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px', fontSize: '12px' }}>
<div><span style={{ color: '#ffd700' }}>头数:</span>0-4头</div>
<div><span style={{ color: '#ffd700' }}>尾数:</span>0-9尾</div>
<div><span style={{ color: '#ffd700' }}>大小:</span>大(25-49) 小(1-24)</div>
<div><span style={{ color: '#ffd700' }}>单双:</span>单(奇) 双(偶)</div>
</div>
</div>
</div>
{/* 赔率说明 */}
<div style={{ marginBottom: '16px' }}>
<h3 style={{ fontSize: '16px', color: '#00e6ff', marginBottom: '8px' }}> 赔率说明</h3>
<div style={{ background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '12px' }}>
<p style={{ margin: '4px 0', color: '#ccc', fontSize: '13px' }}>• 赔率根据实时投注情况动态计算</p>
<p style={{ margin: '4px 0', color: '#ccc', fontSize: '13px' }}>• 投注某号码的人越多,该号码赔率越低</p>
<p style={{ margin: '4px 0', color: '#ccc', fontSize: '13px' }}>• 无人投注的号码赔率最高</p>
<p style={{ margin: '4px 0', color: '#ccc', fontSize: '13px' }}>• 赔率每1-2秒刷新一次</p>
<p style={{ margin: '4px 0', color: '#ccc', fontSize: '13px' }}>• 单号最高赔率:12000倍</p>
</div>
</div>
{/* 中奖规则 */}
<div style={{ marginBottom: '16px' }}>
<h3 style={{ fontSize: '16px', color: '#00e6ff', marginBottom: '8px' }}>🏆 中奖规则</h3>
<div style={{ background: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '12px' }}>
<p style={{ margin: '4px 0', color: '#ccc', fontSize: '13px' }}>• 系统随机开出1个中奖号码(1-49)</p>
<p style={{ margin: '4px 0', color: '#ccc', fontSize: '13px' }}>• 投注该号码的玩家中奖</p>
<p style={{ margin: '4px 0', color: '#ccc', fontSize: '13px' }}>• 赔付金额 = 投注额 × 实际倍率</p>
<p style={{ margin: '4px 0', color: '#ccc', fontSize: '13px' }}>• 无人中奖时,奖池全额滚入下期</p>
</div>
</div>
{/* 赔率颜色 */}
<div style={{ marginBottom: '16px' }}>
<h3 style={{ fontSize: '16px', color: '#00e6ff', marginBottom: '8px' }}>🎨 赔率颜色</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px', fontSize: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: '#fff' }}></div>
<span style={{ color: '#ccc' }}>1-50倍</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: '#00e676' }}></div>
<span style={{ color: '#ccc' }}>51-200倍</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: '#ffd700' }}></div>
<span style={{ color: '#ccc' }}>201-1000倍</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: '#ff9800' }}></div>
<span style={{ color: '#ccc' }}>1001-5000倍</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '12px', height: '12px', borderRadius: '50%', background: '#ff5252' }}></div>
<span style={{ color: '#ccc' }}>5001倍以上</span>
</div>
</div>
</div>
{/* 温馨提示 */}
<div style={{ background: 'rgba(255,215,0,0.1)', borderRadius: '8px', padding: '12px', border: '1px solid rgba(255,215,0,0.3)' }}>
<h3 style={{ fontSize: '14px', color: '#ffd700', marginBottom: '8px' }}> 温馨提示</h3>
<ul style={{ margin: '4px 0', paddingLeft: '20px', color: '#ccc', fontSize: '12px' }}>
<li style={{ marginBottom: '4px' }}>预估倍率仅供参考,封盘后以最终结算倍率为准</li>
<li style={{ marginBottom: '4px' }}>理性投注,量力而行</li>
<li style={{ marginBottom: '4px' }}>本游戏仅虚拟筹码娱乐,不可变现</li>
</ul>
</div>
</div>
</div>
</div>
)}
{/* 底部提示 */}
<div style={{ textAlign: 'center', padding: '12px', fontSize: '11px', color: '#666', borderTop: '1px solid rgba(255,255,255,0.05)', marginTop: '16px' }}>
本游戏仅虚拟筹码娱乐,筹码不可变现,无任何现金交易
</div>
</div>
);
}
// ============ 下注界面(游戏房间) ============
function GameRoomPage({ roomId, user, onBack, onUpdateUser, onForward }) {
const [roomData, setRoomData] = useState(null);
const [roundData, setRoundData] = useState(null);
const [myBets, setMyBets] = useState([]);
const [chips, setChips] = useState(user?.chips || 0);
const [selectedZodiacs, setSelectedZodiacs] = useState([]);
const [betAmount, setBetAmount] = useState('');
const [countdown, setCountdown] = useState('00:00');
const [showConfirm, setShowConfirm] = useState(false);
const [showResult, setShowResult] = useState(false);
const [resultData, setResultData] = useState(null);
const [loading, setLoading] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [historyData, setHistoryData] = useState([]);
const [showRoundDetailModal, setShowRoundDetailModal] = useState(false);
const [roundDetailData, setRoundDetailData] = useState(null);
const [showBetDetail, setShowBetDetail] = useState(false);
const [betDetailData, setBetDetailData] = useState(null);
const [showInvite, setShowInvite] = useState(false);
const [showPoster, setShowPoster] = useState(false);
const [showBetPanel, setShowBetPanel] = useState(false);
const [roomMessages, setRoomMessages] = useState([]);
const [roomMsgInput, setRoomMsgInput] = useState('');
const [recallMenuMsg, setRecallMenuMsg] = useState(null);
const [recallMenuPos, setRecallMenuPos] = useState({x:0,y:0});
const [showEmoji, setShowEmoji] = useState(false);
const [roomShowMorePanel, setRoomShowMorePanel] = useState(false);
const [roomPreviewImg, setRoomPreviewImg] = useState(null);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const roomPreviewOpenTime = useRef(0);
const getInstallGuide = () => {
const ua = navigator.userAgent;
if (/iPad|iPhone|iPod/.test(ua)) return { browser: 'Safari', steps: ['点击底部中间分享图标(□↑)', '滑动菜单找到「添加到主屏幕」', '点击右上角「添加」确认'] };
if (/HarmonyOS|HMSCore/.test(ua)) return { browser: '华为浏览器', steps: ['点击底部「⋮」菜单', '选择「添加到桌面」', '确认添加即可'] };
if (/MiuiBrowser|XiaoMi/.test(ua)) return { browser: '小米浏览器', steps: ['点击底部「⋯」菜单', '选择「添加到桌面」', '确认添加'] };
if (/OPPO|HeyTapBrowser/.test(ua)) return { browser: 'OPPO浏览器', steps: ['点击底部「」菜单', '选择「添加到桌面」', '确认添加'] };
if (/VivoBrowser/.test(ua)) return { browser: 'vivo浏览器', steps: ['点击底部「⋯」菜单', '选择「添加到桌面」', '确认添加'] };
if (/EdgA/.test(ua)) return { browser: 'Edge', steps: ['点击底部「⋯」菜单', '选择「添加到手机」或「安装应用」', '确认添加'] };
if (/CriOS|Chrome/.test(ua)) return { browser: 'Chrome', steps: ['点击右上角「⋮」菜单', '选择「添加到主屏幕」或「安装应用」', '确认添加'] };
if (/QQBrowser/.test(ua)) return { browser: 'QQ浏览器', steps: ['点击底部「≡」菜单', '选择「添加到桌面」', '确认添加'] };
if (/UCBrowser/.test(ua)) return { browser: 'UC浏览器', steps: ['点击底部「≡」菜单', '选择「添加到桌面」', '确认添加'] };
return { browser: '浏览器', steps: ['打开浏览器菜单(⋮ 或 ≡)', '找到「添加到桌面」或「添加到主屏幕」', '确认添加即可'] };
};
const [roomMultiSelect, setRoomMultiSelect] = useState(false);
const [roomSelectedMsgs, setRoomSelectedMsgs] = useState([]);
const roomFileInputRef = useRef(null);
const roomCameraInputRef = useRef(null);
const [roomReplyTo, setRoomReplyTo] = useState(null);
const [mentionUser, setMentionUser] = useState(null);
const longPressTimer = useRef(null);
const timerRef = useRef(null);
const socketRef = useRef(null);
const roundRef = useRef(null);
const chatEndRef = useRef(null);
const EMOJIS = ['😀','😂','🤣','😍','😘','🥰','😎','🤑','😭','😤','👍','👏','🎉','🔥','❤️','💰','🧧','🎲','🐭','🐮','🐯','🐰','🐲','🐍','🐴','🐑','🐵','🐔','🐶','🐷','🍀','💎','🛑'];
const getClearedTime = (key) => {
try { const v = localStorage.getItem('cleared_' + user?.id + '_' + key); return v ? parseInt(v) : 0; } catch(e){ return 0; }
};
const fetchRoom = async () => {
const res = await post('/game/enter-room', { roomId });
if (res.code === 200) {
setRoomData(res.data.room);
setRoundData(res.data.currentRound);
setMyBets(res.data.myBets || []);
setChips(res.data.chips);
roundRef.current = res.data.currentRound;
if (res.data.currentRound?.countdownEnd) startTimer(new Date(res.data.currentRound.countdownEnd));
if (socketRef.current && res.data.currentRound) {
socketRef.current.emit('join_round', { roundId: res.data.currentRound.id });
}
} else { showToast(res.message); onBack(); }
};
const startTimer = (end) => {
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
const diff = Math.max(0, end - Date.now());
if (diff <= 0) {
clearInterval(timerRef.current);
setCountdown('00:00');
setTimeout(async () => {
if (roundRef.current) {
const res = await get('/game/round-result/' + roundRef.current.id);
if (res.code === 200 && (res.data.status === 'settled' || res.data.status === 'void')) {
setResultData(res.data); setShowResult(true);
}
}
}, 3500);
return;
}
const m = String(Math.floor(diff/60000)).padStart(2,'0');
const s = String(Math.floor(diff%60000/1000)).padStart(2,'0');
setCountdown(m + ':' + s);
}, 1000);
};
useEffect(() => { _imgPreviewCb = setRoomPreviewImg; return () => { _imgPreviewCb = null; }; }, []);
useEffect(() => {
fetchRoom();
const sock = io(window.location.origin);
socketRef.current = sock;
// Join room for chat
sock.emit('join_room', { roomId, userId: user?.id, nickname: user?.nickname, avatar: user?.avatar });
window._socket = sock;
// Load chat history
const clearedTime = getClearedTimeGlobal(user?.id, 'r_'+roomId);
get('/room/messages?roomId=' + roomId + '&limit=50').then(histRes => {
if (histRes.code === 200 && histRes.data.length > 0) {
let msgs = histRes.data;
if (clearedTime > 0) {
msgs = msgs.filter(m => new Date(m.createdAt||0).getTime() > clearedTime);
}
setRoomMessages(msgs.length > 0 ? msgs : [{ id: 'sys_' + Date.now(), type: 'system', content: '欢迎进入房间,祝您好运!🎲' }]);
} else {
setRoomMessages([{ id: 'sys_' + Date.now(), type: 'system', content: '欢迎进入房间,祝您好运!🎲' }]);
}
}).catch(()=>{});
sock.on('room_msg', (data) => {
setRoomMessages(prev => {
const n = [...prev, data];
return n.slice(-100);
});
});
sock.on('room_msg_recalled', (data) => {
setRoomMessages(prev => prev.map(m => m.id === data.msgId ? {...m, type:'recalled', content:'消息已撤回'} : m));
});
sock.on('round_result', (data) => {
setResultData(data); setShowResult(true);
// System msg
const names = (data.winnerZodiacs || []).map(z => ZODIAC_NAMES[z]).join(',');
setRoomMessages(prev => [...prev, { id: 'result_' + Date.now(), type: 'system', content: '🎉 开奖结果:' + (names || '无') + ' 奖池:' + (data.totalPool || 0) }]);
});
sock.on('new_round', (data) => {
setShowResult(false); setMyBets([]); setSelectedZodiacs([]); setBetAmount('');
setRoomMessages(prev => [...prev, { id: 'newrd_' + Date.now(), type: 'system', content: '📢 新一轮开始,快来下注!' }]);
fetchRoom();
});
sock.on('bet_update', (data) => {
setRoundData(prev => prev ? { ...prev, totalPool: data.totalPool, playerCount: data.playerCount } : prev);
});
return () => { sock.disconnect(); clearInterval(timerRef.current); };
}, [roomId]);
useEffect(() => {
if (chatEndRef.current) {
const container = chatEndRef.current.parentElement;
if (container) {
const isNear = container.scrollHeight - container.scrollTop - container.clientHeight < 150;
if (isNear) chatEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}
}, [roomMessages]);
const sendRoomMsg = () => {
if (!roomMsgInput.trim() || !socketRef.current) return;
const mentions = [];
if (mentionUser) mentions.push(mentionUser.id);
const payload = { content: roomMsgInput, type: 'text', mentions };
if (roomReplyTo) {
payload.quote = { senderName: roomReplyTo.senderName || '对方', content: roomReplyTo.content };
setRoomReplyTo(null);
}
socketRef.current.emit('room_msg', payload);
setRoomMsgInput(''); setShowEmoji(false); setMentionUser(null);
};
const handleRoomFileSelect = async (e) => {
const file = e.target.files[0];
if (!file || !socketRef.current) return;
const base64 = await compressImage(file);
e.target.value = '';
const imgKey = 'room_' + user.id + '_' + roomId + '_' + Date.now();
try { localStorage.setItem('img_' + imgKey, base64); } catch(err){}
socketRef.current.emit('room_msg', { content: base64, type: 'image' });
};
const clearRoomImages = (prefix) => {
try {
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith('img_' + prefix)) keys.push(k);
}
keys.forEach(k => localStorage.removeItem(k));
} catch(e){}
};
const handleRoomMsgLongPress = (e, m) => {
if (m.type === 'system' || m.type === 'recalled') return;
const msgTime = m.createdAt ? new Date(m.createdAt).getTime() : (parseInt(m.id?.split('_')[1]) || Date.now());
const canRecall = m.senderId === user?.id && Date.now() - msgTime <= 120000;
e.preventDefault();
const rect = e.target.getBoundingClientRect();
setRecallMenuPos({x: rect.left + rect.width/2, y: rect.top - 40});
setRecallMenuMsg({...m, canRecall});
};
const doRecallRoomMsg = () => {
if (!recallMenuMsg || !socketRef.current) return;
socketRef.current.emit('recall_room_msg', { msgId: recallMenuMsg.id, roomId });
setRecallMenuMsg(null);
};
const handleBet = async () => {
if (selectedZodiacs.length === 0 || !betAmount) return showToast('请选择生肖和金额');
const amt = parseInt(betAmount);
if (!amt || amt <= 0) return showToast('请输入有效金额');
if (amt * selectedZodiacs.length > chips) return showToast('筹码不足');
setShowConfirm(true);
};
const confirmBet = async () => {
setShowConfirm(false); setLoading(true);
let res;
for (const z of selectedZodiacs) {
res = await post('/game/bet', { roundId: roundData.id, zodiac: z, amount: parseInt(betAmount) });
if (res.code !== 200) break;
}
setLoading(false);
if (res && res.code === 200) {
setChips(res.data.chips); setMyBets(res.data.myBets);
onUpdateUser({ chips: res.data.chips }); setBetAmount(''); setSelectedZodiacs([]);
setShowBetPanel(false);
} else if (res) showToast(res.message);
};
const closeResult = () => { setShowResult(false); fetchRoom(); };
const fetchHistory = async () => {
const res = await get('/game/rounds/history?roomId=' + roomId + '&limit=20');
if (res.code === 200) setHistoryData(res.data);
};
const fetchBetDetail = async () => {
if (!roundData?.id) return;
const res = await get('/game/round/' + roundData.id + '/bets');
if (res.code === 200) setBetDetailData(res.data);
};
useEffect(() => { if (showHistory) fetchHistory(); }, [showHistory]);
useEffect(() => { if (showBetDetail) fetchBetDetail(); }, [showBetDetail, roundData?.id]);
useEffect(() => { if (showPoster) setTimeout(generatePoster, 100); }, [showPoster]);
const showRoundDetail = async (round) => {
const res = await get('/game/rounds/' + round.id + '/detail');
if (res.code === 200) { setRoundDetailData({ ...round, zodiacStats: res.data.zodiacStats, allBets: res.data.allBets }); setShowRoundDetailModal(true); }
};
const copyInviteLink = async () => {
const link = window.location.origin + '/?ref=' + (user?.inviteCode || '');
try { await navigator.clipboard.writeText(link); showToast('邀请链接已复制!'); }
catch(e) { const ta=document.createElement('textarea'); ta.value=link; ta.style.position='fixed'; ta.style.left='-9999px'; document.body.appendChild(ta); ta.select(); try{document.execCommand('copy');showToast('已复制');}catch(e2){showToast(link);} document.body.removeChild(ta); }
};
const generatePoster = async () => {
const canvas = document.getElementById('posterCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
canvas.width = 800; canvas.height = 1200;
const gradient = ctx.createLinearGradient(0,0,0,1200);
gradient.addColorStop(0,'#C41E3A'); gradient.addColorStop(1,'#8B0000');
ctx.fillStyle = gradient; ctx.fillRect(0,0,800,1200);
ctx.textAlign = 'center'; ctx.font = 'bold 48px Arial'; ctx.fillStyle = '#FFD700';
ctx.fillText('ABC', 400, 200);
ctx.font = '28px Arial'; ctx.fillStyle = '#fff';
ctx.fillText('邀请码: ' + (user?.inviteCode || '---'), 400, 600);
ctx.fillText(window.location.origin + '/?ref=' + (user?.inviteCode||''), 400, 660);
const posterImg = document.getElementById('posterImage');
if (posterImg) { posterImg.src = canvas.toDataURL('image/png'); posterImg.style.display = 'block'; }
};
const roundStatus = roundData?.status;
const isBetting = roundStatus === 'betting' && countdown !== '00:00';
const totalMyBet = myBets.reduce((s, b) => s + b.amount, 0);
const myBetMap = {};
myBets.forEach(b => { myBetMap[b.zodiac] = (myBetMap[b.zodiac] || 0) + b.amount; });
return (
<div style={{position:'fixed',top:0,bottom:0,left:'50%',transform:'translateX(-50%)',width:'100%',maxWidth:'500px',zIndex:50,display:'flex',flexDirection:'column',background:'#EDEDED'}}>
{/* Top info bar */}
<div style={{background:'#3A0000',padding:'8px 12px',display:'flex',flexDirection:'column',gap:'4px',flexShrink:0}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between'}}>
<div style={{display:'flex',alignItems:'center',gap:'8px'}}>
<button style={{background:'none',border:'none',color:'#FFD700',fontSize:'20px',cursor:'pointer'}} onClick={onBack}>←</button>
<span style={{color:'#fff',fontWeight:'bold',fontSize:'15px'}}>第{roundData?.roundNo?.slice(-4)||'1'}轮</span>
</div>
<div style={{display:'flex',alignItems:'center',gap:'6px'}}>
<button style={{background:'rgba(255,215,0,0.15)',border:'1px solid rgba(255,215,0,0.4)',color:'#FFD700',padding:'3px 10px',borderRadius:'14px',fontSize:'12px',cursor:'pointer'}} onClick={()=>setShowHistory(true)}>📋记录</button>
<button style={{background:'rgba(255,215,0,0.15)',border:'1px solid rgba(255,215,0,0.4)',color:'#FFD700',padding:'3px 10px',borderRadius:'14px',fontSize:'12px',cursor:'pointer'}} onClick={()=>setShowInvite(true)}>🔗邀请</button>
<button style={{background:'rgba(255,215,0,0.15)',border:'1px solid rgba(255,215,0,0.4)',color:'#FFD700',padding:'3px 10px',borderRadius:'14px',fontSize:'12px',cursor:'pointer'}} onClick={()=>{if(confirm('确认清空房间聊天记录?')){setRoomMessages([{id:'sys_'+Date.now(),type:'system',content:'聊天记录已清空'}]);clearRoomImages('room_' + user.id + '_' + roomId + '_');markClearedGlobal(user.id,'r_'+roomId);}}}>🗑️清空</button>
</div>
</div>
<div style={{display:'flex',alignItems:'center',gap:'8px',justifyContent:'center'}}>
<div style={{background:'linear-gradient(135deg,#DAA520,#B8860B)',padding:'4px 14px',borderRadius:'14px',display:'flex',alignItems:'center',gap:'6px'}}>
<span style={{fontSize:'11px',color:'#5B0000'}}>⏱</span>
<span style={{fontWeight:'bold',color:'#5B0000',fontFamily:'monospace',fontSize:'16px'}}>{countdown}</span>
</div>
<div style={{background:'linear-gradient(135deg,#DAA520,#B8860B)',padding:'4px 14px',borderRadius:'14px',display:'flex',alignItems:'center',gap:'6px'}}>
<span style={{fontSize:'11px',color:'#5B0000'}}>🏆奖池</span>
<span style={{fontWeight:'bold',color:'#5B0000',fontSize:'14px'}}>{(roundData?.totalPool||0).toLocaleString()}</span>
</div>
<div style={{background:'linear-gradient(135deg,#DAA520,#B8860B)',padding:'4px 14px',borderRadius:'14px',display:'flex',alignItems:'center',gap:'6px'}}>
<span style={{fontSize:'11px',color:'#5B0000'}}>💰</span>
<span style={{fontWeight:'bold',color:'#5B0000',fontSize:'14px'}}>{chips.toLocaleString()}</span>
</div>
</div>
</div>
{/* Chat messages area */}
<div style={{flex:1,overflowY:'auto',padding:'10px 12px',display:'flex',flexDirection:'column',gap:'10px'}}>
{roomMessages.map((m) => {
if (m.type === 'system') {
return <div key={m.id} style={{textAlign:'center',fontSize:'11px',color:'#999',background:'rgba(0,0,0,0.05)',padding:'4px 12px',borderRadius:'10px',alignSelf:'center',maxWidth:'80%'}}>{m.content}</div>;
}
if (m.type === 'recalled') {
return <div key={m.id} style={{textAlign:'center',fontSize:'11px',color:'#999',fontStyle:'italic',padding:'4px 12px'}}>消息已撤回</div>;
}
const isMine = m.senderId === user?.id;
return (
<div key={m.id} style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',alignItems:'flex-start',gap:'8px'}}>
{roomMultiSelect && <div style={{width:'20px',height:'20px',borderRadius:'50%',border:'2px solid #999',display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0,marginTop:'8px',cursor:'pointer',background:roomSelectedMsgs.includes(m.id)?'#07C160':'transparent',borderColor:roomSelectedMsgs.includes(m.id)?'#07C160':'#999'}} onClick={()=>setRoomSelectedMsgs(prev=>prev.includes(m.id)?prev.filter(id=>id!==m.id):[...prev,m.id])}>{roomSelectedMsgs.includes(m.id)&&<span style={{color:'#fff',fontSize:'12px'}}>✓</span>}</div>}
{!isMine && (
<div style={{display:'flex',flexDirection:'column',alignItems:'center',gap:'2px',flexShrink:0}}
onTouchStart={()=>{longPressTimer.current=setTimeout(()=>{setRoomMsgInput(prev=>prev+'@'+m.senderName+' ');setMentionUser({id:m.senderId,name:m.senderName});},600);}}
onTouchEnd={()=>clearTimeout(longPressTimer.current)}
onTouchMove={()=>clearTimeout(longPressTimer.current)}>
<div className="avatar" style={{width:'36px',height:'36px',fontSize:'14px',backgroundImage:m.senderAvatar?'url('+m.senderAvatar+')':'none',backgroundSize:'cover'}}>{!m.senderAvatar&&'👤'}</div>
<span style={{fontSize:'10px',color:'#999',maxWidth:'50px',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{m.senderName}</span>
</div>
)}
<div style={{padding:'10px 14px',borderRadius:isMine?'12px 4px 12px 12px':'4px 12px 12px 12px',maxWidth:'65%',fontSize:'15px',lineHeight:'1.5',wordBreak:'break-all',background:isMine?'#95EC69':'#fff',color:'#333',boxShadow:'0 1px 1px rgba(0,0,0,0.08)',opacity:roomMultiSelect&&roomSelectedMsgs.includes(m.id)?0.6:1}}
onTouchStart={(e)=>{const t=setTimeout(()=>handleRoomMsgLongPress(e,m),600);e.target._lp=t;}}
onTouchEnd={(e)=>clearTimeout(e.target._lp)}
onTouchMove={(e)=>clearTimeout(e.target._lp)}
onContextMenu={(e)=>{e.preventDefault();handleRoomMsgLongPress(e,m);}}>
{m.quote && <div style={{fontSize:'12px',color:'#666',background:'rgba(0,0,0,0.05)',borderRadius:'6px',padding:'4px 8px',marginBottom:'4px',borderLeft:'3px solid #07C160'}}>
<div style={{fontWeight:'bold',fontSize:'11px',color:'#07C160'}}>{m.quote.senderName}</div>
<div style={{overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',maxWidth:'200px'}}>{m.quote.content}</div>
</div>}
{m.type === 'image' ? (
<button style={{background:'none',border:'none',padding:0,margin:0,cursor:'pointer',display:'block'}} onClick={()=>{roomPreviewOpenTime.current=Date.now();setRoomPreviewImg(m.content);}}><img src={m.content} style={{maxWidth:'200px',maxHeight:'200px',borderRadius:'8px',display:'block'}} /></button>
) : (
<span dangerouslySetInnerHTML={{__html: (m.content||'').replace(/@(\S+)/g, '<span style="color:#1E90FF;font-weight:bold">@$1</span>')}}></span>
)}
</div>
{isMine && (
<div className="avatar" style={{width:'36px',height:'36px',fontSize:'14px',flexShrink:0,backgroundImage:user?.avatar?'url('+user.avatar+')':'none',backgroundSize:'cover'}}>{!user?.avatar&&'👤'}</div>
)}
</div>
);
})}
<div ref={chatEndRef} />
{roomMultiSelect && (
<div style={{position:'sticky',bottom:0,display:'flex',alignItems:'center',gap:'8px',padding:'8px 12px',background:'#fff',borderTop:'1px solid #ddd',zIndex:10}}>
<span style={{fontSize:'13px',color:'#666',flex:1}}>已选 {roomSelectedMsgs.length} 条</span>
<button style={{background:'#07C160',color:'#fff',border:'none',borderRadius:'6px',padding:'6px 14px',fontSize:'13px',cursor:'pointer'}} onClick={()=>{if(onForward){onForward({messages:roomMessages.filter(m=>roomSelectedMsgs.includes(m.id)),chatType:'room'});}setRoomMultiSelect(false);}}>转发</button>
<button style={{background:'#FF3B30',color:'#fff',border:'none',borderRadius:'6px',padding:'6px 14px',fontSize:'13px',cursor:'pointer'}} onClick={()=>{setRoomMessages(prev=>prev.filter(m=>!roomSelectedMsgs.includes(m.id)));setRoomSelectedMsgs([]);setRoomMultiSelect(false);}}>删除</button>
<button style={{background:'#ccc',color:'#fff',border:'none',borderRadius:'6px',padding:'6px 14px',fontSize:'13px',cursor:'pointer'}} onClick={()=>{setRoomSelectedMsgs([]);setRoomMultiSelect(false);}}>取消</button>
</div>
)}
</div>
{/* 消息菜单 */}
{recallMenuMsg && (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,zIndex:9999}} onClick={()=>setRecallMenuMsg(null)}>
<div style={{position:'absolute',left:Math.min(recallMenuPos.x,250)+'px',top:Math.max(recallMenuPos.y,10)+'px',background:'#333',borderRadius:'8px',padding:'4px 0',boxShadow:'0 4px 12px rgba(0,0,0,0.5)',display:'flex',gap:'0',transform:'translateX(-50%)'}} onClick={e=>e.stopPropagation()}>
<div style={{padding:'8px 16px',color:'#fff',fontSize:'13px',cursor:'pointer',whiteSpace:'nowrap'}} onClick={()=>{copyToClipboard(recallMenuMsg.content||'');setRecallMenuMsg(null);}}>复制</div>
<div style={{padding:'8px 16px',color:'#fff',fontSize:'13px',cursor:'pointer',whiteSpace:'nowrap',borderLeft:'1px solid #555'}} onClick={()=>{setRoomReplyTo(recallMenuMsg);setRecallMenuMsg(null);}}>引用</div>
<div style={{padding:'8px 16px',color:'#fff',fontSize:'13px',cursor:'pointer',whiteSpace:'nowrap',borderLeft:'1px solid #555'}} onClick={()=>{if(onForward){onForward({messages:[{...recallMenuMsg,chatType:'room'}],chatType:'room'});}setRecallMenuMsg(null);}}>转发</div>
<div style={{padding:'8px 16px',color:'#fff',fontSize:'13px',cursor:'pointer',whiteSpace:'nowrap',borderLeft:'1px solid #555'}} onClick={()=>{setRoomMultiSelect(true);setRecallMenuMsg(null);}}>多选</div>
{recallMenuMsg.canRecall && <div style={{padding:'8px 16px',color:'#fff',fontSize:'13px',cursor:'pointer',whiteSpace:'nowrap',borderLeft:'1px solid #555'}} onClick={doRecallRoomMsg}>撤回</div>}
</div>
</div>
)}
{/* Emoji panel */}
{showEmoji && (
<div style={{background:'#fff',borderTop:'1px solid #ddd',padding:'8px',maxHeight:'160px',overflowY:'auto',display:'grid',gridTemplateColumns:'repeat(8,1fr)',gap:'4px',flexShrink:0}}>
{EMOJIS.map((em,i) => (
<div key={i} style={{textAlign:'center',fontSize:'22px',padding:'4px',cursor:'pointer'}} onClick={()=>setRoomMsgInput(prev=>prev+em)} onMouseDown={e=>e.preventDefault()}>{em}</div>
))}
</div>
)}
{/* Bottom input bar + bet button */}
<div style={{display:'flex',alignItems:'center',gap:'6px',padding:'8px 10px',background:'#F7F7F7',borderTop:'1px solid #ddd',flexShrink:0,flexWrap:'wrap'}}>
{roomReplyTo && <div style={{width:'100%',display:'flex',alignItems:'center',gap:'8px',padding:'4px 0',marginBottom:'2px'}}>
<div style={{flex:1,fontSize:'12px',color:'#666',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
<span style={{color:'#07C160'}}>引用 {roomReplyTo.senderName || '对方'}:</span>{roomReplyTo.content}
</div>
<button style={{background:'none',border:'none',fontSize:'16px',color:'#999',cursor:'pointer'}} onClick={()=>setRoomReplyTo(null)}>✕</button>
</div>}
<button style={{background:'none',border:'none',fontSize:'22px',cursor:'pointer',color:showEmoji?'#FFD700':'#666',padding:'2px'}} onClick={()=>{setShowEmoji(!showEmoji);setRoomShowMorePanel(false);}}>😊</button>
<button style={{background:'none',border:'none',fontSize:'22px',cursor:'pointer',color:roomShowMorePanel?'#FFD700':'#666',padding:'2px'}} onClick={()=>{setRoomShowMorePanel(!roomShowMorePanel);setShowEmoji(false);}}>+</button>
<AutoResizeTextarea
placeholder="聊天互动..."
value={roomMsgInput}
onChange={e=>setRoomMsgInput(e.target.value)}
onSend={sendRoomMsg}
maxHeight={100}
style={{flex:1,padding:'9px 14px',borderRadius:'20px',border:'1px solid #ddd',background:'#fff',fontSize:'14px',color:'#333',outline:'none',resize:'none',lineHeight:'1.4',fontFamily:'inherit',overflowY:'auto',whiteSpace:'pre-wrap',minWidth:0}}
onKeyDown={e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendRoomMsg();}}}
onFocus={()=>{setShowEmoji(false);setRoomShowMorePanel(false);}} />
{roomMsgInput.trim() ? (
<button style={{background:'#07C160',color:'#fff',border:'none',borderRadius:'20px',padding:'8px 16px',fontSize:'13px',fontWeight:'bold',cursor:'pointer'}} onClick={sendRoomMsg}>发送</button>
) : (
<button style={{background:'linear-gradient(135deg,#C41E3A,#8B0000)',color:'#FFD700',border:'2px solid #FFD700',borderRadius:'20px',padding:'8px 16px',fontSize:'13px',fontWeight:'bold',cursor:'pointer',whiteSpace:'nowrap'}} onClick={()=>setShowBetPanel(true)}>🎲 下注</button>
)}
</div>
{roomShowMorePanel && (
<div style={{display:'grid',gridTemplateColumns:'repeat(4,1fr)',gap:'12px',padding:'16px',background:'#F7F7F7',borderTop:'1px solid #ddd'}}>
<label htmlFor="roomFileInput" style={{textAlign:'center',cursor:'pointer'}}>
<div style={{width:'56px',height:'56px',background:'#FFF',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px',boxShadow:'0 1px 3px rgba(0,0,0,0.1)'}}>🖼️</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>相册</div>
</label>
<label htmlFor="roomCameraInput" style={{textAlign:'center',cursor:'pointer'}}>
<div style={{width:'56px',height:'56px',background:'#FFF',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px',boxShadow:'0 1px 3px rgba(0,0,0,0.1)'}}>📷</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>拍摄</div>
</label>
</div>
)}
<input id="roomFileInput" type="file" accept="image/*" style={{position:'absolute',opacity:0,width:0,height:0,pointerEvents:'none'}} ref={roomFileInputRef} onChange={handleRoomFileSelect} />
<input id="roomCameraInput" type="file" accept="image/*" capture="camera" style={{position:'absolute',opacity:0,width:0,height:0,pointerEvents:'none'}} ref={roomCameraInputRef} onChange={handleRoomFileSelect} />
{/* ====== Bet Panel Overlay ====== */}
{showBetPanel && (
<div style={{position:'fixed',top:0,bottom:0,left:'50%',transform:'translateX(-50%)',width:'100%',maxWidth:'500px',zIndex:200,display:'flex',flexDirection:'column',background:'linear-gradient(180deg,#5B0000 0%,#3A0000 100%)'}}>
<div style={{display:'flex',alignItems:'center',padding:'12px',borderBottom:'1px solid rgba(255,215,0,0.3)'}}>
<button style={{background:'none',border:'none',color:'#FFD700',fontSize:'20px',cursor:'pointer',marginRight:'10px'}} onClick={()=>setShowBetPanel(false)}>← 返回聊天</button>
<span style={{color:'#fff',fontWeight:'bold',flex:1,textAlign:'center',fontSize:'16px'}}>第{roundData?.roundNo?.slice(-4)||'1'}轮下注</span>
<div style={{display:'flex',alignItems:'center',gap:'4px'}}>
<span style={{color:'#FFD700',fontSize:'12px'}}>💰</span>
<span style={{color:'#FFD700',fontWeight:'bold',fontSize:'14px'}}>{chips.toLocaleString()}</span>
</div>
</div>
<div style={{overflowY:'auto',flex:1,padding:'10px',paddingBottom:'20px'}}>
{/* Countdown + Pool */}
<div style={{display:'flex',gap:'8px',marginBottom:'10px'}}>
<div style={{flex:1,textAlign:'center',padding:'8px',background:'linear-gradient(135deg,#DAA520,#B8860B)',borderRadius:'10px'}}>
<div style={{fontSize:'11px',color:'#5B0000'}}>倒计时</div>
<div style={{fontWeight:'bold',fontSize:'22px',color:'#5B0000',fontFamily:'monospace'}}>{countdown}</div>
</div>
<div style={{flex:1,textAlign:'center',padding:'8px',background:'linear-gradient(135deg,#DAA520,#B8860B)',borderRadius:'10px'}}>
<div style={{fontSize:'11px',color:'#5B0000'}}>奖池</div>
<div style={{fontWeight:'bold',fontSize:'22px',color:'#5B0000'}}>{(roundData?.totalPool||0).toLocaleString()}</div>
</div>
</div>
{/* 12 Zodiac grid */}
<div style={{background:'rgba(0,0,0,0.3)',padding:'8px',borderRadius:'12px',border:'2px solid rgba(255,215,0,0.3)',marginBottom:'10px'}}>
<div style={{display:'grid',gridTemplateColumns:'repeat(4,1fr)',gap:'6px'}}>
{[1,2,3,4,5,6,7,8,9,10,11,12].map(z => (
<div key={z} onClick={() => isBetting && setSelectedZodiacs(prev => prev.includes(z) ? prev.filter(x=>x!==z) : [...prev, z])}
style={{position:'relative',aspectRatio:'1',borderRadius:'10px',overflow:'hidden',cursor:isBetting?'pointer':'default',opacity:isBetting?1:0.5,
border:selectedZodiacs.includes(z)?'3px solid #FFD700':'3px solid transparent',
boxShadow:selectedZodiacs.includes(z)?'0 0 10px rgba(255,215,0,0.5)':'none',
background:selectedZodiacs.includes(z)?'rgba(255,215,0,0.15)':'rgba(0,0,0,0.2)'}}>
{myBetMap[z] && <span style={{position:'absolute',top:'2px',right:'2px',background:'#C41E3A',color:'#FFD700',fontSize:'9px',padding:'1px 4px',borderRadius:'8px',zIndex:2}}>{myBetMap[z]}</span>}
<img src={ZODIAC_IMAGES[z]} alt={ZODIAC_NAMES[z]} style={{width:'100%',height:'100%',objectFit:'cover'}} />
</div>
))}
</div>
</div>
{/* My bets summary */}
{myBets.length > 0 && (
<div style={{background:'rgba(0,0,0,0.3)',padding:'10px',borderRadius:'10px',border:'1px solid rgba(255,215,0,0.2)'}}>
<div style={{color:'#FFD700',fontSize:'13px',fontWeight:'bold',marginBottom:'6px'}}>我的下注</div>
{myBets.map((b,i) => (
<div key={i} style={{display:'flex',justifyContent:'space-between',fontSize:'12px',padding:'2px 0'}}>
<span style={{color:'#fff'}}>{ZODIAC_EMOJIS[b.zodiac]} {b.zodiacName}</span>
<span style={{color:'#FFD700'}}>{b.amount}</span>
</div>
))}
<div style={{borderTop:'1px solid rgba(255,215,0,0.3)',marginTop:'4px',paddingTop:'4px',display:'flex',justifyContent:'space-between',fontSize:'13px'}}>
<span style={{color:'#fff'}}>合计</span>
<span style={{color:'#FFD700',fontWeight:'bold'}}>{totalMyBet}</span>
</div>
</div>
)}
</div>
{/* Bet input - fixed at bottom */}
<div className="safe-bottom" style={{flexShrink:0,background:'linear-gradient(135deg,#DAA520,#B8860B)',padding:'8px 10px 12px 10px',borderTop:'2px solid rgba(255,215,0,0.5)'}}>
<div style={{display:'flex',alignItems:'center',gap:'6px',marginBottom:'6px'}}>
<input type="number" placeholder="金额"
style={{flex:1,minWidth:0,padding:'8px',borderRadius:'8px',border:'2px solid #8B0000',background:'rgba(255,255,255,0.9)',fontSize:'15px',color:'#333',textAlign:'center',fontWeight:'bold'}}
value={betAmount} onChange={e=>setBetAmount(e.target.value)} disabled={!isBetting} />
<button style={{background:'linear-gradient(135deg,#C41E3A,#8B0000)',color:'#FFD700',border:'2px solid #FFD700',borderRadius:'10px',padding:'8px 14px',fontSize:'14px',fontWeight:'bold',flexShrink:0,cursor:isBetting?'pointer':'default',opacity:isBetting?1:0.5,whiteSpace:'nowrap'}} onClick={handleBet} disabled={!isBetting||loading}>{loading?'...':'确认下注'}</button>
</div>
<div style={{display:'flex',gap:'4px',justifyContent:'center',flexWrap:'wrap'}}>
{[10,50,100,500,1000,5000].map(a => (
<button key={a} style={{padding:'3px 10px',background:'rgba(91,0,0,0.8)',color:'#FFD700',borderRadius:'6px',border:'1px solid rgba(255,215,0,0.3)',fontSize:'12px',cursor:'pointer'}} onClick={()=>setBetAmount(String(a))}>{a}</button>
))}
</div>
</div>
</div>
)}
{/* Confirm bet modal */}
{showConfirm && (
<div className="modal-mask"><div className="modal-box text-center">
<h3 className="text-gold text-lg font-bold mb-3">确认下注</h3>
<div className="text-white mb-2">
<div className="flex flex-wrap gap-2 justify-center mb-2">
{selectedZodiacs.map(z => (
<div key={z} style={{width:'50px',textAlign:'center'}}>
<img src={ZODIAC_IMAGES[z]} style={{width:'40px',height:'40px',borderRadius:'8px',margin:'0 auto'}} />
<div style={{fontSize:'11px',color:'#FFD700'}}>{ZODIAC_NAMES[z]}</div>
</div>
))}
</div>
<div className="text-gold text-sm">每个 {betAmount} 筹码 × {selectedZodiacs.length}个 = <span className="text-lg font-bold">{parseInt(betAmount||0)*selectedZodiacs.length}</span></div>
</div>
<div className="flex gap-3">
<button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setShowConfirm(false)}>取消</button>
<button className="btn-red flex-1" onClick={confirmBet}>确认</button>
</div>
</div></div>
)}
{/* Result modal */}
{showResult && resultData && (
<div className="modal-mask"><div className="modal-box text-center">
<h3 className="text-gold text-lg font-bold mb-3">🎉 开奖结果</h3>
<div className="flex flex-wrap gap-2 justify-center mb-3">
{(resultData.winnerZodiacs||[]).map(z => (
<div key={z} style={{textAlign:'center'}}>
<img src={ZODIAC_IMAGES[z]} style={{width:'50px',height:'50px',borderRadius:'10px',border:'3px solid #FFD700'}} />
<div className="text-gold text-xs mt-1">{ZODIAC_NAMES[z]}</div>
</div>
))}
</div>
{resultData.myWin > 0 && <div className="text-gold text-lg font-bold mb-2">🎉 恭喜赢得 {resultData.myWin} 筹码!</div>}
{resultData.myWin === 0 && resultData.myBet > 0 && <div className="text-red-400 mb-2">😔 很遗憾,本轮未中奖</div>}
<button className="btn-gold w-full" onClick={closeResult}>继续</button>
</div></div>
)}
{/* History modal */}
{showHistory && (
<div className="modal-mask" onClick={()=>setShowHistory(false)}><div className="modal-box" onClick={e=>e.stopPropagation()} style={{maxHeight:'85vh',overflowY:'auto'}}>
<h3 className="text-gold text-lg font-bold text-center mb-3">📋 开奖记录</h3>
{historyData.length === 0 ? <p className="text-gold2 text-center text-sm">暂无记录</p> : historyData.map((r,i) => (
<div key={i} className="bg-dark-card p-2 mb-2 rounded cursor-pointer" onClick={()=>showRoundDetail(r)}>
<div className="flex justify-between text-xs mb-1">
<span className="text-white">第{r.roundNo?.slice(-4)}轮</span>
<span className="text-gold2">{r.createdAt?.split('T')[0]}</span>
</div>
<div className="flex gap-1">
{(r.winnerZodiacs||[]).map(z => <img key={z} src={ZODIAC_IMAGES[z]} style={{width:'28px',height:'28px',borderRadius:'6px',border:'2px solid #FFD700'}} />)}
</div>
{r.myWin > 0 && <span className="text-gold text-xs">+{r.myWin}</span>}
</div>
))}
<button className="btn-gold w-full mt-2" onClick={()=>setShowHistory(false)}>关闭</button>
</div></div>
)}
{/* Round detail modal */}
{showRoundDetailModal && roundDetailData && (
<div className="modal-mask" onClick={()=>setShowRoundDetailModal(false)}><div className="modal-box" onClick={e=>e.stopPropagation()} style={{maxHeight:'85vh',overflowY:'auto'}}>
<h3 className="text-gold text-lg font-bold text-center mb-3">第{roundDetailData.roundNo?.slice(-4)}轮详情</h3>
<div className="grid grid-cols-4 gap-1 mb-3">
{Object.entries(roundDetailData.zodiacStats||{}).map(([z,s]) => (
<div key={z} className="text-center text-xs py-1" style={{background:'rgba(0,0,0,0.2)',borderRadius:'4px'}}>
<div>{ZODIAC_EMOJIS[z]}</div><div className="text-gold">{s.total||0}</div>
</div>
))}
</div>
<button className="btn-gold w-full" onClick={()=>setShowRoundDetailModal(false)}>关闭</button>
</div></div>
)}
{/* Invite modal */}
{showInvite && (
<div className="modal-mask" onClick={()=>setShowInvite(false)}><div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold text-lg font-bold text-center mb-3">🎁 邀请好友</h3>
<div className="bg-dark-card p-3 rounded mb-3 text-center">
<div className="text-xs text-gold2 mb-1">您的邀请码</div>
<div className="text-2xl text-gold font-bold">{user?.inviteCode||'---'}</div>
</div>
<div className="text-xs text-gold2 mb-3 space-y-1">
<div>• 一级好友:下注服务费 8%</div>
<div>• 二级好友:下注服务费 3%</div>
<div>• 三级好友:下注服务费 1%</div>
</div>
<div className="flex gap-2">
<button className="btn-gold flex-1" onClick={copyInviteLink}>📋 复制链接</button>
<button className="btn-red flex-1" onClick={()=>{setShowInvite(false);setShowPoster(true);}}>🎨 生成海报</button>
</div>
<button className="btn-red w-full mt-2" onClick={()=>setShowInvite(false)}>关闭</button>
</div></div>
)}
{/* Poster modal */}
{showPoster && (
<div className="modal-mask" onClick={()=>setShowPoster(false)}><div className="modal-box" onClick={e=>e.stopPropagation()} style={{maxHeight:'90vh',overflowY:'auto'}}>
<h3 className="text-gold text-lg font-bold text-center mb-2">🎨 推广海报</h3>
<div className="text-center mb-3">
<img id="posterImage" style={{maxWidth:'100%',maxHeight:'50vh',borderRadius:'8px',display:'none'}} />
<canvas id="posterCanvas" style={{display:'none'}}></canvas>
</div>
<button className="btn-gold w-full mb-2" onClick={()=>{const c=document.getElementById('posterCanvas');const l=document.createElement('a');l.download='poster.png';l.href=c.toDataURL();l.click();}}>💾 下载海报</button>
<button className="btn-red w-full" onClick={()=>setShowPoster(false)}>关闭</button>
</div></div>
)}
{/* Bet detail modal */}
{showBetDetail && (
<div className="modal-mask" onClick={()=>setShowBetDetail(false)}><div className="modal-box" onClick={e=>e.stopPropagation()} style={{maxHeight:'85vh',overflowY:'auto'}}>
<h3 className="text-gold text-lg font-bold text-center mb-3">下注详情</h3>
{betDetailData && (
<div className="space-y-2">
<div className="grid grid-cols-4 gap-1">
{Object.entries(betDetailData.zodiacTotals||{}).map(([z,amt]) => (
<div key={z} className="text-center text-xs py-1" style={{background:'rgba(0,0,0,0.2)',borderRadius:'4px'}}>
<div>{ZODIAC_EMOJIS[z]}</div><div className="text-gold">{amt||0}</div>
</div>
))}
</div>
{betDetailData?.myBets?.length > 0 && (
<div className="bg-dark-card p-2 rounded">
<div className="text-gold text-xs font-bold mb-1">我的下注</div>
{betDetailData.myBets.map((b,i) => (
<div key={i} className="flex justify-between text-xs"><span>{ZODIAC_EMOJIS[b.zodiac]} {b.zodiacName}</span><span className="text-gold">{b.amount}</span></div>
))}
</div>
)}
</div>
)}
<button className="btn-gold w-full mt-3" onClick={()=>setShowBetDetail(false)}>关闭</button>
</div></div>
)}
{roomPreviewImg && <div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.9)',zIndex:10000,display:'flex',alignItems:'center',justifyContent:'center'}}><button style={{background:'none',border:'none',padding:0,width:'100%',height:'100%',display:'flex',alignItems:'center',justifyContent:'center'}} onClick={()=>{if(Date.now()-roomPreviewOpenTime.current>500)setRoomPreviewImg(null);}}><img src={roomPreviewImg} style={{maxWidth:'95vw',maxHeight:'95vh',objectFit:'contain'}} /></button></div>}
{/* 添加到桌面引导弹窗 */}
{showInstallGuide && (() => {
const guide = getInstallGuide();
return (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.7)',zIndex:10001,display:'flex',alignItems:'center',justifyContent:'center',padding:'20px'}} onClick={()=>setShowInstallGuide(false)}>
<div style={{background:'#fff',borderRadius:'16px',padding:'24px',maxWidth:'360px',width:'100%'}} onClick={e=>e.stopPropagation()}>
<div style={{textAlign:'center',marginBottom:'16px'}}>
<div style={{fontSize:'48px',marginBottom:'8px'}}>📌</div>
<div style={{fontSize:'18px',fontWeight:'bold',color:'#333'}}>添加到手机桌面</div>
<div style={{fontSize:'13px',color:'#999',marginTop:'4px'}}>检测到您使用的是 <span style={{color:'#07C160',fontWeight:'bold'}}>{guide.browser}</span></div>
</div>
<div style={{background:'#f5f5f5',borderRadius:'12px',padding:'16px',marginBottom:'16px'}}>
{guide.steps.map((step,i) => (
<div key={i} style={{display:'flex',alignItems:'flex-start',gap:'10px',marginBottom:i<guide.steps.length-1?'12px':'0'}}>
<div style={{width:'22px',height:'22px',borderRadius:'50%',background:'#07C160',color:'#fff',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'12px',fontWeight:'bold',flexShrink:0}}>{i+1}</div>
<div style={{fontSize:'14px',color:'#333',lineHeight:1.5}}>{step}</div>
</div>
))}
</div>
<div style={{textAlign:'center',fontSize:'12px',color:'#999',marginBottom:'12px'}}>添加后桌面会出现「生肖竞猜」图标,点击即可进入</div>
<button style={{width:'100%',padding:'12px',background:'#07C160',color:'#fff',border:'none',borderRadius:'8px',fontSize:'16px',fontWeight:'bold',cursor:'pointer'}} onClick={()=>setShowInstallGuide(false)}>我知道了</button>
</div>
</div>
);
})()}
</div>
);
}
// ============ 好友页 ============
function FriendsPage({ user, onShowBillRecord, sharedMembers = [], setProfilePanel, setProfileMenu }) {
const [friends, setFriends] = useState([]);
const [friendRequests, setFriendRequests] = useState([]);
const [showAdd, setShowAdd] = useState(false);
const [addQuery, setAddQuery] = useState('');
const [addMode, setAddMode] = useState('friend'); // 'friend' 或 'group'
const [joinGroupId, setJoinGroupId] = useState('');
const [chatFriend, setChatFriend] = useState(null);
// profilePanel / profileMenu 已由 App 层通过 props 传入,此处不再声明 [PROFILE-PANEL-V1]
const [messages, setMessages] = useState([]);
const [forceRefresh, setForceRefresh] = useState(0);
const [msgInput, setMsgInput] = useState('');
const [showTransfer, setShowTransfer] = useState(false);
const [transferAmt, setTransferAmt] = useState('');
const [showEmoji, setShowEmoji] = useState(false);
const [groups, setGroups] = useState([]);
const [showCreateGroup, setShowCreateGroup] = useState(false);
const [groupName, setGroupName] = useState('');
const [showPlusMenu, setShowPlusMenu] = useState(false); // 顶部⊕下拉菜单
const [topSearchFocus, setTopSearchFocus] = useState(false); // 顶部搜索框激活
const [activeGroup, setActiveGroup] = useState(null);
const [groupMessages, setGroupMessages] = useState([]);
const [groupMsgInput, setGroupMsgInput] = useState('');
const [groupDetail, setGroupDetail] = useState(null);
const [showGroupMembers, setShowGroupMembers] = useState(false);
const [showGroupMenu, setShowGroupMenu] = useState(false);
const [showAddGroupMember, setShowAddGroupMember] = useState(false);
const [showTransferOwner, setShowTransferOwner] = useState(false);
const [groupMentionNotify, setGroupMentionNotify] = useState(() => {
try { return JSON.parse(localStorage.getItem('groupMentionNotify_'+user.id)||'{}'); } catch(e) { return {}; }
});
// 共享开奖号码(从服务端获取,用于双方查看中奖金额)
const [sharedDrawNums, setSharedDrawNums] = useState(null);
useEffect(() => {
(async () => {
try {
const [macau1Res, macau2Res, hongkongRes] = await Promise.all([
get('/speed-calc/draw-numbers/current?lottery=macau1'),
get('/speed-calc/draw-numbers/current?lottery=macau2'),
get('/speed-calc/draw-numbers/current?lottery=hongkong')
]);
const macau1Nums = (macau1Res.code === 200 && macau1Res.data) ? macau1Res.data.numbers : null;
const macau2Nums = (macau2Res.code === 200 && macau2Res.data) ? macau2Res.data.numbers : null;
const hongkongNums = (hongkongRes.code === 200 && hongkongRes.data) ? hongkongRes.data.numbers : null;
setSharedDrawNums({
macau1: macau1Nums || [null,null,null,null,null,null,null],
macau2: macau2Nums || [null,null,null,null,null,null,null],
hongkong: hongkongNums || [null,null,null,null,null,null,null]
});
} catch(e) {}
})();
}, [user.id]);
const [groupUnread, setGroupUnread] = useState(() => {
try { return JSON.parse(localStorage.getItem('groupUnread_'+user.id)||'{}'); } catch(e) { return {}; }
});
const [contextMenu, setContextMenu] = useState(null);
const [editGroupName, setEditGroupName] = useState(false);
const [editGroupAnnounce, setEditGroupAnnounce] = useState(false);
const [groupNameInput, setGroupNameInput] = useState('');
const [groupAnnounceInput, setGroupAnnounceInput] = useState('');
const [privateChatMenu, setPrivateChatMenu] = useState(null);
const [showAllFriends, setShowAllFriends] = useState(false);
const [recentContacts, setRecentContacts] = useState(() => {
try {
const cached = JSON.parse(localStorage.getItem('recentContacts_'+user.id) || '[]');
// 优化:仍然从localStorage初始化,但会在登录时从服务器同步
return cached;
} catch(e){ return []; }
});
const [deleteConfirm, setDeleteConfirm] = useState(null);
const [showChatMenu, setShowChatMenu] = useState(false);
const [deletedChats, setDeletedChats] = useState(() => {
try { return JSON.parse(localStorage.getItem('deletedChats_'+user.id) || '[]'); } catch(e){ return []; }
});
const [showMorePanel, setShowMorePanel] = useState(false);
const [groupShowMorePanel, setGroupShowMorePanel] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [playingVoiceId, setPlayingVoiceId] = useState(null);
const mediaRecorderRef = useRef(null);
const audioChunksRef = useRef([]);
const recordingTimerRef = useRef(null);
const recordingTimeRef = useRef(0);
const audioRef = useRef(null);
const [isVoiceMode, setIsVoiceMode] = useState(false);
const [isGroupVoiceMode, setIsGroupVoiceMode] = useState(false);
const pttModeRef_removed = true; // moved to global as pttModeRef_global
const [pttRecording, setPttRecording] = useState(false);
const [pttPressed, setPttPressed] = useState(false);
const [pttTime, setPttTime] = useState(0);
const pttTimerRef = useRef(null);
const pttTimeRef = useRef(0);
// 【VOICE-V2】按住说话录音函数(组件内部,可访问 state)
const startRecording = async (opts = {}) => {
pttModeRef_global = !!opts.ptt;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : (MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/ogg');
const mr = new MediaRecorder(stream, { mimeType });
audioChunksRef.current = [];
mr.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); };
mr.onstop = () => {
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(audioChunksRef.current, { type: mimeType.split(';')[0] });
const dur = recordingTimeRef.current;
if (dur < 1) { showToast('录音时间太短'); return; }
const reader = new FileReader();
reader.onloadend = () => { sendVoiceMessage(reader.result, dur); };
reader.readAsDataURL(blob);
};
mr.start();
mediaRecorderRef.current = mr;
setIsRecording(true);
setRecordingTime(0);
recordingTimeRef.current = 0;
recordingTimerRef.current = setInterval(() => {
setRecordingTime(t => t + 1);
recordingTimeRef.current += 1;
if (recordingTimeRef.current >= 60) { stopRecording(); }
}, 1000);
} catch(e) {
console.error('[VOICE] getUserMedia error:', e.name, e.message);
if (!navigator.mediaDevices) { showToast('当前环境不支持录音(需要HTTPS)'); }
else if (e.name === 'NotAllowedError') { showToast('麦克风权限被拒绝,请在浏览器地址栏点击锁图标允许麦克风'); }
else if (e.name === 'NotFoundError') { showToast('未检测到麦克风设备'); }
else { showToast('录音失败: ' + e.message); }
}
};
const stopRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop();
}
clearInterval(recordingTimerRef.current);
setIsRecording(false);
};
const cancelRecording = () => {
if (mediaRecorderRef.current) {
mediaRecorderRef.current.onstop = null;
mediaRecorderRef.current.stream.getTracks().forEach(t => t.stop());
try { mediaRecorderRef.current.stop(); } catch(e){}
}
clearInterval(recordingTimerRef.current);
setIsRecording(false);
setRecordingTime(0);
};
const playVoice = (msgId, audioData) => {
if (playingVoiceId === msgId) {
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
setPlayingVoiceId(null);
return;
}
if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
const audio = new Audio(audioData);
audio.onended = () => setPlayingVoiceId(null);
audio.onerror = () => { showToast('语音播放失败'); setPlayingVoiceId(null); };
audio.play().catch(() => showToast('语音播放失败'));
audioRef.current = audio;
setPlayingVoiceId(msgId);
};
const sendVoiceMessage = (base64Audio, duration) => {
if (chatFriend) {
const tempMsg = { id: 'temp_voice_' + Date.now(), senderId: user.id, senderName: user.nickname, receiverId: chatFriend.id, content: base64Audio, type: 'voice', duration: duration, createdAt: new Date().toISOString() };
setMessages(prev => [...prev, tempMsg]);
const localKey = 'chat_' + user.id + '_' + chatFriend.id;
try { const arr = JSON.parse(localStorage.getItem(localKey) || '[]'); arr.push(tempMsg); localStorage.setItem(localKey, JSON.stringify(arr)); } catch(e){}
post('/chat/send', { receiverId: chatFriend.id, content: base64Audio, type: 'voice', duration: duration });
scrollToBottom();
}
if (activeGroup) {
const msg = { id: 'tmp_g_voice_' + Date.now(), senderId: user.id, nickname: user.nickname, groupId: activeGroup.id, content: base64Audio, type: 'voice', duration: duration, createdAt: new Date().toISOString() };
setGroupMessages(prev => [...prev, msg]);
if (window._socket) window._socket.emit('group_msg', { groupId: activeGroup.id, userId: user.id, nickname: user.nickname, content: base64Audio, type: 'voice', duration: duration, mentions: [] });
scrollToBottom();
}
};
let _pttStarting = false;
const startPTT = async () => {
if (_pttStarting || pttModeRef_global) return;
_pttStarting = true;
setPttPressed(true);
try {
await startRecording({ ptt: true });
setPttRecording(true);
} catch(e) {
setPttPressed(false);
_pttStarting = false;
console.error('PTT error:', e);
}
};
const stopPTT = () => {
if (!_pttStarting && !pttModeRef_global) return;
_pttStarting = false;
setPttPressed(false);
setPttRecording(false);
stopRecording();
};
const cancelPTT = () => {
_pttStarting = false;
setPttPressed(false);
setPttRecording(false);
cancelRecording();
};
const [redPacketModal, setRedPacketModal] = useState(false);
// 编辑账单模态框
const [editBillModal, setEditBillModal] = useState(null); // {msg, chatType, chatKey}
const [editBillContent, setEditBillContent] = useState('');
// 聊天设置面板(返点比例、赔率)
const [showChatSettings, setShowChatSettings] = useState(false);
// 使用方法弹窗
const [showUsageGuide, setShowUsageGuide] = useState(false);
// 挑码助手弹窗
const [showPickerHelper, setShowPickerHelper] = useState(false);
const [chatSettings, setChatSettings] = useState(() => {
try { return JSON.parse(localStorage.getItem('chatSettings_'+user.id) || '{}'); } catch(e) {
return {};
}
});
const [previewImg, setPreviewImg] = useState(null);
const [linkViewerUrl, setLinkViewerUrl] = useState(null);
const previewOpenTime = useRef(0);
const [redPacketAmt, setRedPacketAmt] = useState('');
const [redPacketCount, setRedPacketCount] = useState('');
const [redPacketMsg, setRedPacketMsg] = useState('');
const [redPacketType, setRedPacketType] = useState('normal'); // normal, relay, mine
const [redPacketMode, setRedPacketMode] = useState('min'); // min=最小接龙, lucky=运气王接龙
const [redPacketMine, setRedPacketMine] = useState('0'); // 0-9
const [redPacketDetail, setRedPacketDetail] = useState(null); // 查看红包详情
const [redPacketOpen, setRedPacketOpen] = useState(null); // 开红包弹窗
// 客服列表
const [customerServiceList, setCustomerServiceList] = useState([]);
const [claimedPacketIds, setClaimedPacketIds] = useState(() => new Map());
// 红包领取通知(谁领了我的红包)
const [redpacketNotifications, setRedpacketNotifications] = useState([]);
const fileInputRef = useRef(null);
const cameraInputRef = useRef(null);
const uploadInputRef = useRef(null);
const groupUploadInputRef = useRef(null);
const groupFileInputRef = useRef(null);
const groupCameraInputRef = useRef(null);
const groupMsgEndRef = useRef(null);
const groupScrollRef = useRef(null);
const groupUserScrolling = useRef(false);
const groupMsgInputRef = useRef(null);
const activeGroupRef = useRef(null);
// 【ACTIVE-GROUP-REF-FIX-v1】2026/05/30 将 activeGroupRef 挂到 window 供 App 组件内的 socket handler 跨作用域访问,根治 ReferenceError
if (typeof window !== 'undefined') { window.__activeGroupRef = activeGroupRef; }
const groupSocketRef = useRef(null);
const msgEndRef = useRef(null);
const msgScrollRef = useRef(null);
const msgUserScrolling = useRef(false);
const chatFriendRef = useRef(null);
const friendsRef = useRef([]);
const EMOJIS = ['😀','😂','🤣','😍','😘','🥰','😎','🤑','😭','😤','👍','👏','🎉','🔥','❤️','💰','🧧','🎲','🐭','🐮','🐯','🐰','🐲','🐍','🐴','🐑','🐵','🐔','🐶','🐷','🍀','💎','🛑'];
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
showToast('已复制');
} catch(e) {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast('已复制');
}
};
const saveQuote = (msgId, quote) => {
try { localStorage.setItem('quote_' + msgId, JSON.stringify(quote)); } catch(e){}
};
const loadQuote = (msgId) => {
try { return JSON.parse(localStorage.getItem('quote_' + msgId) || 'null'); } catch(e){ return null; }
};
const saveImage = (key, base64) => {
try { localStorage.setItem('img_' + key, base64); } catch(e){}
};
const loadImage = (key) => {
try { return localStorage.getItem('img_' + key); } catch(e){ return null; }
};
const clearChatImages = (prefix) => {
try {
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith('img_' + prefix)) keys.push(k);
}
keys.forEach(k => localStorage.removeItem(k));
} catch(e){}
};
// 引用、转发、多选 state
const [replyTo, setReplyTo] = useState(null);
const [groupReplyTo, setGroupReplyTo] = useState(null);
const [roomReplyTo, setRoomReplyTo] = useState(null);
const [showForwardModal, setShowForwardModal] = useState(false);
const [forwardMsg, setForwardMsg] = useState(null);
const [forwardSearch, setForwardSearch] = useState('');
const [recentForwarders, setRecentForwarders] = useState([]);
useEffect(() => {
if (user?.id) {
try { setRecentForwarders(JSON.parse(localStorage.getItem('recentForwarders_' + user.id) || '[]')); } catch(e){}
}
}, [user?.id]);
const saveRecentForwarder = (target) => {
if (!user?.id || !target) return;
const key = 'recentForwarders_' + user.id;
const list = recentForwarders.filter(r => r.id !== target.id);
const updated = [target, ...list].slice(0, 10);
setRecentForwarders(updated);
try { localStorage.setItem(key, JSON.stringify(updated)); } catch(e){}
};
const doForwardToTarget = async (target) => {
if (!forwardMsg?.messages?.length || !target) return;
const isGroup = target.type === 'group';
for (const msg of forwardMsg.messages) {
if (isGroup) {
if (window._socket && window._socket.connected) {
window._socket.emit('group_msg', { groupId: target.id, userId: user.id, nickname: user.nickname, content: msg.content, type: msg.type || 'text', mentions: [] });
}
} else {
await post('/chat/send', { receiverId: target.id, content: msg.content, type: msg.type || 'text' });
}
}
saveRecentForwarder(target);
setShowForwardModal(false);
setForwardMsg(null);
setForwardSearch('');
showToast('已转发给 ' + (target.name || target.nickname));
};
const [multiSelectMode, setMultiSelectMode] = useState(false);
const [selectedMsgs, setSelectedMsgs] = useState([]);
const [groupMultiSelect, setGroupMultiSelect] = useState(false);
const [groupSelectedMsgs, setGroupSelectedMsgs] = useState([]);
const [roomMultiSelect, setRoomMultiSelect] = useState(false);
const [roomSelectedMsgs, setRoomSelectedMsgs] = useState([]);
// 本地消息存储
const getChatKey = (fid) => 'chat_' + user.id + '_' + fid;
const saveLocal = (fid, msgs) => {
try { localStorage.setItem(getChatKey(fid), JSON.stringify(msgs.slice(-200))); } catch(e){}
};
const loadLocal = (fid) => {
try { return JSON.parse(localStorage.getItem(getChatKey(fid)) || '[]'); } catch(e){ return []; }
};
// 跟踪已删除的账单ID(防止服务端数据覆盖删除操作)
const getDeletedBillIds = (fid) => {
try { return JSON.parse(localStorage.getItem('deletedBillIds_'+user.id+'_'+fid) || '[]'); } catch(e){ return []; }
};
const addDeletedBillId = (fid, msgId) => {
try {
let ids = getDeletedBillIds(fid);
if (!ids.includes(msgId)) { ids.push(msgId); localStorage.setItem('deletedBillIds_'+user.id+'_'+fid, JSON.stringify(ids)); }
} catch(e){}
};
const filterDeletedBills = (msgs, fid) => {
const deletedIds = getDeletedBillIds(fid);
if (deletedIds.length === 0) return msgs;
return msgs.filter(m => !deletedIds.includes(m.id));
};
// Weekly auto-clean localStorage chat cache
useEffect(() => {
try {
const cleanKey = 'chatCleanTime_' + user.id;
const lastClean = parseInt(localStorage.getItem(cleanKey) || '0');
const now = Date.now();
const oneWeek = 7 * 24 * 60 * 60 * 1000;
if (now - lastClean > oneWeek) {
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith('chat_' + user.id + '_')) keysToRemove.push(k);
}
keysToRemove.forEach(k => localStorage.removeItem(k));
localStorage.setItem(cleanKey, String(now));
}
} catch(e){}
}, []);
useEffect(() => { /* fetchFriends defined later, call via timeout to avoid TDZ */ }, []);
useEffect(() => { buildRecentContacts(); }, [friends, groups]);
// 【OFFLINE-QUEUE-GLOBAL】全局监听网络恢复,重发离线队列(覆盖私聊场景)
useEffect(() => {
const handleOnline = () => {
console.log('[网络] 恢复在线,检查离线队列');
try {
const offlineKey = 'offline_queue_' + user.id;
const offlineMsgs = JSON.parse(localStorage.getItem(offlineKey) || '[]');
if (offlineMsgs.length === 0) return;
console.log('[离线队列] 网络恢复,重��', offlineMsgs.length, '条消息');
offlineMsgs.forEach((m, idx) => {
setTimeout(() => {
if (m.chatType === 'group' && m.payload) {
if (window._socket && window._socket.connected) {
window._socket.emit('group_msg', m.payload);
} else {
showToast('网络未完全恢复,群消息稍后重发');
}
} else if (m.chatType === 'private') {
const token = localStorage.getItem('token');
fetch(API + '/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ receiverId: m.receiverId, content: m.content, type: m.type || 'text', skipBillStats: m.skipBillStats })
}).then(r => r.json()).then(r => {
if (r.code === 200) {
// 重发成功,从离线队列移除
try {
let q = JSON.parse(localStorage.getItem(offlineKey) || '[]');
q = q.filter(x => x.tempId !== m.tempId);
localStorage.setItem(offlineKey, JSON.stringify(q));
} catch(e) {}
}
}).catch(e => console.warn('[离线队列] 私聊重发失败:', m.tempId));
}
}, idx * 500);
});
} catch(e) { console.warn('[离线队列] 重发异常:', e); }
};
window.addEventListener('online', handleOnline);
// 启动时也检查一次
if (navigator.onLine) setTimeout(handleOnline, 2000);
return () => window.removeEventListener('online', handleOnline);
}, []);
// 监听其他页面收到的新消息,更新联系人列表
useEffect(() => {
const handleUpdate = (e) => { if (e.detail) setRecentContacts(e.detail); };
window.addEventListener('recent_contacts_update', handleUpdate);
// 【新增】监听重建最近联系人事件(例如邀请注册后拉取到上级代理聊天记录后触发)
const handleRebuild = () => { try { buildRecentContacts(); } catch(e) {} };
window.addEventListener('recent_contacts_rebuild', handleRebuild);
// 【新增】监听全局 Socket 消息触发的重建事件
window.addEventListener('rebuild_recent_contacts', handleRebuild);
// 监听 @提醒和群未读更新
const handleMention = (e) => { if (e.detail) setGroupMentionNotify(e.detail); };
const handleUnread = (e) => { if (e.detail) setGroupUnread(e.detail); };
window.addEventListener('group_mention_update', handleMention);
window.addEventListener('group_unread_update', handleUnread);
return () => {
window.removeEventListener('recent_contacts_update', handleUpdate);
window.removeEventListener('recent_contacts_rebuild', handleRebuild);
window.removeEventListener('rebuild_recent_contacts', handleRebuild);
window.removeEventListener('group_mention_update', handleMention);
window.removeEventListener('group_unread_update', handleUnread);
};
}, []);
// 返回键处理
useEffect(() => {
chatFriendRef.current = chatFriend;
window._currentChatFriend = chatFriend; // 同步到全局,ProfilePage 统计页面需要
if (!chatFriend) return;
msgUserScrolling.current = false;
window.history.pushState({ chat: true }, '');
const handlePop = (e) => {
// 【彻底修复】如果红包详情全屏页正在显示,拦截返回键关闭详情页而不是关闭私聊
if (window.__redPacketDetailActive && typeof window.__closeRedPacketDetail === 'function') {
window.history.pushState({ chat: true }, ''); // 重新推入私聊状态
window.__closeRedPacketDetail(); // 关闭详情页
return;
}
if (window.__redPacketOpenActive && typeof window.__closeRedPacketOpen === 'function') {
window.history.pushState({ chat: true }, '');
window.__closeRedPacketOpen();
return;
}
if (e.state && (e.state.chat || e.state.redPacketDetail)) return;
setChatFriend(null); setShowEmoji(false); buildRecentContacts();
};
window.addEventListener('popstate', handlePop);
return () => window.removeEventListener('popstate', handlePop);
}, [chatFriend]);
// 自动滚到底部
useEffect(() => {
if (msgEndRef.current && !msgUserScrolling.current) msgEndRef.current.scrollIntoView({ behavior: 'instant' });
}, [messages]);
const msgClearedRef = useRef(false);
const getClearedKey = (fid) => getClearedKeyGlobal(user.id, fid);
const markCleared = (fid) => markClearedGlobal(user.id, fid);
const getClearedTime = (fid) => getClearedTimeGlobal(user.id, fid);
const isCleared = (fid) => isClearedGlobal(user.id, fid);
const unmarkCleared = (fid) => unmarkClearedGlobal(user.id, fid);
useEffect(() => { _imgPreviewCb = setPreviewImg; return () => { if (_imgPreviewCb === setPreviewImg) _imgPreviewCb = null; }; }, []);
// 轮询消息
useEffect(() => {
if (!chatFriend) return;
msgClearedRef.current = false;
const iv = setInterval(() => { if (!msgClearedRef.current) fetchMessages(chatFriend.id); }, 3000);
return () => clearInterval(iv);
}, [chatFriend]);
// 从服务器加载速算配置
// Group chat effects
useEffect(() => {
activeGroupRef.current = activeGroup;
if (!activeGroup) return;
groupUserScrolling.current = false;
window.history.pushState({ groupChat: true }, '');
const handlePop = (e) => {
// 【彻底修复】如果红包详情全屏页正在显示,拦截返回键关闭详情页而不是关闭群聊
if (window.__redPacketDetailActive && typeof window.__closeRedPacketDetail === 'function') {
window.history.pushState({ groupChat: true }, ''); // 重新推入群聊状态
window.__closeRedPacketDetail(); // 关闭详情页
return;
}
// 【修复】如果红包开启弹窗正在显示,拦截返回键关闭红包弹窗而不是关闭群聊
if (window.__redPacketOpenActive && typeof window.__closeRedPacketOpen === 'function') {
window.history.pushState({ groupChat: true }, ''); // 重新推入群聊状态
window.__closeRedPacketOpen();
return;
}
if (e.state && (e.state.groupChat || e.state.redPacketDetail)) return;
setActiveGroup(null); setShowGroupMembers(false); setShowAddGroupMember(false);
};
window.addEventListener('popstate', handlePop);
return () => window.removeEventListener('popstate', handlePop);
}, [activeGroup]);
useEffect(() => {
if (groupMsgEndRef.current && !groupUserScrolling.current) groupMsgEndRef.current.scrollIntoView({ behavior: 'instant' });
}, [groupMessages]);
// 页面可见性变化时同步消息(最小化后重新打开)
useEffect(() => {
if (!activeGroup) return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// 重新打开群聊窗口,同步最新消息
syncGroupMessages(activeGroup.id);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [activeGroup]);
useEffect(() => {
if (!activeGroup) return;
const waitAndJoin = () => {
if (window._socket && window._socket.connected) {
window._socket.emit('join_group', { groupId: activeGroup.id });
const handler = (data) => {
if (data.groupId === activeGroup.id) {
// visibleTo 过滤:有 visibleTo 的消息只对指定用户可见
if (data.visibleTo && Array.isArray(data.visibleTo) && !data.visibleTo.includes(user.id)) return;
// 自己发的非红包消息:替换本地临时消息(用服务端消息替代tempMsg,避免重复)
if (data.senderId === user.id && data.msgType !== 'redpacket') {
setGroupMessages(prev => {
// 查找并替换临时消息
const tmpIdx = prev.findIndex(m => m.id && m.id.startsWith('tmp_g_') && m.senderId === user.id && m.content === data.content);
if (tmpIdx >= 0) {
const updated = [...prev];
updated[tmpIdx] = data;
saveGroupLocal(activeGroup.id, updated);
return updated;
}
// 如果找不到temp,检查是否已存在
if (prev.some(m => m.id === data.id)) return prev;
const updated = [...prev, data];
saveGroupLocal(activeGroup.id, updated);
return updated;
});
return;
}
setGroupMessages(prev => {
if (prev.some(m => m.id === data.id)) return prev;
const updated = [...prev, data];
saveGroupLocal(activeGroup.id, updated);
return updated;
});
if (data.mentions && data.mentions.includes(user.id) && data.senderId !== user.id) {
try { if (navigator.vibrate) navigator.vibrate(200); } catch(e){}
}
}
};
window._socket.on('group_msg', handler);
// 红包领取通知已改为 group_msg + visibleTo,不再使用 redpacket_claimed 事件
// 监听Socket重连事件,确保重新加入群 + 重发离线消息
const reconnectHandler = () => {
if (window._socket && window._socket.connected && activeGroup) {
window._socket.emit('join_group', { groupId: activeGroup.id });
// 【OFFLINE-QUEUE-RESEND】网络恢复后自动重发离线队列
try {
const offlineKey = 'offline_queue_' + user.id;
const offlineMsgs = JSON.parse(localStorage.getItem(offlineKey) || '[]');
if (offlineMsgs.length > 0) {
console.log('[离线队列] 网络恢复,重发', offlineMsgs.length, '条消息');
offlineMsgs.forEach((m, idx) => {
setTimeout(() => {
if (m.chatType === 'group' && m.payload) {
if (window._socket && window._socket.connected) {
window._socket.emit('group_msg', m.payload);
}
} else if (m.chatType === 'private') {
const token = localStorage.getItem('token');
fetch(API + '/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ receiverId: m.receiverId, content: m.content, type: m.type || 'text', skipBillStats: m.skipBillStats })
}).catch(e => console.warn('[离线队列] 私聊重发失败:', m.tempId));
}
}, idx * 500); // 每条间隔 500ms,避免瞬间爆发
});
}
} catch(e) { console.warn('[离线队列] 重发异常:', e); }
}
};
window._socket.on('reconnect', reconnectHandler);
// 定期检查Socket连接状态
const checkInterval = setInterval(() => {
if (window._socket && !window._socket.connected && activeGroup) {
// Socket断开,尝试重连
window._socket.connect();
}
}, 5000);
return () => {
clearInterval(checkInterval);
window._socket.off('group_msg', handler);
window._socket.off('reconnect', reconnectHandler);
// 【修复】不再发送 leave_group,用户始终留在群房间以确保实时收到消息
};
} else {
const tid = setTimeout(waitAndJoin, 500);
return () => clearTimeout(tid);
}
};
const cleanup = waitAndJoin();
// 监听全局群消息事件(直接socket handler已处理当前群消息,此处不再重复添加)
const globalGroupMsgHandler = (event) => {
// 直接socket handler已经处理了当前活跃群的消息添加
// 此handler不重复添加,避免红包等消息显示两次
};
window.addEventListener('new_group_message', globalGroupMsgHandler);
// 红包领取通知已改为 group_msg + visibleTo,不再监听 redpacket_claimed 事件
return () => {
activeGroupRef.current = null;
if (typeof cleanup === 'function') cleanup();
window.removeEventListener('new_group_message', globalGroupMsgHandler);
};
}, [activeGroup]);
// 【修复-头像本地缓存】头像 base64 本地缓存机制:已禁用 [AVATAR-CACHE-V1-DISABLED]
// 禁用原因:每个 base64 头像 30-100KB,80 个就 4MB+;每次 _getAvatarCache() 都 JSON.parse 整个对象,
// 反复触发导致内存暴增(OOM 元凶)。头像缓存改由 浏览器 HTTP 缓存 + TAB-KEEP-ALIVE-V1 组件常驻 保障。
// 调试编号:[AVATAR-CACHE-V1-DISABLED]
// 启动时清理历史 4MB+ 缓存(一次性,避免老用户残留 OOM 隐患)
try { if (localStorage.getItem('avatarLocalCache_v1')) { localStorage.removeItem('avatarLocalCache_v1'); console.log('[AVATAR-CACHE-V1-DISABLED] cleared legacy cache'); } } catch(e){}
const _avatarCacheKey = 'avatarLocalCache_v1';
const _getAvatarCache = () => ({});
const _saveAvatarCache = () => {};
const _cacheAvatarAsync = () => {};
const resolveAvatar = (url) => url || '';
const applyAvatarCache = (arr) => Array.isArray(arr) ? arr : arr;
const fetchFriends = async (retry = 0) => {
const res = await get('/friends?t=' + Date.now());
if (res.code === 200) {
// 【修复-头像本地缓存】首次访问走网络并异步缓存为 base64;后续切换/重启都从本地读取
const friendsWithData = applyAvatarCache(res.data);
setFriends(friendsWithData);
friendsRef.current = friendsWithData;
// 【新增】对于本地无聊天记录的好友(如邀请注册自动建立的上级代理),尝试从服务器拉取一次聊天记录,确保最近联系人能显示
try {
const deletedKey = 'deletedChats_' + user.id;
const deleted = JSON.parse(localStorage.getItem(deletedKey) || '[]');
let needRebuild = false;
for (const fr of friendsWithData) {
// 跳过已被用户主动删除的聊天,以免被“复活”
if (deleted.includes('f_' + fr.id)) continue;
const localKey = 'chat_' + user.id + '_' + fr.id;
const localMsgs = localStorage.getItem(localKey);
if (localMsgs && localMsgs !== '[]') continue; // 已有本地记录,不需拉取
// 异步拉取不阻塞
(async (frId) => {
try {
const r = await get('/chat/messages?friendId=' + frId);
if (r.code === 200 && Array.isArray(r.data) && r.data.length > 0) {
const k = 'chat_' + user.id + '_' + frId;
localStorage.setItem(k, JSON.stringify(r.data));
// 拉取到内容后触发重建最近联系人
window.dispatchEvent(new CustomEvent('recent_contacts_rebuild'));
}
} catch(e) {}
})(fr.id);
needRebuild = true;
}
} catch(e) {}
}
else if (retry < 3) { setTimeout(() => fetchFriends(retry + 1), 2000 * (retry + 1)); }
};
const fetchRequests = async (retry = 0) => {
const res = await get('/friends/requests');
if (res.code === 200) setFriendRequests(res.data);
else if (retry < 2) { setTimeout(() => fetchRequests(retry + 1), 2000 * (retry + 1)); }
};
const fetchGroups = async () => {
const res = await get('/groups');
if (res.code === 200) {
const gs = res.data;
for (const g of gs) {
try {
const d = await get('/groups/detail?groupId=' + g.id);
if (d.code === 200 && d.data?.memberList) {
// 【修复-头像本地缓存】群成员头像同样走本地缓存,避免切换页面重新下载
g.memberAvatars = d.data.memberList.slice(0, 9).map(m => {
const av = m.avatar || '';
if (!av || av.startsWith('data:')) return av;
const cleanUrl = av.split('?')[0];
const cache = _getAvatarCache();
if (cache[cleanUrl]) return cache[cleanUrl];
_cacheAvatarAsync(cleanUrl);
return av;
});
// 缓存官方群的虚拟用户头像到localStorage
if (g.id === 'official_group_abc' && d.data.memberList) {
const virtualMembers = d.data.memberList.filter(m => m.isVirtual);
if (virtualMembers.length > 0) {
try {
localStorage.setItem('virtualUsersCache', JSON.stringify({
members: virtualMembers,
cachedAt: Date.now()
}));
} catch(e) {}
}
}
}
} catch(e){}
}
setGroups(gs);
}
};
const fetchCustomerService = async () => {
try {
const res = await get('/friends?t=' + Date.now());
if (res.code === 200 && res.data) {
// 【修复-头像本地缓存】首次访问走网络并异步缓存;后续切换/重启都从本地读取
const csList = applyAvatarCache(res.data.filter(u => u.role === 'customer_service'));
setCustomerServiceList(csList);
}
} catch(e) {}
};
const saveDeletedChats = (list) => {
setDeletedChats(list);
try { localStorage.setItem('deletedChats_'+user.id, JSON.stringify(list)); } catch(e){}
};
// 【修复】在所有 fetch* 函数定义之后再调用,避免 TDZ
useEffect(() => { fetchFriends(); fetchRequests(); fetchGroups(); fetchCustomerService(); }, []);
const saveGroupLocal = (groupId, msgs) => {
try { localStorage.setItem('groupMsgs_'+user.id+'_'+groupId, JSON.stringify(msgs)); } catch(e){}
};
const loadGroupLocal = (groupId) => {
try {
let msgs = JSON.parse(localStorage.getItem('groupMsgs_'+user.id+'_'+groupId) || '[]');
// 【修复-清理历史重复数据】清除旧版重复缓存,首次加载时以服务端为准
if (msgs.length > 0) {
const deduped = [];
const seenIds = new Set();
for (const m of msgs) {
if (m.id && !seenIds.has(m.id)) {
seenIds.add(m.id);
deduped.push(m);
}
}
if (deduped.length < msgs.length) {
console.log('[MSG-DEDUP] 清理重复消息:', msgs.length, '->', deduped.length);
saveGroupLocal(groupId, deduped);
msgs = deduped;
}
}
return msgs;
} catch(e){ return []; }
};
function buildRecentContacts(skipServerFetch) {
return (async () => {
// 【修复-状态守卫】如果 friends 和 groups 同时为空(fetch未完成/网络失败),跳过本次构建,避免覆盖列表
if ((!friends || friends.length === 0) && (!groups || groups.length === 0)) {
console.log('[DEBUG-K][buildRecent] SKIP: friends=0 groups=0,避免覆盖');
return;
}
const items = [];
const readKeys = {};
const deleted = deletedChats || [];
// 【优化-已读持久化】先合并本地与服务器 readState,再计算未读数,避免清缓存/重登后红点闪回
try { const rk = JSON.parse(localStorage.getItem('readState_'+user.id) || '{}'); Object.assign(readKeys, rk); } catch(e){}
if (!skipServerFetch) {
try {
const _tk = localStorage.getItem('token');
if (_tk) {
const _rs = await fetch(API + '/sync/read-states', {
headers: { 'Authorization': 'Bearer ' + _tk }
}).then(r => r.json()).catch(() => ({ code: 0 }));
if (_rs && _rs.code === 200 && Array.isArray(_rs.data)) {
_rs.data.forEach(r => {
if (r && r.chatKey && (r.chatKey.startsWith('f_') || r.chatKey.startsWith('g_'))) {
const t = parseInt(r.lastReadTime || 0);
if (t && (!readKeys[r.chatKey] || t > readKeys[r.chatKey])) readKeys[r.chatKey] = t;
}
});
if (Object.keys(readKeys).length > 0) {
localStorage.setItem('readState_'+user.id, JSON.stringify(readKeys));
}
}
// 旧用户兜底:把当前本地已读线补传服务器,之后清缓存/换设备也不会丢
const seedKey = '_readStateSeeded_' + user.id;
if (!window[seedKey] && Object.keys(readKeys).length > 0) {
window[seedKey] = true;
fetch(API + '/sync/read-states', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _tk },
body: JSON.stringify({ states: Object.entries(readKeys).map(([chatKey, lastReadTime]) => ({ chatKey, lastReadTime })) })
}).catch(()=>{});
}
}
} catch(e) { console.warn('[read-states] 服务器同步失败:', e); }
}
// 本地兜底
if (Object.keys(readKeys).length === 0) {
try { const rk = JSON.parse(localStorage.getItem('readState_'+user.id) || '{}'); Object.assign(readKeys, rk); } catch(e){}
}
// 【优化-已读持久化】首次加载(服务器+本地都为空)时,按每个会话独立 initTime
// 逻辑:initTime = 该会话最新消息时间,这样该会话所有消息都算已读(unread=0)
const isFirstLoad = Object.keys(readKeys).length === 0;
if (isFirstLoad) {
const toSync = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (!k) continue;
if (k.startsWith('chat_' + user.id + '_')) {
try {
const msgs = JSON.parse(localStorage.getItem(k) || '[]');
if (msgs.length > 0) {
const last = msgs[msgs.length - 1];
const fid = k.split('_').slice(-1)[0];
const t = new Date(last.createdAt || 0).getTime();
if (t > 0) {
readKeys['f_' + fid] = t;
toSync.push({ chatKey: 'f_' + fid, lastReadTime: t });
}
}
} catch(e){}
} else if (k.startsWith('groupMsgs_' + user.id + '_')) {
try {
const msgs = JSON.parse(localStorage.getItem(k) || '[]');
if (msgs.length > 0) {
const last = msgs[msgs.length - 1];
const gid = k.split('_').slice(-1)[0];
const t = new Date(last.createdAt || 0).getTime();
if (t > 0) {
readKeys['g_' + gid] = t;
toSync.push({ chatKey: 'g_' + gid, lastReadTime: t });
}
}
} catch(e){}
}
}
// 保存到本地
localStorage.setItem('readState_'+user.id, JSON.stringify(readKeys));
// 同步到服务器(首次登录也上传一次)
try {
const _tk = localStorage.getItem('token');
if (_tk && toSync.length > 0) {
toSync.forEach(item => {
fetch(API + '/sync/read-states', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _tk },
body: JSON.stringify(item)
}).catch(()=>{});
});
}
} catch(e){}
}
// 先添加客服(始终显示在顶部)【CS-ALWAYS-INCLUDE-V3】
// 优先使用 customerServiceList 状态,其次使用 localStorage 缓存
const csList = customerServiceList.length > 0 ? customerServiceList : (() => {
try { return JSON.parse(localStorage.getItem('csListCache') || '[]'); } catch(e) { return []; }
})();
for (const cs of csList) {
if (deleted.includes('f_'+cs.id)) continue;
const key = 'chat_' + user.id + '_' + cs.id;
let lastTime = 0, lastMsg = '', unread = 0;
try {
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
const clearedTime = getClearedTimeGlobal(user.id, cs.id);
msgs = msgs.filter(m => new Date(m.createdAt || 0).getTime() > clearedTime);
if (msgs.length > 0) {
const last = msgs[msgs.length - 1];
lastTime = new Date(last.createdAt || 0).getTime();
if (last.type === 'transfer') { try { const td = JSON.parse(last.content); lastMsg = '[筹码转账] ' + td.amount; } catch(e){ lastMsg = '[筹码转账]'; } }
else if (last.type === 'redpacket' || last.msgType === 'redpacket') { try { const rd = JSON.parse(last.content); lastMsg = '[红包] ' + (rd.message || rd.content || ''); } catch(e){ lastMsg = '[红包]'; } }
else if (last.type === 'voice') { lastMsg = '[语音]'; }
else if (last.type === 'image') { lastMsg = '[图片]'; }
else { lastMsg = last.content || ''; }
if (lastMsg.length > 25) lastMsg = lastMsg.substring(0, 25) + '...';
const lastRead = readKeys['f_'+cs.id] || 0;
unread = msgs.filter(m => m.senderId !== user.id && new Date(m.createdAt||0).getTime() > lastRead).length;
}
} catch(e){}
if (lastTime === 0) {
lastTime = 9999999999999;
lastMsg = '🎧 在线客服';
}
items.push({ type: 'friend', data: cs, lastTime, lastMsg, unread });
}
// 添加官方群(始终显示在客服之后)
const officialGroup = groups.find(g => g.id === 'official_group_abc');
if (officialGroup && !deleted.includes('g_' + officialGroup.id)) {
const gKey = 'groupMsgs_' + user.id + '_' + officialGroup.id;
let lastTime = 0, lastMsg = '', unread = 0;
try {
let msgs = JSON.parse(localStorage.getItem(gKey) || '[]');
const clearedTime = getClearedTimeGlobal(user.id, 'g_' + officialGroup.id);
msgs = msgs.filter(m => new Date(m.createdAt || 0).getTime() > clearedTime);
if (msgs.length > 0) {
const last = msgs[msgs.length - 1];
lastTime = new Date(last.createdAt || 0).getTime();
if (last.type === 'system') {
try {
const sc = JSON.parse(last.content);
if (sc && sc.type === 'redpacket_claim') {
lastMsg = '[红包] ' + sc.claimerName + ' 领取了 ' + sc.amount + '元';
} else {
lastMsg = last.content || '';
}
} catch(e) {
lastMsg = last.content || '';
}
}
else if (last.type === 'voice') lastMsg = '[语音]';
else if (last.type === 'image') lastMsg = '[图片]';
else if (last.type === 'redpacket' || last.msgType === 'redpacket') { try { const rd = JSON.parse(last.content); lastMsg = '[红包] ' + (rd.message || rd.content || ''); } catch(e){ lastMsg = '[红包]'; } }
else lastMsg = (last.senderName ? last.senderName + ': ' : '') + (last.content || '');
if (lastMsg.length > 25) lastMsg = lastMsg.substring(0, 25) + '...';
const lastRead = readKeys['g_' + officialGroup.id] || 0;
unread = msgs.filter(m => new Date(m.createdAt || 0).getTime() > lastRead && m.senderId !== user.id).length;
}
} catch(e){}
// 如果没有任何消息,显示默认值
if (lastTime === 0) {
lastTime = 9999999999998; // 仅次于客服
lastMsg = '🎉 欢迎加入' + (officialGroup.name || '官方群');
}
items.push({ type: 'group', data: {...officialGroup, isOfficial: true}, lastTime, lastMsg, unread });
}
for (const fr of friends) {
// 跳过已显示的客服
if (customerServiceList.some(cs => cs.id === fr.id)) continue;
if (deleted.includes('f_'+fr.id)) continue;
const key = 'chat_' + user.id + '_' + fr.id;
let lastTime = 0, lastMsg = '', unread = 0;
let hasHistory = false;
try {
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
hasHistory = msgs.length > 0;
const clearedTime = getClearedTimeGlobal(user.id, fr.id);
msgs = msgs.filter(m => new Date(m.createdAt || 0).getTime() > clearedTime);
if (msgs.length > 0) {
const last = msgs[msgs.length - 1];
lastTime = new Date(last.createdAt || 0).getTime();
if (last.type === 'transfer') { try { const td = JSON.parse(last.content); lastMsg = '[筹码转账] ' + td.amount; } catch(e){ lastMsg = '[筹码转账]'; } }
else if (last.type === 'redpacket' || last.msgType === 'redpacket') { try { const rd = JSON.parse(last.content); lastMsg = '[红包] ' + (rd.message || rd.content || ''); } catch(e){ lastMsg = '[红包]'; } }
else if (last.type === 'voice') { lastMsg = '[语音]'; }
else if (last.type === 'image') { lastMsg = '[图片]'; }
else { lastMsg = last.content || ''; }
if (lastMsg.length > 25) lastMsg = lastMsg.substring(0, 25) + '...';
const lastRead = readKeys['f_'+fr.id] || 0;
unread = msgs.filter(m => m.senderId !== user.id && new Date(m.createdAt||0).getTime() > lastRead).length;
}
} catch(e){}
if (lastTime > 0 || hasHistory) items.push({ type: 'friend', data: fr, lastTime, lastMsg, unread });
}
// 【CS-CHAT-CONTACTS-V1】扫描 localStorage 中有聊天记录但不在好友列表中的用户(确保客服能看到发过消息的用户)
try {
const existingIds = new Set(items.filter(i => i.type === 'friend').map(i => i.data.id));
const chatPrefix = 'chat_' + user.id + '_';
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (!k || !k.startsWith(chatPrefix)) continue;
const fid = k.substring(chatPrefix.length);
if (!fid || existingIds.has(fid)) continue;
// 跳过虚拟用户和客服(客服已在上面单独添加)
if (fid.startsWith('virtual_user_')) continue;
let lastTime = 0, lastMsg = '', unread = 0;
try {
let msgs = JSON.parse(localStorage.getItem(k) || '[]');
const clearedTime = getClearedTimeGlobal(user.id, fid);
msgs = msgs.filter(m => new Date(m.createdAt || 0).getTime() > clearedTime);
if (msgs.length === 0) continue;
const last = msgs[msgs.length - 1];
lastTime = new Date(last.createdAt || 0).getTime();
if (last.type === 'transfer') { try { const td = JSON.parse(last.content); lastMsg = '[筹码转账] ' + td.amount; } catch(e){ lastMsg = '[筹码转账]'; } }
else if (last.type === 'redpacket' || last.msgType === 'redpacket') { try { const rd = JSON.parse(last.content); lastMsg = '[红包] ' + (rd.message || rd.content || ''); } catch(e){ lastMsg = '[红包]'; } }
else if (last.type === 'voice') { lastMsg = '[语音]'; }
else if (last.type === 'image') { lastMsg = '[图片]'; }
else { lastMsg = last.content || ''; }
if (lastMsg.length > 25) lastMsg = lastMsg.substring(0, 25) + '...';
const lastRead = readKeys['f_'+fid] || 0;
unread = msgs.filter(m => m.senderId !== user.id && new Date(m.createdAt||0).getTime() > lastRead).length;
} catch(e){ continue; }
if (lastTime <= 0) continue;
// 尝试从 localStorage 中的消息获取对方昵称和头像
let nick = fid.substring(0, 8) + '...';
let avatar = '';
try {
const sampleMsgs = JSON.parse(localStorage.getItem(k) || '[]');
for (let j = sampleMsgs.length - 1; j >= 0; j--) {
const m = sampleMsgs[j];
if (m.senderId === fid && m.senderName) { nick = m.senderName; avatar = m.senderAvatar || ''; break; }
if (m.receiverId === fid && m.senderId === user.id && m.senderName) { /* 自己是发送者,不能用senderName */ }
}
} catch(e){}
// 异步从服务器拉取真实昵称(不阻塞当前构建)
(async (friendId, idx) => {
try {
const _tk = localStorage.getItem('token');
if (!_tk) return;
const r = await fetch(API + '/user/profile/' + friendId, { headers: { 'Authorization': 'Bearer ' + _tk } });
const d = await r.json();
if (d.code === 200 && d.data) {
setRecentContacts(prev => {
const updated = prev.map(item => {
if (item.type === 'friend' && item.data.id === friendId && item.data.nickname && item.data.nickname.endsWith('...')) {
return { ...item, data: { ...item.data, nickname: d.data.nickname || item.data.nickname, avatar: d.data.avatar || item.data.avatar } };
}
return item;
});
return updated;
});
}
} catch(e) {}
})(fid, items.length);
items.push({ type: 'friend', data: { id: fid, nickname: nick, avatar, online: false, role: 'user' }, lastTime, lastMsg, unread });
existingIds.add(fid);
}
} catch(e) { console.warn('[CS-CHAT-CONTACTS] 扫描聊天记录失败:', e); }
console.log('[DEBUG-K][buildRecent] friends=' + friends.length + ' groups=' + groups.length + ' items.friend=' + items.filter(i=>i.type==='friend').length + ' items.group=' + items.filter(i=>i.type==='group').length);
for (const g of groups) {
// 跳过官方群,官方群已经在前面单独添加了
if (g.id === 'official_group_abc') continue;
let lastMsg = '', lastTime = g.updatedAt ? new Date(g.updatedAt).getTime() : new Date(g.createdAt||0).getTime(), unread = 0;
try {
let localMsgs = loadGroupLocal(g.id);
const clearedTime = getClearedTimeGlobal(user.id, 'g_' + g.id);
localMsgs = localMsgs.filter(m => new Date(m.createdAt || 0).getTime() > clearedTime);
if (localMsgs.length > 0) {
const last = localMsgs[localMsgs.length - 1];
lastTime = new Date(last.createdAt||0).getTime();
if (last.type === 'system') {
// 系统消息,检查是否是红包领取通知
try {
const sc = JSON.parse(last.content);
if (sc && sc.type === 'redpacket_claim') {
lastMsg = (last.senderName ? last.senderName+': ' : '') + '[红包] ' + sc.claimerName + ' 领取了 ' + sc.amount + '元';
} else {
lastMsg = last.content||'';
}
} catch(e) {
lastMsg = last.content||'';
}
}
else if (last.type === 'voice') lastMsg = (last.senderName ? last.senderName+': ' : '') + '[语音]';
else if (last.type === 'image') lastMsg = (last.senderName ? last.senderName+': ' : '') + '[图片]';
else if (last.type === 'redpacket' || last.msgType === 'redpacket') { try { const rd = JSON.parse(last.content); lastMsg = (last.senderName ? last.senderName+': ' : '') + '[红包] ' + (rd.message || rd.content || ''); } catch(e){ lastMsg = (last.senderName ? last.senderName+': ' : '') + '[红包]'; } }
else lastMsg = (last.senderName ? last.senderName+': ' : '') + (last.content||'');
if (lastMsg.length > 25) lastMsg = lastMsg.substring(0, 25) + '...';
const lastRead = readKeys['g_'+g.id] || 0;
unread = localMsgs.filter(m => new Date(m.createdAt||0).getTime() > lastRead && m.senderId !== user.id).length;
}
} catch(e){}
if (!deleted.includes('g_'+g.id)) items.push({ type: 'group', data: g, lastTime, lastMsg, unread });
}
items.sort((a, b) => {
// 有未读消息的排在前面
const aUnread = (a.unread || 0) > 0 ? 1 : 0;
const bUnread = (b.unread || 0) > 0 ? 1 : 0;
if (aUnread !== bUnread) return bUnread - aUnread;
// 同级别内按时间倒序
return b.lastTime - a.lastTime;
});
setRecentContacts(items);
try { localStorage.setItem('recentContacts_'+user.id, JSON.stringify(items)); } catch(e){}
// 优化:同步最近联系人到服务器(确保清除缓存后能恢复)
try {
const token = localStorage.getItem('token');
const contactsToSync = items
.filter(c => c.lastTime > 0 && c.lastTime < 9999999999999) // 排除默认显示的客服和官方群
.map(c => ({
contactId: c.data.id,
contactType: c.type,
lastTime: new Date(c.lastTime).toISOString(),
lastMsg: c.lastMsg || '',
unread: c.unread || 0
}));
if (contactsToSync.length > 0) {
fetch(API + '/sync/recent-contacts/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({ contacts: contactsToSync })
}).catch(e => console.error('Failed to sync recent contacts:', e));
}
} catch(e) {}
// 通知 FriendsPage 更新
window.dispatchEvent(new CustomEvent('recent_contacts_update', { detail: items }));
})();
}
const markRead = (type, id) => {
const chatKey = type + '_' + id;
const now = Date.now();
try {
const rk = JSON.parse(localStorage.getItem('readState_'+user.id) || '{}');
rk[chatKey] = now;
localStorage.setItem('readState_'+user.id, JSON.stringify(rk));
} catch(e){}
// 【优化-已读持久化】同步上传到服务器,保证换设备/重新登录后能恢复已读状态
try {
const _tk = localStorage.getItem('token');
if (_tk) {
fetch(API + '/sync/read-states', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _tk },
body: JSON.stringify({ chatKey, lastReadTime: now })
}).catch(()=>{});
}
} catch(e){}
// 重新构建最近联系人,确保未读数正确清零
// 传入 true 跳过服务器拉取,因为刚上传完本地已是最新
buildRecentContacts(true);
};
// Ensure socket is connected for group chat
useEffect(() => {
if (typeof io !== 'undefined') {
if (!window._socket || !window._socket.connected) {
const sock = io({ transports: ['websocket', 'polling'] });
sock.on('connect', () => { sock.emit('login', { userId: user.id, nickname: user.nickname, avatar: user.avatar }); });
// 【FREEZE-CHECK-V1】监听强制踢出事件(冻结/封禁)
sock.on('force_logout', (data) => {
alert('网络异常');
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
});
window._socket = sock;
}
}
// Transfer accepted listener
const transferHandler = (data) => {
if (data.senderId === user.id) {
// I'm the sender - update my local messages
setMessages(prev => prev.map(m => {
if (m.type === 'transfer') {
try { let td = JSON.parse(m.content); if (td.transferId === data.transferId) { td.status = 'accepted'; return {...m, content: JSON.stringify(td)}; } } catch(e){}
}
return m;
}));
showToast('🧧 ' + data.receiverName + ' 已领取你的' + data.amount + '筹码转账');
// Update localStorage
try {
const key = 'chat_' + user.id + '_' + data.receiverId;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.map(m => {
if (m.type === 'transfer') { try { let td = JSON.parse(m.content); if (td.transferId === data.transferId) { td.status = 'accepted'; return {...m, content: JSON.stringify(td)}; } } catch(e){} }
return m;
});
localStorage.setItem(key, JSON.stringify(msgs));
} catch(e){}
}
};
if (window._socket) window._socket.on('transfer_accepted', transferHandler);
// 实时私聊消息监听
const newMsgHandler = (data) => {
if (data.senderId === user.id) return;
if (data.receiverId !== user.id) return;
const fid = data.senderId;
const msg = data.message || {};
const senderName = msg.senderName || '未知';
// 【NOTIFY-DEDUP-V1】提示音和通知由 App 组件的全局 handler 统一处理,此处只更新最近联系人列表
// 新消息来时,自动恢复已删除的聊天
try {
const dk = 'deletedChats_'+user.id;
let del = JSON.parse(localStorage.getItem(dk) || '[]');
const fkey = 'f_'+data.senderId;
if (del.includes(fkey)) {
del = del.filter(d => d !== fkey);
localStorage.setItem(dk, JSON.stringify(del));
setDeletedChats(del);
}
} catch(e){}
// 【修复】将新消息保存到本地缓存,这样用户不用点开聊天窗口也能看到消息
try {
// 系统消息特殊处理:保存到与receiverId(会员)的聊天中
const chatKey = msg.type === 'system'
? 'chat_' + user.id + '_' + (msg.receiverId || data.receiverId)
: 'chat_' + user.id + '_' + data.senderId;
let msgs = JSON.parse(localStorage.getItem(chatKey) || '[]');
const msgId = msg.id || ('tmp_' + Date.now());
// 去重:检查消息是否已存在
if (!msgs.some(m => m.id === msgId)) {
msgs.push({
id: msgId,
senderId: data.senderId,
senderName: msg.type === 'system' ? '系统' : senderName,
senderAvatar: msg.senderAvatar || '',
receiverId: user.id,
content: msg.content || '',
type: msg.type || 'text',
createdAt: msg.createdAt || new Date().toISOString(),
billStats: msg.billStats || null,
billStatsSavedFor: msg.billStatsSavedFor || []
});
if (msgs.length > 200) msgs = msgs.slice(-200);
localStorage.setItem(chatKey, JSON.stringify(msgs));
// 保存消息到本地
}
} catch(e){}
// 【修复】派发新消息事件,触发总统计实时更新
try { window.dispatchEvent(new CustomEvent('new_message', { detail: data })); } catch(e) {}
// 更新最近联系人列表
setRecentContacts(prev => {
const existing = prev.find(rc => rc.type === 'friend' && rc.data.id === fid);
let lastMsg = data.message?.type === 'image' ? '[图片]' : ((data.message?.type === 'redpacket' || data.message?.msgType === 'redpacket') ? '[红包]' : (data.message?.content || ''));
if (lastMsg.length > 25) lastMsg = lastMsg.substring(0, 25) + '...';
const now = Date.now();
// 检查当前是否正在和这个用户聊天
const cf = chatFriendRef.current;
const isCurrentChat = cf && cf.id === fid;
// 【优化-已读持久化】如果窗口正打开,顺便更新 readState 时间戳(避免 3 秒轮询延迟导致未读)
if (isCurrentChat) {
try {
const rk = JSON.parse(localStorage.getItem('readState_'+user.id) || '{}');
rk['f_' + fid] = now;
localStorage.setItem('readState_'+user.id, JSON.stringify(rk));
} catch(e){}
}
// 【修复-unread准确性】从 localStorage 的消息列表和 readState 重新计算真实 unread 数
// 避免逐条 +1 累加在窗口开关状态切换时失真
let realUnread = 0;
try {
const ck = 'chat_' + user.id + '_' + fid;
const mArr = JSON.parse(localStorage.getItem(ck) || '[]');
const rks = JSON.parse(localStorage.getItem('readState_'+user.id) || '{}');
const lr = rks['f_' + fid] || 0;
realUnread = mArr.filter(m => m.senderId !== user.id && new Date(m.createdAt||0).getTime() > lr).length;
} catch(e){ realUnread = isCurrentChat ? 0 : ((existing?.unread||0) + 1); }
console.log('[DEBUG-K][newMsg] fid=' + fid.slice(0,6) + ' isCurrentChat=' + !!isCurrentChat + ' realUnread=' + realUnread + ' prevUnread=' + (existing?.unread||0));
let result;
if (existing) {
result = prev.map(rc => {
if (rc.type === 'friend' && rc.data.id === fid) {
return {...rc, lastTime: now, lastMsg, unread: realUnread};
}
return rc;
});
} else {
const fr = friendsRef.current.find(f => f.id === fid);
if (fr) {
result = [...prev, {type:'friend', data: fr, lastTime: now, lastMsg, unread: realUnread}];
} else {
result = [...prev, {type:'friend', data: { id: fid, nickname: data.message?.senderName || '未知', avatar: data.message?.senderAvatar || '' }, lastTime: now, lastMsg, unread: realUnread}];
}
}
result.sort((a, b) => b.lastTime - a.lastTime);
try { localStorage.setItem('recentContacts_'+user.id, JSON.stringify(result)); } catch(e){}
return result;
});
// 如果当前正在聊天,也刷新消息
const cf = chatFriendRef.current;
if (cf && cf.id === data.senderId) {
fetchMessages(data.senderId);
}
};
if (window._socket) window._socket.on('new_message', newMsgHandler);
// 注意:群消息的最近联系人更新、@提醒、免打扰计数均由 App 组件的全局 groupHandler 统一处理
const recallHandler = (data) => {
setGroupMessages(prev => prev.map(m => m.id === data.msgId ? {...m, type:'recalled', content: data.content, recalledBy: data.recalledBy, _origContent: m.content} : m));
// 刷新总统计
try { const dn=JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}'); if (showSpeedCalcStats) { setSpeedCalcStatsData(null); } window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e){}
};
const privateRecallHandler = (data) => {
setMessages(prev => prev.map(m => m.id === data.msgId ? {...m, recalled: true, originalContent: m.content, content: data.recalledBy + ' 撤回了一条消息'} : m));
// Also update localStorage
try {
const fid = data.senderId === user.id ? data.friendId : data.senderId;
const key = 'chat_' + user.id + '_' + fid;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.map(m => m.id === data.msgId ? {...m, recalled: true, originalContent: m.content, content: data.recalledBy + ' 撤回了一条消息'} : m);
localStorage.setItem(key, JSON.stringify(msgs));
} catch(e){}
};
const recallFailHandler = (data) => { if (data?.reason) showToast(data.reason); };
// 账单删除事件(接收方同步删除)【DEBUG-BILL-RT-v1】实时刷新统计
const billDeleteHandler = (data) => {
try {
const fid = data.senderId === user.id ? data.friendId : data.senderId;
const key = 'chat_' + user.id + '_' + fid;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.filter(m => m.id !== data.msgId);
localStorage.setItem(key, JSON.stringify(msgs));
// 记录已删除ID(防止服务端重新加载)
addDeletedBillId(fid, data.msgId);
// 更新UI
setMessages(prev => prev.filter(m => m.id !== data.msgId));
// 【DEBUG-BILL-RT-v1】实时刷新总统计和记录
// 实时刷新统计
try { window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e){}
} catch(e){}
};
// 群聊账单删除事件
const billDeleteGroupHandler = (data) => {
try {
const key = 'groupMsgs_' + user.id + '_' + data.groupId;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.filter(m => m.id !== data.msgId);
localStorage.setItem(key, JSON.stringify(msgs));
// 记录已删除ID
addDeletedBillId('g_' + data.groupId, data.msgId);
// 更新UI
setGroupMessages(prev => prev.filter(m => m.id !== data.msgId));
// 【DEBUG-BILL-RT-v1】实时刷新统计
// 实时刷新统计
try { window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e){}
} catch(e){}
};
// 账单修改事件(接收方同步修改)
const billEditHandler = (data) => {
try {
const fid = data.senderId === user.id ? data.friendId : data.senderId;
const key = 'chat_' + user.id + '_' + fid;
const newBillStats = data.billStats;
const newBillStatsSavedFor = data.billStatsSavedFor;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.map(m => m.id === data.msgId ? {...m, content: data.newContent, billStats: newBillStats, billStatsSavedFor: newBillStatsSavedFor || m.billStatsSavedFor} : m);
localStorage.setItem(key, JSON.stringify(msgs));
// 更新UI
setMessages(prev => prev.map(m => m.id === data.msgId ? {...m, content: data.newContent, billStats: newBillStats, billStatsSavedFor: newBillStatsSavedFor || m.billStatsSavedFor} : m));
// 【DEBUG-BILL-RT-v1】实时刷新总统计和记录
// 实时刷新统计
try { window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e){}
} catch(e){}
};
// 群聊账单修改事件
const billEditGroupHandler = (data) => {
try {
const key = 'groupMsgs_' + user.id + '_' + data.groupId;
const newBillStats = data.billStats;
const newBillStatsSavedFor = data.billStatsSavedFor;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.map(m => m.id === data.msgId ? {...m, content: data.newContent, billStats: newBillStats, billStatsSavedFor: newBillStatsSavedFor || m.billStatsSavedFor} : m);
localStorage.setItem(key, JSON.stringify(msgs));
// 更新UI
setGroupMessages(prev => prev.map(m => m.id === data.msgId ? {...m, content: data.newContent, billStats: newBillStats, billStatsSavedFor: newBillStatsSavedFor || m.billStatsSavedFor} : m));
// 【DEBUG-BILL-RT-v1】实时刷新统计
// 实时刷新统计
try { window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e){}
} catch(e){}
};
// 注意:group_msg 已由 App 组件的全局 groupHandler 统一处理,这里不再重复注册
if (window._socket) {
window._socket.on('group_recall', recallHandler);
window._socket.on('private_recall', privateRecallHandler);
window._socket.on('recall_fail', recallFailHandler);
window._socket.on('bill_delete', billDeleteHandler);
window._socket.on('bill_delete_group', billDeleteGroupHandler);
window._socket.on('bill_edit', billEditHandler);
window._socket.on('bill_edit_group', billEditGroupHandler);
}
// 【新增】监听好友接受事件(包括邀请注册自动成为好友场景)
const friendAcceptedHandler = (data) => {
if (data.targetUserId !== user.id) return;
// 重新拉取好友列表
fetchFriends();
fetchRequests();
// 刷新App层好友列表
(async () => { try { const fr = await get('/friends'); if (fr.code === 200 && Array.isArray(fr.data)) setAppFriends(fr.data); } catch(e) {} })();
// 显示提示
if (data.by && data.by.nickname) {
try { showToast('你已和' + data.by.nickname + '成为好友,现在可以聊天了'); } catch(e) {}
// 将对方添加到最近联系人
try {
// 1. 先拉取与对方的聊天记录保存到本地(确保有欢迎消息供 buildRecentContacts 扫描)
const token = localStorage.getItem('token');
fetch(API + '/chat/messages?friendId=' + data.by.id, {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.json()).then(d => {
if (d.code === 200 && Array.isArray(d.data)) {
const localKey = 'chat_' + user.id + '_' + data.by.id;
localStorage.setItem(localKey, JSON.stringify(d.data));
// 触发重建最近联系人列表
window.dispatchEvent(new CustomEvent('recent_contacts_rebuild'));
}
}).catch(()=>{});
// 2. 同时也使用本地缓存快速插入一条记录(如果拉取还未完成)
const contactsKey = 'recentContacts_' + user.id;
const list = JSON.parse(localStorage.getItem(contactsKey) || '[]');
const exists = list.find(c => c.type === 'friend' && c.data?.id === data.by.id);
if (!exists) {
list.unshift({
type: 'friend',
data: { id: data.by.id, nickname: data.by.nickname, avatar: data.by.avatar || '' },
lastMessage: '',
lastTime: new Date().toISOString(),
unreadCount: 0
});
localStorage.setItem(contactsKey, JSON.stringify(list));
window.dispatchEvent(new CustomEvent('recent_contacts_update', { detail: list }));
}
} catch(e) {}
}
};
// 【新增】监听好友申请事件
const friendRequestHandler = (data) => {
if (data.targetUserId !== user.id) return;
// 重新拉取好友申请列表
fetchRequests();
// 显示提示
if (data.from && data.from.nickname) {
try { showToast(data.from.nickname + '请求添加你为好友'); } catch(e) {}
}
};
if (window._socket) {
window._socket.on('friend_accepted', friendAcceptedHandler);
window._socket.on('friend_request', friendRequestHandler);
}
return () => { if (window._socket) { window._socket.off('new_message', newMsgHandler); window._socket.off('group_recall', recallHandler); window._socket.off('private_recall', privateRecallHandler); window._socket.off('recall_fail', recallFailHandler); window._socket.off('transfer_accepted', transferHandler); window._socket.off('bill_delete', billDeleteHandler); window._socket.off('bill_delete_group', billDeleteGroupHandler); window._socket.off('bill_edit', billEditHandler); window._socket.off('bill_edit_group', billEditGroupHandler); window._socket.off('friend_accepted', friendAcceptedHandler); window._socket.off('friend_request', friendRequestHandler); } };
}, []);
const createGroup = async () => {
if (!groupName.trim()) return showToast('请输入群名称');
const res = await post('/groups/create', { name: groupName });
showToast(res.message); if (res.code === 200) { setShowCreateGroup(false); setGroupName(''); fetchGroups(); }
};
const syncGroupMessages = async (groupId) => {
try {
const res = await get('/groups/messages?groupId=' + groupId);
if (res.code === 200 && res.data?.length > 0) {
const groupFid = 'g_' + groupId;
const serverMsgs = filterDeletedBills(res.data, groupFid);
const localMsgs = loadGroupLocal(groupId);
// 【修复-防重复】同时检查ID和内容,避免离线重连时重复加载
const localIdSet = new Set(localMsgs.map(m => m.id));
const newMsgs = serverMsgs.filter(m => !localIdSet.has(m.id));
if (newMsgs.length > 0) {
const merged = [...localMsgs, ...newMsgs].sort((a,b) => new Date(a.createdAt) - new Date(b.createdAt));
const trimmed = merged.slice(-200);
setGroupMessages(trimmed);
saveGroupLocal(groupId, trimmed);
}
}
} catch(e) {}
};
const openGroupChat = async (g) => {
setActiveGroup(g);
// 打开群聊窗口时标记为已读
markRead('g', g.id);
// 【优化-已读持久化】清除群未读计数 + @提醒
try {
const guKey = 'groupUnread_'+user.id;
let gu = JSON.parse(localStorage.getItem(guKey)||'{}');
delete gu[g.id];
localStorage.setItem(guKey, JSON.stringify(gu));
setGroupUnread(gu);
window.dispatchEvent(new CustomEvent('group_unread_update', { detail: gu }));
const mnKey = 'groupMentionNotify_'+user.id;
let mn = JSON.parse(localStorage.getItem(mnKey)||'{}');
delete mn[g.id];
localStorage.setItem(mnKey, JSON.stringify(mn));
setGroupMentionNotify(mn);
window.dispatchEvent(new CustomEvent('group_mention_update', { detail: mn }));
} catch(e){}
const clearedTime = getClearedTime('g_'+g.id);
const groupFid = 'g_' + g.id;
let localMsgs = [];
if (clearedTime > 0) {
// 已清空:只加载清空后本地收到的新消息
localMsgs = loadGroupLocal(g.id).filter(m => new Date(m.createdAt||0).getTime() > clearedTime);
} else {
localMsgs = loadGroupLocal(g.id);
}
// 过滤已删除的账单
localMsgs = filterDeletedBills(localMsgs, groupFid);
setGroupMessages(localMsgs);
const detail = await get('/groups/detail?groupId=' + g.id);
if (detail.code === 200) setGroupDetail(detail.data);
// 从服务端拉取消息,与本地合并(离线消息同步)
try {
const res = await get('/groups/messages?groupId=' + g.id);
if (res.code === 200 && res.data?.length > 0) {
const serverMsgs = res.data;
// 已清空时,只同步清空时间之后的消息
const filteredServer = clearedTime > 0 ? serverMsgs.filter(m => new Date(m.createdAt||0).getTime() > clearedTime) : serverMsgs;
// 过滤服务端已删除的账单
const filteredServerNoDeleted = filterDeletedBills(filteredServer, groupFid);
// 【修复】以服务端数据为准,直接覆盖本地,彻底清理历史重复
const trimmed = filteredServerNoDeleted.slice(-200);
setGroupMessages(trimmed);
saveGroupLocal(g.id, trimmed);
}
} catch(e) {}
// Socket join - ensure connected first
const joinSocket = () => {
if (window._socket && window._socket.connected) {
window._socket.emit('join_group', { groupId: g.id });
} else {
setTimeout(joinSocket, 500);
}
};
joinSocket();
};
const [showGroupEmoji, setShowGroupEmoji] = useState(false);
const GROUP_EMOJIS = ['😀','😂','🤣','😍','😘','😎','🤔','🙄','😭','😡','👍','👎','👋','🙏','🎉','🔥','❤️','💰','🎁','🎲','🐍','🐴','🐏','🐒','🐓','🐕','🐖','🐀','🐂','🐇','🐉','🐯','💪','😊','😉','🤗','🤑','😋','😜','😳','😱','💨','💥','💕','⭐','🌟','👌','✌️','🛑'];
const handleAtMember = (member) => {
const atText = '@' + member.nickname + ' ';
setGroupMsgInput(prev => prev + atText);
if (groupMsgInputRef.current) groupMsgInputRef.current.focus();
};
const handlePrivateRecall = (msg) => {
if (!window._socket || !window._socket.connected) return;
const elapsed = Date.now() - new Date(msg.createdAt).getTime();
if (elapsed > 120000) { showToast('超过2分钟无法撤回'); setPrivateChatMenu(null); return; }
window._socket.emit('private_recall', { msgId: msg.id, userId: user.id, friendId: chatFriend?.id, nickname: user.nickname });
setPrivateChatMenu(null);
};
// 删除账单(调用后端API,同步删除数据库)
const handleDeleteBill = async (msg, chatType) => {
if (!msg.billStats || msg.billStats.error) {
showToast('此消息没有速算数据,无法删除');
return;
}
// 权限检查:发送者才能删除
if (msg.senderId !== user.id) { showToast('只能删除自己发送的账单'); return; }
// 计算 chatKey
let checkKey = '';
if (chatType === 'private' && chatFriend) {
checkKey = 'chat_' + user.id + '_' + chatFriend.id;
} else if (chatType === 'group' && activeGroup) {
checkKey = 'groupMsgs_' + user.id + '_' + activeGroup.id;
}
// 【DEBUG-账单限制v6-DEL】必须传 msgList,才能读到 React state 中的 messages
const _msgListForCheck = chatType === 'group' ? groupMessages : messages;
const { endTime, startTime } = getActivityTimes(chatType || 'private', checkKey, _msgListForCheck);
const billTime = new Date(msg.createdAt).getTime();
console.log('[BILL-CHECK-v6-DEL]', 'billTime:', billTime, 'endTime:', endTime, 'startTime:', startTime, 'listLen:', (_msgListForCheck||[]).length);
if (endTime > 0 && billTime <= endTime) { showToast('活动已结束,无法删除'); return; }
if (!confirm('确认删除此账单?下注信息和计算数据将同步删除。')) return;
try {
const token = localStorage.getItem('token');
const friendId = chatFriend ? chatFriend.id : null;
const groupId = activeGroup ? activeGroup.id : null;
const res = await fetch(API + '/speedcalc/bill/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ msgId: msg.id, chatType, friendId, groupId })
});
const data = await res.json();
if (data.code !== 200) { showToast(data.message || '删除失败'); return; }
// 更新前端状态
if (chatType === 'private') {
setMessages(prev => prev.filter(m => m.id !== msg.id));
} else {
setGroupMessages(prev => prev.filter(m => m.id !== msg.id));
}
showToast('账单已删除');
try { window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e){}
} catch(e) { showToast('删除失败:' + e.message); }
};
// 保存修改后的账单(调用后端API,同步更新数据库)
const saveEditedBill = async () => {
if (!editBillModal || !editBillContent.trim()) return;
const { msg, chatType, chatKey } = editBillModal;
// 权限检查:发送者才能修改
if (msg.senderId !== user.id) { showToast('只能修改自己发送的账单'); setEditBillModal(null); return; }
// 【DEBUG-账单限制v6-EDIT】必须传 msgList
const _msgListForCheck = chatType === 'group' ? groupMessages : messages;
const { endTime, startTime } = getActivityTimes(chatType || 'private', chatKey, _msgListForCheck);
const billTime = new Date(msg.createdAt).getTime();
console.log('[BILL-CHECK-v6-EDIT]', 'billTime:', billTime, 'endTime:', endTime, 'startTime:', startTime, 'listLen:', (_msgListForCheck||[]).length);
if (endTime > 0 && billTime <= endTime) { showToast('活动已结束,无法修改'); setEditBillModal(null); return; }
try {
const token = localStorage.getItem('token');
const friendId = chatFriend ? chatFriend.id : null;
const groupId = activeGroup ? activeGroup.id : null;
const res = await fetch(API + '/speedcalc/bill/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ msgId: msg.id, newContent: editBillContent.trim(), chatType, friendId, groupId })
});
const data = await res.json();
if (data.code !== 200) { showToast(data.message || '修改失败'); return; }
// 更新前端状态
const newBillStats = data.data?.billStats || null;
const newBillStatsSavedFor = data.data?.billStatsSavedFor || [];
if (chatType === 'private') {
setMessages(prev => prev.map(m => m.id === msg.id ? {...m, content: editBillContent.trim(), billStats: newBillStats, billStatsSavedFor: newBillStatsSavedFor} : m));
} else {
setGroupMessages(prev => prev.map(m => m.id === msg.id ? {...m, content: editBillContent.trim(), billStats: newBillStats, billStatsSavedFor: newBillStatsSavedFor} : m));
}
setEditBillModal(null);
setEditBillContent('');
showToast('账单已修改');
try { window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e){}
} catch(e) { showToast('修改失败:' + e.message); }
};
const handleFileSelect = async (e, chatType) => {
const file = e.target.files[0];
if (!file) return;
const base64 = await compressImage(file);
e.target.value = '';
if (chatType === 'private' && chatFriend) {
const imgKey = 'private_' + user.id + '_' + chatFriend.id + '_' + Date.now();
saveImage(imgKey, base64);
// 先显示临时消息
const tempMsg = { id: 'temp_img_' + Date.now(), senderId: user.id, senderName: user.nickname, receiverId: chatFriend.id, content: base64, type: 'image', createdAt: new Date().toISOString() };
setMessages(prev => [...prev, tempMsg]);
saveLocal(chatFriend.id, [...messages, tempMsg]);
// 发送到服务器
try {
const res = await post('/chat/send', { receiverId: chatFriend.id, content: base64, type: 'image' });
if (res.code === 200 && res.data) {
// 服务器返回了正式消息,替换临时消息
const serverMsg = res.data;
const imgKey2 = 'private_' + user.id + '_' + chatFriend.id + '_' + serverMsg.id;
saveImage(imgKey2, base64);
setMessages(prev => prev.map(m => m.id === tempMsg.id ? {...serverMsg, content: base64} : m));
// 更新本地存储
const key = 'chat_' + user.id + '_' + chatFriend.id;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.map(m => m.id === tempMsg.id ? {...serverMsg, content: base64} : m);
localStorage.setItem(key, JSON.stringify(msgs));
}
} catch(err) {
// 发送失败,移除临时消息
setMessages(prev => prev.filter(m => m.id !== tempMsg.id));
showToast('图片发送失败');
}
} else if (chatType === 'group' && activeGroup) {
const imgKey = 'group_' + user.id + '_' + activeGroup.id + '_' + Date.now();
saveImage(imgKey, base64);
const msg = { id: 'tmp_g_img_' + Date.now(), senderId: user.id, nickname: user.nickname, groupId: activeGroup.id, content: base64, type: 'image', createdAt: new Date().toISOString() };
setGroupMessages(prev => [...prev, msg]);
if (window._socket) window._socket.emit('group_msg', { groupId: activeGroup.id, userId: user.id, nickname: user.nickname, content: base64, type: 'image', mentions: [] });
}
};
const handleRecall = (msg) => {
if (!window._socket || !window._socket.connected) return;
const elapsed = Date.now() - new Date(msg.createdAt).getTime();
if (elapsed > 120000) { showToast('超过2分钟无法撤回'); setContextMenu(null); return; }
window._socket.emit('group_recall', { groupId: activeGroup.id, msgId: msg.id, userId: user.id, nickname: user.nickname });
setContextMenu(null);
};
const handleReedit = (msg) => {
if (msg.originalContent) { setGroupMsgInput(msg.originalContent); }
else if (msg._origContent) { setGroupMsgInput(msg._origContent); }
setContextMenu(null);
if (groupMsgInputRef.current) groupMsgInputRef.current.focus();
};
// 识别账单图片
const recognizeBillImage = async (imageBase64, msg) => {
try {
showToast('正在识别账单...');
// 使用Tesseract.js进行本地OCR识别
if (typeof Tesseract === 'undefined') {
// 动态加载Tesseract.js
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js';
document.head.appendChild(script);
await new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = reject;
});
}
const result = await Tesseract.recognize(
imageBase64,
'chi_sim+eng',
{
logger: m => {
if (m.status === 'recognizing text') {
console.log('识别进度:', Math.round(m.progress * 100) + '%');
}
}
}
);
const recognizedText = result.data.text;
console.log('识别结果:', recognizedText);
// 使用parseBillText解析识别出的文本
const billItems = parseBillText(recognizedText);
if (!billItems || billItems.length === 0) {
showToast('未识别到账单数据,请确保图片清晰');
return;
}
const total = calculateTotalBill(billItems);
const summary = billItems.map(i => i.type + (i.count > 1 ? '×' + i.count : '') + '=' + i.total).join('\n');
// 将识别结果作为文本消息发送
const token = localStorage.getItem('token');
if (!token) {
showToast('请先登录');
return;
}
// 构建消息内容(识别出的账单文本)
const billText = recognizedText.trim();
// 发送识别结果消息
const res = await fetch(API + '/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({
receiverId: chatFriend.id,
content: '🧮 图片识别账单:' + billText,
type: 'text',
recognizedBill: {
items: billItems,
total,
summary
}
})
});
if (res.ok) {
showToast('识别成功');
// 刷新消息列表
loadMessages();
} else {
showToast('发送失败');
}
} catch (error) {
console.error('识别失败:', error);
showToast('识别失败:' + error.message);
}
};
const showMsgMenu = (e, msg) => {
e.preventDefault(); e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const isMine = msg.senderId === user.id;
setContextMenu({ msg, x: rect.left + rect.width / 2, y: rect.top - 10, isMine });
};
const sendGroupMsg = (text) => {
const content = text || groupMsgInput.trim();
if (!content || !activeGroup) return;
// 处理停止表情
if (content === '🛑') {
const gKey = 'groupMsgs_'+user.id+'_'+activeGroup.id;
setChatStopTime(gKey, Date.now());
}
// 检查是否已停止下注
const gKey = 'groupMsgs_'+user.id+'_'+activeGroup.id;
const stopTime = getChatStopTime(gKey);
const skipBillStats = stopTime > 0;
unmarkCleared('g_'+activeGroup.id);
// Extract @mentioned user ids
const mentionIds = [];
if (groupDetail?.memberList) {
groupDetail.memberList.forEach(m => {
if (content.includes('@' + m.nickname)) mentionIds.push(m.id);
});
}
const payload = { groupId: activeGroup.id, userId: user.id, nickname: user.nickname, content, mentions: mentionIds, skipBillStats };
if (groupReplyTo) {
payload.quote = { senderName: groupReplyTo.senderName || '对方', content: groupReplyTo.content };
setGroupReplyTo(null);
}
// 立即添加到本地显示
const tempMsg = {
id: 'tmp_g_' + Date.now(),
groupId: activeGroup.id,
senderId: user.id,
senderName: user.nickname,
senderAvatar: user.avatar || '',
content: content,
type: 'text',
createdAt: new Date().toISOString(),
mentions: mentionIds
};
setGroupMessages(prev => [...prev, tempMsg]);
// 通过 WebSocket 发送
if (window._socket && window._socket.connected) {
window._socket.emit('group_msg', payload);
} else {
// 【OFFLINE-QUEUE-GROUP】网络断开时保留消息,加入离线队列
console.warn('[群发送] 网络失败,加入离线队列:', tempMsg.id);
setGroupMessages(prev => prev.map(m => m.id === tempMsg.id ? {...m, offline: true, status: 'pending'} : m));
try {
const offlineKey = 'offline_queue_' + user.id;
let offlineMsgs = JSON.parse(localStorage.getItem(offlineKey) || '[]');
offlineMsgs.push({
tempId: tempMsg.id,
chatType: 'group',
groupId: activeGroup.id,
payload,
createdAt: tempMsg.createdAt
});
localStorage.setItem(offlineKey, JSON.stringify(offlineMsgs));
} catch(e) {}
}
if (!text) setGroupMsgInput('');
setShowGroupEmoji(false);
};
const addMemberToGroup = async (friendId) => {
const res = await post('/groups/addMember', { groupId: activeGroup.id, userId: friendId });
showToast(res.message);
if (res.code === 200) {
const detail = await get('/groups/detail?groupId=' + activeGroup.id);
if (detail.code === 200) setGroupDetail(detail.data);
setShowAddGroupMember(false);
}
};
const fetchMessages = async (fid) => {
const clearedTime = getClearedTime(fid);
const res = await get('/chat/messages?friendId=' + fid);
if (res.code === 200) {
let msgs = res.data || [];
if (clearedTime > 0) {
msgs = msgs.filter(m => new Date(m.createdAt||0).getTime() > clearedTime);
}
// 过滤已删除的账单
msgs = filterDeletedBills(msgs, fid);
msgs = msgs.map(m => {
const quote = loadQuote(m.id);
return quote ? { ...m, quote } : m;
});
setMessages(msgs);
saveLocal(fid, msgs);
}
};
const handleAdd = async () => {
const res = await post('/friends/add', { query: addQuery });
showToast(res.message); if (res.code === 200) { setShowAdd(false); setAddQuery(''); fetchFriends(); }
};
const handleJoinGroup = async () => {
if (!joinGroupId.trim()) {
showToast('请输入群ID');
return;
}
const res = await post('/groups/join-request', { groupId: joinGroupId.trim() });
if (res.code === 200) {
showToast('申请已提交,等待群主审核');
setShowAdd(false);
setJoinGroupId('');
setAddMode('friend');
} else {
showToast(res.message || '申请失败');
}
};
const handleAccept = async (requestId) => {
const res = await post('/friends/accept', { requestId });
if (res.code === 200) {
fetchFriends(); fetchRequests();
// 刷新App层好友列表
try { const fr = await get('/friends'); if (fr.code === 200 && Array.isArray(fr.data)) setAppFriends(fr.data); } catch(e) {}
const friendName = (res.data && res.data.nickname) || '对方';
showToast('你已和' + friendName + '成为好友,现在可以聊天了');
// 将对方加入最近联系人
if (res.data && res.data.friendId) {
try {
const contactsKey = 'recentContacts_' + user.id;
const list = JSON.parse(localStorage.getItem(contactsKey) || '[]');
const exists = list.find(c => c.type === 'friend' && c.data?.id === res.data.friendId);
if (!exists) {
list.unshift({ type:'friend', data:{ id:res.data.friendId, nickname:res.data.nickname, avatar:res.data.avatar||'' }, lastMessage:'', lastTime:new Date().toISOString(), unreadCount:0 });
localStorage.setItem(contactsKey, JSON.stringify(list));
window.dispatchEvent(new CustomEvent('recent_contacts_update', { detail: list }));
}
} catch(e) {}
}
} else {
showToast(res.message);
}
};
const handleReject = async (requestId) => {
const res = await post('/friends/reject', { requestId });
if (res.code === 200) { fetchRequests(); }
showToast(res.message);
};
const openChat = (f) => {
setChatFriend(f);
// 打开聊天窗口时标记为已读
markRead('f', f.id);
const local = loadLocal(f.id);
const clearedTime = getClearedTime(f.id);
let filtered = local;
if (clearedTime > 0) {
filtered = local.filter(m => new Date(m.createdAt||0).getTime() > clearedTime);
}
// 过滤已删除的账单
filtered = filterDeletedBills(filtered, f.id);
setMessages(filtered);
fetchMessages(f.id);
};
const sendMsg = async () => {
if (!msgInput.trim()) return;
const content = msgInput.trim();
setMsgInput(''); setShowEmoji(false); setReplyTo(null);
msgClearedRef.current = false;
// 检查是否已停止下注
const chatKey = 'chat_' + user.id + '_' + chatFriend.id;
// 发送方不设置本地停止状态,只负责广播消息
// 停止/恢复状态由接收方收到消息时设置
const stopTime = getChatStopTime(chatKey);
const skipBillStats = stopTime > 0;
// 账单统计必须由后端落库后返回,前端不再本地预计算
const billStats = null;
// 立即添加到本地显示,不等服务器
const tempMsg = {
id: 'tmp_' + Date.now(),
senderId: user.id,
senderName: user.nickname,
receiverId: chatFriend.id,
content: content,
type: 'text',
createdAt: new Date().toISOString(),
billStats: billStats,
billStatsSavedFor: []
};
setMessages(prev => [...prev, tempMsg]);
// 保存到本地缓存
try {
const localKey = 'chat_' + user.id + '_' + chatFriend.id;
let localMsgs = JSON.parse(localStorage.getItem(localKey) || '[]');
localMsgs.push(tempMsg);
localStorage.setItem(localKey, JSON.stringify(localMsgs));
} catch(e) {}
// 后台发送
const token = localStorage.getItem('token');
const sendHeaders = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
// 【修复】如果本地已停止且不是活动相关消息,携带停止状态让后端同步
if (stopTime > 0 && !content.includes('活动')) {
sendHeaders['X-Chat-Stopped'] = 'true';
}
fetch(API + '/chat/send', {
method: 'POST',
headers: sendHeaders,
body: JSON.stringify({ receiverId: chatFriend.id, content: content, type: 'text', skipBillStats })
}).then(res => res.json()).then(res => {
if (res.code === 200) {
// 用服务器返回的消息替换临时消息(包含正确的 billStats)
const serverMsg = res.data;
setMessages(prev => prev.map(m => m.id === tempMsg.id ? {...m, id: serverMsg.id, billStats: serverMsg.billStats || m.billStats, billStatsSavedFor: serverMsg.billStatsSavedFor || m.billStatsSavedFor} : m));
// 同步更新本地缓存
try {
const localKey = 'chat_' + user.id + '_' + chatFriend.id;
let localMsgs = JSON.parse(localStorage.getItem(localKey) || '[]');
localMsgs = localMsgs.map(m => m.id === tempMsg.id ? {...m, id: serverMsg.id, billStats: serverMsg.billStats || m.billStats, billStatsSavedFor: serverMsg.billStatsSavedFor || m.billStatsSavedFor} : m);
localStorage.setItem(localKey, JSON.stringify(localMsgs));
} catch(e) {}
if (replyTo) {
saveQuote(res.data.id, { senderName: replyTo.senderName || replyTo.senderName || '对方', content: replyTo.content });
}
// 【OFFLINE-QUEUE】发送成功后清除离线队列中的该条消息
try {
const offlineKey = 'offline_queue_' + user.id;
let offlineMsgs = JSON.parse(localStorage.getItem(offlineKey) || '[]');
offlineMsgs = offlineMsgs.filter(m => m.tempId !== tempMsg.id);
localStorage.setItem(offlineKey, JSON.stringify(offlineMsgs));
} catch(e) {}
// 刷新总统计
try {
const dn = JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}');
if (showSpeedCalcStats) {
setSpeedCalcStatsData(null);
}
window.dispatchEvent(new CustomEvent('speedcalc_stats_changed'));
} catch(e){}
}
}).catch(e => {
// 【OFFLINE-QUEUE】发送失败:保留消息在界面,加入离线队列,联网后自动重发
console.warn('[发送] 网络失败,加入离线队列:', tempMsg.id);
setMessages(prev => prev.map(m => m.id === tempMsg.id ? {...m, offline: true, status: 'pending'} : m));
try {
const offlineKey = 'offline_queue_' + user.id;
let offlineMsgs = JSON.parse(localStorage.getItem(offlineKey) || '[]');
offlineMsgs.push({
tempId: tempMsg.id,
chatType: 'private',
receiverId: chatFriend.id,
content: content,
type: 'text',
skipBillStats,
createdAt: tempMsg.createdAt
});
localStorage.setItem(offlineKey, JSON.stringify(offlineMsgs));
} catch(e) {}
});
// 不再 fetchMessages,实时更新由 WebSocket new_message 处理
};
// 发送红包
const sendRedPacket = async () => {
const amt = parseFloat(redPacketAmt);
if (!amt || amt <= 0) { showToast('请输入有效金额'); return; }
const type = redPacketType;
const isGroupNormal = type === 'normal' && !!activeGroup?.id;
// 群聊普通红包:count取用户输入,最少1个,最多按金额/0.01算;私聊普通红包固定1个
let count;
if (isGroupNormal) {
const maxCount = Math.floor(amt / 0.01);
const inputCount = parseInt(redPacketCount);
count = (!redPacketCount || isNaN(inputCount)) ? maxCount : Math.min(Math.max(1, inputCount), maxCount);
} else {
count = type === 'normal' ? 1 : parseInt(redPacketCount);
}
const mineNumber = type === 'mine' ? parseInt(redPacketMine) : null;
// 验证
if (type === 'normal' && !isGroupNormal && amt > 200) { showToast('私聊红包最大200元'); return; }
if (type === 'mine' && (!count || count < 5 || count > 10)) { showToast('扫雷红包个数必须5-10个'); return; }
if (type === 'relay' && (!count || count < 2)) { showToast('接龙红包至少需要2个'); return; }
try {
const res = await post('/redpacket/send', {
type,
amount: amt,
count,
mineNumber,
relayMode: type === 'relay' ? redPacketMode : null,
message: redPacketMsg,
receiverId: chatFriend?.id,
groupId: activeGroup?.id
});
if (res.code === 200) {
// 红包消息会通过 Socket 自动接收,不需要手动添加
setRedPacketModal(false);
setRedPacketAmt('');
setRedPacketCount('');
setRedPacketMsg('');
setRedPacketType('normal');
setRedPacketMode('min');
setRedPacketMine('0');
} else {
showToast(res.message || '发送失败');
}
} catch (e) {
showToast('发送失败: ' + e.message);
}
};
// 【RP-CARD-v4】金币入账音效(Web Audio 合成,尽量贴近微信红包领取“叮”声)
const playCoinSound = () => {
// 【RP-CARD-v7】优先播放微信原始音效文件(/sounds/redpacket-open.mp3),失败才回退 v6 合成
try {
if (!window.__rpAudio) {
try {
window.__rpAudio = new Audio('/sounds/redpacket-open.mp3');
window.__rpAudio.preload = 'auto';
window.__rpAudio.volume = 1.0;
} catch(e) { window.__rpAudio = null; }
}
if (window.__rpAudio) {
try { window.__rpAudio.currentTime = 0; } catch(e) {}
const p = window.__rpAudio.play();
if (p && typeof p.then === 'function') {
p.then(() => {})
.catch(err => {
console.warn('[RP-CARD-v7][COIN-SOUND][MP3-FALLBACK]', err && err.message);
_playCoinSoundSynth();
});
} else {
// MP3 play no promise
}
return;
}
_playCoinSoundSynth();
} catch (e) {
console.warn('[RP-CARD-v7][COIN-SOUND][ERR]', e && e.message);
try { _playCoinSoundSynth(); } catch(e2) {}
}
};
// 【RP-CARD-v6】Web Audio 合成兑底方案:金属敲击瞬态 + 多频泛音共鸣 + 拖尾衰减
const _playCoinSoundSynth = () => {
try {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
const ctx = new AC();
const now = ctx.currentTime;
// 阶段1:0-25ms 高通白噪声 burst(模拟金属敲击瞬态“咔”)
try {
const noiseLen = Math.floor(ctx.sampleRate * 0.025);
const noiseBuf = ctx.createBuffer(1, noiseLen, ctx.sampleRate);
const nd = noiseBuf.getChannelData(0);
for (let i = 0; i < noiseLen; i++) { nd[i] = (Math.random() * 2 - 1) * (1 - i / noiseLen); }
const noiseSrc = ctx.createBufferSource();
noiseSrc.buffer = noiseBuf;
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 4000;
const ng = ctx.createGain();
ng.gain.setValueAtTime(0.35, now);
ng.gain.exponentialRampToValueAtTime(0.0001, now + 0.025);
noiseSrc.connect(hp); hp.connect(ng); ng.connect(ctx.destination);
noiseSrc.start(now); noiseSrc.stop(now + 0.03);
} catch(e) {}
// 阶段2:主共鸣体——基频 2000Hz + 二次 3000Hz + 高泛音 4500Hz,频率轻微下滑
const partials = [
{ f0: 2000, f1: 1700, vol: 0.55, dur: 0.32 },
{ f0: 3000, f1: 2550, vol: 0.20, dur: 0.22 },
{ f0: 4500, f1: 3800, vol: 0.08, dur: 0.14 }
];
partials.forEach(p => {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(p.f0, now);
osc.frequency.exponentialRampToValueAtTime(p.f1, now + p.dur);
osc.connect(g); g.connect(ctx.destination);
g.gain.setValueAtTime(0.0001, now);
g.gain.exponentialRampToValueAtTime(p.vol, now + 0.006);
g.gain.exponentialRampToValueAtTime(0.0001, now + p.dur);
osc.start(now);
osc.stop(now + p.dur + 0.02);
});
setTimeout(() => { try { ctx.close(); } catch(e) {} }, 600);
} catch (e) { console.warn('[RP-CARD-v6][COIN-SOUND][ERR]', e && e.message); }
};
// 领取红包
const claimRedPacket = async (packetId) => {
try {
const res = await post('/redpacket/claim', { packetId });
if (res.code === 200) {
// 【RP-CARD-v7】播放金币入账音效(微信原始 mp3 优先 + 合成兑底)
try { playCoinSound(); } catch(e) {}
// 【RP-CARD-v5】乐观更新:领取成功后标记为 'mine'(自己领到金额)
try {
setClaimedPacketIds(prev => {
const next = new Map(prev);
next.set(packetId, 'mine');
try { localStorage.setItem('claimedPacketIds_' + user.id, JSON.stringify([...next.entries()])); } catch(e) {}
return next;
});
} catch(e) {}
// 【修复】立即关闭开红包弹窗,避免用户等待
setRedPacketOpen(null);
// 领取成功,立即加载红包详情页
await fetchRedPacketDetail(packetId);
// 【RELAY-CARD-FIX】领取接龙红包后,立即本地创建接龙卡片 + 延迟同步兑底
if (res.data && res.data.relayTriggered && res.data.relayInfo && activeGroup) {
const ri = res.data.relayInfo;
const relayMsg = {
id: 'rp_' + ri.newPacketId,
groupId: activeGroup.id,
senderId: ri.senderId,
senderName: ri.senderName,
senderAvatar: user.avatar || '',
content: JSON.stringify({
type: 'redpacket',
packetId: ri.newPacketId,
redPacketType: 'relay',
relayMode: ri.relayMode || 'min',
totalAmount: ri.relayCost,
totalCount: ri.totalCount,
message: '恭喜发财,大吉大利'
}),
type: 'text',
msgType: 'redpacket',
createdAt: new Date().toISOString()
};
setGroupMessages(prev => {
if (prev.some(m => m.id === relayMsg.id)) return prev;
const updated = [...prev, relayMsg];
try { saveGroupLocal(activeGroup.id, updated); } catch(e) {}
return updated;
});
// 延迟同步兑底,确保服务端数据一致
setTimeout(() => {
try { syncGroupMessages(activeGroup.id); } catch(e) {}
}, 1500);
}
// 【修复】claimed-list 和 notifications 改为后台非阻塞同步,不阻塞详情页加载
(async () => {
try {
const res1 = await get('/redpacket/claimed-list');
if (res1.code === 200) {
setClaimedPacketIds(prev => {
const merged = new Map(prev);
((res1.data) || []).forEach(id => { if (!merged.has(id)) merged.set(id, 'mine'); });
try { localStorage.setItem('claimedPacketIds_' + user.id, JSON.stringify([...merged.entries()])); } catch(e) {}
return merged;
});
}
const res2 = await get('/redpacket/notifications');
if (res2.code === 200) {
setRedpacketNotifications(res2.data);
}
} catch(e) {}
})();
} else {
showToast(res.message || '领取失败');
}
} catch (e) {
showToast('领取失败: ' + e.message);
}
};
// 查看红包详情
const fetchRedPacketDetail = async (packetId) => {
try {
const res = await get('/redpacket/detail/' + packetId);
if (res.code === 200) {
setRedPacketDetail(res.data);
setRedPacketOpen(null);
setRedPacketModal(false);
// 【RP-CARD-v5】精准三态:过期 / 已领完 / 自己领到金额。“点击查看但未领到金额”不变淡
const packet = res.data.packet || {};
const isFinished = packet.status === 'finished' || packet.remainCount <= 0;
const isExpired = packet.status === 'expired';
const myClaim = Array.isArray(res.data.claims) ? res.data.claims.find(c => c.userId === user.id) : null;
const myAmount = myClaim ? Number(myClaim.amount || 0) : 0;
const myClaimedWithAmount = !!myClaim && myAmount > 0;
// 过期红包点击时额外弹 toast(即时反馈)
if (isExpired) {
try { showToast('🕰️ 该红包已过期'); console.log('[RP-CARD-v5][EXPIRED-TOAST]', packetId); } catch(e) {}
}
let cardStatus = null;
if (isExpired) cardStatus = 'expired';
else if (isFinished) cardStatus = 'finished';
else if (myClaimedWithAmount) cardStatus = 'mine';
if (cardStatus) {
setClaimedPacketIds(prev => {
const next = new Map(prev);
// 优先级:expired > finished > mine,高优先可覆盖低优先
const cur = next.get(packetId);
const rank = { mine: 1, finished: 2, expired: 3 };
if (!cur || (rank[cardStatus] || 0) >= (rank[cur] || 0)) {
next.set(packetId, cardStatus);
}
try { localStorage.setItem('claimedPacketIds_' + user.id, JSON.stringify([...next.entries()])); } catch(e) {}
console.log('[RP-CARD-v5][DETAIL-MARK]', packetId, 'status:', cardStatus, 'finished:', isFinished, 'expired:', isExpired, 'mine:', myClaimedWithAmount, 'amount:', myAmount);
return next;
});
} else {
console.log('[RP-CARD-v5][DETAIL-NO-MARK]', packetId, '未领到金额且红包未结束,卡片保持原色');
}
} else {
showToast(res.message || '查询失败');
}
} catch (e) {
showToast('查询失败: ' + e.message);
}
};
// 点击红包卡片:未领取显示开红包弹窗,已领取/过期直接看详情
const openRedPacket = async (packetId) => {
try {
const res = await get('/redpacket/detail/' + packetId);
if (res.code === 200) {
const packet = res.data.packet || {};
const myClaim = res.data.myClaim;
const isFinished = packet.status === 'finished' || packet.remainCount <= 0;
const isExpired = packet.status === 'expired';
if (myClaim || isFinished || isExpired) {
setRedPacketDetail(res.data);
} else {
setRedPacketOpen(res.data);
}
// 同步 claimedPacketIds(同 fetchRedPacketDetail)
const myClaimFromClaims = Array.isArray(res.data.claims) ? res.data.claims.find(c => c.userId === user.id) : null;
const myAmount = myClaimFromClaims ? Number(myClaimFromClaims.amount || 0) : 0;
const myClaimedWithAmount = !!myClaimFromClaims && myAmount > 0;
if (isExpired) {
try { showToast('🕰️ 该红包已过期'); } catch(e) {}
}
let cardStatus = null;
if (isExpired) cardStatus = 'expired';
else if (isFinished) cardStatus = 'finished';
else if (myClaimedWithAmount) cardStatus = 'mine';
if (cardStatus) {
setClaimedPacketIds(prev => {
const next = new Map(prev);
const cur = next.get(packetId);
const rank = { mine: 1, finished: 2, expired: 3 };
if (!cur || (rank[cardStatus] || 0) >= (rank[cur] || 0)) {
next.set(packetId, cardStatus);
}
try { localStorage.setItem('claimedPacketIds_' + user.id, JSON.stringify([...next.entries()])); } catch(e) {}
return next;
});
}
} else {
showToast(res.message || '查询失败');
}
} catch (e) {
showToast('查询失败: ' + e.message);
}
};
// 停止时间辅助函数
const insertEmoji = (em) => {
if (em === '🛑' && chatFriend) {
// 发送停止消息
const chatKey = 'chat_' + user.id + '_' + chatFriend.id;
const stopTime = Date.now();
setChatStopTime(chatKey, stopTime);
const content = '🛑 活动结束';
setMsgInput(content);
// 直接发送
setTimeout(() => sendMsg(), 50);
return;
}
setMsgInput(prev => prev + em);
};
const sendTransfer = async () => {
const amt = parseInt(transferAmt);
if (!amt || amt <= 0) return showToast('请输入有效金额');
const res = await post('/friends/transfer', { friendId: chatFriend.id, amount: amt });
showToast(res.message);
if (res.code === 200) { setShowTransfer(false); setTransferAmt(''); fetchMessages(chatFriend.id); }
};
const acceptTransfer = async (transferId) => {
const res = await post('/friends/transfer/accept', { transferId });
if (res.code === 200) {
showToast('🧧 +' + (res.data?.chips ? '' : '') + res.message);
// Update local messages immediately
setMessages(prev => prev.map(m => {
if (m.type === 'transfer') {
try { let td = JSON.parse(m.content); if (td.transferId === transferId) { td.status = 'accepted'; return {...m, content: JSON.stringify(td)}; } } catch(e){}
}
return m;
}));
// Save to localStorage
if (chatFriend) {
const key = 'chat_' + user.id + '_' + chatFriend.id;
try {
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.map(m => {
if (m.type === 'transfer') { try { let td = JSON.parse(m.content); if (td.transferId === transferId) { td.status = 'accepted'; return {...m, content: JSON.stringify(td)}; } } catch(e){} }
return m;
});
localStorage.setItem(key, JSON.stringify(msgs));
} catch(e){}
}
if (res.data?.chips) setUser(prev => ({...prev, chips: res.data.chips}));
} else {
showToast(res.message);
}
};
// 红包详情全屏页(改为叠加层,避免卸载群聊/私聊组件导致滚动位置丢失)
// 聊天界面
if (chatFriend) {
return (
<div key={'chat_'+chatFriend.id} data-chat-container style={{position:'fixed',top:0,bottom:0,left:0,right:0,margin:'0 auto',width:'100%',maxWidth:'500px',zIndex:300,display:'flex',flexDirection:'column',background:'#EDEDED',willChange:'transform',backfaceVisibility:'hidden',animation:'slideInRight 0.2s ease-out'}}>
{/* 顶部栏 - 微信风格 */}
<div style={{display:'flex',alignItems:'center',gap:'10px',padding:'10px 12px',background:'#E0E0E0',borderBottom:'1px solid #d5d5d5'}}>
<button style={{background:'none',border:'none',cursor:'pointer',padding:'6px',display:'flex',alignItems:'center'}} onClick={()=>{
const el = document.querySelector('[data-chat-container]');
if (el) { el.style.animation='slideOutRight 0.25s ease-in forwards'; setTimeout(()=>{setChatFriend(null);setShowEmoji(false);buildRecentContacts();},240); }
else { setChatFriend(null);setShowEmoji(false);buildRecentContacts(); }
}}><span style={{display:'inline-block',width:'10px',height:'10px',borderLeft:'2px solid #333',borderBottom:'2px solid #333',transform:'rotate(45deg)'}} /></button>
{/* 点击昵称进入资料页 */}
<span style={{color:'#333',fontWeight:'bold',flex:1,textAlign:'center',fontSize:'16px',cursor:'pointer',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}} onClick={()=>{setProfilePanel(chatFriend);setProfileMenu(false);}}>{chatFriend.remark || chatFriend.nickname}{chatFriend.role==='customer_service'&&<span style={{marginLeft:'6px',fontSize:'11px',color:'#FF6B00',background:'rgba(255,107,0,0.1)',padding:'2px 6px',borderRadius:'8px'}}>客服</span>}</span>
<div style={{position:'relative'}}>
<button style={{background:'none',border:'none',color:'#333',fontSize:'16px',cursor:'pointer',padding:'4px',letterSpacing:'1px'}} onClick={()=>setShowChatMenu(!showChatMenu)}>...</button>
{showChatMenu && <div style={{position:'absolute',right:0,top:'100%',background:'#333',borderRadius:'8px',boxShadow:'0 4px 12px rgba(0,0,0,0.5)',zIndex:100,minWidth:'120px'}}>
<div style={{padding:'10px 16px',color:'#fff',fontSize:'14px',cursor:'pointer',whiteSpace:'nowrap',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setShowChatSettings(true);setShowChatMenu(false);}}>⚙️ 设置</div>
<div style={{padding:'10px 16px',color:'#fff',fontSize:'14px',cursor:'pointer',whiteSpace:'nowrap'}} onClick={()=>{
if(confirm('确认清空与'+chatFriend.nickname+'的聊天记录?\n\n(清空后聊天窗口不再显示历史消息,但账单数据仍会保留在记录和总统计中)')){
// 【修复】不删除 localStorage 中的聊天数据,仅通过 markCleared 时间戳过滤显示
// 这样 calcSpeedCalcStats 仍能从本地读取完整账单数据进行总统计
clearChatImages('private_' + user.id + '_' + chatFriend.id + '_');
setMessages([]);
msgClearedRef.current = true;
markCleared(chatFriend.id);
buildRecentContacts();
// 【新增】清空后,如果是共享成员,重新从服务器加载其账单数据到临时key
try {
const sharedMembers = JSON.parse(localStorage.getItem('speedCalcSharedMembers_' + user.id) || '[]');
const isShared = sharedMembers.some(m => m.friendId === chatFriend.id);
if (isShared) {
(async () => {
try {
const token = localStorage.getItem('token');
const today = getBeijingDate();
const billsRes = await fetch(API + '/speedcalc/shared-bills?date=' + today, {
headers: { 'Authorization': 'Bearer ' + token }
});
if (billsRes.ok) {
const billsData = await billsRes.json();
if (billsData.code === 200 && billsData.data) {
const serverBillsKey = 'chat_' + user.id + '_server_bills';
localStorage.setItem(serverBillsKey, JSON.stringify(billsData.data));
window.dispatchEvent(new CustomEvent('speedcalc_stats_changed'));
}
}
} catch(e) {}
})();
}
} catch(e) {}
}
setShowChatMenu(false);
}}>🗑️ 清空记录</div>
</div>}
</div>
</div>
{/* 消息列表 - 微信风格 */}
<div key={forceRefresh} ref={msgScrollRef} onScroll={(e)=>{const el=e.currentTarget;msgUserScrolling.current=el.scrollHeight-el.scrollTop-el.clientHeight>150;}} style={{flex:1,overflowY:'auto',padding:'12px',display:'flex',flexDirection:'column',gap:'12px',background:'#EDEDED'}}>
{messages.map((m,idx) => {
const isMine = m.senderId === user.id;
const showTime = idx === 0 || (new Date(m.createdAt) - new Date(messages[idx-1]?.createdAt)) > 300000;
// 系统消息渲染(活动结束/开始)
if (m.type === 'system' || m.senderId === 'system') {
return (
<React.Fragment key={m.id}>
{showTime && m.createdAt && <div style={{textAlign:'center',fontSize:'11px',color:'#999',margin:'4px 0'}}>{formatMsgTime(m.createdAt)}</div>}
<div style={{textAlign:'center',fontSize:'13px',color:'#FF6B6B',background:'rgba(255,107,107,0.1)',padding:'8px 16px',borderRadius:'12px',alignSelf:'center',fontWeight:'bold',border:'1px solid rgba(255,107,107,0.3)'}}>
{m.content}
</div>
</React.Fragment>
);
}
if (m.type === 'transfer') {
let td = {}; try { td = JSON.parse(m.content); } catch(e){}
return (
<React.Fragment key={m.id}>
{showTime && m.createdAt && <div style={{textAlign:'center',fontSize:'11px',color:'#999',margin:'4px 0'}}>{formatMsgTime(m.createdAt)}</div>}
<div style={{display:'flex',justifyContent:'center'}}>
<div style={{padding:'14px',borderRadius:'8px',textAlign:'center',background:'linear-gradient(135deg,#FA9D3B,#E8740C)',maxWidth:'70%',minWidth:'200px',boxShadow:'0 1px 2px rgba(0,0,0,0.1)'}}>
<div style={{fontSize:'13px',fontWeight:'bold',color:'#fff'}}>🧧 筹码转账</div>
<div style={{fontSize:'28px',fontWeight:'bold',color:'#fff',margin:'6px 0'}}>{td.amount}</div>
<div style={{fontSize:'11px',color:'rgba(255,255,255,0.8)'}}>{isMine ? '你发送的转账' : '来自 ' + (td.senderName || '好友')}</div>
{!isMine && td.status === 'pending' && <button style={{marginTop:'8px',background:'linear-gradient(135deg,#C41E3A,#8B0000)',color:'#fff',border:'2px solid #FFD700',borderRadius:'16px',padding:'8px 24px',fontSize:'14px',fontWeight:'bold',cursor:'pointer',boxShadow:'0 2px 8px rgba(196,30,58,0.4)'}} onClick={()=>acceptTransfer(td.transferId)}>🧧 点击领取</button>}
{isMine && td.status === 'pending' && <div style={{fontSize:'12px',color:'rgba(255,255,255,0.7)',marginTop:'8px'}}>⏳ 等待对方领取</div>}
{td.status === 'accepted' && <div style={{fontSize:'12px',color:'#fff',marginTop:'8px',background:'rgba(0,0,0,0.15)',borderRadius:'12px',padding:'4px 12px',display:'inline-block'}}>{isMine ? '📦 已被领取' : '✔️ 已领取'}</div>}
</div>
</div>
</React.Fragment>
);
}
// 【CARD-V1】名片消息渲染
if (m.type === 'card' || m.msgType === 'card') {
let cardData = {};
try { cardData = typeof m.content === 'string' ? JSON.parse(m.content) : m.content; } catch(e){}
const cardUser = cardData.cardUser || {};
return (
<React.Fragment key={m.id}>
{showTime && m.createdAt && <div style={{textAlign:'center',fontSize:'11px',color:'#999',margin:'4px 0'}}>{formatMsgTime(m.createdAt)}</div>}
<div style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',alignItems:'flex-start',gap:'8px'}}>
{!isMine && <div style={{position:'relative',width:'36px',height:'36px',borderRadius:'4px',overflow:'hidden',flexShrink:0,border:'none',background:'rgba(255,255,255,0.1)'}}>
{chatFriend.avatar ? <img src={chatFriend.avatar} style={{width:'100%',height:'100%',objectFit:'cover',borderRadius:'4px'}} onError={(e)=>{e.target.style.display='none';e.target.nextSibling.style.display='flex';}} /> : null}
<div style={{width:'100%',height:'100%',display:chatFriend.avatar?'none':'flex',alignItems:'center',justifyContent:'center',fontSize:'16px',color:'#999'}}>👤</div>
</div>}
<div style={{padding:'12px',borderRadius:'12px',background:'#fff',maxWidth:'240px',minWidth:'200px',boxShadow:'0 1px 3px rgba(0,0,0,0.12)',cursor:'pointer'}} onClick={()=>{
const isFriend = friends.some(f => f.id === cardUser.id);
const target = { id: cardUser.id, nickname: cardUser.nickname, avatar: cardUser.avatar, isFriend };
setProfilePanel(target);
setProfileMenu(false);
}}>
<div style={{fontSize:'11px',color:'#999',marginBottom:'8px'}}>个人名片</div>
<div style={{display:'flex',alignItems:'center',gap:'10px'}}>
<div style={{width:'48px',height:'48px',borderRadius:'50%',overflow:'hidden',flexShrink:0,background:'rgba(0,0,0,0.08)'}}>
{cardUser.avatar ? <img src={cardUser.avatar} style={{width:'100%',height:'100%',objectFit:'cover'}} onError={(e)=>{e.target.style.display='none';}} /> : <div style={{width:'100%',height:'100%',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'20px',color:'#999'}}>👤</div>}
</div>
<div style={{flex:1}}>
<div style={{fontSize:'15px',fontWeight:'bold',color:'#333'}}>{cardUser.nickname || '未知用户'}</div>
<div style={{fontSize:'11px',color:'#999',marginTop:'2px'}}>点击申请添加好友</div>
</div>
<span style={{color:'#FFD700',fontSize:'16px'}}>›</span>
</div>
</div>
{isMine && <div style={{width:'36px',height:'36px',fontSize:'16px',flexShrink:0,borderRadius:'4px',border:'none',backgroundImage:user.avatar?'url('+user.avatar+')':'none',backgroundSize:'cover'}}>{!user.avatar&&'👤'}</div>}
</div>
</React.Fragment>
);
}
// 红包消息渲染
if (m.msgType === 'redpacket' || (m.content && m.content.startsWith('{') && m.content.includes('"type":"redpacket"'))) {
let rpData = {};
try { rpData = typeof m.content === 'string' ? JSON.parse(m.content) : m.content; } catch(e){}
if (rpData.type === 'redpacket') {
const _cardStatus = (claimedPacketIds && typeof claimedPacketIds.get === 'function') ? claimedPacketIds.get(rpData.packetId) : null;
const isExpired = rpData.status === 'expired' || _cardStatus === 'expired';
const isFinishedDim = _cardStatus === 'finished';
const isMineDim = _cardStatus === 'mine';
const isDim = isExpired || isFinishedDim || isMineDim;
const isClaimed = isMineDim;
return (
<React.Fragment key={m.id}>
{showTime && m.createdAt && <div style={{textAlign:'center',fontSize:'11px',color:'#999',margin:'4px 0'}}>{formatMsgTime(m.createdAt)}</div>}
<div style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',alignItems:'flex-start',gap:'8px'}}>
{!isMine && <div style={{position:'relative',width:'36px',height:'36px',borderRadius:'4px',overflow:'hidden',flexShrink:0,border:'none',background:chatFriend.role==='customer_service'?'linear-gradient(135deg,#F44336,#C62828)':'rgba(255,255,255,0.1)',cursor:'pointer'}} onClick={()=>{setProfilePanel(chatFriend);setProfileMenu(false);}}>
{chatFriend.avatar ? (
<img src={chatFriend.avatar} style={{width:'100%',height:'100%',objectFit:'cover',borderRadius:'4px'}} onError={(e)=>{e.target.style.display='none';e.target.nextSibling.style.display='flex';}} />
) : null}
<div style={{width:'100%',height:'100%',display:chatFriend.avatar?'none':'flex',alignItems:'center',justifyContent:'center',fontSize:'16px',color:chatFriend.role==='customer_service'?'#fff':'#999'}}>
{chatFriend.role==='customer_service'?'👨💼':'👤'}
</div>
</div>}
<div style={{padding:'8px 12px',borderRadius:'8px',background:isDim?'linear-gradient(135deg,#F4A45C,#D88134)':'linear-gradient(135deg,#FA9D3B,#E8740C)',maxWidth:'240px',minWidth:'180px',boxShadow:isDim?'0 2px 8px rgba(216,129,52,0.3)':'0 2px 8px rgba(232,116,12,0.3)',cursor:'pointer',opacity:isExpired?0.7:1}} data-rp-card-v5={isExpired?'expired':isFinishedDim?'finished':isMineDim?'mine':'normal'} onClick={()=>openRedPacket(rpData.packetId)}>
<div style={{display:'flex',alignItems:'center',gap:'10px'}}>
<div style={{width:'40px',height:'50px',background:isDim?'linear-gradient(135deg,#E07A55,#C56638)':'linear-gradient(135deg,#F44336,#D32F2F)',borderRadius:'6px',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'20px'}}>¥</div>
<div style={{flex:1}}>
<div style={{fontSize:'15px',fontWeight:'bold',color:'#fff',marginBottom:'4px'}}>{rpData.message || '恭喜发财,大吉大利'}</div>
<div style={{fontSize:'11px',color:'rgba(255,255,255,0.85)'}}>
{rpData.redPacketType === 'relay' ? (rpData.relayMode === 'lucky' ? '👑 运气王接龙红包' : '🔻 最小接龙红包') + ' ' + (rpData.totalAmount || rpData.amount || 0) + '元' : rpData.redPacketType === 'mine' ? '扫雷红包 ' + (rpData.totalAmount || rpData.amount || 0) + '元 雷点' + (rpData.mineNumber != null ? rpData.mineNumber : '?') : '微信红包 ' + (rpData.totalAmount || rpData.amount || 0) + '元'}
{isExpired && <span style={{marginLeft:'6px',fontSize:'10px',color:'rgba(255,255,255,0.95)'}}>🕰️ 已过期</span>}
{!isExpired && isFinishedDim && <span style={{marginLeft:'6px',fontSize:'10px',color:'rgba(255,255,255,0.95)'}}>🤑 已领完</span>}{!isExpired && !isFinishedDim && isMineDim && <span style={{marginLeft:'6px',fontSize:'10px',color:'rgba(255,255,255,0.95)'}}>✔️ 已领</span>}
</div>
</div>
</div>
</div>
{isMine && <div style={{width:'36px',height:'36px',fontSize:'16px',flexShrink:0,borderRadius:'4px',border:'none',backgroundImage:user.avatar?'url('+user.avatar+')':'none',backgroundSize:'cover'}}>{!user.avatar&&'👤'}</div>}
</div>
</React.Fragment>
);
}
}
if (m.recalled) return (
<React.Fragment key={m.id}>
{showTime && m.createdAt && <div style={{textAlign:'center',fontSize:'11px',color:'#999',margin:'4px 0'}}>{formatMsgTime(m.createdAt)}</div>}
<div style={{textAlign:'center',margin:'8px 0'}}><span style={{fontSize:'12px',color:'#999',background:'rgba(0,0,0,0.05)',padding:'3px 12px',borderRadius:'10px'}}>{m.content}{m.senderId === user.id && m.originalContent && <a style={{color:'#4A9EFF',marginLeft:'6px',cursor:'pointer',textDecoration:'underline',fontSize:'12px'}} onClick={()=>{setMsgInput(m.originalContent);}}>重新编辑</a>}</span></div>
</React.Fragment>
);
return (
<React.Fragment key={m.id}>
{showTime && m.createdAt && <div style={{textAlign:'center',fontSize:'11px',color:'#999',margin:'4px 0'}}>{formatMsgTime(m.createdAt)}</div>}
<div style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',alignItems:'flex-start',gap:'8px'}}>
{multiSelectMode && <div style={{width:'20px',height:'20px',borderRadius:'50%',border:'2px solid #999',display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0,marginTop:'8px',cursor:'pointer',background:selectedMsgs.includes(m.id)?'#07C160':'transparent',borderColor:selectedMsgs.includes(m.id)?'#07C160':'#999'}} onClick={()=>setSelectedMsgs(prev=>prev.includes(m.id)?prev.filter(id=>id!==m.id):[...prev,m.id])}>{selectedMsgs.includes(m.id)&&<span style={{color:'#fff',fontSize:'12px'}}>✓</span>}</div>}
{!isMine && <div style={{position:'relative',width:'36px',height:'36px',borderRadius:'4px',overflow:'hidden',flexShrink:0,border:'none',background:chatFriend.role==='customer_service'?'linear-gradient(135deg,#F44336,#C62828)':'rgba(255,255,255,0.1)',cursor:'pointer'}} onClick={()=>{setProfilePanel(chatFriend);setProfileMenu(false);}}>
{chatFriend.avatar ? (
<img src={chatFriend.avatar} style={{width:'100%',height:'100%',objectFit:'cover',borderRadius:'4px'}} onError={(e)=>{e.target.style.display='none';e.target.nextSibling.style.display='flex';}} />
) : null}
<div style={{width:'100%',height:'100%',display:chatFriend.avatar?'none':'flex',alignItems:'center',justifyContent:'center',fontSize:'16px',color:chatFriend.role==='customer_service'?'#fff':'#999'}}>
{chatFriend.role==='customer_service'?'👨💼':'👤'}
</div>
</div>}
<div style={{position:'relative',padding:'6px 10px',borderRadius:isMine?'6px 2px 6px 6px':'2px 6px 6px 6px',maxWidth:'65%',fontSize:'15px',lineHeight:'1.5',wordBreak:'break-word',whiteSpace:'pre-wrap',
background:isMine?'#95EC69':'#fff',color:'#333',boxShadow:'0 1px 1px rgba(0,0,0,0.08)',userSelect:'none',opacity:multiSelectMode&&selectedMsgs.includes(m.id)?0.6:1
}} onContextMenu={(e)=>{e.preventDefault();const rect=e.currentTarget.getBoundingClientRect();setPrivateChatMenu({msg:m,x:rect.left+rect.width/2,y:rect.top-10,isMine});}} onTouchStart={(e)=>{e.currentTarget._pmt=setTimeout(()=>{const rect=e.currentTarget.getBoundingClientRect();setPrivateChatMenu({msg:m,x:rect.left+rect.width/2,y:rect.top-10,isMine});},600);}} onTouchEnd={(e)=>{clearTimeout(e.currentTarget._pmt);}} onTouchMove={(e)=>{clearTimeout(e.currentTarget._pmt);}}>
<div style={{position:'absolute',top:'10px',width:0,height:0,borderTop:'6px solid transparent',borderBottom:'6px solid transparent',...(isMine?{right:'-6px',borderLeft:'6px solid #95EC69'}:{left:'-6px',borderRight:'6px solid #fff'})}} />
{m.quote && <div style={{fontSize:'12px',color:'#666',background:'rgba(0,0,0,0.05)',borderRadius:'6px',padding:'4px 8px',marginBottom:'4px',borderLeft:'3px solid #07C160'}}>
<div style={{fontWeight:'bold',fontSize:'11px',color:'#07C160'}}>{m.quote.senderName}</div>
<div style={{overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',maxWidth:'200px'}}>{m.quote.content}</div>
</div>}
{m.type === 'voice' ? (
<div onClick={()=>playVoice(m.id, m.content)} style={{display:'flex',alignItems:'center',gap:'8px',minWidth:'80px',cursor:'pointer',padding:'4px 0',userSelect:'none'}}>
<span style={{fontSize:'20px'}}>{playingVoiceId===m.id ? '⏸️' : '▶️'}</span>
<div style={{display:'flex',alignItems:'center',gap:'2px'}}>
{[1,2,3].map(i => <div key={i} style={{width:'3px',height:(8+i*5)+'px',background:isMine?'#fff':'#07C160',borderRadius:'2px'}} />)}
</div>
<span style={{fontSize:'12px',color:isMine?'rgba(255,255,255,0.7)':'#999'}}>{m.duration||0}''</span>
</div>
) : m.type === 'image' ? (
<div style={{position:'relative'}}>
<button style={{background:'none',border:'none',padding:0,margin:0,cursor:'pointer',display:'block'}} onClick={()=>{previewOpenTime.current=Date.now();setPreviewImg(m.content);}}><img src={m.content} style={{maxWidth:'200px',maxHeight:'200px',borderRadius:'8px',display:'block'}} /></button>
{!isMine && <button style={{position:'absolute',bottom:'8px',right:'8px',background:'rgba(0,0,0,0.7)',color:'#fff',border:'none',borderRadius:'12px',padding:'6px 12px',fontSize:'12px',cursor:'pointer'}} onClick={(e)=>{e.stopPropagation();recognizeBillImage(m.content,m);}}>识别账单</button>}
</div>
) : (
renderTextWithLinks(m.content)
)}
</div>
{isMine && <div style={{width:'36px',height:'36px',fontSize:'16px',flexShrink:0,borderRadius:'4px',border:'none',backgroundImage:user.avatar?'url('+user.avatar+')':'none',backgroundSize:'cover'}}>{!user.avatar&&'👤'}</div>}
</div>
{/* 六合彩速算结果 */}
{(() => {
const chatKey = 'chat_' + user.id + '_' + chatFriend.id;
const stopTime = getChatStopTime(chatKey);
const stoppedPeriodTime = getStoppedPeriodTime(chatKey);
// [AUTO-CLOSE-VALID-BILL-V4] 自动截止只锁改删/分流,不把已入库账单显示为无效
// 调试:打印key和stopTime
if (isMine && freshStats) {
console.log('DEBUG chatKey:', chatKey);
console.log('DEBUG localStorage chatStopTime_:' + chatKey, localStorage.getItem('chatStopTime_' + chatKey));
console.log('DEBUG stopTime:', stopTime, 'stoppedPeriodTime:', stoppedPeriodTime);
}
// 无效账单判断:
// 扫描消息列表,找到最后一条停止/恢复消息的时间
const allMsgsForStop = messages || [];
let lastStopTime = 0;
let lastResumeTime = 0;
for (const msg of allMsgsForStop) {
const stopContent = msg.content || '';
const isAutoCloseNotice = stopContent.includes('后续账单自动归澳门2') || stopContent.includes('今日活动全部结束') || stopContent.includes('澳门1/香港已截止') || stopContent.includes('澳门2已截止');
if (!isAutoCloseNotice && stopContent && (stopContent.includes('🛑 活动结束') || stopContent.includes('活动结束') || stopContent.includes('停止下注'))) {
lastStopTime = new Date(msg.createdAt).getTime();
lastResumeTime = 0; // 停止后清除恢复时间
} else if (stopContent && (stopContent.includes('🟢 活动开始') || stopContent.includes('活动开始') || stopContent.includes('恢复下注'))) {
lastResumeTime = new Date(msg.createdAt).getTime();
}
}
// 如果消息中有活动状态,优先使用消息中的状态;否则使用localStorage
if (lastStopTime === 0 && stopTime > 0) {
lastStopTime = stopTime;
}
let isStopped = false;
const msgTime = new Date(m.createdAt).getTime();
const isAutoCloseNoticeMsg = m.content && ((m.content || '').includes('后续账单自动归澳门2') || (m.content || '').includes('今日活动全部结束') || (m.content || '').includes('澳门1/香港已截止') || (m.content || '').includes('澳门2已截止'));
const isStopOrResumeMsg = m.content && !isAutoCloseNoticeMsg && (m.content.includes('活动结束') || m.content.includes('活动开始') || m.content.includes('🛑') || m.content.includes('🟢'));
if (!isStopOrResumeMsg && lastStopTime > 0) {
if (lastResumeTime > 0) {
// 已恢复:只有停止期间的消息无效
isStopped = msgTime >= lastStopTime && msgTime < lastResumeTime;
} else {
// 还在停止中:所有 >= lastStopTime 的消息无效
isStopped = msgTime >= lastStopTime;
}
}
// 优先使用后端实际落库的billStats;前端不再私自重算
const freshStats = m.billStats;
if (freshStats && freshStats.error) {
return (
<div style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',marginTop:'4px'}}>
<div style={{padding:'6px 12px',borderRadius:'8px',background:'#FFF0F0',maxWidth:'80%',fontSize:'12px',color:'#D32F2F',lineHeight:'1.5',border:'1px solid #FFCDD2'}}>
⚠️ {freshStats.message || '账单出错'}
</div>
</div>
);
}
// 手动一键结束期间的账单:显示活动已结束;自动截止账单仍保持有效展示
if (isStopped) {
return (
<div style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',marginTop:'4px'}}>
<div style={{padding:'6px 12px',borderRadius:'8px',background:'#f0f0f0',maxWidth:'80%',fontSize:'12px',color:'#999',lineHeight:'1.5'}}>
🛑 活动已结束,账单无效
</div>
</div>
);
}
// 速算开关逻辑:
// 只有后端把该账单实际落库到当前用户维度,才显示速算结果
// 旧消息没有 billStatsSavedFor 时保持兼容
const billSavedForMe = !m.billStatsSavedFor || m.billStatsSavedFor.includes(user.id);
const shouldShow = freshStats && !freshStats.error && billSavedForMe && !isStopped;
if (!shouldShow) return null;
if (typeof window !== 'undefined' && !window.__AUTO_CLOSE_VALID_BILL_V4) { window.__AUTO_CLOSE_VALID_BILL_V4 = true; console.log('[AUTO-CLOSE-VALID-BILL-V4] 自动截止账单保持有效展示'); }
return (
<div style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',marginTop:'4px'}}>
<div style={{padding:'8px 12px',borderRadius:'8px',background:'linear-gradient(135deg,#FFF8E1,#FFECB3)',maxWidth:'80%',fontSize:'12px',color:'#5D4037',lineHeight:'1.5',boxShadow:'0 1px 2px rgba(0,0,0,0.1)'}}>
<div style={{fontWeight:'bold',marginBottom:'2px',display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span>🧮 速算结果</span>
<div style={{display:'flex',gap:'8px'}}>
<span style={{fontSize:'11px',color:'#2196F3',cursor:'pointer',fontWeight:'bold'}} onClick={(e)=>{e.stopPropagation();setShowUsageGuide(true);}}>❓ 使用方法</span>
<span style={{fontSize:'11px',color:'#FF6B35',cursor:'pointer',fontWeight:'bold'}} onClick={(e)=>{e.stopPropagation();setShowPickerHelper(true);}}>🎯 挑码助手</span>
</div>
</div>
<div style={{whiteSpace:'pre-wrap'}}>{freshStats.summary}</div>
<div style={{marginTop:'4px',paddingTop:'4px',borderTop:'1px dashed rgba(93,64,55,0.2)',display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span style={{fontSize:'11px',opacity:0.7}}>合计:{freshStats.total}</span>
<span style={{fontSize:'11px',color:'#8B4513',cursor:'pointer',fontWeight:'bold'}} onClick={async ()=>{
try {
const senderId = m.senderId;
const senderName = m.senderName || (senderId === user.id ? user.nickname : '对方');
const savedNums = sharedDrawNums || (()=>{try{return JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}')}catch(e){return {macau1:[null,null,null,null,null,null,null],macau2:[null,null,null,null,null,null,null],hongkong:[null,null,null,null,null,null,null]}}})();
// 【修复】查当前点击账单所在日期(而不是始终今日)——避免点历史记录上的 📋 记录返回空
const billDateStr = ((m.createdAt || new Date().toISOString())).split('T')[0];
// 【HISTORY-OWNER-FIX-V3】记录弹窗绑定点击速算卡片的用户维度:只查该用户自己的账单
const token = localStorage.getItem('token');
const recordOwnerId = senderId;
const recordReceiverId = senderId === user.id ? (chatFriendRef.current ? chatFriendRef.current.id : chatFriend.id) : user.id;
const recordMsgId = m.id || m.msgId;
const statResults = await Promise.all(['macau1','macau2','hongkong'].map(async lt => {
const dnArr = savedNums[lt] || [null,null,null,null,null,null,null];
const hasAnyNumber = dnArr.some(n => n !== null && n !== '');
const drawParam = hasAnyNumber ? encodeURIComponent(JSON.stringify(dnArr)) : '';
let url = API + '/speedcalc/stats?lottery=' + lt + '&date=' + billDateStr + '&receiverId=' + encodeURIComponent(recordReceiverId) + '&senderFilter=' + encodeURIComponent(recordOwnerId) + '&ownerOnly=1&recordMsgId=' + encodeURIComponent(recordMsgId || '');
if (drawParam) url += '&drawNums=' + drawParam;
const statRes = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
const statData = await statRes.json();
return { lottery: lt, stats: (statData.code === 200 && statData.data) ? statData.data : null };
}));
const records = [];
const billRecords = { macau1: [], macau2: [], hongkong: [] };
const backendStats = { macau1: null, macau2: null, hongkong: null };
statResults.forEach(({lottery: lt, stats}) => {
backendStats[lt] = stats;
const list = (stats && stats.billList) ? stats.billList : [];
list.forEach(b => {
const items = Array.isArray(b.items) ? b.items : [];
const totalPayout = items.reduce((sum, it) => sum + (it._payout || 0), 0);
const record = { id: b.id, msgId: b.id, senderId: b.senderId, senderName: b.senderName || senderName || '未知', content: b.content, time: b.time, lottery: lt, total: b.total || 0, summary: '', items, payout: totalPayout };
records.push(record);
if (billRecords[lt]) billRecords[lt].push(record);
});
});
console.log('[HISTORY-OWNER-FIX-V3] backendStats:', 'ownerId:', recordOwnerId, 'ownerName:', senderName, 'macau1=' + billRecords.macau1.length + '条, macau2=' + billRecords.macau2.length + '条, hongkong=' + billRecords.hongkong.length + '条', 'date:', billDateStr);
const total = statResults.reduce((sum, r) => sum + ((r.stats && r.stats.total) || 0), 0);
const totalPayout = records.reduce((sum, r) => sum + (r.payout || 0), 0);
onShowBillRecord({ chatName: chatFriendRef.current ? chatFriendRef.current.nickname : '私聊', senderName, senderId, recordOwnerId, recordMsgId, records, total, drawNumbers: savedNums, totalPayout, billRecords, backendStats, recordDate: billDateStr, receiverId: recordReceiverId, rebateRate: chatSettings.rebateRate ?? 4, chatFriendId: recordReceiverId });
} catch(e) { alert('打开记录失败:'+e.message); }
}}>📋 记录</span>
</div>
</div>
</div>
);
})()}
</React.Fragment>
);
})}
<div ref={msgEndRef} />
{privateChatMenu && <div style={{position:'fixed',top:0,left:0,right:0,bottom:0,zIndex:999,background:'rgba(0,0,0,0.5)'}} onClick={()=>setPrivateChatMenu(null)}><div style={{position:'absolute',left:'50%',bottom:'20%',transform:'translateX(-50%)',background:'#333',borderRadius:'12px',padding:'8px 0',boxShadow:'0 4px 20px rgba(0,0,0,0.5)',minWidth:'140px',maxWidth:'80vw'}}>
<div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{copyToClipboard(privateChatMenu.msg.content||'');setPrivateChatMenu(null);}}>复制</div>
<div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setReplyTo(privateChatMenu.msg);setPrivateChatMenu(null);}}>引用</div>
<div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setForwardMsg({messages:[{...privateChatMenu.msg,chatType:'private'}],chatType:'private'});setShowForwardModal(true);setPrivateChatMenu(null);}}>转发</div>
<div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setMultiSelectMode(true);setPrivateChatMenu(null);}}>多选</div>
{privateChatMenu.isMine && <div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>handlePrivateRecall(privateChatMenu.msg)}>撤回</div>}
{privateChatMenu.isMine && <div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setMsgInput(privateChatMenu.msg.content||'');setPrivateChatMenu(null);}}>编辑</div>}
{(() => {
const chatKey = chatFriend ? 'chat_' + user.id + '_' + chatFriend.id : '';
return canEditDeleteBill(privateChatMenu.msg, chatKey, 'private', user, messages) && (
<>
<div style={{padding:'12px 20px',color:'#FFD700',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setEditBillModal({msg:privateChatMenu.msg,chatType:'private',chatKey});setEditBillContent(privateChatMenu.msg.content||'');setPrivateChatMenu(null);}}>✏️ 修改账单</div>
<div style={{padding:'12px 20px',color:'#FF3B30',fontSize:'14px',cursor:'pointer',textAlign:'center'}} onClick={()=>{handleDeleteBill(privateChatMenu.msg,'private');setPrivateChatMenu(null);}}>🗑️ 删除账单</div>
</>
);
})()}
</div></div>}
{multiSelectMode && (
<div style={{position:'sticky',bottom:0,display:'flex',alignItems:'center',gap:'8px',padding:'8px 12px',background:'#fff',borderTop:'1px solid #ddd',zIndex:10}}>
<span style={{fontSize:'13px',color:'#666',flex:1}}>已选 {selectedMsgs.length} 条</span>
<button style={{background:'#07C160',color:'#fff',border:'none',borderRadius:'6px',padding:'6px 14px',fontSize:'13px',cursor:'pointer'}} onClick={()=>{setForwardMsg({messages:messages.filter(m=>selectedMsgs.includes(m.id)),chatType:'private'});setShowForwardModal(true);setMultiSelectMode(false);}}>转发</button>
<button style={{background:'#FF3B30',color:'#fff',border:'none',borderRadius:'6px',padding:'6px 14px',fontSize:'13px',cursor:'pointer'}} onClick={()=>{setMessages(prev=>prev.filter(m=>!selectedMsgs.includes(m.id)));setSelectedMsgs([]);setMultiSelectMode(false);}}>删除</button>
<button style={{background:'#ccc',color:'#fff',border:'none',borderRadius:'6px',padding:'6px 14px',fontSize:'13px',cursor:'pointer'}} onClick={()=>{setSelectedMsgs([]);setMultiSelectMode(false);}}>取消</button>
</div>
)}
</div>
{/* 输入栏 - 微信风格 */}
{replyTo && <div style={{display:'flex',alignItems:'center',gap:'8px',padding:'6px 12px',background:'#F0F0F0',borderTop:'1px solid #ddd'}}>
<div style={{flex:1,fontSize:'12px',color:'#666',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
<span style={{color:'#07C160'}}>引用 {replyTo.senderName || '对方'}:</span>{replyTo.content}
</div>
<button style={{background:'none',border:'none',fontSize:'16px',color:'#999',cursor:'pointer'}} onClick={()=>setReplyTo(null)}>✕</button>
</div>}
<div style={{display:'flex',alignItems:'center',gap:'8px',padding:'8px 10px',background:'#F7F7F7',borderTop:'1px solid #ddd'}}>
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px',flexShrink:0,display:'flex',alignItems:'center'}} onMouseDown={e=>e.preventDefault()} onTouchStart={e=>e.preventDefault()} onClick={()=>{setIsVoiceMode(!isVoiceMode);setShowEmoji(false);setShowMorePanel(false);}}><svg width="24" height="24" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10.5" stroke={isVoiceMode?'#07C160':'#333'} strokeWidth="2"/><circle cx="7.5" cy="12" r="1.8" fill={isVoiceMode?'#07C160':'#333'}/><path d="M10.5 9a3.5 3.5 0 0 1 0 6" stroke={isVoiceMode?'#07C160':'#333'} strokeWidth="1.8" fill="none" strokeLinecap="round"/><path d="M13 6.5a7.5 7.5 0 0 1 0 11" stroke={isVoiceMode?'#07C160':'#333'} strokeWidth="1.8" fill="none" strokeLinecap="round"/></svg></button>
{isVoiceMode ? (
<div style={{flex:1,display:'flex',gap:'8px',alignItems:'center'}}>
<button
onMouseDown={(e)=>{e.preventDefault();startPTT();}}
onMouseUp={(e)=>{e.preventDefault();stopPTT();}}
onMouseLeave={(e)=>{if(isRecording&&pttModeRef_global)stopPTT();}}
onTouchStart={(e)=>{startPTT();}}
onTouchEnd={(e)=>{stopPTT();}}
style={{flex:1,padding:'6px 0',borderRadius:'4px',border:'1px solid '+(pttPressed||pttRecording?'#C8E6C9':'#ddd'),background:(pttPressed||pttRecording)?'#E8F5E9':'#fff',fontSize:'15px',color:'#333',cursor:'pointer',textAlign:'center',fontWeight:'500',userSelect:'none',WebkitUserSelect:'none',touchAction:'none',transition:'background 0.1s,border-color 0.1s'}}
>{pttRecording ? '松开发送' : '按住 说话'}</button>
<button style={{background:'none',border:'none',fontSize:'18px',color:'#666',cursor:'pointer',flexShrink:0,padding:'4px'}} onClick={()=>setIsVoiceMode(false)}>⌨️</button>
</div>
) : (
<>
<AutoResizeTextarea
placeholder="输入消息..."
value={msgInput}
onChange={e=>setMsgInput(e.target.value)}
onSend={sendMsg}
maxHeight={110}
style={{flex:1,padding:'6px 14px',borderRadius:'4px',border:'1px solid #ddd',background:'#fff',fontSize:'15px',color:'#333',outline:'none',resize:'none',lineHeight:'1.4',fontFamily:'inherit',overflowY:'auto',whiteSpace:'pre-wrap',minWidth:0}}
onKeyDown={e=>{if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();sendMsg();}}}
onFocus={()=>{setShowEmoji(false);setShowMorePanel(false);}} />
{msgInput.trim() && <button style={{background:'linear-gradient(135deg,#07C160,#05a350)',color:'#fff',border:'none',borderRadius:'20px',padding:'8px 18px',fontSize:'14px',fontWeight:'bold',cursor:'pointer'}} onClick={sendMsg}>发送</button>}
</>
)}
<button style={{background:'none',border:'none',fontSize:'22px',cursor:'pointer',padding:'4px',color:showEmoji?'#07C160':'#666',flexShrink:0}} onMouseDown={e=>e.preventDefault()} onTouchStart={e=>e.preventDefault()} onClick={()=>{document.activeElement&&document.activeElement.blur();setShowEmoji(!showEmoji);setShowMorePanel(false);}}>😊</button>
<button style={{width:'22px',height:'22px',borderRadius:'50%',border:'1.5px solid #999',background:'none',cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center',padding:0,color:showMorePanel?'#07C160':'#666',fontSize:'15px',flexShrink:0,lineHeight:1}} onMouseDown={e=>e.preventDefault()} onTouchStart={e=>e.preventDefault()} onClick={()=>{document.activeElement&&document.activeElement.blur();setShowMorePanel(!showMorePanel);setShowEmoji(false);}}>+</button>
</div>
{showMorePanel && (
<div style={{display:'grid',gridTemplateColumns:'repeat(4,1fr)',gap:'12px',padding:'16px',background:'#F7F7F7',borderTop:'1px solid #ddd'}}>
{/* 【FILE-LABEL-FIX-V1】使用 label 关联 input,避免 JS .click() 在移动端 WebView 中被拦截 */}
<label htmlFor="privateFileInput" style={{textAlign:'center',cursor:'pointer'}}>
<div style={{width:'56px',height:'56px',background:'#fff',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px'}}>🖼️</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>相册</div>
</label>
<label htmlFor="privateCameraInput" style={{textAlign:'center',cursor:'pointer'}}>
<div style={{width:'56px',height:'56px',background:'#fff',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px'}}>📷</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>拍摄</div>
</label>
<div style={{textAlign:'center',cursor:'pointer'}} onClick={()=>{setShowMorePanel(false);setRedPacketModal(true);}}>
<div style={{width:'56px',height:'56px',background:'#fff',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px'}}>🧧</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>红包</div>
</div>
<div style={{textAlign:'center',cursor:'pointer'}} onClick={()=>{setShowMorePanel(false);setShowTransfer(true);}}>
<div style={{width:'56px',height:'56px',background:'#fff',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px'}}>💰</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>转账</div>
</div>
<div style={{textAlign:'center',cursor:'pointer'}} onClick={()=>{setShowMorePanel(false);startRecording();}}>
<div style={{width:'56px',height:'56px',background:'#fff',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px'}}>🎤</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>语音</div>
</div>
<label htmlFor="privateUploadInput" style={{textAlign:'center',cursor:'pointer'}}>
<div style={{width:'56px',height:'56px',background:'#fff',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px'}}>📁</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>上传</div>
</label>
</div>
)}
{showEmoji && (
<div style={{background:'#fff',borderTop:'1px solid #ddd',padding:'8px',maxHeight:'180px',overflowY:'auto',display:'grid',gridTemplateColumns:'repeat(8,1fr)',gap:'4px'}}>
{EMOJIS.map((em,i) => (
<div key={i} style={{textAlign:'center',fontSize:'24px',padding:'6px',cursor:'pointer',borderRadius:'8px',background:'transparent'}} onClick={()=>insertEmoji(em)} onMouseDown={e=>e.preventDefault()} onTouchStart={e=>e.preventDefault()}>{em}</div>
))}
</div>
)}
<input id="privateFileInput" type="file" accept="image/*" style={{position:'absolute',opacity:0,width:0,height:0,pointerEvents:'none'}} ref={fileInputRef} onChange={(e)=>handleFileSelect(e,'private')} />
<input id="privateCameraInput" type="file" accept="image/*" capture="camera" style={{position:'absolute',opacity:0,width:0,height:0,pointerEvents:'none'}} ref={cameraInputRef} onChange={(e)=>handleFileSelect(e,'private')} />
<input id="privateUploadInput" type="file" accept="*/*" style={{position:'absolute',opacity:0,width:0,height:0,pointerEvents:'none'}} ref={uploadInputRef} onChange={(e)=>handleFileSelect(e,'private')} />
{/* 转账弹窗 */}
{showTransfer && (
<div className="modal-mask" onClick={()=>setShowTransfer(false)}><div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-3">💰 转账给 {chatFriend.nickname}</h3>
<p className="text-gold2 text-xs text-center mb-2">对方点击即可领取筹码</p>
<input className="input-main mb-3" type="number" placeholder="筹码数量" value={transferAmt} onChange={e=>setTransferAmt(e.target.value)} />
<div className="flex gap-3">
<button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setShowTransfer(false)}>取消</button>
<button className="btn-red flex-1" onClick={sendTransfer}>确认转账</button>
</div>
</div></div>
)}
{/* 编辑账单模态框 */}
{editBillModal && (
<div className="modal-mask" onClick={()=>{setEditBillModal(null);setEditBillContent('');}}><div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-3">✏️ 修改账单</h3>
<p className="text-gold2 text-xs text-center mb-2">修改后需要刷新页面才能重新计算统计</p>
<textarea className="input-main mb-3" style={{minHeight:'80px',resize:'vertical'}} placeholder="输入新的账单内容..." value={editBillContent} onChange={e=>setEditBillContent(e.target.value)} />
<div className="flex gap-3">
<button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>{setEditBillModal(null);setEditBillContent('');}}>取消</button>
<button className="btn-gold flex-1" onClick={saveEditedBill}>保存修改</button>
</div>
</div></div>
)}
{/* 红包弹窗 */}
{redPacketModal && (
<div className="modal-mask" onClick={()=>setRedPacketModal(false)}><div className="modal-box" onClick={e=>e.stopPropagation()} style={{maxWidth:'90vw'}}>
<h3 className="text-gold font-bold text-center mb-3">🧧 发红包</h3>
<div className="text-center text-xs text-gold2 mb-2">可用余额:<span className="text-gold font-bold">{(user?.chips || 0).toLocaleString()}</span> <span className="text-orange-400">(私聊红包仅限普通筹码)</span></div>
{/* 红包类型选择 */}
<div className="flex gap-2 mb-3">
<button className={`flex-1 py-2 rounded-lg text-sm ${redPacketType==='normal'?'btn-gold':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketType('normal')}>普通</button>
<button className={`flex-1 py-2 rounded-lg text-sm ${redPacketType==='relay'?'btn-gold':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketType('relay')}>接龙</button>
<button className={`flex-1 py-2 rounded-lg text-sm ${redPacketType==='mine'?'btn-gold':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketType('mine')}>扫雷</button>
</div>
{/* 接龙模式选择 */}
{redPacketType === 'relay' && (
<div className="flex gap-2 mb-3">
<button className={`flex-1 py-2 rounded-lg text-xs ${redPacketMode==='min'?'btn-gold':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketMode('min')}>🔻 最小接龙</button>
<button className={`flex-1 py-2 rounded-lg text-xs ${redPacketMode==='lucky'?'btn-gold':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketMode('lucky')}>👑 运气王接龙</button>
</div>
)}
<div className="mb-3">
<label className="text-gold2 text-xs mb-1 block">金额(元)</label>
<input className="input-main" type="number" placeholder={redPacketType==='normal'?'最大200':'输入金额'} value={redPacketAmt} onChange={e=>setRedPacketAmt(e.target.value)} max={redPacketType==='normal'?200:99999} />
</div>
{/* 群聊普通红包:显示可选个数 */}
{redPacketType === 'normal' && (
<div className="mb-3">
<label className="text-gold2 text-xs mb-1 block">个数(可选,最少分到每个0.01元)</label>
<input className="input-main" type="number" placeholder={redPacketAmt ? `默认${Math.floor((parseFloat(redPacketAmt)||0)/0.01)}(最多)` : '默认最多'} value={redPacketCount} onChange={e=>setRedPacketCount(e.target.value)} min="1" />
</div>
)}
{/* 接龙/扫雷红包:个数必填 */}
{redPacketType !== 'normal' && (
<div className="mb-3">
<label className="text-gold2 text-xs mb-1 block">个数(5-10个)</label>
<input className="input-main" type="number" placeholder="5-10" value={redPacketCount} onChange={e=>setRedPacketCount(e.target.value)} min="5" max="10" />
</div>
)}
{redPacketType === 'mine' && (
<div className="mb-3">
<label className="text-gold2 text-xs mb-1 block">雷点(0-9)</label>
<div className="grid grid-cols-5 gap-2">
{['0','1','2','3','4','5','6','7','8','9'].map(n => (
<button key={n} className={`py-2 rounded-lg text-sm ${redPacketMine===n?'btn-red':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketMine(n)}>{n}</button>
))}
</div>
</div>
)}
<div className="mb-3">
<label className="text-gold2 text-xs mb-1 block">留言(可选)</label>
<input className="input-main" placeholder="恭喜发财,大吉大利" value={redPacketMsg} onChange={e=>setRedPacketMsg(e.target.value)} />
</div>
<div className="flex gap-3">
<button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setRedPacketModal(false)}>取消</button>
<button className="btn-gold flex-1" onClick={sendRedPacket}>发送红包</button>
</div>
</div></div>
)}
{previewImg && <div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.9)',zIndex:10000,display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center'}}>
<button style={{background:'none',border:'none',padding:0,width:'100%',height:'100%',display:'flex',alignItems:'center',justifyContent:'center'}} onClick={()=>{if(Date.now()-previewOpenTime.current>500)setPreviewImg(null);}}><img src={previewImg} style={{maxWidth:'95vw',maxHeight:'95vh',objectFit:'contain'}} /></button>
<div style={{position:'absolute',bottom:40,left:0,right:0,display:'flex',justifyContent:'center',gap:20}}>
<button style={{background:'rgba(255,215,0,0.3)',border:'1px solid #FFD700',color:'#FFD700',padding:'10px 32px',borderRadius:'20px',fontSize:'14px',cursor:'pointer'}} onClick={()=>savePreviewImage(previewImg)}>💾 保存到相册</button>
</div>
</div>}
{linkViewerUrl && <div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'#000',zIndex:10001,display:'flex',flexDirection:'column'}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',padding:'10px 12px',background:'#1a1a1a'}}>
<div style={{color:'#ccc',fontSize:'12px',flex:1,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',marginRight:'8px'}}>{linkViewerUrl}</div>
<button style={{background:'#FF4444',border:'none',color:'#fff',padding:'6px 16px',borderRadius:'16px',fontSize:'13px',cursor:'pointer',fontWeight:'bold',flexShrink:0}} onClick={()=>setLinkViewerUrl(null)}>✕ 关闭</button>
</div>
<iframe src={linkViewerUrl} style={{flex:1,width:'100%',border:'none'}} title="链接查看" sandbox="allow-scripts allow-same-origin allow-popups allow-forms" />
</div>}
{/* 聊天设置面板 */}
{showChatSettings && (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.85)',zIndex:1000,display:'flex',alignItems:'center',justifyContent:'center',padding:'20px'}} onClick={()=>setShowChatSettings(false)}>
<div style={{background:'linear-gradient(135deg,#1a1a2e,#16213e)',borderRadius:'16px',padding:'20px',width:'100%',maxWidth:'360px',border:'1px solid rgba(255,215,0,0.2)',maxHeight:'80vh',overflowY:'auto'}} onClick={e=>e.stopPropagation()}>
<h3 style={{color:'#FFD700',fontSize:'18px',fontWeight:'bold',textAlign:'center',marginBottom:'16px'}}>⚙️ 聊天设置</h3>
{[
{key:'rebateRate',label:'返点比例(%)',defaultValue:4,step:0.1},
].map(field => (
<div key={field.key} style={{marginBottom:'12px'}}>
<label style={{color:'#aaa',fontSize:'13px',display:'block',marginBottom:'4px'}}>{field.label}</label>
<input type="number" style={{width:'100%',padding:'10px 12px',borderRadius:'8px',border:'1px solid rgba(255,215,0,0.2)',background:'rgba(255,255,255,0.05)',color:'#fff',fontSize:'15px',outline:'none'}}
value={chatSettings[field.key] ?? field.defaultValue}
step={field.step}
onChange={e=>{
const val = parseFloat(e.target.value);
const updated = {...chatSettings, [field.key]: isNaN(val) ? field.defaultValue : val};
setChatSettings(updated);
localStorage.setItem('chatSettings_'+user.id, JSON.stringify(updated));
}} />
</div>
))}
<div style={{color:'#FFD700',fontSize:'12px',textAlign:'center',padding:'8px',background:'rgba(255,215,0,0.08)',borderRadius:'8px',marginBottom:'12px'}}>
📌 固定赔率:特号47 | 平特2/马1.7 | 特肖11/马10 | 连肖4/10/30/60 | 包波2.5/红2.4 | 包大小1.8 | 包单双1.8
</div>
<button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'none',background:'linear-gradient(135deg,#FFD700,#DAA520)',color:'#5B0000',fontSize:'15px',fontWeight:'bold',cursor:'pointer',marginTop:'8px'}} onClick={()=>setShowChatSettings(false)}>保存并关闭</button>
</div>
</div>
)}
{/* 使用方法弹窗 */}
{showUsageGuide && (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.85)',zIndex:1000,display:'flex',alignItems:'center',justifyContent:'center',padding:'20px'}} onClick={()=>setShowUsageGuide(false)}>
<div style={{background:'linear-gradient(135deg,#1a1a2e,#16213e)',borderRadius:'16px',padding:'20px',width:'100%',maxWidth:'400px',border:'1px solid rgba(255,215,0,0.2)',maxHeight:'80vh',overflowY:'auto'}} onClick={e=>e.stopPropagation()}>
<h3 style={{color:'#FFD700',fontSize:'18px',fontWeight:'bold',textAlign:'center',marginBottom:'16px'}}>📖 速算使用方法</h3>
<div style={{marginBottom:'16px'}}>
<div style={{color:'#FFD700',fontSize:'14px',fontWeight:'bold',marginBottom:'8px'}}>📝 账单格式示例</div>
<div style={{background:'rgba(255,255,255,0.05)',padding:'12px',borderRadius:'8px',fontSize:'12px',color:'#ccc',lineHeight:'1.8'}}>
<div style={{color:'#FFA500',marginBottom:'4px'}}><strong>特码</strong></div>
<div>特码:01 05 10@10</div>
<div style={{color:'#888',fontSize:'11px',marginTop:'4px'}}>→ 特码3个号,每个号10元</div>
<div style={{color:'#FFA500',margin:'8px 0 4px'}}><strong>平码</strong></div>
<div>平码:02 08 15@20</div>
<div style={{color:'#888',fontSize:'11px',marginTop:'4px'}}>→ 平码3个号,每个号20元</div>
<div style={{color:'#FFA500',margin:'8px 0 4px'}}><strong>正码</strong></div>
<div>正一:03 07@15</div>
<div>正二:12 18@15</div>
<div style={{color:'#888',fontSize:'11px',marginTop:'4px'}}>→ 正码1/2各2个号,每个号15元</div>
<div style={{color:'#FFA500',margin:'8px 0 4px'}}><strong>特肖</strong></div>
<div>特肖:鼠龙马@30</div>
<div style={{color:'#888',fontSize:'11px',marginTop:'4px'}}>→ 特肖3个生肖,每个30元</div>
<div style={{color:'#FFA500',margin:'8px 0 4px'}}><strong>连肖</strong></div>
<div>二连肖:鼠牛@50</div>
<div>三连肖:龙马猴@40</div>
<div style={{color:'#888',fontSize:'11px',marginTop:'4px'}}>→ 二/三连肖各1注,每注50/40元</div>
<div style={{color:'#FFA500',margin:'8px 0 4px'}}><strong>包波/大小/单双</strong></div>
<div>包红波@100</div>
<div>包大@80</div>
<div>包单@60</div>
</div>
</div>
<div style={{marginBottom:'16px'}}>
<div style={{color:'#FFD700',fontSize:'14px',fontWeight:'bold',marginBottom:'8px'}}>⚙️ 设置方法</div>
<div style={{background:'rgba(255,255,255,0.05)',padding:'12px',borderRadius:'8px',fontSize:'12px',color:'#ccc',lineHeight:'1.8'}}>
<div><strong style={{color:'#4CAF50'}}>1. 返点比例设置</strong></div>
<div>• 默认值: 4%</div>
<div>• 点击速算结果右上角 ⚙️ 图标</div>
<div>• 输入自定义返点比例(%)</div>
<div>• 水 = 总金额 × 返点比例%</div>
<div style={{marginTop:'8px'}}><strong style={{color:'#4CAF50'}}>2. 固定赔率</strong></div>
<div>• 特号: 47倍</div>
<div>• 平特2码: 1.7倍</div>
<div>• 特肖11码: 10倍</div>
<div>• 连肖: 4/10/30/60倍</div>
<div>• 包波: 2.5倍(红波4.4倍)</div>
<div>• 大小/单双: 1.8倍</div>
<div style={{marginTop:'8px'}}><strong style={{color:'#4CAF50'}}>3. 共享成员</strong></div>
<div>• 在个人中心添加共享成员</div>
<div>• 共享成员发的账单自动统计</div>
<div>• 无需点开聊天也能看到统计</div>
</div>
</div>
<div style={{marginBottom:'16px'}}>
<div style={{color:'#FFD700',fontSize:'14px',fontWeight:'bold',marginBottom:'8px'}}>💡 温馨提示</div>
<div style={{background:'rgba(255,255,255,0.05)',padding:'12px',borderRadius:'8px',fontSize:'12px',color:'#ccc',lineHeight:'1.8'}}>
<div>• 账单数据自动保存到服务器</div>
<div>• 清除缓存不影响统计数据</div>
<div>• 换设备登录后数据自动同步</div>
<div>• 每天0点自动重置开奖号码</div>
</div>
</div>
<button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'none',background:'linear-gradient(135deg,#FFD700,#DAA520)',color:'#5B0000',fontSize:'15px',fontWeight:'bold',cursor:'pointer'}} onClick={()=>setShowUsageGuide(false)}>我知道了</button>
</div>
</div>
)}
{/* 挑码助手弹窗 */}
{showPickerHelper && (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.7)',zIndex:1001,display:'flex',alignItems:'center',justifyContent:'center',padding:'20px'}} onClick={()=>setShowPickerHelper(false)}>
<div style={{background:'#fff',borderRadius:'16px',width:'100%',maxWidth:'500px',height:'80vh',maxHeight:'600px',display:'flex',flexDirection:'column',overflow:'hidden',boxShadow:'0 8px 32px rgba(0,0,0,0.3)'}} onClick={e=>e.stopPropagation()}>
{/* 标题栏 */}
<div style={{background:'linear-gradient(135deg,#FF6B35,#FF8C42)',padding:'12px 16px',display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span style={{color:'#fff',fontSize:'16px',fontWeight:'bold'}}>🎯 挑码助手</span>
<button style={{background:'rgba(255,255,255,0.2)',border:'none',color:'#fff',fontSize:'18px',cursor:'pointer',padding:'4px 12px',borderRadius:'8px'}} onClick={()=>setShowPickerHelper(false)}>✕ 关闭</button>
</div>
{/* iframe内容 */}
<iframe src="https://ninfjos.ttcir-k66t2-mtsatd.xyz:16677/topic/320294.html" style={{flex:1,width:'100%',border:'none'}} title="挑码助手" />
</div>
</div>
)}
{/* 转发弹窗 - 在聊天窗口内显示 */}
{showForwardModal && forwardMsg && (
<div className="modal-mask" onClick={()=>{setShowForwardModal(false);setForwardSearch('');}}>
<div className="modal-box" onClick={e=>e.stopPropagation()} style={{maxHeight:'80vh',overflow:'hidden',display:'flex',flexDirection:'column',width:'90vw',maxWidth:'400px'}}>
<h3 className="text-gold text-lg font-bold mb-3">选择聊天</h3>
<div style={{marginBottom:'10px',fontSize:'13px',color:'rgba(255,255,255,0.6)'}}>
{forwardMsg.messages?.length > 1 ? `已选择 ${forwardMsg.messages.length} 条消息` : `消息:${(forwardMsg.messages?.[0]?.content || '').slice(0,30)}${(forwardMsg.messages?.[0]?.content || '').length > 30 ? '...' : ''}`}
</div>
<div style={{marginBottom:'10px'}}>
<input
type="text"
placeholder="搜索好友/群聊"
value={forwardSearch}
onChange={e=>setForwardSearch(e.target.value)}
style={{width:'100%',padding:'8px 12px',borderRadius:'20px',border:'none',background:'rgba(255,255,255,0.15)',color:'#fff',outline:'none',fontSize:'14px'}}
/>
</div>
<div style={{flex:1,overflowY:'auto'}}>
{recentForwarders.length > 0 && !forwardSearch && (
<>
<div style={{fontSize:'12px',color:'rgba(255,255,255,0.5)',padding:'8px 0'}}>最近转发</div>
<div style={{display:'flex',gap:'12px',overflowX:'auto',padding:'8px 0'}}>
{recentForwarders.map(target => (
<div key={target.id} style={{display:'flex',flexDirection:'column',alignItems:'center',minWidth:'60px',cursor:'pointer'}} onClick={()=>doForwardToTarget(target)}>
<div style={{width:'50px',height:'50px',borderRadius:'8px',background: target.type === 'group' ? 'linear-gradient(135deg,#1565C0,#0D47A1)' : (target.avatar ? 'url('+target.avatar+')' : 'linear-gradient(135deg,#888,#666)'),backgroundSize:'cover',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'20px',color:'#fff'}}>{target.type === 'group' ? '👬' : (!target.avatar && '👤')}</div>
<span style={{fontSize:'11px',color:'#fff',marginTop:'4px',maxWidth:'60px',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{target.name}</span>
</div>
))}
</div>
</>
)}
<div style={{fontSize:'12px',color:'rgba(255,255,255,0.5)',padding:'8px 0'}}>好友</div>
{friends.filter(fr => !forwardSearch || fr.nickname?.toLowerCase().includes(forwardSearch.toLowerCase())).map(fr => (
<div key={fr.id} style={{display:'flex',alignItems:'center',padding:'10px',borderBottom:'1px solid rgba(255,255,255,0.08)',cursor:'pointer'}} onClick={()=>doForwardToTarget({id:fr.id,name:fr.nickname,avatar:fr.avatar,type:'friend'})}>
<div className="avatar" style={{width:'40px',height:'40px',fontSize:'14px',backgroundImage:fr.avatar?'url('+fr.avatar+')':'none',backgroundSize:'cover'}}>{!fr.avatar&&'👤'}</div>
<span style={{color:'#fff',marginLeft:'10px',fontSize:'14px'}}>{fr.nickname}</span>
</div>
))}
<div style={{fontSize:'12px',color:'rgba(255,255,255,0.5)',padding:'8px 0',marginTop:'8px'}}>群聊</div>
{groups.filter(g => !forwardSearch || g.name?.toLowerCase().includes(forwardSearch.toLowerCase())).map(g => (
<div key={g.id} style={{display:'flex',alignItems:'center',padding:'10px',borderBottom:'1px solid rgba(255,255,255,0.08)',cursor:'pointer'}} onClick={()=>doForwardToTarget({id:g.id,name:g.name,type:'group'})}>
<div style={{width:'40px',height:'40px',borderRadius:'8px',background:'linear-gradient(135deg,#1565C0,#0D47A1)',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'18px'}}>👬</div>
<span style={{color:'#fff',marginLeft:'10px',fontSize:'14px'}}>{g.name}</span>
</div>
))}
</div>
<button className="w-full mt-3 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>{setShowForwardModal(false);setForwardSearch('');}}>取消</button>
</div>
</div>
)}
{redPacketOpen && <RedPacketOpenPage detail={redPacketOpen} onClose={()=>setRedPacketOpen(null)} user={user} onClaim={claimRedPacket} title="" />}
{redPacketDetail && <RedPacketDetailPage detail={redPacketDetail} onClose={()=>setRedPacketDetail(null)} user={user} onClaim={claimRedPacket} />}
</div>
);
}
// 好友列表
return (
<div className="pb-20" style={{background:'#f5f5f5',minHeight:'100vh'}}>
{/* 顶部栏:微信风格悬浮顶栏【HEADER-FLOAT-V1】 */}
<div style={{position:'sticky',top:0,zIndex:100,display:'flex',alignItems:'center',gap:'10px',padding:'10px 16px',background:'#EDEDED',borderBottom:'1px solid #e0e0e0',minHeight:'44px'}}>
{/* 左侧:返回键 + 放大镜 */}
<div style={{display:'flex',alignItems:'center',gap:'8px',flexShrink:0}}>
{/* 返回按钮(仅好友列表视图显示) */}
{showAllFriends && (
<button style={{background:'none',border:'none',cursor:'pointer',padding:'6px',display:'flex',alignItems:'center'}} onClick={()=>setShowAllFriends(false)}><span style={{display:'inline-block',width:'10px',height:'10px',borderLeft:'2px solid #333',borderBottom:'2px solid #333',transform:'rotate(45deg)'}} /></button>
)}
{/* 放大镜按钮 */}
{!topSearchFocus && (
<button style={{background:'none',border:'none',cursor:'pointer',padding:'6px',display:'flex',alignItems:'center',fontSize:'18px'}} onClick={()=>setTopSearchFocus(true)}>🔍</button>
)}
</div>
{/* 中间:标题 或 搜索框 */}
{topSearchFocus ? (
<div style={{flex:1,display:'flex',alignItems:'center',gap:'8px'}}>
<div style={{flex:1,display:'flex',alignItems:'center',background:'#fff',borderRadius:'20px',padding:'6px 12px',border:'1px solid #4a90d9'}}>
<input
autoFocus
placeholder="搜索好友/群聊"
value={addQuery}
onChange={e=>setAddQuery(e.target.value)}
style={{background:'none',border:'none',outline:'none',color:'#333',fontSize:'14px',width:'100%',padding:'0'}}
/>
</div>
<button style={{background:'none',border:'none',color:'#4a90d9',fontSize:'15px',cursor:'pointer',flexShrink:0,padding:'4px 0'}} onClick={()=>{setTopSearchFocus(false);setAddQuery('');}}>取消</button>
</div>
) : (
<div style={{flex:1,textAlign:'center',color:'#333',fontSize:'16px',fontWeight:'bold',display:'flex',alignItems:'center',justifyContent:'center',gap:'8px'}}>
<span>{showAllFriends ? '好友列表' : 'ABC'}</span>
{!showAllFriends && (Object.values(groupMentionNotify).reduce((a,b)=>a+b,0) + Object.values(groupUnread).reduce((a,b)=>a+b,0)) > 0 && (
<span style={{background:'#FF3B30',color:'#fff',fontSize:'10px',fontWeight:'bold',borderRadius:'10px',padding:'1px 6px',minWidth:'16px',textAlign:'center',marginLeft:'6px',lineHeight:'16px'}}>
{(() => { const t = Object.values(groupMentionNotify).reduce((a,b)=>a+b,0) + Object.values(groupUnread).reduce((a,b)=>a+b,0); return t > 99 ? '99+' : t; })()}
</span>
)}
</div>
)}
{/* 右侧:⊕按钮 + 下拉菜单 */}
{!topSearchFocus && (
<div style={{position:'relative',flexShrink:0}}>
<button
style={{width:'20px',height:'20px',borderRadius:'50%',border:'1.5px solid #999',background:'transparent',color:'#333',fontSize:'12px',fontWeight:'bold',cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center',lineHeight:1,padding:0}}
onClick={()=>setShowPlusMenu(v=>!v)}
>+</button>
{showPlusMenu && (
<>
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,zIndex:900}} onClick={()=>setShowPlusMenu(false)} />
<div style={{position:'absolute',top:'44px',right:0,background:'#fff',border:'1px solid #e0e0e0',borderRadius:'12px',overflow:'hidden',zIndex:901,minWidth:'160px',boxShadow:'0 4px 20px rgba(0,0,0,0.15)'}}>
<div style={{padding:'12px 16px',cursor:'pointer',color:'#333',fontSize:'14px',borderBottom:'1px solid #f0f0f0',display:'flex',alignItems:'center',gap:'10px'}}
onMouseEnter={e=>e.currentTarget.style.background='#f5f5f5'}
onMouseLeave={e=>e.currentTarget.style.background='transparent'}
onClick={()=>{setShowAdd(true);setAddMode('friend');setShowPlusMenu(false);setAddQuery('');}}>👤 添加好友</div>
<div style={{padding:'12px 16px',cursor:'pointer',color:'#333',fontSize:'14px',borderBottom:'1px solid #f0f0f0',display:'flex',alignItems:'center',gap:'10px'}}
onMouseEnter={e=>e.currentTarget.style.background='#f5f5f5'}
onMouseLeave={e=>e.currentTarget.style.background='transparent'}
onClick={()=>{setShowAllFriends(!showAllFriends);setShowPlusMenu(false);}}>📋 好友列表</div>
<div style={{padding:'12px 16px',cursor:'pointer',color:'#333',fontSize:'14px',display:'flex',alignItems:'center',gap:'10px'}}
onMouseEnter={e=>e.currentTarget.style.background='#f5f5f5'}
onMouseLeave={e=>e.currentTarget.style.background='transparent'}
onClick={()=>{setShowCreateGroup(true);setShowPlusMenu(false);}}>👥 创建群聊</div>
</div>
</>
)}
</div>
)}
</div>
{/* 列表内容区域【HEADER-FLOAT-V1】 */}
<div style={{padding:'8px 16px'}}>
{/* 实时搜索结果 */}
{topSearchFocus && addQuery.trim() && (() => {
const q = addQuery.trim().toLowerCase();
const matchedFriends = friends.filter(f => (f.nickname||'').toLowerCase().includes(q) || (f.remark||'').toLowerCase().includes(q));
const matchedGroups = groups.filter(g => (g.name||'').toLowerCase().includes(q));
if (matchedFriends.length === 0 && matchedGroups.length === 0) {
return <div style={{textAlign:'center',padding:'32px 0',color:'#999',fontSize:'14px'}}>未找到匹配的好友或群聊</div>;
}
return (
<>
{matchedFriends.map(f => (
<div key={'sf_'+f.id} style={{background:'#fff',padding:'12px',marginBottom:'6px',display:'flex',alignItems:'center',borderRadius:'10px',cursor:'pointer',boxShadow:'0 1px 3px rgba(0,0,0,0.06)'}}
onClick={()=>{setTopSearchFocus(false);setAddQuery('');openChat(f);}}>
<div style={{width:'40px',height:'40px',borderRadius:'50%',overflow:'hidden',flexShrink:0,background:'rgba(0,0,0,0.05)',display:'flex',alignItems:'center',justifyContent:'center'}}>
{f.avatar ? <img src={f.avatar} style={{width:'100%',height:'100%',objectFit:'cover'}} /> : <span style={{fontSize:'20px',color:'#999'}}>👤</span>}
</div>
<div style={{marginLeft:'12px',flex:1}}>
<div style={{color:'#333',fontSize:'14px',fontWeight:'bold'}}>{f.remark || f.nickname}</div>
{f.remark && <div style={{color:'#999',fontSize:'12px'}}>{f.nickname}</div>}
{!f.remark && <div style={{color:'#999',fontSize:'12px'}}>好友</div>}
</div>
<span style={{color:'#ccc',fontSize:'16px'}}>›</span>
</div>
))}
{matchedGroups.map(g => (
<div key={'sg_'+g.id} style={{background:'#fff',padding:'12px',marginBottom:'6px',display:'flex',alignItems:'center',borderRadius:'10px',cursor:'pointer',boxShadow:'0 1px 3px rgba(0,0,0,0.06)'}}
onClick={()=>{setTopSearchFocus(false);setAddQuery('');openGroupChat(g);}}>
<div style={{width:'40px',height:'40px',borderRadius:'8px',overflow:'hidden',flexShrink:0,background:'linear-gradient(135deg,#1565C0,#0D47A1)',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'18px'}}>👬</div>
<div style={{marginLeft:'12px',flex:1}}>
<div style={{color:'#333',fontSize:'14px',fontWeight:'bold'}}>{g.name}</div>
<div style={{color:'#999',fontSize:'12px'}}>群聊</div>
</div>
<span style={{color:'#ccc',fontSize:'16px'}}>›</span>
</div>
))}
</>
);
})()}
{/* 搜索激活时隐藏原有列表 */}
{!topSearchFocus && (<>
{friendRequests.length > 0 && (
<div className="mx-4 mb-3">
<div style={{color:'#999',fontSize:'12px',fontWeight:'bold',marginBottom:'8px'}}>待处理申请 ({friendRequests.length})</div>
{friendRequests.map(r => (
<div key={r.requestId} style={{background:'#fff',padding:'12px',marginBottom:'8px',display:'flex',alignItems:'center',borderRadius:'10px',boxShadow:'0 1px 3px rgba(0,0,0,0.06)'}}>
<div className="avatar" style={{width:'36px',height:'36px',fontSize:'16px',flexShrink:0,backgroundImage:r.avatar?'url('+r.avatar+')':'none',backgroundSize:'cover'}}>{!r.avatar&&'👤'}</div>
<div className="flex-1 ml-3"><div style={{color:'#333',fontSize:'14px',fontWeight:'bold'}}>{r.nickname}</div><div style={{color:'#999',fontSize:'12px'}}>请求添加你为好友</div></div>
<div className="flex gap-2">
<button style={{background:'linear-gradient(135deg,#43A047,#2E7D32)',color:'#fff',border:'none',borderRadius:'8px',padding:'4px 12px',fontSize:'12px',fontWeight:'bold',cursor:'pointer'}} onClick={()=>handleAccept(r.requestId)}>通过</button>
<button style={{background:'rgba(255,255,255,0.15)',color:'#ccc',border:'none',borderRadius:'8px',padding:'4px 12px',fontSize:'12px',cursor:'pointer'}} onClick={()=>handleReject(r.requestId)}>拒绝</button>
</div>
</div>
))}
</div>
)}
{showAllFriends ? (
<>
{friends.length === 0 ? (
<div style={{textAlign:'center',padding:'48px 0'}}><div style={{fontSize:'48px',marginBottom:'8px'}}>👬</div><p style={{color:'#999',fontSize:'14px'}}>暂无好友,快去添加吧</p></div>
) : friends.map(fr => {
// 计算该好友的未读数
let frUnread = 0;
try {
const key = 'chat_' + user.id + '_' + fr.id;
const msgs = JSON.parse(localStorage.getItem(key) || '[]');
const readKeys = JSON.parse(localStorage.getItem('readState_'+user.id) || '{}');
const lastRead = readKeys['f_'+fr.id] || 0;
frUnread = msgs.filter(m => m.senderId !== user.id && new Date(m.createdAt||0).getTime() > lastRead).length;
} catch(e){}
return (
<div key={fr.id} style={{background:'#fff',padding:'12px',marginBottom:'8px',display:'flex',alignItems:'center',borderRadius:'10px',cursor:'pointer',boxShadow:'0 1px 3px rgba(0,0,0,0.06)',position:'relative'}} onClick={()=>{openChat(fr);setShowAllFriends(false);}} onDoubleClick={()=>{setProfilePanel(fr);setProfileMenu(false);}} onTouchStart={(e)=>{e.currentTarget._dlt=setTimeout(()=>{setProfilePanel(fr);setProfileMenu(false);},600);}} onTouchEnd={(e)=>{clearTimeout(e.currentTarget._dlt);}} onTouchMove={(e)=>{clearTimeout(e.currentTarget._dlt);}}>
<div className="flex items-center gap-3 flex-1">
<div style={{position:'relative',width:'48px',height:'48px',borderRadius:'4px',overflow:'hidden',flexShrink:0,border:'none',background:fr.role==='customer_service'?'linear-gradient(135deg,#F44336,#C62828)':'rgba(255,255,255,0.1)'}}>
{fr.avatar ? (
<img src={fr.avatar} style={{width:'100%',height:'100%',objectFit:'cover',borderRadius:'4px'}} onError={(e)=>{e.target.style.display='none';e.target.nextSibling.style.display='flex';}} />
) : null}
<div style={{width:'100%',height:'100%',display:fr.avatar?'none':'flex',alignItems:'center',justifyContent:'center',fontSize:'24px',color:fr.role==='customer_service'?'#fff':'#999'}}>
{fr.role==='customer_service'?'👨💼':'👤'}
</div>
{fr.role==='customer_service' && <div style={{position:'absolute',bottom:0,right:0,width:'18px',height:'18px',borderRadius:'50%',background:'#FFD700',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'10px',border:'2px solid #1a1a2e'}}>⭐</div>}
{frUnread > 0 && <span style={{position:'absolute',top:'-4px',right:'-4px',background:'#FF3B30',color:'#fff',fontSize:'10px',fontWeight:'bold',borderRadius:'10px',padding:'1px 5px',minWidth:'16px',textAlign:'center',lineHeight:'14px',border:'2px solid #1a1a2e'}}>{frUnread > 99 ? '99+' : frUnread}</span>}
</div>
<div>
<div style={{color:'#333',fontSize:'14px',fontWeight:'bold'}}>{fr.remark || fr.nickname}{fr.role==='customer_service'&&<span style={{marginLeft:'6px',fontSize:'11px',color:'#FFD700',background:'rgba(255,215,0,0.15)',padding:'2px 6px',borderRadius:'8px'}}>客服</span>}</div>
<div style={{color:'#999',fontSize:'12px'}}>{fr.remark ? fr.nickname : '点击聊天'}</div>
</div>
</div>
<span style={{color:'#ccc',fontSize:'18px'}}>›</span>
</div>
);
})}
</>
) : (
<>
{recentContacts.length === 0 && groups.length === 0 ? (
<div style={{textAlign:'center',padding:'48px 0'}}><div style={{fontSize:'48px',marginBottom:'8px'}}>💬</div><p style={{color:'#999',fontSize:'14px'}}>暂无消息,去添加好友或创建群聊吧</p></div>
) : (
<>
{recentContacts.map((item,idx) => item.type === 'friend' ? (
<div key={'f_'+item.data.id} style={{display:'flex',alignItems:'center',padding:'2px 0',marginBottom:'0',background:'transparent',cursor:'pointer',position:'relative'}}
onClick={()=>{markRead('f',item.data.id);openChat(item.data);}}
onDoubleClick={()=>{setProfilePanel(item.data);setProfileMenu(false);}}
onTouchStart={(e)=>{e.currentTarget._dlt=setTimeout(()=>{setDeleteConfirm({type:'friend',item});},600);}}
onTouchEnd={(e)=>{clearTimeout(e.currentTarget._dlt);}}
onTouchMove={(e)=>{clearTimeout(e.currentTarget._dlt);}}
onContextMenu={(e)=>{e.preventDefault();setDeleteConfirm({type:'friend',item});}}>
<div style={{position:'relative',width:'48px',height:'48px',borderRadius:'4px',overflow:'hidden',flexShrink:0,background:item.data.role==='customer_service'?'linear-gradient(135deg,#F44336,#C62828)':'rgba(255,255,255,0.1)'}}>
{item.data.avatar ? (
<img src={item.data.avatar} style={{width:'100%',height:'100%',objectFit:'cover'}} onError={(e)=>{e.target.style.display='none';e.target.nextSibling.style.display='flex';}} />
) : null}
<div style={{width:'100%',height:'100%',display:item.data.avatar?'none':'flex',alignItems:'center',justifyContent:'center',fontSize:'24px',color:item.data.role==='customer_service'?'#fff':'#999'}}>
{item.data.role==='customer_service'?'👨💼':'👤'}
</div>
{item.data.role==='customer_service' && <div style={{position:'absolute',bottom:0,right:0,width:'16px',height:'16px',borderRadius:'50%',background:'#FFD700',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'9px',border:'2px solid rgba(0,0,0,0.15)'}}>⭐</div>}
</div>
<div style={{flex:1,marginLeft:'12px',minWidth:0,borderBottom:'1px solid #e8e8e8',paddingBottom:'2px'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span style={{color:'#333',fontSize:'15px',fontWeight:'bold',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',flex:1}}>{item.data.remark || item.data.nickname}{item.data.role==='customer_service'&&<span style={{marginLeft:'6px',fontSize:'11px',color:'#FFD700',background:'rgba(255,215,0,0.15)',padding:'2px 6px',borderRadius:'8px'}}>客服</span>}</span>
<span style={{color:'#999',fontSize:'11px',flexShrink:0,marginLeft:'8px'}}>{formatRelativeTime(item.lastTime)}</span>
</div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginTop:'4px'}}>
<span style={{color:'#999',fontSize:'13px',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',flex:1}}>{item.lastMsg}</span>
{item.unread > 0 && <span style={{background:'#FF3B30',color:'#fff',fontSize:'10px',fontWeight:'bold',borderRadius:'10px',padding:'1px 6px',minWidth:'18px',textAlign:'center',flexShrink:0,marginLeft:'8px'}}>{item.unread > 99 ? '99+' : item.unread}</span>}
</div>
</div>
</div>
) : (
<div key={'g_'+item.data.id} style={{display:'flex',alignItems:'center',padding:'2px 0',marginBottom:'0',background:'transparent',cursor:'pointer',position:'relative'}}
onClick={()=>{
markRead('g',item.data.id);
// 清除 localStorage 中的群未读和 @提醒
try {
const guKey = 'groupUnread_'+user.id;
let gu = JSON.parse(localStorage.getItem(guKey)||'{}');
delete gu[item.data.id];
localStorage.setItem(guKey, JSON.stringify(gu));
setGroupUnread(gu);
window.dispatchEvent(new CustomEvent('group_unread_update', { detail: gu }));
const mnKey = 'groupMentionNotify_'+user.id;
let mn = JSON.parse(localStorage.getItem(mnKey)||'{}');
delete mn[item.data.id];
localStorage.setItem(mnKey, JSON.stringify(mn));
setGroupMentionNotify(mn);
window.dispatchEvent(new CustomEvent('group_mention_update', { detail: mn }));
} catch(e){}
openGroupChat(item.data);
}}
onTouchStart={(e)=>{e.currentTarget._dlt=setTimeout(()=>{setDeleteConfirm({type:'group',item});},600);}}
onTouchEnd={(e)=>{clearTimeout(e.currentTarget._dlt);}}
onTouchMove={(e)=>{clearTimeout(e.currentTarget._dlt);}}
onContextMenu={(e)=>{e.preventDefault();setDeleteConfirm({type:'group',item});}}>
<div style={{width:'48px',height:'48px',borderRadius:'4px',overflow:'hidden',flexShrink:0,background:'rgba(255,255,255,0.1)',display:'grid',gridTemplateColumns:'repeat(3,1fr)',gridTemplateRows:'repeat(3,1fr)',gap:'1px',padding:'1px'}}>
{(item.data.memberAvatars || []).slice(0,9).map((av,ai) => <div key={ai} style={{background:'#444',overflow:'hidden'}}>{av ? <img src={av} style={{width:'100%',height:'100%',objectFit:'cover',display:'block'}} /> : <div style={{width:'100%',height:'100%',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'8px',color:'#999'}}>👤</div>}</div>)}
{(!item.data.memberAvatars || item.data.memberAvatars.length === 0) && <div style={{gridColumn:'1/4',gridRow:'1/4',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'22px',background:'linear-gradient(135deg,#1565C0,#0D47A1)'}}>👬</div>}
</div>
<div style={{flex:1,marginLeft:'12px',minWidth:0,borderBottom:'1px solid #e8e8e8',paddingBottom:'2px'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span style={{color:'#333',fontSize:'15px',fontWeight:'bold',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',flex:1}}>{item.data.name}</span>
<span style={{color:'#999',fontSize:'11px',flexShrink:0,marginLeft:'8px'}}>{formatRelativeTime(item.lastTime)}</span>
</div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginTop:'4px'}}>
<span style={{color:'#999',fontSize:'13px',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',flex:1}}>{item.lastMsg || ''}</span>
{(item.unread > 0 || groupMentionNotify[item.data.id] || groupUnread[item.data.id]) && <span style={{background:'#FF3B30',color:'#fff',fontSize:'10px',fontWeight:'bold',borderRadius:'10px',padding:'1px 6px',minWidth:'18px',textAlign:'center',flexShrink:0,marginLeft:'8px'}}>{groupMentionNotify[item.data.id] ? '@'+(groupMentionNotify[item.data.id]) : ((groupUnread[item.data.id]||item.unread||0) > 99 ? '99+' : (groupUnread[item.data.id]||item.unread||0))}</span>}
</div>
</div>
</div>
))}
{groups.filter(g => !recentContacts.some(rc => rc.type === 'group' && rc.data.id === g.id)).map(g => (
<div key={'gx_'+g.id} style={{display:'flex',alignItems:'center',padding:'2px 0',marginBottom:'0',background:'transparent',cursor:'pointer'}} onClick={()=>{
markRead('g',g.id);
try {
const guKey = 'groupUnread_'+user.id;
let gu = JSON.parse(localStorage.getItem(guKey)||'{}');
delete gu[g.id];
localStorage.setItem(guKey, JSON.stringify(gu));
setGroupUnread(gu);
window.dispatchEvent(new CustomEvent('group_unread_update', { detail: gu }));
const mnKey = 'groupMentionNotify_'+user.id;
let mn = JSON.parse(localStorage.getItem(mnKey)||'{}');
delete mn[g.id];
localStorage.setItem(mnKey, JSON.stringify(mn));
setGroupMentionNotify(mn);
window.dispatchEvent(new CustomEvent('group_mention_update', { detail: mn }));
} catch(e){}
openGroupChat(g);
}}>
<div style={{width:'48px',height:'48px',borderRadius:'4px',overflow:'hidden',flexShrink:0,display:'flex',alignItems:'center',justifyContent:'center',fontSize:'22px',background:'linear-gradient(135deg,#1565C0,#0D47A1)'}}>👬</div>
<div style={{flex:1,marginLeft:'12px',minWidth:0,borderBottom:'1px solid #e8e8e8',paddingBottom:'2px'}}>
<div style={{color:'#333',fontSize:'15px',fontWeight:'bold'}}>{g.name}</div>
<div style={{color:'#999',fontSize:'13px',marginTop:'4px'}}></div>
</div>
{groupMentionNotify[g.id] && <span style={{background:'#FF3B30',color:'#fff',fontSize:'10px',fontWeight:'bold',borderRadius:'10px',padding:'1px 6px'}}>@</span>}
</div>
))}
</>
)}
</>
)}
</>
)}
{/* 删除聊天确认弹窗 */}
{deleteConfirm && (
<div className="modal-mask" onClick={()=>setDeleteConfirm(null)}><div className="modal-box" onClick={e=>e.stopPropagation()} style={{textAlign:'center'}}>
<h3 className="text-gold font-bold mb-3">删除该聊天?</h3>
<p className="text-gold2 text-sm mb-4">将从最近联系人中移除{deleteConfirm.type==='friend'?'「'+deleteConfirm.item.data.nickname+'」':'「'+deleteConfirm.item.data.name+'」'}的聊天记录</p>
<div className="flex gap-3">
<button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setDeleteConfirm(null)}>取消</button>
<button className="btn-red flex-1" onClick={()=>{
const delKey = (deleteConfirm.type==='friend'?'f_':'g_')+deleteConfirm.item.data.id;
if(deleteConfirm.type==='friend'){
try{localStorage.removeItem('chat_'+user.id+'_'+deleteConfirm.item.data.id);}catch(e){}
}
const newDeleted = [...deletedChats, delKey];
saveDeletedChats(newDeleted);
setRecentContacts(prev=>prev.filter(rc=>!(rc.type===deleteConfirm.type&&rc.data.id===deleteConfirm.item.data.id)));
setDeleteConfirm(null);
}}>确认删除</button>
</div>
</div></div>
)}
{/* ====== 群聊界面 ====== */}
{activeGroup && !chatFriend && (
<div data-group-container key={'grp_'+activeGroup.id} style={{position:'fixed',top:0,bottom:0,left:0,right:0,margin:'0 auto',width:'100%',maxWidth:'500px',zIndex:300,display:'flex',flexDirection:'column',background:'#F5F5F5',willChange:'transform',backfaceVisibility:'hidden',animation:'slideInRight 0.2s ease-out'}}>
<div style={{display:'flex',alignItems:'center',padding:'10px 12px',borderBottom:'1px solid #d5d5d5',background:'#E0E0E0'}}>
<button style={{background:'none',border:'none',cursor:'pointer',padding:'6px',display:'flex',alignItems:'center'}} onClick={()=>{
const el = document.querySelector('[data-group-container]');
if (el) { el.style.animation='slideOutRight 0.25s ease-in forwards'; setTimeout(()=>setActiveGroup(null),240); }
else { setActiveGroup(null); }
}}><span style={{display:'inline-block',width:'10px',height:'10px',borderLeft:'2px solid #333',borderBottom:'2px solid #333',transform:'rotate(45deg)'}} /></button>
<span style={{color:'#333',fontWeight:'bold',flex:1,textAlign:'center',fontSize:'16px',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{activeGroup.name}</span>
<div style={{position:'relative',display:'flex',alignItems:'center'}}>
<button style={{background:'none',border:'none',color:'#333',fontSize:'16px',cursor:'pointer',padding:'4px',letterSpacing:'1px'}} onClick={()=>setShowGroupMembers(true)}>...</button>
</div>
</div>
<div ref={groupScrollRef} onScroll={(e)=>{const el=e.currentTarget;groupUserScrolling.current=el.scrollHeight-el.scrollTop-el.clientHeight>150;}} style={{flex:1,overflowY:'auto',padding:'10px',background:'#F5F5F5'}}>
{groupMessages.map((m,i) => {
const isMine = m.senderId === user.id;
const isSystem = m.type === 'system' || m.senderId === 'system';
const prevMsg = i > 0 ? groupMessages[i-1] : null; const showTime = (i === 0) || (!prevMsg?.createdAt || !m.createdAt) || (new Date(m.createdAt).getTime() - new Date(prevMsg.createdAt).getTime() > 1800000);
if (m.type === 'recalled') return (<div key={m.id||i} style={{textAlign:'center',margin:'12px 0'}}><span style={{fontSize:'12px',color:'rgba(255,255,255,0.5)',background:'rgba(0,0,0,0.2)',padding:'3px 12px',borderRadius:'10px'}}>{m.content}{m.recalledBy === user.nickname && <a style={{color:'#4A9EFF',marginLeft:'6px',cursor:'pointer',textDecoration:'underline'}} onClick={()=>handleReedit(m)}>重新编辑</a>}</span></div>);
// 红包领取广播渲染(必须在isSystem通用渲染之前)
if (m.msgType === 'redpacket_claim' || (isSystem && m.content && m.content.startsWith('{') && m.content.includes('"type":"redpacket_claim"'))) {
let claimData = {};
try { claimData = typeof m.content === 'string' ? JSON.parse(m.content) : m.content; } catch(e){}
if (claimData.type === 'redpacket_claim') {
const isSender = claimData.senderId === user.id;
const isClaimer = claimData.claimerId === user.id;
let claimText = '';
if (isSender) {
claimText = <>{claimData.claimerName}领取了我的<span style={{color:'#FFD700',fontWeight:'bold',cursor:'pointer',textDecoration:'underline'}} onClick={()=>fetchRedPacketDetail(claimData.packetId)}>红包</span></>;
} else if (isClaimer) {
claimText = <>我领取了{claimData.senderName}的<span style={{color:'#FFD700',fontWeight:'bold',cursor:'pointer',textDecoration:'underline'}} onClick={()=>fetchRedPacketDetail(claimData.packetId)}>红包</span></>;
} else {
claimText = <>{claimData.claimerName}领取了{claimData.senderName}的<span style={{color:'#FFD700',fontWeight:'bold',cursor:'pointer',textDecoration:'underline'}} onClick={()=>fetchRedPacketDetail(claimData.packetId)}>红包</span></>;
}
return (
<React.Fragment key={m.id||i}>
{showTime && m.createdAt && <div style={{textAlign:'center',margin:'10px 0 6px'}}><span style={{fontSize:'11px',color:'rgba(255,255,255,0.4)',background:'rgba(0,0,0,0.15)',padding:'2px 10px',borderRadius:'8px'}}>{formatMsgTime(m.createdAt)}</span></div>}
<div style={{textAlign:'center',margin:'12px 0'}}>
<span style={{fontSize:'12px',color:'rgba(255,255,255,0.5)',background:'rgba(0,0,0,0.2)',padding:'3px 12px',borderRadius:'10px'}}>
{claimText}
</span>
</div>
</React.Fragment>
);
}
}
if (isSystem) return (<div key={m.id||i} style={{textAlign:'center',margin:'12px 0'}}><span style={{fontSize:'12px',color:'rgba(255,255,255,0.5)',background:'rgba(0,0,0,0.2)',padding:'3px 12px',borderRadius:'10px'}}>{m.content}</span></div>);
// 红包消息渲染
if (m.msgType === 'redpacket' || (m.content && m.content.startsWith('{') && m.content.includes('"type":"redpacket"'))) {
let rpData = {};
try { rpData = typeof m.content === 'string' ? JSON.parse(m.content) : m.content; } catch(e){}
if (rpData.type === 'redpacket') {
// 【RP-CARD-v5】群聊红包卡片三态判定(修复 isDim is not defined)
const _cardStatus = (claimedPacketIds && typeof claimedPacketIds.get === 'function') ? claimedPacketIds.get(rpData.packetId) : null;
const isExpired = rpData.status === 'expired' || _cardStatus === 'expired';
const isFinishedDim = _cardStatus === 'finished' || rpData.status === 'finished';
const isMineDim = _cardStatus === 'mine';
const isDim = isExpired || isFinishedDim || isMineDim;
const isClaimed = isMineDim;
const rpTypeNames = {normal: '普通红包', relay: '接龙红包', mine: '扫雷红包'};
const rpIcons = {normal: '🧧', relay: '🐉', mine: '💣'};
return (
<React.Fragment key={m.id||i}>
{showTime && m.createdAt && <div style={{textAlign:'center',margin:'10px 0 6px'}}><span style={{fontSize:'11px',color:'rgba(255,255,255,0.4)',background:'rgba(0,0,0,0.15)',padding:'2px 10px',borderRadius:'8px'}}>{formatMsgTime(m.createdAt)}</span></div>}
<div style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',alignItems:'flex-start',gap:'8px',marginBottom:'8px'}}>
{!isMine && <div style={{width:'36px',height:'36px',fontSize:'14px',flexShrink:0,borderRadius:'4px',border:'none',backgroundImage:m.senderAvatar?'url('+m.senderAvatar+')':'none',backgroundSize:'cover'}}>{!m.senderAvatar&&'👤'}</div>}
<div>
{!isMine && <div style={{fontSize:'11px',color:'#DAA520',marginBottom:'2px'}}>{m.senderName}</div>}
<div style={{padding:'8px 12px',borderRadius:'8px',background:isDim?'linear-gradient(135deg,#F4A45C,#D88134)':'linear-gradient(135deg,#FA9D3B,#E8740C)',maxWidth:'240px',minWidth:'180px',boxShadow:isDim?'0 2px 8px rgba(216,129,52,0.3)':'0 2px 8px rgba(232,116,12,0.3)',cursor:'pointer',opacity:isExpired?0.7:1}} data-rp-card-v5={isExpired?'expired':isFinishedDim?'finished':isMineDim?'mine':'normal'} onClick={()=>openRedPacket(rpData.packetId)}>
<div style={{display:'flex',alignItems:'center',gap:'10px'}}>
<div style={{width:'40px',height:'50px',background:isDim?'linear-gradient(135deg,#E07A55,#C56638)':'linear-gradient(135deg,#F44336,#D32F2F)',borderRadius:'6px',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'20px'}}>¥</div>
<div style={{flex:1}}>
<div style={{fontSize:'15px',fontWeight:'bold',color:'#fff',marginBottom:'4px'}}>{rpData.message || '恭喜发财,大吉大利'}</div>
<div style={{fontSize:'11px',color:'rgba(255,255,255,0.85)'}}>
{rpData.redPacketType === 'relay' ? (rpData.relayMode === 'lucky' ? '👑 运气王接龙红包' : '🔻 最小接龙红包') + ' ' + (rpData.totalAmount || rpData.amount || 0) + '元' : rpData.redPacketType === 'mine' ? '扫雷红包 ' + (rpData.totalAmount || rpData.amount || 0) + '元 雷点' + (rpData.mineNumber != null ? rpData.mineNumber : '?') : '微信红包 ' + (rpData.totalAmount || rpData.amount || 0) + '元'}
{isExpired && <span style={{marginLeft:'6px',fontSize:'10px',color:'rgba(255,255,255,0.95)'}}>🕰️ 已过期</span>}
{!isExpired && isFinishedDim && <span style={{marginLeft:'6px',fontSize:'10px',color:'rgba(255,255,255,0.95)'}}>🤑 已领完</span>}{!isExpired && !isFinishedDim && isMineDim && <span style={{marginLeft:'6px',fontSize:'10px',color:'rgba(255,255,255,0.95)'}}>✔️ 已领</span>}
</div>
</div>
</div>
</div>
</div>
{isMine && <div style={{width:'36px',height:'36px',fontSize:'14px',flexShrink:0,borderRadius:'4px',border:'none',backgroundImage:user.avatar?'url('+user.avatar+')':'none',backgroundSize:'cover'}}>{!user.avatar&&'👤'}</div>}
</div>
</React.Fragment>
);
}
}
return (
<React.Fragment key={m.id||i}>
{showTime && m.createdAt && <div style={{textAlign:'center',margin:'10px 0 6px'}}><span style={{fontSize:'11px',color:'rgba(255,255,255,0.4)',background:'rgba(0,0,0,0.15)',padding:'2px 10px',borderRadius:'8px'}}>{formatMsgTime(m.createdAt)}</span></div>}
<div style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',alignItems:'flex-start',gap:'8px',marginBottom:'8px'}}>
{groupMultiSelect && !isSystem && <div style={{width:'20px',height:'20px',borderRadius:'50%',border:'2px solid #999',display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0,marginTop:'8px',cursor:'pointer',background:groupSelectedMsgs.includes(m.id)?'#07C160':'transparent',borderColor:groupSelectedMsgs.includes(m.id)?'#07C160':'#999'}} onClick={()=>setGroupSelectedMsgs(prev=>prev.includes(m.id)?prev.filter(id=>id!==m.id):[...prev,m.id])}>{groupSelectedMsgs.includes(m.id)&&<span style={{color:'#fff',fontSize:'12px'}}>✓</span>}</div>}
{!isMine && <div style={{width:'36px',height:'36px',fontSize:'14px',flexShrink:0,borderRadius:'4px',border:'none',backgroundImage:m.senderAvatar?'url('+m.senderAvatar+')':'none',backgroundSize:'cover',cursor:'pointer'}} onContextMenu={(e)=>{e.preventDefault();handleAtMember({id:m.senderId,nickname:m.senderName});}} onTouchStart={(e)=>{e.currentTarget._lt=setTimeout(()=>{handleAtMember({id:m.senderId,nickname:m.senderName});},500);}} onTouchEnd={(e)=>{clearTimeout(e.currentTarget._lt);}}>{!m.senderAvatar&&'👤'}</div>}
<div>
{!isMine && <div style={{fontSize:'11px',color:'#DAA520',marginBottom:'2px'}}>{m.senderName}</div>}
<div style={{position:'relative',padding:'6px 10px',borderRadius:isMine?'6px 2px 6px 6px':'2px 6px 6px 6px',maxWidth:'220px',fontSize:'15px',lineHeight:'1.5',wordBreak:'break-word',whiteSpace:'pre-wrap',background:isMine?'#95EC69':'#fff',color:'#333',userSelect:'none',opacity:groupMultiSelect&&groupSelectedMsgs.includes(m.id)?0.6:1}} onContextMenu={(e)=>showMsgMenu(e,m)} onTouchStart={(e)=>{e.currentTarget._mt=setTimeout(()=>showMsgMenu(e,m),600);}} onTouchEnd={(e)=>{clearTimeout(e.currentTarget._mt);}} onTouchMove={(e)=>{clearTimeout(e.currentTarget._mt);}}>
<div style={{position:'absolute',top:'10px',width:0,height:0,borderTop:'6px solid transparent',borderBottom:'6px solid transparent',...(isMine?{right:'-6px',borderLeft:'6px solid #95EC69'}:{left:'-6px',borderRight:'6px solid #fff'})}} />
{m.quote && <div style={{fontSize:'12px',color:'#666',background:'rgba(0,0,0,0.05)',borderRadius:'6px',padding:'4px 8px',marginBottom:'4px',borderLeft:'3px solid #07C160'}}>
<div style={{fontWeight:'bold',fontSize:'11px',color:'#07C160'}}>{m.quote.senderName}</div>
<div style={{overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',maxWidth:'180px'}}>{m.quote.content}</div>
</div>}
{m.type === 'voice' ? (
<div onClick={()=>playVoice(m.id, m.content)} style={{display:'flex',alignItems:'center',gap:'8px',minWidth:'80px',cursor:'pointer',padding:'4px 0',userSelect:'none'}}>
<span style={{fontSize:'20px'}}>{playingVoiceId===m.id ? '⏸️' : '▶️'}</span>
<div style={{display:'flex',alignItems:'center',gap:'2px'}}>
{[1,2,3].map(i => <div key={i} style={{width:'3px',height:(8+i*5)+'px',background:isMine?'#fff':'#07C160',borderRadius:'2px'}} />)}
</div>
<span style={{fontSize:'12px',color:isMine?'rgba(255,255,255,0.7)':'#999'}}>{m.duration||0}''</span>
</div>
) : m.type === 'image' ? (
<div style={{position:'relative'}}>
<button style={{background:'none',border:'none',padding:0,margin:0,cursor:'pointer',display:'block'}} onClick={()=>{previewOpenTime.current=Date.now();setPreviewImg(m.content);}}><img src={m.content} style={{maxWidth:'200px',maxHeight:'200px',borderRadius:'8px',display:'block'}} /></button>
{!isMine && <button style={{position:'absolute',bottom:'8px',right:'8px',background:'rgba(0,0,0,0.7)',color:'#fff',border:'none',borderRadius:'12px',padding:'6px 12px',fontSize:'12px',cursor:'pointer'}} onClick={(e)=>{e.stopPropagation();recognizeBillImage(m.content,m);}}>识别账单</button>}
</div>
) : (
(() => { const c = m.content || ''; const urlRegex = /(https?:\/\/[^\s<]+)/g; const atReg = /@([^\n\s@]+)/g; const allRegex = new RegExp('(' + urlRegex.source + '|' + atReg.source + ')', 'g'); const parts = []; let last = 0; let match; while ((match = allRegex.exec(c)) !== null) { if (match.index > last) parts.push(c.slice(last, match.index)); if (match[1].match(urlRegex)) { parts.push(<a key={match.index} href={match[1]} target="_blank" rel="noopener noreferrer" style={{color:'#1E90FF',textDecoration:'underline',wordBreak:'break-all',cursor:'pointer',position:'relative',zIndex:10}} onTouchEnd={(e)=>{e.stopPropagation();}} onClick={(e)=>{e.stopPropagation();}}>{match[1]}</a>); } else { parts.push(<span key={match.index} style={{color:'#1E90FF',fontWeight:'bold'}}>{match[0]}</span>); } last = match.index + match[0].length; } if (last < c.length) parts.push(c.slice(last)); return parts.length ? parts : c; })()
)}
</div>
</div>
{isMine && <div style={{width:'36px',height:'36px',fontSize:'14px',flexShrink:0,borderRadius:'4px',border:'none',backgroundImage:user.avatar?'url('+user.avatar+')':'none',backgroundSize:'cover'}}>{!user.avatar&&''}</div>}
</div>
{/* 【禁用】群聊不使用速算功能,账单统计只在共享成员(私聊)之间生效 */}
{false && (() => {
const gKey = 'groupMsgs_' + user.id + '_' + activeGroup.id;
const stopTime = getChatStopTime(gKey);
const stoppedPeriodTime = getStoppedPeriodTime(gKey);
// 无效账单判断:
// 扫描消息列表,找到最后一条停止/恢复消息的时间
const allMsgsForStop = groupMessages || [];
let lastStopTime = 0;
let lastResumeTime = 0;
for (const msg of allMsgsForStop) {
if (msg.content && (msg.content.includes('🛑 活动结束') || msg.content.includes('活动结束'))) {
lastStopTime = new Date(msg.createdAt).getTime();
lastResumeTime = 0; // 停止后清除恢复时间
} else if (msg.content && (msg.content.includes('🟢 活动开始') || msg.content.includes('活动开始'))) {
lastResumeTime = new Date(msg.createdAt).getTime();
}
}
// 如果消息中有活动状态,优先使用消息中的状态;否则使用localStorage
if (lastStopTime === 0 && stopTime > 0) {
lastStopTime = stopTime;
}
let isStopped = false;
const msgTime = new Date(m.createdAt).getTime();
const isAutoCloseNoticeMsg = m.content && ((m.content || '').includes('后续账单自动归澳门2') || (m.content || '').includes('今日活动全部结束') || (m.content || '').includes('澳门1/香港已截止') || (m.content || '').includes('澳门2已截止'));
const isStopOrResumeMsg = m.content && !isAutoCloseNoticeMsg && (m.content.includes('活动结束') || m.content.includes('活动开始') || m.content.includes('🛑') || m.content.includes('🟢'));
if (!isStopOrResumeMsg && lastStopTime > 0) {
if (lastResumeTime > 0) {
// 已恢复:只有停止期间的消息无效
isStopped = msgTime >= lastStopTime && msgTime < lastResumeTime;
} else {
// 还在停止中:所有 >= lastStopTime 的消息无效
isStopped = msgTime >= lastStopTime;
}
}
// 优先使用后端实际落库的billStats;前端不再私自重算
const freshStats = m.billStats;
if (freshStats && freshStats.error) {
return (
<div style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',marginBottom:'8px'}}>
<div style={{padding:'6px 12px',borderRadius:'8px',background:'#FFF0F0',maxWidth:'80%',fontSize:'12px',color:'#D32F2F',lineHeight:'1.5',border:'1px solid #FFCDD2'}}>
⚠️ {freshStats.message || '账单出错'}
</div>
</div>
);
}
// 停止期间的账单:无论开关状态,都显示"活动已停止"
if (isStopped) {
return (
<div style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',marginBottom:'8px'}}>
<div style={{padding:'6px 12px',borderRadius:'8px',background:'#f0f0f0',maxWidth:'80%',fontSize:'12px',color:'#999',lineHeight:'1.5'}}>
🛑 活动已结束,账单无效
</div>
</div>
);
}
// 速算开关逻辑:
// 只有后端把该账单实际落库到当前用户维度,才显示速算结果
// 旧消息没有 billStatsSavedFor 时保持兼容
const billSavedForMe = !m.billStatsSavedFor || m.billStatsSavedFor.includes(user.id);
const shouldShow = freshStats && !freshStats.error && billSavedForMe && !isStopped;
if (!shouldShow) return null;
if (typeof window !== 'undefined' && !window.__AUTO_CLOSE_VALID_BILL_V4) { window.__AUTO_CLOSE_VALID_BILL_V4 = true; console.log('[AUTO-CLOSE-VALID-BILL-V4] 自动截止账单保持有效展示'); }
return (
<div style={{display:'flex',justifyContent:isMine?'flex-end':'flex-start',marginBottom:'8px'}}>
<div style={{padding:'8px 12px',borderRadius:'8px',background:'linear-gradient(135deg,#FFF8E1,#FFECB3)',maxWidth:'80%',fontSize:'12px',color:'#5D4037',lineHeight:'1.5',boxShadow:'0 1px 2px rgba(0,0,0,0.1)'}}>
<div style={{fontWeight:'bold',marginBottom:'2px',display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span>🧮 速算结果</span>
<div style={{display:'flex',gap:'8px'}}>
<span style={{fontSize:'11px',color:'#2196F3',cursor:'pointer',fontWeight:'bold'}} onClick={(e)=>{e.stopPropagation();setShowUsageGuide(true);}}>❓ 使用方法</span>
<span style={{fontSize:'11px',color:'#FF6B35',cursor:'pointer',fontWeight:'bold'}} onClick={(e)=>{e.stopPropagation();setShowPickerHelper(true);}}>🎯 挑码助手</span>
</div>
</div>
<div style={{whiteSpace:'pre-wrap'}}>{freshStats.summary}</div>
<div style={{marginTop:'4px',paddingTop:'4px',borderTop:'1px dashed rgba(93,64,55,0.2)',display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span style={{fontSize:'11px',opacity:0.7}}>合计:{freshStats.total}</span>
<span style={{fontSize:'11px',color:'#8B4513',cursor:'pointer',fontWeight:'bold'}} onClick={async ()=>{
try {
const savedNums = sharedDrawNums || (()=>{try{return JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}')}catch(e){return {macau1:[null,null,null,null,null,null,null],macau2:[null,null,null,null,null,null,null],hongkong:[null,null,null,null,null,null,null]}}})();
const billDateStr = ((m.createdAt || new Date().toISOString())).split('T')[0];
const token = localStorage.getItem('token');
const statResults = await Promise.all(['macau1','macau2','hongkong'].map(async lt => {
const dnArr = savedNums[lt] || [null,null,null,null,null,null,null];
const hasAnyNumber = dnArr.some(n => n !== null && n !== '');
const drawParam = hasAnyNumber ? encodeURIComponent(JSON.stringify(dnArr)) : '';
let url = API + '/speedcalc/stats?lottery=' + lt + '&date=' + billDateStr + '&userFilter=all&groupId=' + encodeURIComponent(activeGroup.id);
if (drawParam) url += '&drawNums=' + drawParam;
const statRes = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
const statData = await statRes.json();
return { lottery: lt, stats: (statData.code === 200 && statData.data) ? statData.data : null };
}));
const records = [];
const billRecords = { macau1: [], macau2: [], hongkong: [] };
const backendStats = { macau1: null, macau2: null, hongkong: null };
statResults.forEach(({lottery: lt, stats}) => {
backendStats[lt] = stats;
const list = (stats && stats.billList) ? stats.billList : [];
list.forEach(b => {
const items = Array.isArray(b.items) ? b.items : [];
const totalPayout = items.reduce((sum, it) => sum + (it._payout || 0), 0);
const record = { id: b.id, msgId: b.id, senderId: b.senderId, senderName: b.senderName || '未知', content: b.content, time: b.time, lottery: lt, total: b.total || 0, summary: '', items, payout: totalPayout };
records.push(record);
if (billRecords[lt]) billRecords[lt].push(record);
});
});
const total = statResults.reduce((sum, r) => sum + ((r.stats && r.stats.total) || 0), 0);
const totalPayout = records.reduce((sum, r) => sum + (r.payout || 0), 0);
const senderName = '全部成员';
onShowBillRecord({ chatName: activeGroup.name || '群聊', senderName, records, total, drawNumbers: savedNums, totalPayout, billRecords, backendStats, recordDate: billDateStr, groupId: activeGroup.id, rebateRate: chatSettings.rebateRate ?? 4, chatFriendId: null });
} catch(e) { alert('打开记录失败:'+e.message); }
}}>📋 记录</span>
</div>
</div>
</div>
);
})()}
</React.Fragment>
);
})}
{contextMenu && <div style={{position:'fixed',top:0,left:0,right:0,bottom:0,zIndex:999,background:'rgba(0,0,0,0.5)'}} onClick={()=>setContextMenu(null)}><div style={{position:'absolute',left:'50%',bottom:'20%',transform:'translateX(-50%)',background:'#333',borderRadius:'12px',padding:'8px 0',boxShadow:'0 4px 20px rgba(0,0,0,0.5)',minWidth:'140px',maxWidth:'80vw'}}>
<div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{copyToClipboard(contextMenu.msg.content||contextMenu.msg._origContent||'');setContextMenu(null);}}>复制</div>
<div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setGroupReplyTo(contextMenu.msg);setContextMenu(null);}}>引用</div>
<div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setForwardMsg({messages:[{...contextMenu.msg,chatType:'group'}],chatType:'group'});setShowForwardModal(true);setContextMenu(null);}}>转发</div>
<div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setGroupMultiSelect(true);setContextMenu(null);}}>多选</div>
{contextMenu.isMine && <div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>handleRecall(contextMenu.msg)}>撤回</div>}
{contextMenu.isMine && <div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setGroupMsgInput(contextMenu.msg.content||contextMenu.msg._origContent||'');setContextMenu(null);if(groupMsgInputRef.current)groupMsgInputRef.current.focus();}}>编辑</div>}
{(() => {
const gKey = activeGroup ? 'groupMsgs_' + user.id + '_' + activeGroup.id : '';
return canEditDeleteBill(contextMenu.msg, gKey, 'group', user, groupMessages) && (
<>
<div style={{padding:'12px 20px',color:'#FFD700',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setEditBillModal({msg:contextMenu.msg,chatType:'group',chatKey:gKey});setEditBillContent(contextMenu.msg.content||'');setContextMenu(null);}}>✏️ 修改账单</div>
<div style={{padding:'12px 20px',color:'#FF3B30',fontSize:'14px',cursor:'pointer',textAlign:'center',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{handleDeleteBill(contextMenu.msg,'group');setContextMenu(null);}}>🗑️ 删除账单</div>
</>
);
})()}
<div style={{padding:'12px 20px',color:'#fff',fontSize:'14px',cursor:'pointer',textAlign:'center'}} onClick={()=>{handleAtMember({id:contextMenu.msg.senderId,nickname:contextMenu.msg.senderName});setContextMenu(null);}}>@TA</div>
</div></div>}
<div ref={groupMsgEndRef}/>
{groupMultiSelect && (
<div style={{position:'sticky',bottom:0,display:'flex',alignItems:'center',gap:'8px',padding:'8px 12px',background:'rgba(0,0,0,0.5)',borderTop:'1px solid rgba(255,215,0,0.3)',zIndex:10}}>
<span style={{fontSize:'13px',color:'rgba(255,255,255,0.7)',flex:1}}>已选 {groupSelectedMsgs.length} 条</span>
<button style={{background:'#07C160',color:'#fff',border:'none',borderRadius:'6px',padding:'6px 14px',fontSize:'13px',cursor:'pointer'}} onClick={()=>{setForwardMsg({messages:groupMessages.filter(m=>groupSelectedMsgs.includes(m.id)),chatType:'group'});setShowForwardModal(true);setGroupMultiSelect(false);}}>转发</button>
<button style={{background:'#FF3B30',color:'#fff',border:'none',borderRadius:'6px',padding:'6px 14px',fontSize:'13px',cursor:'pointer'}} onClick={()=>{setGroupMessages(prev=>prev.filter(m=>!groupSelectedMsgs.includes(m.id)));setGroupSelectedMsgs([]);setGroupMultiSelect(false);}}>删除</button>
<button style={{background:'rgba(255,255,255,0.2)',color:'#fff',border:'none',borderRadius:'6px',padding:'6px 14px',fontSize:'13px',cursor:'pointer'}} onClick={()=>{setGroupSelectedMsgs([]);setGroupMultiSelect(false);}}>取消</button>
</div>
)}
</div>
<div style={{display:'flex',gap:'8px',padding:'10px',borderTop:'1px solid #E0E0E0',background:'#FFFFFF',alignItems:'center',flexWrap:'wrap'}}>
{groupReplyTo && <div style={{width:'100%',display:'flex',alignItems:'center',gap:'8px',padding:'6px 0',marginBottom:'4px'}}>
<div style={{flex:1,fontSize:'12px',color:'#666',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
<span style={{color:'#999'}}>引用 {groupReplyTo.senderName || '对方'}:</span>{groupReplyTo.content}
</div>
<button style={{background:'none',border:'none',fontSize:'16px',color:'#999',cursor:'pointer'}} onClick={()=>setGroupReplyTo(null)}>✕</button>
</div>}
<button style={{background:'none',border:'none',cursor:'pointer',padding:'4px',flexShrink:0,display:'flex',alignItems:'center'}} onMouseDown={e=>e.preventDefault()} onTouchStart={e=>e.preventDefault()} onClick={()=>{setIsGroupVoiceMode(!isGroupVoiceMode);setShowGroupEmoji(false);setGroupShowMorePanel(false);}}><svg width="24" height="24" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10.5" stroke={isGroupVoiceMode?'#07C160':'#333'} strokeWidth="2"/><circle cx="7.5" cy="12" r="1.8" fill={isGroupVoiceMode?'#07C160':'#333'}/><path d="M10.5 9a3.5 3.5 0 0 1 0 6" stroke={isGroupVoiceMode?'#07C160':'#333'} strokeWidth="1.8" fill="none" strokeLinecap="round"/><path d="M13 6.5a7.5 7.5 0 0 1 0 11" stroke={isGroupVoiceMode?'#07C160':'#333'} strokeWidth="1.8" fill="none" strokeLinecap="round"/></svg></button>
{isGroupVoiceMode ? (
<div style={{flex:1,display:'flex',gap:'8px',alignItems:'center'}}>
<button
onMouseDown={(e)=>{e.preventDefault();startPTT();}}
onMouseUp={(e)=>{e.preventDefault();stopPTT();}}
onMouseLeave={(e)=>{if(isRecording&&pttModeRef_global)stopPTT();}}
onTouchStart={(e)=>{startPTT();}}
onTouchEnd={(e)=>{stopPTT();}}
style={{flex:1,padding:'6px 0',borderRadius:'4px',border:'1px solid '+(pttPressed||pttRecording?'#C8E6C9':'#DDD'),background:(pttPressed||pttRecording)?'#E8F5E9':'#F5F5F5',fontSize:'15px',color:'#333',cursor:'pointer',textAlign:'center',fontWeight:'500',userSelect:'none',WebkitUserSelect:'none',touchAction:'none',transition:'background 0.1s,border-color 0.1s'}}
>{pttRecording ? '松开发送' : '按住 说话'}</button>
<button style={{background:'none',border:'none',fontSize:'18px',color:'#666',cursor:'pointer',flexShrink:0,padding:'4px'}} onClick={()=>setIsGroupVoiceMode(false)}>⌨️</button>
</div>
) : (
<>
<AutoResizeTextarea ref={groupMsgInputRef}
placeholder="输入消息..."
value={groupMsgInput}
onChange={e=>setGroupMsgInput(e.target.value)}
onSend={sendGroupMsg}
maxHeight={100}
style={{flex:1,padding:'6px',borderRadius:'4px',border:'1px solid #DDD',background:'#F5F5F5',color:'#333',fontSize:'14px',minWidth:0,outline:'none',resize:'none',overflowY:'auto',whiteSpace:'pre-wrap',fontFamily:'inherit'}}
onKeyDown={e=>{if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();sendGroupMsg();}}}
onFocus={()=>{setShowGroupEmoji(false);setGroupShowMorePanel(false);}} />
{groupMsgInput.trim() && <button style={{background:'linear-gradient(135deg,#4CAF50,#45A049)',border:'none',color:'#fff',padding:'10px 18px',borderRadius:'20px',fontWeight:'bold',cursor:'pointer',flexShrink:0}} onClick={()=>sendGroupMsg()}>发送</button>}
</>
)}
<button style={{background:'none',border:'none',fontSize:'22px',cursor:'pointer',color:showGroupEmoji?'#07C160':'#666',padding:'2px',flexShrink:0}} onMouseDown={e=>e.preventDefault()} onTouchStart={e=>e.preventDefault()} onClick={()=>{document.activeElement&&document.activeElement.blur();setShowGroupEmoji(!showGroupEmoji);setGroupShowMorePanel(false);}}>😊</button>
<button style={{width:'22px',height:'22px',borderRadius:'50%',border:'1.5px solid #999',background:'none',cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center',padding:0,color:groupShowMorePanel?'#07C160':'#666',fontSize:'15px',flexShrink:0,lineHeight:1}} onMouseDown={e=>e.preventDefault()} onTouchStart={e=>e.preventDefault()} onClick={()=>{document.activeElement&&document.activeElement.blur();setGroupShowMorePanel(!groupShowMorePanel);setShowGroupEmoji(false);}}>+</button>
</div>
{groupShowMorePanel && (
<div style={{display:'grid',gridTemplateColumns:'repeat(4,1fr)',gap:'12px',padding:'16px',background:'#F5F5F5',borderTop:'1px solid #E0E0E0'}}>
<label htmlFor="groupFileInput" style={{textAlign:'center',cursor:'pointer'}}>
<div style={{width:'56px',height:'56px',background:'#FFF',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px',boxShadow:'0 1px 3px rgba(0,0,0,0.1)'}}>🖼️</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>相册</div>
</label>
<label htmlFor="groupCameraInput" style={{textAlign:'center',cursor:'pointer'}}>
<div style={{width:'56px',height:'56px',background:'#FFF',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px',boxShadow:'0 1px 3px rgba(0,0,0,0.1)'}}>📷</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>拍摄</div>
</label>
<div style={{textAlign:'center',cursor:'pointer'}} onClick={()=>{setGroupShowMorePanel(false);setRedPacketModal(true);}}>
<div style={{width:'56px',height:'56px',background:'#FFF',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px',boxShadow:'0 1px 3px rgba(0,0,0,0.1)'}}>🧧</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>红包</div>
</div>
<div style={{textAlign:'center',cursor:'pointer'}} onClick={()=>{setGroupShowMorePanel(false);startRecording();}}>
<div style={{width:'56px',height:'56px',background:'#FFF',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px',boxShadow:'0 1px 3px rgba(0,0,0,0.1)'}}>🎤</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>语音</div>
</div>
<label htmlFor="groupUploadInput" style={{textAlign:'center',cursor:'pointer'}}>
<div style={{width:'56px',height:'56px',background:'#FFF',borderRadius:'12px',display:'flex',alignItems:'center',justifyContent:'center',margin:'0 auto',fontSize:'28px',boxShadow:'0 1px 3px rgba(0,0,0,0.1)'}}>📁</div>
<div style={{fontSize:'12px',color:'#666',marginTop:'6px'}}>上传</div>
</label>
</div>
)}
{showGroupEmoji && (
<div style={{background:'#fff',padding:'10px',borderTop:'1px solid #E0E0E0',display:'flex',flexWrap:'wrap',gap:'4px',maxHeight:'140px',overflowY:'auto'}}>
{GROUP_EMOJIS.map((e,i) => <span key={i} style={{fontSize:'24px',cursor:'pointer',padding:'4px'}} onClick={()=>sendGroupMsg(e)} onMouseDown={ev=>ev.preventDefault()} onTouchStart={ev=>ev.preventDefault()}>{e}</span>)}
</div>
)}
<input id="groupFileInput" type="file" accept="image/*" style={{position:'absolute',opacity:0,width:0,height:0,pointerEvents:'none'}} ref={groupFileInputRef} onChange={(e)=>handleFileSelect(e,'group')} />
<input id="groupCameraInput" type="file" accept="image/*" capture="camera" style={{position:'absolute',opacity:0,width:0,height:0,pointerEvents:'none'}} ref={groupCameraInputRef} onChange={(e)=>handleFileSelect(e,'group')} />
<input id="groupUploadInput" type="file" accept="*/*" style={{position:'absolute',opacity:0,width:0,height:0,pointerEvents:'none'}} ref={groupUploadInputRef} onChange={(e)=>handleFileSelect(e,'group')} />
{showGroupMembers && (
<div style={{position:'fixed',top:0,bottom:0,left:'50%',transform:'translateX(-50%)',width:'100%',maxWidth:'500px',zIndex:400,display:'flex',flexDirection:'column',background:'linear-gradient(180deg,#5B0000 0%,#3A0000 100%)'}}>
<div style={{display:'flex',alignItems:'center',padding:'12px',borderBottom:'1px solid rgba(255,215,0,0.3)'}}>
<button style={{background:'none',border:'none',color:'#FFD700',fontSize:'18px',cursor:'pointer'}} onClick={()=>{setShowGroupMembers(false);setShowAddGroupMember(false);setEditGroupName(false);setEditGroupAnnounce(false);}}>← 返回</button>
<span style={{color:'#fff',fontWeight:'bold',flex:1,textAlign:'center',fontSize:'16px'}}>聊天信息({groupDetail?.memberList?.length||0})</span>
<div style={{width:'40px'}}></div>
</div>
<div style={{flex:1,overflowY:'auto',padding:'16px'}}>
<div style={{display:'flex',flexWrap:'wrap',gap:'12px'}}>
{groupDetail?.memberList?.map(m => {
// 优先使用localStorage缓存的头像
let displayAvatar = m.avatar;
if (m.isVirtual) {
try {
const cache = JSON.parse(localStorage.getItem('virtualUsersCache') || '{}');
if (cache.members && cache.members.length > 0) {
const cached = cache.members.find(vm => vm.id === m.id);
if (cached) displayAvatar = cached.avatar;
}
} catch(e) {}
}
return (
<div key={m.id} style={{width:'60px',display:'flex',flexDirection:'column',alignItems:'center',gap:'4px',cursor:(activeGroup.ownerId===user.id&&!m.isOwner&&!m.isVirtual)?'pointer':'default',position:'relative'}} onClick={()=>{
// 虚拟用户不能被踢出
if(activeGroup.ownerId===user.id && !m.isOwner && m.id!==user.id && !m.isVirtual){
if(confirm('确认将「'+m.nickname+'」移出群聊?')){
post('/groups/removeMember',{groupId:activeGroup.id,userId:m.id}).then(r=>{
if(r.code===200){showToast('已移除');get('/groups/detail?groupId='+activeGroup.id).then(d=>{if(d.code===200)setGroupDetail(d.data);});}
else showToast(r.message||'移除失败');
});
}
}
}}>
<div style={{width:'50px',height:'50px',borderRadius:'8px',overflow:'hidden',background:'rgba(255,255,255,0.1)',position:'relative'}}>
{displayAvatar ? <img src={displayAvatar} style={{width:'100%',height:'100%',objectFit:'cover'}} onError={(e)=>{e.target.style.display='none';e.target.nextSibling.style.display='flex';}} /> : <div style={{width:'100%',height:'100%',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'24px'}}>👤</div>}
<div style={{width:'100%',height:'100%',display:'none',alignItems:'center',justifyContent:'center',fontSize:'24px',position:'absolute',top:0,left:0}}>👤</div>
{activeGroup.ownerId===user.id && !m.isOwner && m.id!==user.id && <div style={{position:'absolute',top:'-4px',right:'-4px',width:'18px',height:'18px',borderRadius:'50%',background:'#FF4444',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'12px',color:'#fff',lineHeight:1}}>✕</div>}
</div>
<span style={{color:'#fff',fontSize:'11px',textAlign:'center',width:'100%',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{m.nickname}{m.isOwner?' 👑':''}</span>
</div>
);
})}
{activeGroup.ownerId === user.id && (
<div style={{width:'60px',display:'flex',flexDirection:'column',alignItems:'center',gap:'4px',cursor:'pointer'}} onClick={()=>setShowAddGroupMember(true)}>
<div style={{width:'50px',height:'50px',borderRadius:'8px',border:'2px dashed rgba(255,255,255,0.4)',display:'flex',alignItems:'center',justifyContent:'center'}}>
<span style={{fontSize:'28px',color:'rgba(255,255,255,0.5)',lineHeight:1}}>+</span>
</div>
<span style={{color:'rgba(255,255,255,0.5)',fontSize:'11px'}}>添加</span>
</div>
)}
{activeGroup.ownerId !== user.id && (
<div style={{width:'60px',display:'flex',flexDirection:'column',alignItems:'center',gap:'4px',cursor:'pointer'}} onClick={()=>setShowAddGroupMember(true)}>
<div style={{width:'50px',height:'50px',borderRadius:'8px',border:'2px dashed rgba(255,255,255,0.4)',display:'flex',alignItems:'center',justifyContent:'center'}}>
<span style={{fontSize:'28px',color:'rgba(255,255,255,0.5)',lineHeight:1}}>+</span>
</div>
<span style={{color:'rgba(255,255,255,0.5)',fontSize:'11px'}}>邀请</span>
</div>
)}
</div>
<div style={{marginTop:'20px',borderTop:'1px solid rgba(255,215,0,0.2)',paddingTop:'16px'}}>
{/* 群聊名称 */}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'14px 0',borderBottom:'1px solid rgba(255,255,255,0.08)',cursor:activeGroup.ownerId===user.id?'pointer':'default'}} onClick={()=>{if(activeGroup.ownerId===user.id){setGroupNameInput(activeGroup.name);setEditGroupName(true);}}}>
<span style={{color:'#fff',fontSize:'15px'}}>群聊名称</span>
<span style={{color:'#DAA520',fontSize:'15px'}}>{activeGroup.name} {activeGroup.ownerId===user.id?'›':''}</span>
</div>
{/* 群主 */}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'14px 0',borderBottom:'1px solid rgba(255,255,255,0.08)'}}>
<span style={{color:'#fff',fontSize:'15px'}}>群主</span>
<span style={{color:'#DAA520',fontSize:'15px'}}>{groupDetail?.memberList?.find(m=>m.isOwner)?.nickname||''}</span>
</div>
{/* 群公告 */}
<div style={{padding:'14px 0',borderBottom:'1px solid rgba(255,255,255,0.08)',cursor:activeGroup.ownerId===user.id?'pointer':'default'}} onClick={()=>{if(activeGroup.ownerId===user.id){setGroupAnnounceInput(groupDetail?.announcement||'');setEditGroupAnnounce(true);}}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span style={{color:'#fff',fontSize:'15px'}}>群公告</span>
<span style={{color:'#DAA520',fontSize:'13px'}}>{activeGroup.ownerId===user.id?'编辑 ›':''}</span>
</div>
{groupDetail?.announcement ? <div style={{color:'rgba(255,255,255,0.6)',fontSize:'13px',marginTop:'6px',lineHeight:'1.5'}}>{groupDetail.announcement}</div> : <div style={{color:'rgba(255,255,255,0.3)',fontSize:'13px',marginTop:'6px'}}>未设置</div>}
</div>
{/* 群ID */}
<div style={{padding:'14px 0',borderBottom:'1px solid rgba(255,255,255,0.08)'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:'8px'}}>
<span style={{color:'#fff',fontSize:'15px'}}>群ID</span>
<button style={{background:'linear-gradient(135deg,#FFD700,#FFA500)',border:'none',color:'#000',fontSize:'12px',padding:'4px 12px',borderRadius:'12px',cursor:'pointer',fontWeight:'bold'}} onClick={async ()=>{
try {
await navigator.clipboard.writeText(activeGroup.id);
showToast('群ID已复制');
} catch(e) {
// fallback
const ta = document.createElement('textarea');
ta.value = activeGroup.id;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast('群ID已复制');
}
}}>📋 复制</button>
</div>
<div style={{color:'#DAA520',fontSize:'13px',background:'rgba(255,215,0,0.1)',padding:'8px 12px',borderRadius:'8px',wordBreak:'break-all',fontFamily:'monospace'}}>{activeGroup.id}</div>
</div>
{/* 消息免打扰 */}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'14px 0',borderBottom:'1px solid rgba(255,255,255,0.08)'}}>
<span style={{color:'#fff',fontSize:'15px'}}>消息免打扰</span>
<div style={{width:'44px',height:'24px',borderRadius:'12px',background:JSON.parse(localStorage.getItem('groupMute_'+user.id)||'{}')[activeGroup.id]?'#43A047':'rgba(255,255,255,0.2)',cursor:'pointer',position:'relative',transition:'background 0.3s'}} onClick={()=>{
const key='groupMute_'+user.id;
const mutes=JSON.parse(localStorage.getItem(key)||'{}');
mutes[activeGroup.id]=!mutes[activeGroup.id];
localStorage.setItem(key,JSON.stringify(mutes));
setShowGroupMembers(prev=>!prev);setTimeout(()=>setShowGroupMembers(true),0);
}}>
<div style={{width:'20px',height:'20px',borderRadius:'50%',background:'#fff',position:'absolute',top:'2px',left:JSON.parse(localStorage.getItem('groupMute_'+user.id)||'{}')[activeGroup.id]?'22px':'2px',transition:'left 0.3s'}}></div>
</div>
</div>
{/* 保存到群列表 */}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'14px 0',borderBottom:'1px solid rgba(255,255,255,0.08)'}}>
<span style={{color:'#fff',fontSize:'15px'}}>保存到群列表</span>
<div style={{width:'44px',height:'24px',borderRadius:'12px',background:JSON.parse(localStorage.getItem('groupSaved_'+user.id)||'{}')[activeGroup.id]?'#43A047':'rgba(255,255,255,0.2)',cursor:'pointer',position:'relative',transition:'background 0.3s'}} onClick={()=>{
const key='groupSaved_'+user.id;
const saved=JSON.parse(localStorage.getItem(key)||'{}');
saved[activeGroup.id]=!saved[activeGroup.id];
localStorage.setItem(key,JSON.stringify(saved));
setShowGroupMembers(prev=>!prev);setTimeout(()=>setShowGroupMembers(true),0);
}}>
<div style={{width:'20px',height:'20px',borderRadius:'50%',background:'#fff',position:'absolute',top:'2px',left:JSON.parse(localStorage.getItem('groupSaved_'+user.id)||'{}')[activeGroup.id]?'22px':'2px',transition:'left 0.3s'}}></div>
</div>
</div>
{/* 清空聊天记录 */}
<div style={{padding:'14px 0',borderBottom:'1px solid rgba(255,255,255,0.08)',cursor:'pointer'}} onClick={()=>{
if(confirm('确认清空群聊记录?\n\n(清空后聊天窗口不再显示历史消息,但账单数据仍会保留在记录和总统计中)')){
// 【修复】不删除 localStorage 中的群聊数据,仅通过 markCleared 时间戳过滤显示
// 这样 calcSpeedCalcStats 仍能从本地读取完整账单数据进行总统计
setGroupMessages([]);
clearChatImages('group_'+user.id+'_'+activeGroup.id+'_');
markCleared('g_'+activeGroup.id);
buildRecentContacts();
}
}}>
<span style={{color:'#FF4444',fontSize:'15px'}}>🗑️ 清空聊天记录</span>
</div>
{/* 群主转让 */}
{activeGroup.ownerId===user.id && <div style={{padding:'14px 0',borderBottom:'1px solid rgba(255,255,255,0.08)',cursor:'pointer'}} onClick={()=>setShowTransferOwner(true)}>
<span style={{color:'#FFD700',fontSize:'15px'}}>👑 转让群主</span>
</div>}
{/* 群主解散群 */}
{activeGroup.ownerId===user.id && <div style={{padding:'14px 0',cursor:'pointer'}} onClick={async()=>{
if(!confirm('确认解散该群?所有成员将被移除')) return;
// 移除所有成员
for(const m of (groupDetail?.memberList||[])){
if(m.id!==user.id) await post('/groups/removeMember',{groupId:activeGroup.id,userId:m.id});
}
setShowGroupMembers(false);setActiveGroup(null);fetchGroups();
}}>
<span style={{color:'#FF4444',fontSize:'15px'}}>解散群聊</span>
</div>}
{activeGroup.ownerId!==user.id && <div style={{padding:'14px 0',cursor:'pointer'}} onClick={async()=>{
if(!confirm('确认退出该群?')) return;
await post('/groups/removeMember',{groupId:activeGroup.id,userId:user.id});
setShowGroupMembers(false);setActiveGroup(null);fetchGroups();
}}>
<span style={{color:'#FF4444',fontSize:'15px'}}>退出群聊</span>
</div>}
</div>
</div>
{/* 修改群名弹窗 */}
{editGroupName && <div className="modal-mask" onClick={()=>setEditGroupName(false)}><div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold text-lg font-bold mb-3">修改群名称</h3>
<input value={groupNameInput} onChange={e=>setGroupNameInput(e.target.value)} maxLength={20} style={{width:'100%',padding:'10px',borderRadius:'8px',border:'1px solid rgba(255,215,0,0.3)',background:'rgba(0,0,0,0.3)',color:'#fff',fontSize:'15px',marginBottom:'12px'}} />
<div style={{display:'flex',gap:'8px'}}>
<button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setEditGroupName(false)}>取消</button>
<button className="flex-1 py-2 rounded-xl text-white" style={{background:'#43A047'}} onClick={async()=>{
if(!groupNameInput.trim()){showToast('请输入群名');return;}
const r=await post('/groups/rename',{groupId:activeGroup.id,name:groupNameInput.trim()});
if(r.code===200){activeGroup.name=groupNameInput.trim();setEditGroupName(false);fetchGroups();}
else showToast(r.message||'修改失败');
}}>确定</button>
</div>
</div></div>}
{/* 编辑公告弹窗 */}
{editGroupAnnounce && <div className="modal-mask" onClick={()=>setEditGroupAnnounce(false)}><div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold text-lg font-bold mb-3">编辑群公告</h3>
<textarea value={groupAnnounceInput} onChange={e=>setGroupAnnounceInput(e.target.value)} maxLength={500} rows={5} style={{width:'100%',padding:'10px',borderRadius:'8px',border:'1px solid rgba(255,215,0,0.3)',background:'rgba(0,0,0,0.3)',color:'#fff',fontSize:'15px',marginBottom:'12px',resize:'vertical'}} placeholder="输入群公告内容..." />
<div style={{display:'flex',gap:'8px'}}>
<button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setEditGroupAnnounce(false)}>取消</button>
<button className="flex-1 py-2 rounded-xl text-white" style={{background:'#43A047'}} onClick={async()=>{
const r=await post('/groups/announcement',{groupId:activeGroup.id,announcement:groupAnnounceInput});
if(r.code===200){if(groupDetail)groupDetail.announcement=groupAnnounceInput.trim();setEditGroupAnnounce(false);}
else showToast(r.message||'修改失败');
}}>确定</button>
</div>
</div></div>}
{showAddGroupMember && (
<div className="modal-mask" onClick={()=>setShowAddGroupMember(false)}><div className="modal-box" onClick={e=>e.stopPropagation()} style={{maxHeight:'60vh',overflow:'auto'}}>
<h3 className="text-gold text-lg font-bold mb-3">添加好友到群</h3>
{friends.filter(fr => !groupDetail?.memberList?.some(gm => gm.id === fr.id)).length === 0 && <div style={{color:'rgba(255,255,255,0.5)',textAlign:'center',padding:'20px'}}>没有可添加的好友</div>}
{friends.filter(fr => !groupDetail?.memberList?.some(gm => gm.id === fr.id)).map(fr => (
<div key={fr.id} className="flex items-center gap-3 mb-2 p-2 bg-dark-card" style={{borderRadius:'8px'}}>
<div style={{width:'36px',height:'36px',borderRadius:'8px',overflow:'hidden',background:'rgba(255,255,255,0.1)',flexShrink:0}}>
{fr.avatar ? <img src={fr.avatar} style={{width:'100%',height:'100%',objectFit:'cover'}} /> : <div style={{width:'100%',height:'100%',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'18px'}}>👤</div>}
</div>
<span className="text-white text-sm flex-1">{fr.nickname}</span>
<button style={{background:'#43A047',color:'#fff',border:'none',borderRadius:'6px',padding:'5px 14px',fontSize:'13px',cursor:'pointer'}} onClick={async()=>{await addMemberToGroup(fr.id);}}>添加</button>
</div>
))}
<button className="w-full mt-3 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setShowAddGroupMember(false)}>关闭</button>
</div></div>
)}
{/* 转让群主弹窗 */}
{showTransferOwner && (
<div className="modal-mask" onClick={()=>setShowTransferOwner(false)}><div className="modal-box" onClick={e=>e.stopPropagation()} style={{maxHeight:'70vh',overflow:'auto'}}>
<h3 className="text-gold text-lg font-bold mb-3">👑 转让群主</h3>
<p className="text-gold2 text-xs mb-3">选择要转让的群成员,转让后你将变为普通成员</p>
{(!groupDetail?.memberList || groupDetail.memberList.filter(m => !m.isVirtual && m.id !== user.id).length <= 0) ? (
<div style={{color:'rgba(255,255,255,0.5)',textAlign:'center',padding:'20px'}}>没有其他成员可转让</div>
) : (
groupDetail.memberList.filter(m => !m.isVirtual && m.id !== user.id).map(member => (
<div key={member.id} className="flex items-center gap-3 mb-2 p-2 bg-dark-card" style={{borderRadius:'8px',cursor:'pointer'}} onClick={async()=>{
if(!confirm(`确认将群主转让给「${member.nickname}」?`)) return;
const res = await post('/groups/transferOwner', {
groupId: activeGroup.id,
newOwnerId: member.id
});
if(res.code === 200) {
showToast('群主已转让');
setShowTransferOwner(false);
setShowGroupMembers(false);
fetchGroups();
// 刷新群详情
const d = await get('/groups/detail?groupId=' + activeGroup.id);
if(d.code === 200) setGroupDetail(d.data);
} else {
showToast(res.message || '转让失败');
}
}}>
<div style={{width:'36px',height:'36px',borderRadius:'8px',overflow:'hidden',background:'rgba(255,255,255,0.1)',flexShrink:0}}>
{member.avatar ? <img src={member.avatar} style={{width:'100%',height:'100%',objectFit:'cover'}} /> : <div style={{width:'100%',height:'100%',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'18px'}}>👤</div>}
</div>
<span className="text-white text-sm flex-1">{member.nickname}</span>
<span style={{color:'#FFD700',fontSize:'12px'}}>转让 ›</span>
</div>
))
)}
<button className="w-full mt-3 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setShowTransferOwner(false)}>取消</button>
</div></div>
)}
</div>
)}
{redPacketModal && (
<div className="modal-mask" onClick={()=>setRedPacketModal(false)}><div className="modal-box" onClick={e=>e.stopPropagation()} style={{maxWidth:'90vw'}}>
<h3 className="text-gold font-bold text-center mb-3">🧧 发红包</h3>
{activeGroup?.id === 'official_group_abc' ? (
<div className="text-center text-xs text-gold2 mb-2">可用余额:<span className="text-gold font-bold">{((user?.chips || 0) + (user?.bonusChips || 0)).toLocaleString()}</span> <span className="text-orange-400">(含赠送筹码{(user?.bonusChips || 0).toLocaleString()})</span></div>
) : (
<div className="text-center text-xs text-gold2 mb-2">可用余额:<span className="text-gold font-bold">{(user?.chips || 0).toLocaleString()}</span> <span className="text-orange-400">(仅限普通筹码)</span></div>
)}
<div className="flex gap-2 mb-3">
<button className={`flex-1 py-2 rounded-lg text-sm ${redPacketType==='normal'?'btn-gold':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketType('normal')}>普通</button>
<button className={`flex-1 py-2 rounded-lg text-sm ${redPacketType==='relay'?'btn-gold':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketType('relay')}>接龙</button>
<button className={`flex-1 py-2 rounded-lg text-sm ${redPacketType==='mine'?'btn-gold':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketType('mine')}>扫雷</button>
</div>
{/* 接龙模式选择 */}
{redPacketType === 'relay' && (
<div className="flex gap-2 mb-3">
<button className={`flex-1 py-2 rounded-lg text-xs ${redPacketMode==='min'?'btn-gold':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketMode('min')}>🔻 最小接龙</button>
<button className={`flex-1 py-2 rounded-lg text-xs ${redPacketMode==='lucky'?'btn-gold':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketMode('lucky')}>👑 运气王接龙</button>
</div>
)}
<div className="mb-3">
<label className="text-gold2 text-xs mb-1 block">金额(元)</label>
<input className="input-main" type="number" placeholder={redPacketType==='normal'?'最大200':'输入金额'} value={redPacketAmt} onChange={e=>setRedPacketAmt(e.target.value)} />
</div>
{/* 群聊普通红包:显示可选个数 */}
{redPacketType === 'normal' && (
<div className="mb-3">
<label className="text-gold2 text-xs mb-1 block">个数(可选,最少分到每个0.01元)</label>
<input className="input-main" type="number" placeholder={redPacketAmt ? `默认${Math.floor((parseFloat(redPacketAmt)||0)/0.01)}(最多)` : '默认最多'} value={redPacketCount} onChange={e=>setRedPacketCount(e.target.value)} min="1" />
</div>
)}
{/* 接龙/扫雷红包:个数必填 */}
{redPacketType !== 'normal' && (
<div className="mb-3">
<label className="text-gold2 text-xs mb-1 block">个数(5-10个)</label>
<input className="input-main" type="number" placeholder="5-10" value={redPacketCount} onChange={e=>setRedPacketCount(e.target.value)} min="5" max="10" />
</div>
)}
{redPacketType === 'mine' && (
<div className="mb-3">
<label className="text-gold2 text-xs mb-1 block">雷点(0-9)</label>
<div className="grid grid-cols-5 gap-2">
{['0','1','2','3','4','5','6','7','8','9'].map(n => (
<button key={n} className={`py-2 rounded-lg text-sm ${redPacketMine===n?'btn-red':'bg-dark-card text-gold2'}`} onClick={()=>setRedPacketMine(n)}>{n}</button>
))}
</div>
</div>
)}
<div className="mb-3">
<label className="text-gold2 text-xs mb-1 block">留言(可选)</label>
<input className="input-main" placeholder="恭喜发财,大吉大利" value={redPacketMsg} onChange={e=>setRedPacketMsg(e.target.value)} />
</div>
<div className="flex gap-3">
<button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setRedPacketModal(false)}>取消</button>
<button className="btn-gold flex-1" onClick={sendRedPacket}>发送红包</button>
</div>
</div></div>
)}
</div>
)}
{/* ====== 群聊列表 ====== */}
{showAdd && (
<div className="modal-mask" onClick={()=>setShowAdd(false)}><div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-3">添加好友</h3>
{/* 选项卡 */}
<div style={{display:'flex',gap:'8px',marginBottom:'12px'}}>
<button style={{flex:1,padding:'8px',borderRadius:'8px',border:'none',background:addMode==='friend'?'linear-gradient(135deg,#FFD700,#DAA520)':'rgba(255,255,255,0.1)',color:addMode==='friend'?'#5B0000':'#fff',fontSize:'13px',fontWeight:'bold',cursor:'pointer'}} onClick={()=>setAddMode('friend')}>添加好友</button>
<button style={{flex:1,padding:'8px',borderRadius:'8px',border:'none',background:addMode==='group'?'linear-gradient(135deg,#FFD700,#DAA520)':'rgba(255,255,255,0.1)',color:addMode==='group'?'#5B0000':'#fff',fontSize:'13px',fontWeight:'bold',cursor:'pointer'}} onClick={()=>setAddMode('group')}>加入群聊</button>
</div>
{addMode === 'friend' ? (
<>
<p className="text-gold2 text-xs mb-2">可通过用户ID或手机号添加</p>
<input className="input-main mb-3" placeholder="输入用户ID或手机号" value={addQuery} onChange={e=>setAddQuery(e.target.value)} />
<div className="flex gap-3"><button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setShowAdd(false)}>取消</button>
<button className="btn-red flex-1" onClick={handleAdd}>添加</button></div>
</>
) : (
<>
<p className="text-gold2 text-xs mb-2">输入群ID申请加入,需群主审核通过</p>
<input className="input-main mb-3" placeholder="输入群ID" value={joinGroupId} onChange={e=>setJoinGroupId(e.target.value)} />
<div className="flex gap-3"><button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setShowAdd(false)}>取消</button>
<button className="btn-gold flex-1" onClick={handleJoinGroup}>申请加入</button></div>
</>
)}
</div></div>
)}
{showCreateGroup && (
<div className="modal-mask" onClick={()=>setShowCreateGroup(false)}><div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold text-lg font-bold mb-3">创建群聊</h3>
<input className="input-main mb-3" placeholder="输入群名称" value={groupName} onChange={e=>setGroupName(e.target.value)} />
<div className="flex gap-3">
<button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setShowCreateGroup(false)}>取消</button>
<button className="btn-gold flex-1" onClick={createGroup}>创建</button>
</div>
</div></div>
)}
{/* 转发弹窗 - 在群聊窗口内显示 */}
{showForwardModal && forwardMsg && (
<div className="modal-mask" onClick={()=>{setShowForwardModal(false);setForwardSearch('');}}>
<div className="modal-box" onClick={e=>e.stopPropagation()} style={{maxHeight:'80vh',overflow:'hidden',display:'flex',flexDirection:'column',width:'90vw',maxWidth:'400px'}}>
<h3 className="text-gold text-lg font-bold mb-3">选择聊天</h3>
<div style={{marginBottom:'10px',fontSize:'13px',color:'rgba(255,255,255,0.6)'}}>
{forwardMsg.messages?.length > 1 ? `已选择 ${forwardMsg.messages.length} 条消息` : `消息:${(forwardMsg.messages?.[0]?.content || '').slice(0,30)}${(forwardMsg.messages?.[0]?.content || '').length > 30 ? '...' : ''}`}
</div>
<div style={{marginBottom:'10px'}}>
<input
type="text"
placeholder="搜索好友/群聊"
value={forwardSearch}
onChange={e=>setForwardSearch(e.target.value)}
style={{width:'100%',padding:'8px 12px',borderRadius:'20px',border:'none',background:'rgba(255,255,255,0.15)',color:'#fff',outline:'none',fontSize:'14px'}}
/>
</div>
<div style={{flex:1,overflowY:'auto'}}>
{recentForwarders.length > 0 && !forwardSearch && (
<>
<div style={{fontSize:'12px',color:'rgba(255,255,255,0.5)',padding:'8px 0'}}>最近转发</div>
<div style={{display:'flex',gap:'12px',overflowX:'auto',padding:'8px 0'}}>
{recentForwarders.map(target => (
<div key={target.id} style={{display:'flex',flexDirection:'column',alignItems:'center',minWidth:'60px',cursor:'pointer'}} onClick={()=>doForwardToTarget(target)}>
<div style={{width:'50px',height:'50px',borderRadius:'8px',background: target.type === 'group' ? 'linear-gradient(135deg,#1565C0,#0D47A1)' : (target.avatar ? 'url('+target.avatar+')' : 'linear-gradient(135deg,#888,#666)'),backgroundSize:'cover',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'20px',color:'#fff'}}>{target.type === 'group' ? '👬' : (!target.avatar && '👤')}</div>
<span style={{fontSize:'11px',color:'#fff',marginTop:'4px',maxWidth:'60px',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{target.name}</span>
</div>
))}
</div>
</>
)}
<div style={{fontSize:'12px',color:'rgba(255,255,255,0.5)',padding:'8px 0'}}>好友</div>
{friends.filter(fr => !forwardSearch || fr.nickname?.toLowerCase().includes(forwardSearch.toLowerCase())).map(fr => (
<div key={fr.id} style={{display:'flex',alignItems:'center',padding:'10px',borderBottom:'1px solid rgba(255,255,255,0.08)',cursor:'pointer'}} onClick={()=>doForwardToTarget({id:fr.id,name:fr.nickname,avatar:fr.avatar,type:'friend'})}>
<div className="avatar" style={{width:'40px',height:'40px',fontSize:'14px',backgroundImage:fr.avatar?'url('+fr.avatar+')':'none',backgroundSize:'cover'}}>{!fr.avatar&&'👤'}</div>
<span style={{color:'#fff',marginLeft:'10px',fontSize:'14px'}}>{fr.nickname}</span>
</div>
))}
<div style={{fontSize:'12px',color:'rgba(255,255,255,0.5)',padding:'8px 0',marginTop:'8px'}}>群聊</div>
{groups.filter(g => !forwardSearch || g.name?.toLowerCase().includes(forwardSearch.toLowerCase())).map(g => (
<div key={g.id} style={{display:'flex',alignItems:'center',padding:'10px',borderBottom:'1px solid rgba(255,255,255,0.08)',cursor:'pointer'}} onClick={()=>doForwardToTarget({id:g.id,name:g.name,type:'group'})}>
<div style={{width:'40px',height:'40px',borderRadius:'8px',background:'linear-gradient(135deg,#1565C0,#0D47A1)',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'18px'}}>👬</div>
<span style={{color:'#fff',marginLeft:'10px',fontSize:'14px'}}>{g.name}</span>
</div>
))}
</div>
<button className="w-full mt-3 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>{setShowForwardModal(false);setForwardSearch('');}}>取消</button>
</div>
</div>
)}
{previewImg && <div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.9)',zIndex:10000,display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center'}}>
<button style={{background:'none',border:'none',padding:0,width:'100%',height:'100%',display:'flex',alignItems:'center',justifyContent:'center'}} onClick={()=>{if(Date.now()-previewOpenTime.current>500)setPreviewImg(null);}}><img src={previewImg} style={{maxWidth:'95vw',maxHeight:'95vh',objectFit:'contain'}} /></button>
<div style={{position:'absolute',bottom:40,left:0,right:0,display:'flex',justifyContent:'center',gap:20}}>
<button style={{background:'rgba(255,215,0,0.3)',border:'1px solid #FFD700',color:'#FFD700',padding:'10px 32px',borderRadius:'20px',fontSize:'14px',cursor:'pointer'}} onClick={()=>savePreviewImage(previewImg)}>💾 保存到相册</button>
</div>
</div>}
{redPacketOpen && <RedPacketOpenPage detail={redPacketOpen} onClose={()=>setRedPacketOpen(null)} user={user} onClaim={claimRedPacket} title="" />}
{redPacketDetail && <RedPacketDetailPage detail={redPacketDetail} onClose={()=>setRedPacketDetail(null)} user={user} onClaim={claimRedPacket} />}
{/* 【VOICE-V2】PTT录音小浮层(微信风格) */}
{isRecording && pttModeRef_global && (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.45)',zIndex:10001,display:'flex',alignItems:'center',justifyContent:'center',pointerEvents:'none'}}>
<div style={{width:'160px',background:'rgba(0,0,0,0.85)',borderRadius:'12px',padding:'24px 20px',textAlign:'center'}}>
<div style={{fontSize:'48px',marginBottom:'12px'}}>🎤</div>
<div style={{color:'#fff',fontSize:'32px',fontWeight:'bold',marginBottom:'8px'}}>{recordingTime}s</div>
<div style={{color:'rgba(255,255,255,0.7)',fontSize:'13px'}}>松开发送,上滑取消</div>
</div>
</div>
)}
{/* 【VOICE-V1】录音中覆盖层 */}
{isRecording && !pttModeRef_global && (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.75)',zIndex:10002,display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center',gap:'20px'}}>
<div style={{width:'100px',height:'100px',borderRadius:'50%',background:'rgba(255,59,48,0.9)',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'44px',boxShadow:'0 0 30px rgba(255,59,48,0.5)'}}>🎤</div>
<div style={{color:'#fff',fontSize:'28px',fontWeight:'bold'}}>{recordingTime}s</div>
<div style={{color:'rgba(255,255,255,0.7)',fontSize:'14px'}}>录音中,点击停止并发送</div>
<button onClick={stopRecording} style={{background:'#FF3B30',color:'#fff',border:'none',borderRadius:'25px',padding:'14px 44px',fontSize:'16px',fontWeight:'bold',cursor:'pointer',marginTop:'8px'}}>停止并发送</button>
<button onClick={cancelRecording} style={{background:'transparent',color:'rgba(255,255,255,0.6)',border:'1px solid rgba(255,255,255,0.3)',borderRadius:'25px',padding:'8px 30px',fontSize:'14px',cursor:'pointer'}}>取消</button>
</div>
)}
</div>
</div>
);
}
// ============ 推广页 ============
function PromotionPage({ user }) {
const [promoData, setPromoData] = useState(null);
const [records, setRecords] = useState([]);
const [showRecords, setShowRecords] = useState(false);
const [showSubordinates, setShowSubordinates] = useState(false);
const [subordinateList, setSubordinateList] = useState([]);
const [exchangeAmt, setExchangeAmt] = useState('');
useEffect(() => { fetchPromo(); fetchRecords(); if(showSubordinates) fetchSubordinates(); }, [showSubordinates]);
const fetchPromo = async () => { const res = await get('/promotion/info'); if (res.code === 200) setPromoData(res.data); };
const fetchRecords = async () => { const res = await get('/user/commission-records'); if (res.code === 200) setRecords(res.data.list || []); };
const copyCode = () => {
if (promoData?.inviteCode) { navigator.clipboard?.writeText(promoData.inviteCode); showToast('邀请码已复制'); }
};
const fetchSubordinates = async () => {
try {
console.log('开始获取下级列表...');
const res = await get('/user/subordinates');
console.log('下级列表API响应:', res);
if (res.code === 200) {
console.log('下级成员数据:', res.data);
setSubordinateList(res.data || []);
} else {
console.error('API返回错误:', res.message);
}
} catch(e) {
console.error('获取下级列表失败:', e);
showToast('获取下级列表失败: ' + e.message);
}
};
const handleExchange = async () => {
const res = await post('/user/exchange-commission', { amount: parseInt(exchangeAmt) });
showToast(res.message); if (res.code === 200) { setExchangeAmt(''); fetchPromo(); }
};
if (!promoData) return <div className="text-center pt-20 text-gold">加载中...</div>;
return (
<div className="pb-20 px-4 pt-4">
<h2 className="text-gold text-lg font-bold text-center mb-4">推广中心</h2>
{/* 推广数据 */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="bg-dark-card p-3 text-center cursor-pointer" onClick={()=>{console.log('点击下级总人数, 当前状态:', showSubordinates);setShowSubordinates(!showSubordinates);}}>
<div className="text-gold text-2xl font-bold">{promoData.totalSubCount}</div>
<div className="text-gold2 text-xs">下级总人数 {showSubordinates ? '▼' : '▶'}</div>
</div>
<div className="bg-dark-card p-3 text-center"><div className="text-gold text-2xl font-bold">{promoData.commissionBalance?.toLocaleString()}</div><div className="text-gold2 text-xs">佣金余额</div></div>
<div className="bg-dark-card p-3 text-center"><div className="text-gold text-2xl font-bold">{promoData.todayCommission?.toLocaleString()}</div><div className="text-gold2 text-xs">今日佣金</div></div>
<div className="bg-dark-card p-3 text-center"><div className="text-gold text-2xl font-bold">{promoData.totalCommission?.toLocaleString()}</div><div className="text-gold2 text-xs">累计佣金</div></div>
</div>
{/* 邀请码 */}
<div className="bg-card p-4 mb-4">
<div className="text-gold text-sm mb-2">我的邀请码</div>
<div className="flex items-center gap-2">
<div className="flex-1 text-center py-3 bg-dark-card rounded-lg"><span className="text-gold text-2xl font-bold tracking-widest">{promoData.inviteCode}</span></div>
<button className="btn-gold px-4 py-3" onClick={copyCode}>复制</button>
</div>
<p className="text-gold2 text-xs mt-2">好友注册时填写您的邀请码,即可绑定推广关系</p>
{/* 邀请好友按钮 */}
<button className="btn-red w-full mt-3 py-3" onClick={()=>{
const randomStr = Math.random().toString(36).substr(2, 8);
const shareUrl = window.location.origin + '/?ref=' + promoData.inviteCode + '&t=' + Date.now() + '&k=' + randomStr;
if (navigator.share) {
navigator.share({ title: 'ABC', text: '免费建群,红包接龙/扫雷,无跑包无封号,点击就能玩', url: shareUrl });
} else {
navigator.clipboard?.writeText(shareUrl);
showToast('邀请分享链接已复制:' + shareUrl);
}
}}>📨 邀请好友</button>
{/* APP下载推广链接 */}
<div className="mt-3 p-3 bg-dark-card rounded-lg">
<div className="text-gold text-sm mb-2">APP下载推广链接</div>
<div className="text-gold2 text-xs mb-2">好友通过此链接下载APP,注册自动绑定您的代理关系</div>
<div className="flex items-center gap-2">
<input className="input-main flex-1 text-xs" value={window.location.origin + '/download?ref=' + promoData.inviteCode} readOnly />
<button className="btn-gold px-3 py-2 text-sm" onClick={()=>{navigator.clipboard?.writeText(window.location.origin + '/download?ref=' + promoData.inviteCode); showToast('下载链接已复制');}}>复制</button>
</div>
</div>
</div>
{/* 佣金兑换 */}
<div className="bg-card p-4 mb-4">
<div className="text-gold text-sm mb-2">佣金兑换筹码</div>
<div className="flex items-center gap-2">
<input className="input-main flex-1" type="number" placeholder="兑换数量" value={exchangeAmt} onChange={e=>setExchangeAmt(e.target.value)} />
<button className="btn-red px-4 py-2" onClick={handleExchange}>兑换</button>
</div>
</div>
{/* 佣金规则 */}
<div className="bg-card p-4 mb-4">
<div className="text-gold text-sm font-bold mb-3">三级佣金规则</div>
{[{lv:'一级',desc:'直接邀请',rate:promoData.commissionRates?.level1 || '8%',color:'#FFD700'},
{lv:'二级',desc:'好友的好友',rate:promoData.commissionRates?.level2 || '3%',color:'#C0C0C0'},
{lv:'三级',desc:'三级好友',rate:promoData.commissionRates?.level3 || '1%',color:'#CD7F32'}
].map(l => (
<div key={l.lv} className="flex items-center gap-3 p-2 bg-dark-card rounded-lg mb-2">
<div className="w-10 h-10 rounded-full flex items-center justify-center text-xs font-bold" style={{background:l.color,color:'#3A0000'}}>{l.lv}</div>
<div className="flex-1"><div className="text-white text-sm">{l.lv}好友</div><div className="text-gold2 text-xs">{l.desc}</div></div>
<div className="text-gold font-bold">{(parseFloat(l.rate)*100).toFixed(0)}%</div>
</div>
))}
<div className="mt-2 p-2 rounded-lg" style={{background:'rgba(255,215,0,0.1)',border:'1px solid rgba(255,215,0,0.3)'}}>
<p className="text-gold2 text-xs">💡 佣金从平台服务费中计提,下级每完成一局有效下注,您即可获得对应比例佣金,永久有效!</p>
</div>
</div>
{/* 下级成员列表 */}
{showSubordinates && (
<div className="bg-card p-4 mb-4">
<div className="text-gold text-sm font-bold mb-3">下级成员 ({subordinateList.length})</div>
{subordinateList.length === 0 ? (
<p className="text-center text-gold2 text-sm py-4">暂无下级成员</p>
) : (
<div className="space-y-2" style={{maxHeight:'400px',overflowY:'auto'}}>
{subordinateList.map((sub, idx) => (
<div key={idx} className="flex items-center gap-3 p-3 bg-dark-card rounded-lg">
<div className="avatar w-10 h-10 text-lg" style={{backgroundImage:sub.avatar?'url('+sub.avatar+')':'none',backgroundSize:'cover',backgroundPosition:'center'}}>{!sub.avatar && '👤'}</div>
<div className="flex-1">
<div className="text-white text-sm font-bold">{sub.nickname}</div>
<div className="text-gold2 text-xs">ID: {sub.id?.substring(0,8)} | 注册: {sub.createdAt?.split('T')[0]}</div>
</div>
<div className="text-right">
<div className="text-gold text-sm">Lv.{sub.level || 1}级</div>
<div className="text-gold2 text-xs">下注: {sub.totalBet?.toLocaleString() || 0}</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* 佣金记录 */}
<button className="btn-gold w-full mb-3" onClick={()=>setShowRecords(!showRecords)}>{showRecords ? '收起记录' : '查看佣金记录'}</button>
{showRecords && (
<div className="bg-dark-card p-3">
{records.length === 0 ? <p className="text-center text-gold2 text-sm py-4">暂无记录</p> :
records.slice(0, 20).map((r, i) => (
<div key={i} className="flex justify-between py-2 border-b" style={{borderColor:'rgba(255,215,0,0.1)'}}>
<div><span className="text-white text-sm">{r.level}级佣金</span><span className="text-gold2 text-xs ml-2">{r.createdAt?.split('T')[0]}</span></div>
<span className="text-gold text-sm font-bold">+{r.amount}</span>
</div>
))}
</div>
)}
</div>
);
}
// ============ 个人中心 ============
function ProfilePage({ user, onLogout, onUpdateUser, sharedMembers, setSharedMembers }) {
const [userInfo, setUserInfo] = useState(null);
const [speedCalcPrice, setSpeedCalcPrice] = useState({super:500, basic:200}); // 会员价格
const [showNickname, setShowNickname] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// 共享成员列表直接从props获取,不需要localStorage
React.useEffect(() => {
console.log('[ProfilePage] 共享成员:', sharedMembers);
}, [sharedMembers]);
const [showRecords, setShowRecords] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
const [speedCalcLoading, setSpeedCalcLoading] = useState(false);
const [showSpeedCalcStats, setShowSpeedCalcStats] = useState(false);
const [speedCalcStatsData, setSpeedCalcStatsData] = useState(null);
const [statsUserList, setStatsUserList] = useState([]); // 独立保存用户列表,不随切换变化
const [statsUserFilter, setStatsUserFilter] = useState('all');
const [statsLottery, setStatsLottery] = useState('macau1');
// 【修复】用 ref 存最新值,让监听器在整个会话期间都能读取最新状态
const statsUserFilterRef = React.useRef(statsUserFilter);
const statsLotteryRef = React.useRef(statsLottery);
React.useEffect(() => { statsUserFilterRef.current = statsUserFilter; }, [statsUserFilter]);
React.useEffect(() => { statsLotteryRef.current = statsLottery; }, [statsLottery]);
// 【PRE-CALC-v1】数据加载后一次性预计算所有 bill 的 payout,避免每次展开都重算
React.useEffect(() => {
if (!speedCalcStatsData?.billList) return;
// 【修复】从 state 读取开奖号码(而非 localStorage),确保输入后实时重算
const drawNums = (drawNumbers[statsLottery] || []).map(n => n === '' ? null : (parseInt(n) || null));
const dnArr = drawNums.length >= 7 ? drawNums : [...drawNums, ...Array(7 - drawNums.length).fill(null)];
speedCalcStatsData.billList.forEach(bill => {
(bill.items || []).forEach(item => {
// 【修复】始终重算(drawNumbers 变化时需要更新赔付)
item._payout = calcPayout(item, dnArr);
});
});
// 【修复】触发 UI 更新(强制重新渲染以显示新的出/余)
setSpeedCalcStatsData(prev => prev ? {...prev} : prev);
console.log('[PRE-CALC] 预计算完成, billList长度:', speedCalcStatsData.billList.length);
}, [speedCalcStatsData, statsLottery, drawNumbers]);
// 统计页面打开时,监听新账单事件并自动刷新
React.useEffect(() => {
console.log('[统计监听] useEffect 执行,showSpeedCalcStats:', showSpeedCalcStats);
const handleStatsUpdate = async (source) => {
// 【STATS-DEBOUNCE】500ms 防抖,避免频繁请求导致 UI 卡顿
if (window._statsDebounceTimer) clearTimeout(window._statsDebounceTimer);
window._statsDebounceTimer = setTimeout(async () => {
// 【修复】使用 ref 读取最新值,不受闭包影响
const currentUserFilter = statsUserFilterRef.current;
const currentLottery = statsLotteryRef.current;
console.log('[统计] 触发刷新,来源:', source, 'userFilter:', currentUserFilter, 'lottery:', currentLottery);
// 新账单到达,自动刷新所有彩种统计
const dn = JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}');
// 【修复】聊天窗口场景:传递 chatFriend.id 按接收方过滤(用全局变量避免跨组件问题)
const chatFriendId = window._currentChatFriend ? window._currentChatFriend.id : null;
const results = await Promise.allSettled([
fetchSpeedCalcStatsFromServer(currentUserFilter, 'macau1', dn['macau1'], null, chatFriendId),
fetchSpeedCalcStatsFromServer(currentUserFilter, 'macau2', dn['macau2'], null, chatFriendId),
fetchSpeedCalcStatsFromServer(currentUserFilter, 'hongkong', dn['hongkong'], null, chatFriendId)
]);
// 更新当前显示的数据
const currentIdx = ['macau1','macau2','hongkong'].indexOf(currentLottery);
if (results[currentIdx].status === 'fulfilled') {
setSpeedCalcStatsData(results[currentIdx].value);
// 更新用户列表(保持所有共享成员)
if (results[currentIdx].value?.userList && results[currentIdx].value.userList.length > 0) {
setStatsUserList(results[currentIdx].value.userList);
}
console.log('[统计] 刷新完成, 新数据:', results[currentIdx].value);
}
// 更新所有彩种的cachedTotals和cachedOutTotals
const newCachedTotals = {macau1: 0, macau2: 0, hongkong: 0};
const newCachedOutTotals = {macau1: 0, macau2: 0, hongkong: 0};
results.forEach((r, idx) => {
if (r.status === 'fulfilled' && r.value) {
newCachedTotals[['macau1','macau2','hongkong'][idx]] = r.value.total || 0;
newCachedOutTotals[['macau1','macau2','hongkong'][idx]] = r.value.out || 0;
}
});
setCachedTotals(newCachedTotals);
setCachedOutTotals(newCachedOutTotals);
console.log('[统计] cachedTotals更新:', newCachedTotals, 'cachedOutTotals:', newCachedOutTotals);
}, 500);
};
// 监听账单相关事件
const h1 = () => handleStatsUpdate('speedcalc_stats_changed');
const h2 = () => handleStatsUpdate('new_message');
const h3 = () => handleStatsUpdate('new_group_message');
const h4 = (data) => { console.log('[统计] 收到bill_saved:', data); handleStatsUpdate('bill_saved'); };
window.addEventListener('speedcalc_stats_changed', h1);
window.addEventListener('new_message', h2);
window.addEventListener('new_group_message', h3);
if (window._socket) {
console.log('[统计监听] WebSocket已连接,添加bill_saved监听');
window._socket.on('bill_saved', h4);
} else {
console.log('[统计监听] WebSocket未连接');
}
return () => {
window.removeEventListener('speedcalc_stats_changed', h1);
window.removeEventListener('new_message', h2);
window.removeEventListener('new_group_message', h3);
if (window._socket) {
window._socket.off('bill_saved', h4);
}
};
}, []); // 【修复】移除依赖项,监听器在整个会话期间保持注册,不受页面切换/彩种切换影响
const [sortByAmount, setSortByAmount] = useState(false);
const [demoCap, setDemoCap] = useState(0);
const [showFlyout, setShowFlyout] = useState(false);
const [flyoutLimit, setFlyoutLimit] = useState(0);
const [flyoutOdds, setFlyoutOdds] = useState(47);
const [flyoutRecords, setFlyoutRecords] = useState([]);
const [cachedTotals, setCachedTotals] = useState({macau1: 0, macau2: 0, hongkong: 0});
const [cachedOutTotals, setCachedOutTotals] = useState({macau1: 0, macau2: 0, hongkong: 0});
const [expandedPlay, setExpandedPlay] = useState(null);
const [showStatsSettings, setShowStatsSettings] = useState(false);
const [showHistoryQuery, setShowHistoryQuery] = useState(false);
const [historyQueryDate, setHistoryQueryDate] = useState('');
const [historyQueryStats, setHistoryQueryStats] = useState(null);
const [showHistoryStatsPage, setShowHistoryStatsPage] = useState(false);
const [statsSettings, setStatsSettings] = useState(() => {
try { return JSON.parse(localStorage.getItem('chatSettings_'+user.id) || '{}'); } catch(e) { return {}; }
});
const [drawNumbers, setDrawNumbers] = useState(() => {
try {
const saved = localStorage.getItem('speedCalcDrawNumbers_' + user.id);
if (saved) {
const parsed = JSON.parse(saved);
// 兼容旧格式:单数组转为对象
if (Array.isArray(parsed)) return {macau1: parsed, macau2: [null,null,null,null,null,null,null], hongkong: [null,null,null,null,null,null,null]};
// 兼容旧 {macau: ...} 格式 -> 默认归到 macau1
if (parsed && parsed.macau && !parsed.macau1) {
return {macau1: parsed.macau, macau2: [null,null,null,null,null,null,null], hongkong: parsed.hongkong || [null,null,null,null,null,null,null]};
}
return parsed;
}
} catch(e) {}
return {macau1: [null,null,null,null,null,null,null], macau2: [null,null,null,null,null,null,null], hongkong: [null,null,null,null,null,null,null]};
});
const [drawNumber, setDrawNumber] = useState('');
const [newNick, setNewNick] = useState('');
const [pwdForm, setPwdForm] = useState({ old: '', new1: '', new2: '' });
const [records, setRecords] = useState([]);
const [recordTab, setRecordTab] = useState('all');
const [speedCalcHistory, setSpeedCalcHistory] = useState([]);
const [historyDate, setHistoryDate] = useState(() => {
var d = new Date();
return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
});
const [notifySound, setNotifySound] = useState(() => localStorage.getItem('notifySound') !== 'off');
const [notifySoundType, setNotifySoundType] = useState(() => localStorage.getItem('notifySoundType') || 'default');
const [notifyBadge, setNotifyBadge] = useState(() => localStorage.getItem('notifyBadge') !== 'off');
const [notifyLockScreen, setNotifyLockScreen] = useState(() => localStorage.getItem('notifyLockScreen') !== 'off');
const [notifyFloat, setNotifyFloat] = useState(() => localStorage.getItem('notifyFloat') !== 'off');
const [showBetControl, setShowBetControl] = useState(false);
const [stoppedChats, setStoppedChats] = useState([]);
// 会员共享列表UI状态
const [showSharedMembers, setShowSharedMembers] = useState(false);
const [showAddMember, setShowAddMember] = useState(false);
const [memberSearch, setMemberSearch] = useState('');
const [availableFriends, setAvailableFriends] = useState([]);
useEffect(() => { fetchInfo(); }, []);
// 持久化开奖号码
useEffect(() => {
try {
localStorage.setItem('speedCalcDrawNumbers_' + user.id, JSON.stringify(drawNumbers));
} catch(e) {}
}, [drawNumbers, user.id]);
// 开奖号码同步到服务端(让聊天双方都能查看中奖金额)
useEffect(() => {
const timer = setTimeout(async () => {
try {
const nums = drawNumbers;
if (nums.macau1 && nums.macau1.some(n => n !== null && n !== '')) {
await post('/speed-calc/draw-numbers/save', { numbers: nums.macau1, lottery: 'macau1' });
}
if (nums.macau2 && nums.macau2.some(n => n !== null && n !== '')) {
await post('/speed-calc/draw-numbers/save', { numbers: nums.macau2, lottery: 'macau2' });
}
if (nums.hongkong && nums.hongkong.some(n => n !== null && n !== '')) {
await post('/speed-calc/draw-numbers/save', { numbers: nums.hongkong, lottery: 'hongkong' });
}
} catch(e) {}
}, 2000);
return () => clearTimeout(timer);
}, [drawNumbers, user.id]);
const fetchInfo = async () => {
const res = await get('/user/info');
if (res.code === 200) { setUserInfo(res.data); onUpdateUser(res.data); try { localStorage.setItem('user_activityTimes', res.data.activityTimes || '{}'); localStorage.setItem('user_chatStopTimes', res.data.chatStopTimes || '{}'); localStorage.setItem('user_chatResumeTimes', res.data.chatResumeTimes || '{}'); } catch(e){} }
// 获取会员价格配置
try {
const priceRes = await get('/speed-calc/price');
if (priceRes.code === 200 && priceRes.data) {
if (priceRes.data && typeof priceRes.data === 'object') {
setSpeedCalcPrice({super: parseInt(priceRes.data.super)||500, basic: parseInt(priceRes.data.basic)||200});
} else {
setSpeedCalcPrice({super: parseInt(priceRes.data)||500, basic: 200});
}
}
} catch(e) {}
};
// 每天0点自动清除开奖号码,并从服务器恢复最近记录
useEffect(() => {
const checkDailyReset = async () => {
try {
const lastResetKey = 'speedCalcLastReset_' + user.id;
var now = new Date();
var today = now.getFullYear() + '-' + String(now.getMonth()+1).padStart(2,'0') + '-' + String(now.getDate()).padStart(2,'0');
const lastReset = localStorage.getItem(lastResetKey);
if (lastReset !== today) {
// 先保存当前数据到服务器(包括账单列表)
const currentNums = drawNumbers[statsLottery] || [];
if (currentNums.some(n => n !== null && n !== '')) {
const currentStats = {total:0,count:0,out:0,types:{},numberStats:{},billList:[],userStats:{},playStats:{}};
await post('/user/speed-calc/save', {
date: lastReset || today,
drawNumbers: currentNums,
statsSummary: { total: currentStats?.total || 0, count: currentStats?.count || 0 },
lottery: statsLottery,
billList: (currentStats?.billList || []).map(b => ({ id: b.id, senderId: b.senderId, senderName: b.senderName, content: b.content, time: b.time, total: b.total }))
});
}
// 清除开奖号码
setDrawNumbers({macau1: [null,null,null,null,null,null,null], macau2: [null,null,null,null,null,null,null], hongkong: [null,null,null,null,null,null,null]});
localStorage.setItem(lastResetKey, today);
// 从服务器拉取今天的记录(如果有)- 恢复当前彩种
const res = await get('/user/speed-calc/records?lottery=' + statsLottery);
if (res.code === 200 && res.data && res.data.length > 0) {
const todayRecord = res.data.find(r => r.date === today);
if (todayRecord && todayRecord.drawNumbers) {
setDrawNumbers(prev => ({...prev, [statsLottery]: todayRecord.drawNumbers}));
}
}
}
} catch (e) { console.error('每日重置检查失败:', e); }
};
checkDailyReset();
// 每小时检查一次
const interval = setInterval(checkDailyReset, 60 * 60 * 1000);
return () => clearInterval(interval);
}, [user.id, statsLottery]);
const fetchRecords = async (type) => {
if (type === 'speedcalc') {
const res = await get('/user/speed-calc/history');
if (res.code === 200) setSpeedCalcHistory(res.data || []);
return;
}
const url = type === 'bet' ? '/user/bet-records' : '/user/transactions' + (type !== 'all' ? `?type=${type}` : '');
const res = await get(url);
if (res.code === 200) setRecords(res.data.list || []);
};
const renderRecordsContent = function() {
if (recordTab === 'speedcalc') {
var selectedRecord = speedCalcHistory.find(function(day) { return day.date === historyDate; });
if (!selectedRecord || !selectedRecord.items || selectedRecord.items.length === 0) return React.createElement('p',{className:'text-center text-gold2 py-4 text-sm'},historyDate+' 暂无速算记录');
var items = selectedRecord.items;
return React.createElement('div',{style:{border:'1px solid rgba(255,215,0,0.2)',borderRadius:'8px',overflow:'hidden'}},
React.createElement('div',{style:{background:'rgba(255,215,0,0.15)',padding:'8px 12px',display:'flex',justifyContent:'space-between',alignItems:'center'}},
React.createElement('span',{style:{color:'#FFD700',fontSize:'13px',fontWeight:'bold'}},historyDate),
React.createElement('span',{style:{color:'#999',fontSize:'11px'}},items.length+'个彩种')
),
items.map(function(item, ii) {
var lotteryName = item.lottery === 'macau1' ? '澳门1' : item.lottery === 'macau2' ? '澳门2' : item.lottery === 'hongkong' ? '香港' : (item.lottery || '未知');
var nums = (item.drawNumbers || []).filter(function(n){return n !== null && n !== ''});
var numStr = nums.length > 0 ? nums.join(',') : '无号码';
return React.createElement('div',{key:ii,style:{padding:'8px 12px',borderBottom:ii<(items.length-1)?'1px solid rgba(255,215,0,0.1)':'none',display:'flex',justifyContent:'space-between',alignItems:'center'}},
React.createElement('div',null,
React.createElement('span',{style:{color:'#FFD700',fontSize:'12px',fontWeight:'bold'}},lotteryName),
React.createElement('div',{style:{color:'#888',fontSize:'11px',marginTop:'2px'}},numStr)
),
React.createElement('span',{style:{color:'#4CAF50',fontSize:'14px',fontWeight:'bold'}},item.total.toLocaleString())
);
})
);
}
if (records.length === 0) return React.createElement('p',{className:'text-center text-gold2 py-4 text-sm'},'暂无记录');
return records.slice(0,30).map(function(r, i) {
return React.createElement('div',{key:i,className:'flex justify-between py-2 border-b',style:{borderColor:'rgba(255,215,0,0.1)'}},
React.createElement('div',null,
React.createElement('div',{className:'text-white text-sm'},r.remark || r.zodiacName || r.type),
React.createElement('div',{className:'text-gold2 text-xs'},(r.createdAt || '').split('T')[0])
),
React.createElement('span',{className:'text-sm font-bold ' + ((r.amount||0) >= 0 ? 'text-green-400' : 'text-red-400')},((r.amount||0) >= 0 ? '+' : '') + r.amount)
);
});
};
const handleNickname = async () => {
const res = await post('/user/update-nickname', { nickname: newNick });
showToast(res.message); if (res.code === 200) { setShowNickname(false); fetchInfo(); }
};
const handlePassword = async () => {
if (pwdForm.new1 !== pwdForm.new2) return showToast('两次密码不一致');
const res = await post('/user/update-password', { oldPassword: pwdForm.old, newPassword: pwdForm.new1 });
showToast(res.message); if (res.code === 200) { setShowPassword(false); onLogout(); }
};
// calcSpeedCalcStats 已移至全局作用域
if (!userInfo) return <div className="text-center pt-20 text-gold">加载中...</div>;
return (
<div className="pb-20 px-4 pt-4">
{/* 用户卡片 */}
<div className="bg-card p-4 mb-4 flex items-center gap-4">
<div style={{position:'relative',cursor:'pointer'}} onClick={()=>document.getElementById('avatarUpload').click()}>
<div className="avatar w-16 h-16 text-3xl" style={{backgroundImage:userInfo.avatar?'url('+userInfo.avatar+')':'none',backgroundSize:'cover',backgroundPosition:'center'}}>{!userInfo.avatar && '🐲'}</div>
<div style={{position:'absolute',bottom:0,right:0,width:'20px',height:'20px',borderRadius:'50%',background:'#FFD700',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'11px',color:'#5B0000'}}>📷</div>
<input id="avatarUpload" type="file" accept="image/*" style={{position:'absolute',opacity:0,width:0,height:0,pointerEvents:'none'}} onChange={async(e)=>{
const file=e.target.files[0]; if(!file)return;
const fd=new FormData(); fd.append('avatar',file);
const res=await fetch('/api/user/avatar',{method:'POST',headers:{'Authorization':'Bearer '+localStorage.getItem('token')},body:fd}).then(r=>r.json());
if(res.code===200){showToast('头像已更新');fetchInfo();}else{showToast(res.message||'上传失败');}
}} />
</div>
<div className="flex-1">
<div className="text-white text-lg font-bold">{userInfo.nickname} <span onClick={(e)=>{e.stopPropagation();document.getElementById("avatarUpload").click();}} style={{fontSize:"11px",background:"linear-gradient(135deg,#FFD700,#FFA500)",color:"#5B0000",padding:"2px 8px",borderRadius:"10px",cursor:"pointer",marginLeft:"8px",fontWeight:"bold"}}>更换头像</span></div>
<div className="text-gold2 text-xs">ID: {userInfo.id?.substring(0,8)} <span onClick={()=>{navigator.clipboard.writeText(userInfo.id).then(()=>showToast("ID 已复制"));}} style={{cursor:"pointer",marginLeft:"4px",fontSize:"13px"}}>📋</span></div>
<div className="text-gold2 text-xs">邀请码: {userInfo.inviteCode}</div>
</div>
</div>
{/* 资产 */}
<div className="bg-card p-4 mb-4">
<div className="text-gold text-sm font-bold mb-3">我的资产</div>
<div className="grid grid-cols-3 gap-2">
<div className="bg-dark-card p-3 text-center"><div className="text-gold text-lg font-bold">{userInfo.chips?.toLocaleString()}</div><div className="text-gold2 text-xs">可用筹码</div></div>
<div className="bg-dark-card p-3 text-center"><div className="text-gold text-lg font-bold">{userInfo.frozenChips?.toLocaleString()}</div><div className="text-gold2 text-xs">冻结筹码</div></div>
<div className="bg-dark-card p-3 text-center"><div className="text-gold text-lg font-bold">{userInfo.commissionBalance?.toLocaleString()}</div><div className="text-gold2 text-xs">佣金余额</div></div>
</div>
</div>
{/* 统计 */}
<div className="bg-card p-4 mb-4">
<div className="text-gold text-sm font-bold mb-3">游戏统计</div>
<div className="space-y-2">
<div className="flex justify-between"><span className="text-gold2 text-sm">累计下注</span><span className="text-white text-sm">{userInfo.totalBet?.toLocaleString()}</span></div>
<div className="flex justify-between"><span className="text-gold2 text-sm">累计中奖</span><span className="text-gold text-sm">{userInfo.totalWin?.toLocaleString()}</span></div>
<div className="flex justify-between"><span className="text-gold2 text-sm">今日佣金</span><span className="text-gold text-sm">{userInfo.todayCommission?.toLocaleString()}</span></div>
<div className="flex justify-between"><span className="text-gold2 text-sm">注册时间</span><span className="text-white text-sm">{userInfo.createdAt?.split('T')[0]}</span></div>
</div>
</div>
{/* 会员共享列表 */}
{userInfo?.speedCalcEnabled && <div className="bg-card p-4 mb-4">
<div className="space-y-1">
<div className="list-item cursor-pointer" onClick={async()=>{
setShowSharedMembers(true);
}}>
<span className="text-white text-sm"> 会员共享列表 ({sharedMembers.length})</span>
<span className="text-gold">›</span>
</div>
</div>
</div>}
{/* 功能 */}
<div className="bg-card p-4 mb-4">
<div className="space-y-1">
<div className="list-item cursor-pointer" onClick={()=>setShowNickname(true)}><span className="text-white text-sm">修改昵称</span><span className="text-gold">›</span></div>
<div className="list-item cursor-pointer" onClick={()=>setShowPassword(true)}><span className="text-white text-sm">修改密码</span><span className="text-gold">›</span></div>
<div className="list-item cursor-pointer" onClick={()=>{setShowRecords(true);fetchRecords('all');}}><span className="text-white text-sm">流水记录</span><span className="text-gold">›</span></div>
<div className="list-item cursor-pointer" onClick={()=>{setShowSpeedCalc(true);fetchInfo();}}>
<span className="text-white text-sm">🧮 会员 {userInfo.vipLevel === 'super' ? <span style={{fontSize:'11px',background:'#FFD700',color:'#000',padding:'1px 6px',borderRadius:'8px',marginLeft:'6px'}}>超级会员</span> : userInfo.vipLevel === 'basic' ? <span style={{fontSize:'11px',background:'#4FC3F7',color:'#000',padding:'1px 6px',borderRadius:'8px',marginLeft:'6px'}}>普通会员</span> : <span style={{fontSize:'11px',background:'#FF6B00',color:'#fff',padding:'1px 6px',borderRadius:'8px',marginLeft:'6px'}}>开通会员</span>}</span>
<span className="text-gold">›</span>
</div>
</div>
</div>
{/* 设置 */}
<div className="bg-card p-4 mb-4">
<div className="space-y-1">
<div className="list-item cursor-pointer" onClick={()=>setShowSettings(true)}><span className="text-white text-sm">⚙️ 设置</span><span className="text-gold">›</span></div>
</div>
</div>
<button className="w-full py-3 rounded-xl text-white font-bold" style={{background:'rgba(200,0,0,0.6)',border:'1px solid rgba(255,255,255,0.2)'}} onClick={onLogout}>退出登录</button>
{/* 修改昵称 */}
{showNickname && (
<div className="modal-mask" onClick={()=>setShowNickname(false)}><div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-3">修改昵称</h3>
<input className="input-main mb-3" placeholder="新昵称(2-8位)" value={newNick} onChange={e=>setNewNick(e.target.value)} maxLength={8} />
<div className="flex gap-3"><button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setShowNickname(false)}>取消</button>
<button className="btn-red flex-1" onClick={handleNickname}>确认</button></div>
</div></div>
)}
{/* 修改密码 */}
{showPassword && (
<div className="modal-mask" onClick={()=>setShowPassword(false)}><div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-3">修改密码</h3>
<div className="space-y-3 mb-3">
<input className="input-main" type="password" placeholder="原密码" value={pwdForm.old} onChange={e=>setPwdForm({...pwdForm,old:e.target.value})} />
<input className="input-main" type="password" placeholder="新密码(6-16位)" value={pwdForm.new1} onChange={e=>setPwdForm({...pwdForm,new1:e.target.value})} />
<input className="input-main" type="password" placeholder="确认新密码" value={pwdForm.new2} onChange={e=>setPwdForm({...pwdForm,new2:e.target.value})} />
</div>
<div className="flex gap-3"><button className="flex-1 py-2 rounded-xl text-white" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setShowPassword(false)}>取消</button>
<button className="btn-red flex-1" onClick={handlePassword}>确认</button></div>
</div></div>
)}
{/* 设置弹窗 */}
{showSettings && (
<div className="modal-mask" onClick={()=>setShowSettings(false)}><div className="modal-box" style={{maxHeight:'85vh',overflowY:'auto'}} onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-4">⚙️ 设置</h3>
<div className="space-y-3">
{/* 提示音开关 */}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'12px',background:'rgba(255,255,255,0.05)',borderRadius:'10px'}}>
<span className="text-white text-sm">🔔 消息提示音</span>
<div onClick={()=>{const next=!notifySound;setNotifySound(next);localStorage.setItem('notifySound',next?'on':'off');}} style={{width:'44px',height:'24px',borderRadius:'12px',background:notifySound?'#07C160':'rgba(255,255,255,0.2)',position:'relative',cursor:'pointer',transition:'background 0.3s'}}>
<div style={{width:'20px',height:'20px',borderRadius:'10px',background:'#fff',position:'absolute',top:'2px',left:notifySound?'22px':'2px',transition:'left 0.3s',boxShadow:'0 1px 3px rgba(0,0,0,0.3)'}}></div>
</div>
</div>
{/* 提示音选择 */}
{notifySound && (
<div style={{padding:'12px',background:'rgba(255,255,255,0.05)',borderRadius:'10px'}}>
<div className="text-gold2 text-xs mb-2">🎵 选择提示音</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr 1fr',gap:'8px'}}>
{Object.entries(_notifySounds).map(([key, snd]) => (
<button key={key} onClick={()=>{setNotifySoundType(key);localStorage.setItem('notifySoundType',key);playNotifySound(key);}} style={{padding:'8px 4px',borderRadius:'8px',border:notifySoundType===key?'2px solid #FFD700':'1px solid rgba(255,255,255,0.15)',background:notifySoundType===key?'rgba(255,215,0,0.15)':'rgba(255,255,255,0.05)',color:notifySoundType===key?'#FFD700':'#fff',fontSize:'12px',cursor:'pointer',textAlign:'center'}}>{snd.name}{notifySoundType===key?' ✓':''}</button>
))}
</div>
</div>
)}
{/* 通知设置 */}
<div style={{padding:'12px',background:'rgba(255,255,255,0.05)',borderRadius:'10px'}}>
<div className="text-gold2 text-xs mb-2">📢 通知设置</div>
<div className="space-y-2">
{/* 通知权限状态和请求按钮 */}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'8px 0',borderBottom:'1px solid rgba(255,255,255,0.1)'}}>
<div>
<span className="text-white text-sm">🔔 通知权限</span>
<div className="text-gold2 text-xs mt-1">
{typeof Notification !== 'undefined'
? (Notification.permission === 'granted' ? '✅ 已开启'
: Notification.permission === 'denied' ? '❌ 已拒绝'
: '⚠️ 未授权')
: '❌ 不支持'}
</div>
</div>
{typeof Notification !== 'undefined' && Notification.permission !== 'granted' && (
<button onClick={async()=>{
try {
const perm = await Notification.requestPermission();
if (perm === 'granted') {
showToast('✅ 通知权限已开启');
new Notification('通知测试', { body: '您已成功开启消息通知', icon: '/favicon.ico' });
} else if (perm === 'denied') {
showToast('❌ 通知权限被拒绝,请在浏览器设置中手动开启');
}
} catch(e) {
showToast('❌ 请求权限失败: ' + e.message);
}
}} style={{padding:'6px 12px',borderRadius:'6px',background:'#07C160',color:'#fff',border:'none',fontSize:'12px',cursor:'pointer'}}>开启通知</button>
)}
</div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'8px 0'}}>
<span className="text-white text-sm">📌 桌面图标角标</span>
<div onClick={()=>{const next=!notifyBadge;setNotifyBadge(next);localStorage.setItem('notifyBadge',next?'on':'off');}} style={{width:'44px',height:'24px',borderRadius:'12px',background:notifyBadge?'#07C160':'rgba(255,255,255,0.2)',position:'relative',cursor:'pointer',transition:'background 0.3s'}}>
<div style={{width:'20px',height:'20px',borderRadius:'10px',background:'#fff',position:'absolute',top:'2px',left:notifyBadge?'22px':'2px',transition:'left 0.3s',boxShadow:'0 1px 3px rgba(0,0,0,0.3)'}}></div>
</div>
</div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'8px 0'}}>
<span className="text-white text-sm">📱 熄屏通知</span>
<div onClick={()=>{const next=!notifyLockScreen;setNotifyLockScreen(next);localStorage.setItem('notifyLockScreen',next?'on':'off');}} style={{width:'44px',height:'24px',borderRadius:'12px',background:notifyLockScreen?'#07C160':'rgba(255,255,255,0.2)',position:'relative',cursor:'pointer',transition:'background 0.3s'}}>
<div style={{width:'20px',height:'20px',borderRadius:'10px',background:'#fff',position:'absolute',top:'2px',left:notifyLockScreen?'22px':'2px',transition:'left 0.3s',boxShadow:'0 1px 3px rgba(0,0,0,0.3)'}}></div>
</div>
</div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'8px 0'}}>
<span className="text-white text-sm">🔳 悬浮通知</span>
<div onClick={()=>{const next=!notifyFloat;setNotifyFloat(next);localStorage.setItem('notifyFloat',next?'on':'off');}} style={{width:'44px',height:'24px',borderRadius:'12px',background:notifyFloat?'#07C160':'rgba(255,255,255,0.2)',position:'relative',cursor:'pointer',transition:'background 0.3s'}}>
<div style={{width:'20px',height:'20px',borderRadius:'10px',background:'#fff',position:'absolute',top:'2px',left:notifyFloat?'22px':'2px',transition:'left 0.3s',boxShadow:'0 1px 3px rgba(0,0,0,0.3)'}}></div>
</div>
</div>
</div>
</div>
<div style={{padding:'12px',background:'rgba(255,255,255,0.05)',borderRadius:'10px'}}>
<div className="text-gold2 text-xs mb-2">📦 版本管理</div>
<div className="space-y-2">
<button className="w-full py-3 rounded-xl text-white text-sm font-bold" style={{background:'rgba(0,150,255,0.5)',border:'1px solid rgba(100,180,255,0.3)'}} onClick={async()=>{
try {
const r = await fetch('/app/version.json?t='+Date.now());
if (!r.ok) throw new Error('HTTP ' + r.status);
const v = await r.json();
const localVer = parseInt(localStorage.getItem('apkVersion')||'0');
if (v.apkVersion > localVer) {
if (confirm('发现新版本 v1.'+v.apkVersion+'.0\n'+(v.updateMsg||'')+'\n\n点击确定下载安装')) {
localStorage.removeItem('apkVersion');
window.location.href = v.apkUrl || '/app/abc-app.apk';
}
} else {
try{caches.keys().then(ks=>ks.forEach(k=>caches.delete(k)));}catch(e){}
showToast('已是最新版本,已刷新页面缓存');
location.reload(true);
}
} catch(e) {
console.error('Check update failed:', e);
try{caches.keys().then(ks=>ks.forEach(k=>caches.delete(k)));}catch(e2){}
showToast('检查更新失败: ' + e.message + ',已刷新页面缓存');
location.reload(true);
}
}}>⬆️ 检查更新</button>
<div className="text-gold2 text-xs text-center" style={{opacity:0.6}}>当前版本: v1.{localStorage.getItem('apkVersion')||'0'}.0</div>
</div>
</div>
<div style={{padding:'12px',background:'rgba(255,255,255,0.05)',borderRadius:'10px'}}>
<div className="text-gold2 text-xs mb-2">🧹 缓存管理</div>
<div className="space-y-2">
<button className="w-full py-3 rounded-xl text-white text-sm font-bold" style={{background:'rgba(100,100,100,0.5)',border:'1px solid rgba(200,200,200,0.3)'}} onClick={()=>{
if(!confirm('确定清除本地缓存?将保留聊天记录和最近联系人列表')) return;
try {
const keepPrefixes = ['recentContacts_','token','user','apkVersion','notifyLockScreen','notifyFloat','chatSettings_','chat_','groupMsgs_','img_','cleared_','readState_','quote_','speedCalcDrawNumbers_','speedCalcLastReset_','chatStopTime_','myGroups_','claimedPacketIds_','_app_ver'];
const keysToKeep = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && keepPrefixes.some(p => k.startsWith(p) || k === p)) keysToKeep.push(k);
}
const saved = {};
keysToKeep.forEach(k => { saved[k] = localStorage.getItem(k); });
localStorage.clear();
Object.entries(saved).forEach(([k,v]) => { if(v !== null) localStorage.setItem(k,v); });
try{caches.keys().then(ks=>ks.forEach(k=>caches.delete(k)));}catch(e){}
showToast('缓存已清除,最近联系人列表已保留');
location.reload(true);
} catch(e) { showToast('清除失败: ' + e.message); }
}}>🧹 清除缓存</button>
</div>
</div>
{/* 账号操作 */}
<div style={{padding:'12px',background:'rgba(255,255,255,0.05)',borderRadius:'10px'}}>
<div className="text-gold2 text-xs mb-2">👤 账号管理</div>
<div className="space-y-2">
<button className="w-full py-3 rounded-xl text-white text-sm font-bold" style={{background:'rgba(255,165,0,0.5)',border:'1px solid rgba(255,215,0,0.3)'}} onClick={()=>{if(confirm('确定切换账号?将退出当前账号')){localStorage.removeItem('token');localStorage.removeItem('user');onLogout();}}}>🔄 切换账号</button>
<button className="w-full py-3 rounded-xl text-white text-sm font-bold" style={{background:'rgba(200,0,0,0.5)',border:'1px solid rgba(255,255,255,0.2)'}} onClick={()=>{if(confirm('确定退出登录?')){localStorage.removeItem('token');localStorage.removeItem('user');onLogout();}}}>🚪 退出登录</button>
</div>
</div>
</div>
<button className="btn-gold w-full mt-4" onClick={()=>setShowSettings(false)}>关闭</button>
</div></div>
)}
{/* 六合彩下注控制弹窗 */}
{showBetControl && (
<div className="modal-mask" onClick={()=>setShowBetControl(false)}><div className="modal-box" style={{maxHeight:'85vh',overflowY:'auto'}} onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-4">🎲 六合彩下注控制</h3>
{stoppedChats.length === 0 ? (
<div className="text-center text-gold2 py-8">
<div style={{fontSize:'48px',marginBottom:'12px'}}>📋</div>
<div className="text-white text-sm">暂无下注记录</div>
<div className="text-xs mt-2">与其他用户下注后,可在此管理</div>
</div>
) : (
<div>
<div className="text-gold2 text-xs mb-3">可管理的聊天({stoppedChats.length}个)</div>
<div className="space-y-2" style={{maxHeight:'55vh',overflowY:'auto'}}>
{stoppedChats.map((chat, idx) => (
<div key={idx} style={{padding:'12px',background:'rgba(255,255,255,0.05)',borderRadius:'10px',border:'1px solid rgba(255,215,0,0.15)'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<div>
<div className="text-white text-sm font-bold">{chat.chatType === 'group' ? '👥' : '💬'} {chat.chatName}</div>
{chat.isStopped ? (
<div className="text-gold2 text-xs mt-1">🛑 已停止 | {new Date(chat.stopTime).toLocaleString('zh-CN')}</div>
) : (
<div className="text-gold2 text-xs mt-1">✅ 下注中</div>
)}
</div>
{chat.isStopped ? (
<button style={{padding:'6px 12px',borderRadius:'8px',border:'none',background:'linear-gradient(135deg,#07C160,#06AD56)',color:'#fff',fontSize:'12px',fontWeight:'bold',cursor:'pointer'}} onClick={()=>{
if (!confirm(`确认恢复"${chat.chatName}"的下注?\n恢复后新消息将重新计算统计。`)) return;
const now = Date.now();
// 保存停止时间到停时期记录,恢复后统计仍排除停时段的账单
const curStopVal = localStorage.getItem('chatStopTime_' + chat.chatKey);
if (curStopVal) localStorage.setItem('chatStoppedPeriod_' + chat.chatKey, curStopVal);
localStorage.removeItem('chatStopTime_' + chat.chatKey);
// 记录恢复时间
localStorage.setItem('chatResumeTime_' + chat.chatKey, String(now));
// 同时清除反向 key
const parts = chat.chatKey.split('_');
if (parts.length >= 3 && parts[0] === 'chat') {
const curRevVal = localStorage.getItem('chatStopTime_chat_' + parts[2] + '_' + parts[1]);
if (curRevVal) localStorage.setItem('chatStoppedPeriod_chat_' + parts[2] + '_' + parts[1], curRevVal);
localStorage.removeItem('chatStopTime_chat_' + parts[2] + '_' + parts[1]);
localStorage.setItem('chatResumeTime_chat_' + parts[2] + '_' + parts[1], String(now));
}
// 发送恢复消息通知对方同步清除停止状态
try {
if (chat.chatType === 'private' && chat.friendId) {
fetch(API + '/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (localStorage.getItem('token') || '') }, body: JSON.stringify({ receiverId: chat.friendId, content: '\uD83D\uDFE2 恢复下注', type: 'text', skipBillStats: true }) });
} else if (chat.chatType === 'group' && chat.groupId && window._socket && window._socket.connected) {
window._socket.emit('group_msg', { groupId: chat.groupId, userId: user.id, nickname: user.nickname, content: '\uD83D\uDFE2 恢复下注', mentions: [], skipBillStats: true });
}
} catch(e) {}
setStoppedChats(prev => prev.map(c => c.chatKey === chat.chatKey ? {...c, isStopped: false, stopTime: 0} : c));
showToast('已恢复下注');
try { window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e){}
}}>恢复下注</button>
) : (
<button style={{padding:'6px 12px',borderRadius:'8px',border:'none',background:'rgba(200,0,0,0.5)',color:'#fff',fontSize:'12px',fontWeight:'bold',cursor:'pointer'}} onClick={()=>{
if (!confirm(`确认停止"${chat.chatName}"的下注?\n停止后新消息将不再计算统计。`)) return;
const now = Date.now();
setChatStopTime(chat.chatKey, now);
setStoppedChats(prev => prev.map(c => c.chatKey === chat.chatKey ? {...c, isStopped: true, stopTime: now} : c));
// 发送停止消息通知对方,让对方也能同步停止状态
try {
if (chat.chatType === 'private' && chat.friendId) {
fetch(API + '/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (localStorage.getItem('token') || '') }, body: JSON.stringify({ receiverId: chat.friendId, content: '🛑 停止下注', type: 'text', skipBillStats: true }) });
} else if (chat.chatType === 'group' && chat.groupId && window._socket && window._socket.connected) {
window._socket.emit('group_msg', { groupId: chat.groupId, userId: user.id, nickname: user.nickname, content: '🛑 停止下注', mentions: [], skipBillStats: true });
}
} catch(e) {}
showToast('已停止下注');
try { window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e){}
}}>停止下注</button>
)}
</div>
</div>
))}
</div>
<div style={{display:'flex',gap:'8px',marginTop:'12px'}}>
<button style={{flex:1,padding:'10px',borderRadius:'10px',border:'none',background:'rgba(200,0,0,0.4)',color:'#fff',fontSize:'13px',fontWeight:'bold',cursor:'pointer'}} onClick={()=>{
if (!confirm('确认停止所有聊天下注?')) return;
const now = Date.now();
stoppedChats.forEach(chat => {
setChatStopTime(chat.chatKey, now);
// 发送停止消息通知对方
try {
if (chat.chatType === 'private' && chat.friendId) {
fetch(API + '/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (localStorage.getItem('token') || '') }, body: JSON.stringify({ receiverId: chat.friendId, content: '🛑 停止下注', type: 'text', skipBillStats: true }) });
} else if (chat.chatType === 'group' && chat.groupId && window._socket && window._socket.connected) {
window._socket.emit('group_msg', { groupId: chat.groupId, userId: user.id, nickname: user.nickname, content: '🛑 停止下注', mentions: [], skipBillStats: true });
}
} catch(e) {}
});
setStoppedChats(prev => prev.map(c => ({...c, isStopped: true, stopTime: now})));
showToast('已停止所有下注');
try { window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e){}
}}>🛑 一键停止全部</button>
<button style={{flex:1,padding:'10px',borderRadius:'10px',border:'none',background:'rgba(0,150,255,0.4)',color:'#fff',fontSize:'13px',fontWeight:'bold',cursor:'pointer'}} onClick={()=>{
if (!confirm('确认恢复所有聊天下注?')) return;
const now = Date.now();
stoppedChats.forEach(chat => {
// 保存停止时间到停时期记录
const curStopVal = localStorage.getItem('chatStopTime_' + chat.chatKey);
if (curStopVal) localStorage.setItem('chatStoppedPeriod_' + chat.chatKey, curStopVal);
localStorage.removeItem('chatStopTime_' + chat.chatKey);
// 记录恢复时间
localStorage.setItem('chatResumeTime_' + chat.chatKey, String(now));
const parts = chat.chatKey.split('_');
if (parts.length >= 3 && parts[0] === 'chat') {
const curRevVal = localStorage.getItem('chatStopTime_chat_' + parts[2] + '_' + parts[1]);
if (curRevVal) localStorage.setItem('chatStoppedPeriod_chat_' + parts[2] + '_' + parts[1], curRevVal);
localStorage.removeItem('chatStopTime_chat_' + parts[2] + '_' + parts[1]);
localStorage.setItem('chatResumeTime_chat_' + parts[2] + '_' + parts[1], String(now));
}
// 发送恢复消息通知对方
try {
if (chat.chatType === 'private' && chat.friendId) {
fetch(API + '/chat/send', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (localStorage.getItem('token') || '') }, body: JSON.stringify({ receiverId: chat.friendId, content: '🟢 恢复下注', type: 'text', skipBillStats: true }) });
} else if (chat.chatType === 'group' && chat.groupId && window._socket && window._socket.connected) {
window._socket.emit('group_msg', { groupId: chat.groupId, userId: user.id, nickname: user.nickname, content: '🟢 恢复下注', mentions: [], skipBillStats: true });
}
} catch(e) {}
});
setStoppedChats(prev => prev.map(c => ({...c, isStopped: false, stopTime: 0})));
showToast('已恢复所有下注');
try { window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e){}
}}>🔄 一键恢复全部</button>
</div>
</div>
)}
<button className="btn-gold w-full mt-4" onClick={()=>setShowBetControl(false)}>关闭</button>
</div></div>
)}
{/* 会员共享列表弹窗 */}
{showSharedMembers && (
<div className="modal-mask" onClick={()=>setShowSharedMembers(false)}><div className="modal-box" style={{maxHeight:'85vh',overflowY:'auto'}} onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-4"> 会员共享列表</h3>
{sharedMembers.length === 0 ? (
<div className="text-center text-gold2 py-8">
<div style={{fontSize:'48px',marginBottom:'12px'}}>👥</div>
<div className="text-white text-sm">暂无共享成员</div>
<div className="text-xs mt-2">点击下方按钮添加好友</div>
</div>
) : (
<div>
<div className="text-gold2 text-xs mb-3">共享成员 ({sharedMembers.length})</div>
<div className="space-y-2" style={{maxHeight:'50vh',overflowY:'auto'}}>
{sharedMembers.map((member, idx) => (
<div key={idx} style={{padding:'12px',background:'rgba(255,255,255,0.05)',borderRadius:'10px',border:'1px solid rgba(255,215,0,0.15)'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<div>
<div className="text-white text-sm font-bold">💬 {member.friendName}</div>
{member.isStopped ? (
<div className="text-gold2 text-xs mt-1"> 已停止 | {new Date(member.stoppedAt).toLocaleString('zh-CN')}</div>
) : (
<div className="text-gold2 text-xs mt-1">✅ 下注中</div>
)}
</div>
<div style={{display:'flex',gap:'8px'}}>
<button style={{padding:'6px 12px',borderRadius:'8px',border:'none',background:'rgba(100,100,100,0.5)',color:'#fff',fontSize:'12px',cursor:'pointer'}} onClick={async()=>{
if (!confirm(`确认移除"${member.friendName}"?`)) return;
const token = localStorage.getItem('token');
const res = await fetch(API + '/speedcalc/shared-members/remove', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ friendId: member.friendId })
});
const data = await res.json();
if (data.code === 200) {
setSharedMembers(data.data);
try { localStorage.setItem('speedCalcSharedMembers_' + user.id, JSON.stringify(data.data)); } catch(e) {}
showToast('已移除');
} else {
showToast(data.message || '操作失败');
}
}}>移除</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
<div style={{display:'flex',gap:'8px',marginTop:'12px'}}>
<button style={{flex:1,padding:'10px',borderRadius:'10px',border:'none',background:'linear-gradient(135deg,#8B4513,#D2691E)',color:'#fff',fontSize:'13px',fontWeight:'bold',cursor:'pointer'}} onClick={async()=>{
// 加载可用好友列表
const token = localStorage.getItem('token');
try {
const res = await fetch(API + '/friends', { headers: { 'Authorization': 'Bearer ' + token } });
const data = await res.json();
if (data.code === 200) {
// 过滤掉已在共享列表中的好友
const existingIds = sharedMembers.map(m => m.friendId);
setAvailableFriends((data.data || []).filter(f => !existingIds.includes(f.id)));
setShowAddMember(true);
}
} catch(e) { showToast('加载好友列表失败'); }
}}>+ 添加成员</button>
</div>
<div style={{marginTop:'10px',padding:'10px',borderRadius:'10px',background:'rgba(255,215,0,0.08)',border:'1px solid rgba(255,215,0,0.18)'}}>
<div style={{color:'#FFD700',fontSize:'12px',fontWeight:'bold',marginBottom:'8px'}}>统一结束时间设置</div>
<div style={{display:'grid',gridTemplateColumns:'1fr 1fr auto',gap:'8px',alignItems:'end'}}>
<label style={{fontSize:'11px',color:'#FFD700'}}>澳1/香港
<input type="time" value={(sharedMembers[0]?.macau1HongkongEndTime || sharedMembers[0]?.closeTimes?.macau1HongkongEndTime || '21:30')} style={{width:'100%',marginTop:'4px',padding:'7px',borderRadius:'8px',border:'1px solid rgba(255,215,0,0.25)',background:'rgba(0,0,0,0.25)',color:'#fff',fontSize:'13px'}} onChange={e=>{const val=e.target.value;setSharedMembers(prev=>prev.map(x=>({...x,macau1HongkongEndTime:val,closeTimes:{...(x.closeTimes||{}),macau1HongkongEndTime:val}})));}} />
</label>
<label style={{fontSize:'11px',color:'#FFD700'}}>澳门2
<input type="time" value={(sharedMembers[0]?.macau2EndTime || sharedMembers[0]?.closeTimes?.macau2EndTime || '23:59')} style={{width:'100%',marginTop:'4px',padding:'7px',borderRadius:'8px',border:'1px solid rgba(255,215,0,0.25)',background:'rgba(0,0,0,0.25)',color:'#fff',fontSize:'13px'}} onChange={e=>{const val=e.target.value;setSharedMembers(prev=>prev.map(x=>({...x,macau2EndTime:val,closeTimes:{...(x.closeTimes||{}),macau2EndTime:val}})));}} />
</label>
<button style={{padding:'8px 10px',borderRadius:'8px',border:'none',background:'linear-gradient(135deg,#FFD700,#DAA520)',color:'#5B0000',fontSize:'12px',fontWeight:'bold',cursor:'pointer'}} onClick={async()=>{const token=localStorage.getItem('token');const first=sharedMembers[0]||{};const firstTime=first.macau1HongkongEndTime||first.closeTimes?.macau1HongkongEndTime||'21:30';const secondTime=first.macau2EndTime||first.closeTimes?.macau2EndTime||'23:59';const res=await fetch(API + '/speedcalc/shared-members/close-times',{method:'POST',headers:{'Authorization':'Bearer '+token,'Content-Type':'application/json'},body:JSON.stringify({macau1HongkongEndTime:firstTime,macau2EndTime:secondTime})});const data=await res.json();if(data.code===200){setSharedMembers(data.data);try{localStorage.setItem('speedCalcSharedMembers_' + user.id, JSON.stringify(data.data));}catch(e){}showToast('统一截止时间已保存');console.log('[AUTO-CLOSE-UNIFIED-V2] 前端统一保存截止时间',firstTime,secondTime,data.data);}else{showToast(data.message||'保存失败');}}}>保存</button>
</div>
</div>
<div style={{display:'flex',gap:'8px',marginTop:'8px'}}>
<button style={{flex:1,padding:'10px',borderRadius:'10px',border:'none',background:'rgba(200,0,0,0.5)',color:'#fff',fontSize:'13px',fontWeight:'bold',cursor:'pointer'}} onClick={async()=>{
if (!confirm('确认结束所有共享成员活动?')) return;
const token = localStorage.getItem('token');
// 批量调用后端停止 API(后端会广播并保存状态)
for (const m of sharedMembers) {
try {
await fetch(API + '/speedcalc/shared-members/stop', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ friendId: m.friendId })
});
} catch(e) {
console.error('Failed to stop member', m.friendId, e);
}
}
// 更新本地状态
const updated = sharedMembers.map(m => ({...m, isStopped: true, stoppedAt: new Date().toISOString()}));
setSharedMembers(updated);
// 【修复】重新拉取 /user/info 以同步后端写入的 activityTimes
try {
const r = await fetch(API + '/user/info', { headers: { 'Authorization': 'Bearer ' + token } });
const d = await r.json();
if (d.code === 200) {
localStorage.setItem('user_activityTimes', d.data.activityTimes || '{}');
localStorage.setItem('user_chatStopTimes', d.data.chatStopTimes || '{}');
localStorage.setItem('user_chatResumeTimes', d.data.chatResumeTimes || '{}');
}
} catch(e){}
showToast('已结束所有活动');
}}>一键结束</button>
<button style={{flex:1,padding:'10px',borderRadius:'10px',border:'none',background:'linear-gradient(135deg,#07C160,#06AD56)',color:'#fff',fontSize:'13px',fontWeight:'bold',cursor:'pointer'}} onClick={async()=>{
if (!confirm('确认开始所有共享成员活动?')) return;
const token = localStorage.getItem('token');
// 批量调用后端恢复 API(后端会广播并保存状态)
for (const m of sharedMembers) {
try {
await fetch(API + '/speedcalc/shared-members/resume', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ friendId: m.friendId })
});
} catch(e) {
console.error('Failed to resume member', m.friendId, e);
}
}
// 更新本地状态
const updated = sharedMembers.map(m => { const copy = {...m}; delete copy.isStopped; delete copy.stoppedAt; return copy; });
setSharedMembers(updated);
// 【修复】重新拉取 /user/info 以同步后端写入的 activityTimes
try {
const r = await fetch(API + '/user/info', { headers: { 'Authorization': 'Bearer ' + token } });
const d = await r.json();
if (d.code === 200) {
localStorage.setItem('user_activityTimes', d.data.activityTimes || '{}');
localStorage.setItem('user_chatStopTimes', d.data.chatStopTimes || '{}');
localStorage.setItem('user_chatResumeTimes', d.data.chatResumeTimes || '{}');
}
} catch(e){}
showToast('已开始所有活动');
}}>一键开始</button>
</div>
<button className="btn-gold w-full mt-4" onClick={()=>setShowSharedMembers(false)}>关闭</button>
</div></div>
)}
{/* 添加成员弹窗 */}
{showAddMember && (
<div className="modal-mask" onClick={()=>setShowAddMember(false)}><div className="modal-box" style={{maxHeight:'70vh',overflowY:'auto'}} onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-4">添加共享成员</h3>
<input className="input-main mb-3" placeholder="搜索好友昵称" value={memberSearch} onChange={e=>setMemberSearch(e.target.value)} />
<div className="space-y-2" style={{maxHeight:'45vh',overflowY:'auto'}}>
{availableFriends.filter(f => !memberSearch || (f.nickname||'').includes(memberSearch) || (f.username||'').includes(memberSearch)).map(f => (
<div key={f.id} style={{padding:'10px',background:'rgba(255,255,255,0.05)',borderRadius:'8px',display:'flex',justifyContent:'space-between',alignItems:'center',cursor:'pointer'}} onClick={async()=>{
const token = localStorage.getItem('token');
const res = await fetch(API + '/speedcalc/shared-members/add', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ friendId: f.id })
});
const data = await res.json();
if (data.code === 200) {
setSharedMembers(data.data);
try { localStorage.setItem('speedCalcSharedMembers_' + user.id, JSON.stringify(data.data)); } catch(e) {}
setAvailableFriends(prev => prev.filter(x => x.id !== f.id));
showToast('添加成功');
} else {
showToast(data.message || '添加失败');
}
}}>
<span className="text-white text-sm">{f.nickname || f.username}</span>
<span className="text-gold">+</span>
</div>
))}
{availableFriends.filter(f => !memberSearch || (f.nickname||'').includes(memberSearch) || (f.username||'').includes(memberSearch)).length === 0 && (
<div className="text-center text-gold2 py-4">{memberSearch ? '无匹配好友' : '暂无可添加的好友'}</div>
)}
</div>
<button className="btn-gold w-full mt-4" onClick={()=>{setShowAddMember(false);setMemberSearch('');}}>关闭</button>
</div></div>
)}
{/* 流水记录 */}
{showRecords && (
<div className="modal-mask" onClick={()=>setShowRecords(false)}><div className="modal-box" style={{maxHeight:'80vh'}} onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-3">流水记录</h3>
<div className="flex gap-2 mb-3 flex-wrap justify-center">
{[{k:'all',v:'全部'},{k:'bet',v:'下注'},{k:'win',v:'中奖'},{k:'commission',v:'佣金'},{k:'gift_send',v:'赠送'},{k:'speedcalc',v:'速算历史'}].map(t => (
<button key={t.k} className={`tab-btn ${recordTab===t.k?'active':''}`} onClick={()=>{setRecordTab(t.k);fetchRecords(t.k);}}>{t.v}</button>
))}
</div>
{recordTab === 'speedcalc' && React.createElement('div',{style:{display:'flex',alignItems:'center',gap:'8px',marginBottom:'12px',justifyContent:'center'}},
React.createElement('span',{style:{color:'#FFD700',fontSize:'12px'}},'选择日期:'),
React.createElement('input',{type:'date',value:historyDate,onChange:function(e){setHistoryDate(e.target.value);},style:{padding:'6px 12px',borderRadius:'6px',border:'1px solid rgba(255,215,0,0.3)',background:'rgba(0,0,0,0.3)',color:'#FFD700',fontSize:'13px'}})
)}
<div style={{maxHeight:'50vh',overflowY:'auto'}}>
{renderRecordsContent()}
</div>
<button className="btn-gold w-full mt-3" onClick={()=>setShowRecords(false)}>关闭</button>
</div></div>
)}
{/* 六合彩速算 */}
{showSpeedCalc && (
<div className="modal-mask" onClick={()=>setShowSpeedCalc(false)}><div className="modal-box" onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-3">🧮 账单速算</h3>
<div className="text-center mb-3">
<div className="text-white text-sm mb-2">当前状态:{userInfo.vipLevel === 'super' ? <span style={{color:'#FFD700',fontWeight:'bold'}}>超级会员</span> : userInfo.vipLevel === 'basic' ? <span style={{color:'#4FC3F7',fontWeight:'bold'}}>普通会员</span> : <span style={{color:'#999'}}>未开通会员</span>}{(userInfo.vipLevel === 'super' || userInfo.vipLevel === 'basic') && userInfo.speedCalcExpiry && <span style={{color:'#888',fontSize:'11px',marginLeft:'8px'}}>有效期至:{new Date(userInfo.speedCalcExpiry).toLocaleDateString()}</span>}</div>
</div>
<div className="text-gold2 text-xs mb-3" style={{lineHeight:'1.6'}}>
<p>功能说明:</p>
<p>1. 开通后在聊天窗口可开启速算开关</p>
<p>2. 系统自动解析对方发来的账单文本</p>
<p>3. 实时显示统计结果在消息下方</p>
<p>4. 超级会员可查看所有成员账单的总统计</p>
<p>5. 普通会员可使用全部计算功能,但不可查看总统计</p>
</div>
{/* 普通会员 */}
<div style={{background:'rgba(255,255,255,0.08)',borderRadius:'10px',padding:'12px',marginBottom:'10px'}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'center',gap:'8px',marginBottom:'8px'}}>
<span style={{color:'#888',fontSize:'12px',textDecoration:'line-through'}}>原价500</span>
<span style={{color:'#4FC3F7',fontSize:'14px',fontWeight:'bold'}}>普通会员:{speedCalcPrice.basic}筹码/30天</span>
</div>
<div style={{color:'#aaa',fontSize:'11px',textAlign:'center',marginBottom:'8px'}}>✅ 全部计算功能 | ❌ 不可查看总统计</div>
{speedCalcPrice.basic < 500 && <div style={{color:'#ff4757',fontSize:'11px',textAlign:'center',marginBottom:'6px'}}>🔥 限时活动价</div>}
<button className="w-full py-2 rounded-xl text-white text-sm font-bold" style={{background:'linear-gradient(135deg,#4FC3F7,#0288D1)',border:'none',cursor:'pointer'}} disabled={speedCalcLoading} onClick={async()=>{
if (!confirm('确认花费' + speedCalcPrice.basic + '筹码开通普通会员30天?')) return;
setSpeedCalcLoading(true);
const res = await post('/speed-calc/subscribe', {level:'basic'});
setSpeedCalcLoading(false);
showToast(res.message);
if (res.code === 200) { fetchInfo(); }
}}>{speedCalcLoading ? '处理中...' : (userInfo.vipLevel === 'basic' ? '续费普通会员' : '开通普通会员')}</button>
</div>
{/* 超级会员 */}
<div style={{background:'rgba(255,215,0,0.1)',borderRadius:'10px',padding:'12px',marginBottom:'10px'}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'center',gap:'8px',marginBottom:'8px'}}>
<span style={{color:'#888',fontSize:'12px',textDecoration:'line-through'}}>原价500</span>
<span style={{color:'#FFD700',fontSize:'14px',fontWeight:'bold'}}>超级会员:{speedCalcPrice.super}筹码/30天</span>
</div>
<div style={{color:'#aaa',fontSize:'11px',textAlign:'center',marginBottom:'8px'}}>✅ 全部计算功能 | ✅ 可查看总统计</div>
{speedCalcPrice.super < 500 && <div style={{color:'#ff4757',fontSize:'11px',textAlign:'center',marginBottom:'6px'}}>🔥 限时活动价</div>}
<button className="btn-gold w-full" disabled={speedCalcLoading} onClick={async()=>{
if (!confirm('确认花费' + speedCalcPrice.super + '筹码开通超级会员30天?')) return;
setSpeedCalcLoading(true);
const res = await post('/speed-calc/subscribe', {level:'super'});
setSpeedCalcLoading(false);
showToast(res.message);
if (res.code === 200) { fetchInfo(); }
}}>{speedCalcLoading ? '处理中...' : (userInfo.vipLevel === 'super' ? '续费超级会员' : '开通超级会员')}</button>
</div>
{userInfo.vipLevel === 'super' && <button className="w-full mt-2 py-2 rounded-xl text-white text-sm font-bold" style={{background:'linear-gradient(135deg,#8B4513,#D2691E)',border:'1px solid rgba(255,215,0,0.3)'}} onClick={async ()=>{
setStatsUserFilter('all');
setStatsLottery('macau1');
// 清空旧数据
setCachedTotals({macau1: 0, macau2: 0, hongkong: 0});
setCachedOutTotals({macau1: 0, macau2: 0, hongkong: 0});
const dn = JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}');
// 【修复】聊天窗口场景:传递 chatFriend.id 按接收方过滤
const chatFriendId = window._currentChatFriend ? window._currentChatFriend.id : null;
// 并发加载所有彩种
const results = await Promise.allSettled([
fetchSpeedCalcStatsFromServer('all', 'macau1', dn['macau1'], null, chatFriendId),
fetchSpeedCalcStatsFromServer('all', 'macau2', dn['macau2'], null, chatFriendId),
fetchSpeedCalcStatsFromServer('all', 'hongkong', dn['hongkong'], null, chatFriendId)
]);
// 设置当前显示澳门1
if (results[0].status === 'fulfilled') {
setSpeedCalcStatsData(results[0].value);
// 初始化用户列表(从总统计中获取所有共享成员)
if (results[0].value?.userList) {
setStatsUserList(results[0].value.userList);
}
}
// 更新所有彩种的cachedTotals和cachedOutTotals
const newCachedTotals = {macau1: 0, macau2: 0, hongkong: 0};
const newCachedOutTotals = {macau1: 0, macau2: 0, hongkong: 0};
results.forEach((r, idx) => {
if (r.status === 'fulfilled' && r.value) {
newCachedTotals[['macau1','macau2','hongkong'][idx]] = r.value.total || 0;
newCachedOutTotals[['macau1','macau2','hongkong'][idx]] = r.value.out || 0;
}
});
setCachedTotals(newCachedTotals);
setCachedOutTotals(newCachedOutTotals);
setShowSpeedCalcStats(true);
}}>📊 查看总统计(超级会员专属)</button>}
<button className="w-full mt-2 py-2 rounded-xl text-white text-sm" style={{background:'rgba(255,255,255,0.15)'}} onClick={()=>setShowSpeedCalc(false)}>关闭</button>
</div></div>
)}
{/* 总统计全屏页面 */}
{showSpeedCalcStats && speedCalcStatsData && (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'#f5f5f5',zIndex:1000,overflowY:'auto',display:'flex',flexDirection:'column'}}>
{/* 顶部导航 */}
<div style={{position:'sticky',top:0,background:'#fff',padding:'12px 16px',borderBottom:'1px solid #eee',display:'flex',alignItems:'center',gap:'12px',zIndex:10}}>
<button style={{background:'none',border:'none',color:'#333',fontSize:'24px',padding:'0 4px',cursor:'pointer'}} onClick={()=>{setShowSpeedCalcStats(false);localStorage.removeItem('historyQueryDate');}}>‹</button>
<div style={{color:'#333',fontSize:'18px',fontWeight:'bold',flex:1,textAlign:'center'}}>
统计
{(() => {
const histDate = localStorage.getItem('historyQueryDate');
return histDate ? <span style={{fontSize:'12px',color:'#FF9800',marginLeft:'8px'}}>({histDate})</span> : null;
})()}
</div>
<div style={{width:'32px'}}></div>
</div>
{/* 用户切换 Tab */}
<div style={{background:'#fff',padding:'8px 12px',borderBottom:'1px solid #eee'}}>
<div style={{display:'flex',flexWrap:'wrap',gap:'6px'}}>
{['all', ...statsUserList].map(u => (
<button key={u} onClick={async (e)=>{
e.stopPropagation();
setStatsUserFilter(u);
const dn=JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}');
// 清空旧数据
setCachedTotals({macau1: 0, macau2: 0, hongkong: 0});
// 【TOTAL-HISTORY-FIX-V1】用户切换时沿用历史日期;总统计页面不按当前聊天过滤
const activeStatsDate = localStorage.getItem('historyQueryDate') || null;
const chatFriendId = activeStatsDate ? null : (window._currentChatFriend ? window._currentChatFriend.id : null);
// 加载所有彩种
const results = await Promise.allSettled([
fetchSpeedCalcStatsFromServer(u, 'macau1', dn['macau1'], activeStatsDate, chatFriendId),
fetchSpeedCalcStatsFromServer(u, 'macau2', dn['macau2'], activeStatsDate, chatFriendId),
fetchSpeedCalcStatsFromServer(u, 'hongkong', dn['hongkong'], activeStatsDate, chatFriendId)
]);
// 设置当前显示
const currentIdx = ['macau1','macau2','hongkong'].indexOf(statsLottery);
if (results[currentIdx].status === 'fulfilled') {
setSpeedCalcStatsData(results[currentIdx].value);
}
// 更新所有彩种的cachedTotals和cachedOutTotals
const newCachedTotals = {macau1: 0, macau2: 0, hongkong: 0};
const newCachedOutTotals = {macau1: 0, macau2: 0, hongkong: 0};
results.forEach((r, idx) => {
if (r.status === 'fulfilled' && r.value) {
newCachedTotals[['macau1','macau2','hongkong'][idx]] = r.value.total || 0;
newCachedOutTotals[['macau1','macau2','hongkong'][idx]] = r.value.out || 0;
}
});
setCachedTotals(newCachedTotals);
setCachedOutTotals(newCachedOutTotals);
}} style={{padding:'10px 18px',borderRadius:'20px',border:'none',fontSize:'15px',cursor:'pointer',background:statsUserFilter===u?'#2196F3':'#f0f0f0',color:statsUserFilter===u?'#fff':'#666',whiteSpace:'nowrap',flexShrink:0}}>
{u==='all'?'总统计':u}
</button>
))}
</div>
</div>
{/* 汇总行:入/出/水/余/设置 - 显示当前彩种数据 */}
{(() => {
// 显示当前彩种的入
const currentTotal = speedCalcStatsData?.total || 0;
// 显示当前彩种的出(中奖赔付)【PRE-CALC-v1】直接读取预计算值
let displayOut = speedCalcStatsData?.out || 0;
if (displayOut === 0 && speedCalcStatsData?.billList) {
speedCalcStatsData.billList.forEach(bill => {
(bill.items || []).forEach(item => {
displayOut += item._payout || 0;
});
});
}
// 当前彩种的水/余优先使用后端按日期和用户返回的结果
const currentShui = speedCalcStatsData?.shui ?? Math.round(currentTotal * 4 / 100);
const currentYu = speedCalcStatsData?.yu ?? (currentTotal - displayOut - currentShui);
return (
<div style={{background:'#fff',padding:'10px 16px',display:'flex',alignItems:'center',gap:'12px',borderBottom:'1px solid #eee'}}>
<div style={{display:'flex',gap:'12px',flex:1,flexWrap:'wrap'}}>
<div><span style={{color:'#666',fontSize:'12px'}}>入: </span><span style={{color:'#333',fontSize:'13px',fontWeight:'bold'}}>{currentTotal.toLocaleString()}</span></div>
<div><span style={{color:'#666',fontSize:'12px'}}>出: </span><span style={{color:'#333',fontSize:'13px',fontWeight:'bold'}}>{displayOut.toLocaleString()}</span></div>
<div><span style={{color:'#666',fontSize:'12px'}}>水: </span><span style={{color:'#333',fontSize:'13px',fontWeight:'bold'}}>{currentShui.toLocaleString()}</span></div>
<div><span style={{color:'#666',fontSize:'12px'}}>余: </span><span style={{color:'#333',fontSize:'13px',fontWeight:'bold'}}>{currentYu.toLocaleString()}</span></div>
</div>
<button style={{padding:'4px 10px',borderRadius:'4px',border:'1px solid #ddd',background:'#fff',color:'#666',fontSize:'12px',cursor:'pointer',whiteSpace:'nowrap'}} onClick={()=>setShowStatsSettings(true)}>设置</button>
</div>
);
})()}
{/* 彩种切换 */}
<div style={{display:'flex',background:'#fff'}}>
<div onClick={async ()=>{const dn=JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}');const activeStatsDate=localStorage.getItem('historyQueryDate')||null;const chatFriendId=activeStatsDate?null:(window._currentChatFriend?window._currentChatFriend.id:null);const s=await fetchSpeedCalcStatsFromServer(statsUserFilter,'macau1',dn['macau1'],activeStatsDate,chatFriendId);setSpeedCalcStatsData(s);setStatsLottery('macau1');if(s?.userList)setStatsUserList(s.userList);setCachedTotals(prev=>({...prev,macau1:s.total||0,macau2:prev.macau2||0,hongkong:prev.hongkong||0}));setCachedOutTotals(prev=>({...prev,macau1:s.out||0,macau2:prev.macau2||0,hongkong:prev.hongkong||0}));}} style={{flex:1,padding:'12px',textAlign:'center',borderBottom:statsLottery==='macau1'?'2px solid #2196F3':'2px solid transparent',cursor:'pointer'}}>
<span style={{color:statsLottery==='macau1'?'#2196F3':'#666',fontSize:'14px',fontWeight:'bold'}}>澳门1(21:30) {(statsLottery==='macau1'?(speedCalcStatsData?.total||0):cachedTotals.macau1).toLocaleString()}</span>
</div>
<div onClick={async ()=>{const dn=JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}');const activeStatsDate=localStorage.getItem('historyQueryDate')||null;const chatFriendId=activeStatsDate?null:(window._currentChatFriend?window._currentChatFriend.id:null);const s=await fetchSpeedCalcStatsFromServer(statsUserFilter,'macau2',dn['macau2'],activeStatsDate,chatFriendId);setSpeedCalcStatsData(s);setStatsLottery('macau2');if(s?.userList)setStatsUserList(s.userList);setCachedTotals(prev=>({...prev,macau2:s.total||0,macau1:prev.macau1||0,hongkong:prev.hongkong||0}));setCachedOutTotals(prev=>({...prev,macau2:s.out||0,macau1:prev.macau1||0,hongkong:prev.hongkong||0}));}} style={{flex:1,padding:'12px',textAlign:'center',borderBottom:statsLottery==='macau2'?'2px solid #2196F3':'2px solid transparent',cursor:'pointer'}}>
<span style={{color:statsLottery==='macau2'?'#2196F3':'#666',fontSize:'14px',fontWeight:'bold'}}>澳门2(22:30) {(statsLottery==='macau2'?(speedCalcStatsData?.total||0):cachedTotals.macau2).toLocaleString()}</span>
</div>
<div onClick={async ()=>{const dn=JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}');const activeStatsDate=localStorage.getItem('historyQueryDate')||null;const chatFriendId=activeStatsDate?null:(window._currentChatFriend?window._currentChatFriend.id:null);const s=await fetchSpeedCalcStatsFromServer(statsUserFilter,'hongkong',dn['hongkong'],activeStatsDate,chatFriendId);setSpeedCalcStatsData(s);setStatsLottery('hongkong');if(s?.userList)setStatsUserList(s.userList);setCachedTotals(prev=>({...prev,hongkong:s.total||0,macau1:prev.macau1||0,macau2:prev.macau2||0}));setCachedOutTotals(prev=>({...prev,hongkong:s.out||0,macau1:prev.macau1||0,macau2:prev.macau2||0}));}} style={{flex:1,padding:'12px',textAlign:'center',borderBottom:statsLottery==='hongkong'?'2px solid #2196F3':'2px solid transparent',cursor:'pointer'}}>
<span style={{color:statsLottery==='hongkong'?'#2196F3':'#666',fontSize:'14px',fontWeight:'bold'}}>香港 {(statsLottery==='hongkong'?(speedCalcStatsData?.total||0):cachedTotals.hongkong).toLocaleString()}</span>
</div>
</div>
{/* 开奖号码输入 */}
<div style={{background:'#fff',padding:'12px 16px',borderBottom:'1px solid #eee'}}>
<div style={{display:'flex',gap:'6px',alignItems:'center',flexWrap:'wrap'}}>
{Array.from({length:7},(_,i)=>i).map(idx => {
const num = (drawNumbers[statsLottery]||[])[idx];
const zodiac = num && NUMBER_TO_ZODIAC[num] ? NUMBER_TO_ZODIAC[num] : '';
return (
<div key={idx} style={{display:'flex',flexDirection:'column',alignItems:'center'}}>
<input type="number" min="1" max="49" placeholder={idx < 6 ? '平' : '特'} style={{width:'40px',padding:'8px 4px',borderRadius:'8px',border:'1px solid #ddd',background:'#f8f8f8',color:'#333',fontSize:'14px',outline:'none',textAlign:'center'}} value={((drawNumbers[statsLottery]||[])[idx]||'')} onChange={async e=>{const v=e.target.value===''?'':parseInt(e.target.value);if(v===''||(!isNaN(v)&&v>=1&&v<=49)){let nextNums=null;setDrawNumbers(prev=>{const numArr=[...(prev[statsLottery]||[])];numArr[idx]=v===''?'':v;const next={...prev,[statsLottery]:numArr};nextNums=next;localStorage.setItem('speedCalcDrawNumbers_'+user.id, JSON.stringify(next));return next;});setTimeout(async()=>{try{const activeStatsDate=localStorage.getItem('historyQueryDate')||null;const chatFriendId=activeStatsDate?null:(window._currentChatFriend?window._currentChatFriend.id:null);const s=await fetchSpeedCalcStatsFromServer(statsUserFilter,statsLottery,(nextNums||drawNumbers)[statsLottery],activeStatsDate,chatFriendId);setSpeedCalcStatsData(s);setCachedTotals(prev=>({...prev,[statsLottery]:s.total||0}));setCachedOutTotals(prev=>({...prev,[statsLottery]:s.out||0}));console.log('[TOTAL-HISTORY-FIX-V1] 开奖号码重算 date=',activeStatsDate,'user=',statsUserFilter,'lottery=',statsLottery,'total=',s.total,'out=',s.out,'shui=',s.shui,'yu=',s.yu);}catch(err){console.error('[TOTAL-HISTORY-FIX-V1] 开奖号码重算失败:',err.message);}},0);}}}/>
{zodiac && <div style={{fontSize:'10px',color:'#666',marginTop:'2px'}}>{zodiac}</div>}
</div>
);
})}
</div>
</div>
{/* 特号区域 */}
<div style={{background:'#fff',padding:'12px 16px',borderBottom:'1px solid #eee',marginTop:'8px'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span style={{color:'#333',fontSize:'15px',fontWeight:'bold'}}>特号</span>
<div style={{display:'flex',alignItems:'center',gap:'12px'}}>
<span style={{fontSize:'12px',cursor:'pointer',padding:'4px 8px',borderRadius:'12px',background:showFlyout?'#FF6B00':'#f0f0f0',color:showFlyout?'#fff':'#666'}} onClick={()=>setShowFlyout(v=>!v)}>飞出</span>
{flyoutRecords.length > 0 && <span style={{fontSize:'12px',color:'#FF6B00',fontWeight:'bold'}}>飞总额 {flyoutRecords.reduce((s,r)=>s+r.totalExcess,0).toLocaleString()}</span>}
<span style={{fontSize:'12px',cursor:'pointer',padding:'4px 8px',borderRadius:'12px',background:sortByAmount?'#2196F3':'#f0f0f0',color:sortByAmount?'#fff':'#666'}} onClick={()=>setSortByAmount(v=>!v)}>
{sortByAmount ? '默认排序' : '金额排序'}
</span>
<span style={{color:'#666',fontSize:'13px'}}>入 {(speedCalcStatsData.playStats?.tehao?.in || 0).toLocaleString()}</span>
<span style={{color:'#666',fontSize:'13px',marginLeft:'16px'}}>出 {(() => {
// 【PRE-CALC-v1】直接读取预计算值
let tehaoOut = speedCalcStatsData.playStats?.tehao?.out || 0;
if (tehaoOut === 0 && speedCalcStatsData?.billList) {
speedCalcStatsData.billList.forEach(bill => {
(bill.items || []).filter(it => it.playType === 'tehao').forEach(item => {
tehaoOut += item._payout || 0;
});
});
}
return tehaoOut.toLocaleString();
})()}</span>
</div>
</div>
</div>
{/* 飞出面板 */}
{showFlyout && (() => {
const numStats = speedCalcStatsData.numberStats || {};
const filtered = Object.entries(numStats).filter(([num, amt]) => amt > flyoutLimit).map(([num, amt]) => ({num: parseInt(num), amount: amt, excess: amt - flyoutLimit, payoutCost: (amt - flyoutLimit) * flyoutOdds}));
const groups = {};
filtered.forEach(f => { const k = f.excess; if (!groups[k]) groups[k] = []; groups[k].push(f); });
const copyText = Object.entries(groups).sort(([a],[b]) => b - a).map(([excess, items]) => items.map(f => String(f.num).padStart(2,'0')).join(',') + '号' + Number(excess).toLocaleString()).join('\n');
const flyoutTotal = flyoutRecords.reduce((s, r) => s + r.totalExcess, 0);
const drawNumsArr = (drawNumbers[statsLottery] || []).map(n => parseInt(n)).filter(n => !isNaN(n) && n >= 1 && n <= 49);
let flyoutWinPayout = 0;
flyoutRecords.forEach(r => {
r.entries.forEach(e => {
if (drawNumsArr.includes(e.num)) {
flyoutWinPayout += e.excess * 47;
}
});
});
return (
<div style={{background:'#fff',padding:'12px 16px',borderBottom:'1px solid #eee'}}>
<div style={{display:'flex',gap:'12px',alignItems:'center',marginBottom:'8px',flexWrap:'wrap'}}>
<div style={{display:'flex',alignItems:'center',gap:'6px'}}>
<span style={{fontSize:'13px',color:'#666'}}>上限金额</span>
<input type="number" min="0" value={flyoutLimit} onChange={e=>setFlyoutLimit(parseInt(e.target.value)||0)} style={{width:'80px',padding:'6px 8px',borderRadius:'8px',border:'1px solid #ddd',fontSize:'14px',outline:'none',textAlign:'center'}} />
</div>
<div style={{display:'flex',alignItems:'center',gap:'6px'}}>
<span style={{fontSize:'13px',color:'#666'}}>赔率</span>
<input type="number" min="0" value={flyoutOdds} onChange={e=>setFlyoutOdds(parseInt(e.target.value)||47)} style={{width:'60px',padding:'6px 8px',borderRadius:'8px',border:'1px solid #ddd',fontSize:'14px',outline:'none',textAlign:'center'}} />
</div>
<button onClick={() => {
if (filtered.length === 0) return;
const newRecord = {
limit: flyoutLimit,
odds: flyoutOdds,
time: Date.now(),
totalExcess: filtered.reduce((s, f) => s + f.excess, 0),
entries: filtered.map(f => ({num: f.num, excess: f.excess}))
};
setFlyoutRecords(prev => [...prev, newRecord]);
}} style={{background:'#FF6B00',color:'#fff',border:'none',borderRadius:'8px',padding:'6px 16px',fontSize:'13px',cursor:'pointer'}}>确认飞出</button>
{flyoutRecords.length > 0 && <button onClick={()=>setFlyoutRecords([])} style={{background:'#999',color:'#fff',border:'none',borderRadius:'8px',padding:'6px 16px',fontSize:'13px',cursor:'pointer'}}>取消飞出</button>}
<button onClick={()=>{try{navigator.clipboard.writeText(copyText)}catch(e){const ta=document.createElement('textarea');ta.value=copyText;document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta)}}} style={{background:'#2196F3',color:'#fff',border:'none',borderRadius:'8px',padding:'6px 16px',fontSize:'13px',cursor:'pointer'}}>复制 {filtered.length}条</button>
</div>
{filtered.length > 0 ? (
<div style={{maxHeight:'120px',overflowY:'auto',fontSize:'12px'}}>
{filtered.map(f => (
<div key={f.num} style={{display:'flex',justifyContent:'space-between',padding:'4px 0',borderBottom:'1px solid #f0f0f0'}}>
<span style={{color:'#333',fontWeight:'bold',minWidth:'30px'}}>{String(f.num).padStart(2,'0')}</span>
<span style={{color:'#FF6B00'}}>金额 {f.amount.toLocaleString()}</span>
<span style={{color:'#D32F2F'}}>超出 {f.excess.toLocaleString()}</span>
<span style={{color:'#2196F3'}}>赔付 {f.payoutCost.toLocaleString()}</span>
</div>
))}
</div>
) : (
<div style={{textAlign:'center',color:'#999',fontSize:'13px',padding:'12px 0'}}>没有超出上限的号码</div>
)}
{/* 飞出记录 */}
{flyoutRecords.length > 0 && (
<div style={{marginTop:'8px',borderTop:'1px solid #eee',paddingTop:'8px'}}>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:'6px'}}>
<span style={{fontSize:'13px',color:'#333',fontWeight:'bold'}}>飞出记录</span>
<span style={{fontSize:'13px',color:'#FF6B00',fontWeight:'bold'}}>飞出总额 {flyoutTotal.toLocaleString()}</span>
{flyoutWinPayout > 0 && <span style={{fontSize:'13px',color:'#4CAF50',fontWeight:'bold'}}>中奖赔付 {flyoutWinPayout.toLocaleString()}</span>}
</div>
<div style={{maxHeight:'120px',overflowY:'auto',fontSize:'11px'}}>
{flyoutRecords.map((rec, ri) => (
<div key={ri} style={{padding:'4px 0',borderBottom:'1px solid #f5f5f5'}}>
<div style={{color:'#999',marginBottom:'2px'}}>#{ri+1} 上限{rec.limit} 赔率{rec.odds} 小计{rec.totalExcess.toLocaleString()}</div>
<div style={{color:'#666'}}>{rec.entries.map(e => String(e.num).padStart(2,'0')).join('、')}</div>
{drawNumsArr.length > 0 && (() => {
const won = rec.entries.filter(e => drawNumsArr.includes(e.num));
if (won.length === 0) return null;
return <div style={{color:'#4CAF50',marginTop:'2px'}}>中奖 {won.map(w => String(w.num).padStart(2,'0')).join('、')} 赔付 {won.reduce((s, w) => s + w.excess * 47, 0).toLocaleString()}</div>;
})()}
</div>
))}
</div>
</div>
)}
</div>
);
})()}
{/* 演示滑动条 */}
<div style={{background:'#fff',padding:'12px 16px',borderBottom:'1px solid #eee'}}>
{(() => {
const tehaoTotal = speedCalcStatsData.playStats?.tehao?.in || 0;
const numStats = speedCalcStatsData.numberStats || {};
const maxAmount = Math.max(...Object.values(numStats), 0);
let sliderMax = maxAmount;
if (flyoutRecords.length > 0) {
const earliestTime = Math.min(...flyoutRecords.map(r => r.time));
const postAmounts = {};
(speedCalcStatsData.billList || []).forEach(bill => {
if (new Date(bill.time).getTime() >= earliestTime) {
(bill.items || []).forEach(item => {
(item.numbers || []).forEach(num => {
postAmounts[num] = (postAmounts[num] || 0) + (item.amount || 0);
});
});
}
});
const maxAbove = Math.max(...Object.values(postAmounts).filter(a => a > flyoutLimit), 0);
sliderMax = Math.max(flyoutLimit, maxAbove);
}
const cap = Math.min(demoCap, sliderMax || 1);
const collected = Object.values(numStats).reduce((s, amt) => s + Math.min(amt, cap), 0);
const flown = tehaoTotal - collected;
const payout = cap * 47;
const flyoutTotal = flyoutRecords.reduce((s, r) => s + r.totalExcess, 0);
const drawNumsArr = (drawNumbers[statsLottery] || []).map(n => parseInt(n)).filter(n => !isNaN(n) && n >= 1 && n <= 49);
let flyoutWinPayout = 0;
flyoutRecords.forEach(r => { r.entries.forEach(e => { if (drawNumsArr.includes(e.num)) flyoutWinPayout += e.excess * 47; }); });
const rem = Math.round((collected - payout - collected * 0.04 - flyoutTotal + flyoutWinPayout) * 100) / 100;
return (
<>
<div style={{display:'flex',justifyContent:'space-around',textAlign:'center',marginBottom:'8px'}}>
<div>
<div style={{fontSize:'11px',color:'#999'}}>顶</div>
<div style={{fontSize:'16px',fontWeight:'bold',color:'#FF6B00'}}>{cap.toLocaleString()}</div>
</div>
<div>
<div style={{fontSize:'11px',color:'#999'}}>收</div>
<div style={{fontSize:'16px',fontWeight:'bold',color:'#4CAF50'}}>{collected.toLocaleString()}</div>
</div>
<div>
<div style={{fontSize:'11px',color:'#999'}}>飞</div>
<div style={{fontSize:'16px',fontWeight:'bold',color:'#999'}}>{flown.toLocaleString()}</div>
</div>
<div>
<div style={{fontSize:'11px',color:'#999'}}>出</div>
<div style={{fontSize:'16px',fontWeight:'bold',color:'#D32F2F'}}>{payout.toLocaleString()}</div>
</div>
<div>
<div style={{fontSize:'11px',color:'#999'}}>余</div>
<div style={{fontSize:'16px',fontWeight:'bold',color:rem>=0?'#4CAF50':'#D32F2F'}}>{rem >= 0 ? '+' : ''}{rem.toFixed(2)}</div>
</div>
</div>
<input type="range" min="0" max={sliderMax || 1} value={cap} onChange={e=>setDemoCap(parseInt(e.target.value)||0)} style={{width:'100%',height:'6px',WebkitAppearance:'none',appearance:'none',background:'#ddd',borderRadius:'3px',outline:'none',cursor:'pointer'}} />
</>
);
})()}
</div>
{/* 49号码表格 */}
<div style={{padding:'12px',background:'#fff',marginBottom:'8px'}}>
<div style={{display:'grid',gridTemplateColumns:'repeat(6, 1fr)',gap:'6px'}}>
{(sortByAmount ? Array.from({length:49}, (_,i) => i+1).sort((a,b) => (speedCalcStatsData.numberStats?.[b] || 0) - (speedCalcStatsData.numberStats?.[a] || 0)) : Array.from({length:49}, (_,i) => i+1)).map(num => {
const amount = speedCalcStatsData.numberStats?.[num] || 0;
const count = speedCalcStatsData.numberCount?.[num] || 0;
const zodiac = NUMBER_TO_ZODIAC[num];
const color = NUMBER_TO_COLOR[num];
const hasBet = amount > 0;
return (
<div key={num} style={{
background: '#fff',
border: '1px solid #eee',
borderRadius:'8px',
padding:'4px 2px',
textAlign:'center',
minHeight:'60px',
display:'flex',
flexDirection:'column',
justifyContent:'center',
alignItems:'center'
}}>
<div style={{background: hasBet && color ? COLOR_BG_MAP[color] : 'transparent', color:'#fff', fontSize:'11px', fontWeight:'bold', padding:'2px 6px', borderRadius:'4px', marginBottom:'2px'}}>{num}/{zodiac}</div>
<div style={{color:hasBet?'#FF6B00':'#999',fontSize:'13px',fontWeight:'bold'}}>{hasBet ? amount.toLocaleString() : '-'}</div>
<div style={{color:hasBet?'#4CAF50':'#999',fontSize:'9px'}}>{count > 0 ? `${count}次` : '-'}</div>
{hasBet && (() => {
const tehaoTotal = speedCalcStatsData.playStats?.tehao?.in || 0;
const profitLoss = Math.round(tehaoTotal * 0.96 - amount * 47);
return (
<div style={{color:profitLoss>=0?'#4CAF50':'#D32F2F',fontSize:'9px',background:profitLoss>=0?'#E8F5E9':'#FFEBEE',padding:'0 3px',borderRadius:'3px',marginTop:'2px',lineHeight:'1.4'}}>
{profitLoss >= 0 ? '+' : ''}{profitLoss.toLocaleString()}
</div>
);
})()}
</div>
);
})}
</div>
</div>
{/* 玩法统计详情 */}
<div style={{background:'#fff',padding:'0 16px',marginBottom:'8px'}}>
{[
{key:'tehao',label:'特号',odds:'47'},
{key:'pingte',label:'平特',odds:'2(马1.7)'},
{key:'pingtewei',label:'平特尾',odds:'1.75'},
{key:'texiao',label:'特肖',odds:'11(马10)'},
{key:'lianxiao2',label:'二连肖',odds:'4'},
{key:'lianxiao3',label:'三连肖',odds:'10'},
{key:'lianxiao4',label:'四连肖',odds:'30'},
{key:'lianxiao5',label:'五连肖',odds:'60'},
{key:'baobo',label:'包波',odds:'2.5(红2.4)'},
{key:'baodaxiao',label:'包大小',odds:'1.8'},
{key:'baodanshuang',label:'包单双',odds:'1.8'},
].map((ps,idx,arr) => {
const isExpanded = expandedPlay === ps.key;
const playBills = (speedCalcStatsData.billList || []).filter(b =>
(b.items || []).some(it => it.playType === ps.key)
);
return (
<div key={ps.key}>
<div
key={ps.key}
style={{padding:'12px 0',borderBottom:idx===arr.length-1?'none':'1px solid #f0f0f0',display:'flex',justifyContent:'space-between',alignItems:'center',cursor:'pointer'}}
onClick={() => setExpandedPlay(isExpanded ? null : ps.key)}
>
<span style={{color:'#333',fontSize:'14px'}}>
{ps.label}<span style={{color:'#999',fontSize:'10px',marginLeft:'4px'}}>{ps.odds}x</span>
<span style={{color:'#999',fontSize:'11px',marginLeft:'8px'}}>({playBills.length}条)</span>
{isExpanded ? ' ▲' : ' ▼'}
</span>
<div>
<span style={{color:'#666',fontSize:'13px'}}>入 {(speedCalcStatsData.playStats?.[ps.key]?.in || 0).toLocaleString()}</span>
<span style={{color:'#666',fontSize:'13px',marginLeft:'24px'}}>出 {(() => {
// 【PRE-CALC-v1】直接读取预计算值
let playOut = speedCalcStatsData.playStats?.[ps.key]?.out || 0;
if (playOut === 0 && playBills.length > 0) {
playBills.forEach(bill => {
(bill.items || []).filter(it => it.playType === ps.key).forEach(item => {
playOut += item._payout || 0;
});
});
}
return playOut.toLocaleString();
})()}</span>
</div>
</div>
{isExpanded && playBills.length > 0 && (
<div style={{padding:'8px 12px',background:'#f9f9f9',borderRadius:'8px',marginBottom:'8px'}}>
{playBills.map((bill, bi) => {
const playItems = (bill.items || []).filter(it => it.playType === ps.key);
// 【PRE-CALC-v1】直接读取预计算的 _payout,不再重复计算
const totalPayout = playItems.reduce((sum, it) => sum + (it._payout || 0), 0);
const hasWin = totalPayout > 0;
return (
<div key={bi} style={{
padding:'8px 10px',
marginBottom:'6px',
background: hasWin ? 'linear-gradient(135deg,#FFF8E1,#FFF3E0)' : '#fff',
borderLeft: hasWin ? '3px solid #FF9800' : '3px solid #ddd',
borderRadius:'6px',
fontSize:'12px'
}}>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:'4px'}}>
<span style={{color:'#333',fontWeight:'bold'}}>{bill.senderName}</span>
<div style={{display:'flex',alignItems:'center',gap:'8px'}}>
<span style={{color:'#999',fontSize:'10px'}}>{new Date(bill.time).toLocaleString('zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})}</span>
<span style={{color:'#ccc',fontSize:'12px',cursor:'pointer',padding:'2px 4px'}} onClick={(e)=>{e.stopPropagation();if(!confirm('确定删除这条账单?'))return;const token=localStorage.getItem('token');fetch(API+'/speedcalc/bills/'+encodeURIComponent(bill.msgId||bill.id)+'?from=stats',{method:'DELETE',headers:{'Authorization':'Bearer '+token}}).then(r=>r.json()).then(d=>{if(d.code===200){if(window._loadServerBills)window._loadServerBills();}else{alert(d.message||'删除失败');}}).catch(()=>alert('删除失败'));}}>✕</span>
</div>
</div>
<div style={{color:'#666',fontSize:'11px',marginBottom:'2px'}}>{bill.content}</div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginTop:'4px'}}>
<span style={{color:'#4CAF50',fontWeight:'bold'}}>收 {(bill.total || 0).toLocaleString()}</span>
{hasWin ? (
<span style={{color:'#FF6B00',fontWeight:'bold'}}>🏆 中 {totalPayout.toLocaleString()}</span>
) : (
<span style={{color:'#999'}}>未中</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
{/* 统计设置弹窗 */}
{showStatsSettings && (
<div className="modal-mask" style={{zIndex:1001}} onClick={()=>setShowStatsSettings(false)}>
<div className="modal-box" style={{maxWidth:'340px'}} onClick={e=>e.stopPropagation()}>
<h3 style={{color:'#333',fontSize:'16px',fontWeight:'bold',textAlign:'center',marginBottom:'16px'}}>⚙️ 统计设置</h3>
<div className="space-y-3">
<div style={{background:'#FFF3E0',padding:'8px 12px',borderRadius:'8px',marginBottom:'8px'}}>
<span style={{color:'#E65100',fontSize:'12px'}}>💡 赔率已内置固定值(特号47、平特2/马1.7、特肖11/马10、连肖4/10/30/60、包波2.5/红2.4、大小1.8、单双1.8),无需手动设置</span>
</div>
{[
{key:'rebateRate',label:'返点比例(%)',defaultValue:4,step:0.1},
].map(field => (
<div key={field.key}>
<label style={{color:'#666',fontSize:'13px',display:'block',marginBottom:'4px'}}>{field.label}</label>
<input type="number" style={{width:'100%',padding:'10px 12px',borderRadius:'8px',border:'1px solid #ddd',background:'#fff',color:'#333',fontSize:'15px',outline:'none'}}
value={statsSettings[field.key] ?? field.defaultValue}
step={field.step}
onChange={e=>{
const val = parseFloat(e.target.value);
const updated = {...statsSettings, [field.key]: isNaN(val) ? field.defaultValue : val};
setStatsSettings(updated);
localStorage.setItem('chatSettings_'+user.id, JSON.stringify(updated));
// 实时刷新统计
setSpeedCalcStatsData(null);
}} />
</div>
))}
</div>
<button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'none',background:'linear-gradient(135deg,#2196F3,#1976D2)',color:'#fff',fontSize:'15px',fontWeight:'bold',cursor:'pointer',marginTop:'16px'}} onClick={()=>setShowStatsSettings(false)}>保存并关闭</button>
{userInfo.vipLevel === 'super' && <button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'2px solid #FF9800',background:'#fff',color:'#FF9800',fontSize:'15px',fontWeight:'bold',cursor:'pointer',marginTop:'8px'}} onClick={()=>{setShowStatsSettings(false);setShowHistoryQuery(true);}}>📅 历史统计查询</button>}
</div>
</div>
)}
{/* 历史统计查询弹窗 */}
{showHistoryQuery && (() => {
// 生成最近5天日期
const dates = [];
for (let i = 0; i < 5; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
dates.push(dateStr);
}
// 查询历史统计
const queryHistoryStats = async () => {
if (!historyQueryDate) {
alert('请选择日期');
return;
}
// 直接打开统计页面,传入日期
setShowHistoryQuery(false);
// 清空旧数据
setCachedTotals({macau1: 0, macau2: 0, hongkong: 0});
setCachedOutTotals({macau1: 0, macau2: 0, hongkong: 0});
// 【TOTAL-HISTORY-FIX-V1】按历史日期加载所有彩种,并刷新该日期的用户列表
localStorage.setItem('historyQueryDate', historyQueryDate);
setStatsUserFilter('all');
setStatsLottery('macau1');
const dn = JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}');
const results = await Promise.allSettled([
fetchSpeedCalcStatsFromServer('all', 'macau1', dn['macau1'], historyQueryDate, null),
fetchSpeedCalcStatsFromServer('all', 'macau2', dn['macau2'], historyQueryDate, null),
fetchSpeedCalcStatsFromServer('all', 'hongkong', dn['hongkong'], historyQueryDate, null)
]);
// 设置当前显示澳门1
if (results[0].status === 'fulfilled') {
setSpeedCalcStatsData(results[0].value);
}
// 更新所有彩种的cachedTotals和cachedOutTotals
const newCachedTotals = {macau1: 0, macau2: 0, hongkong: 0};
const newCachedOutTotals = {macau1: 0, macau2: 0, hongkong: 0};
const historyUserSet = new Set();
results.forEach((r, idx) => {
if (r.status === 'fulfilled' && r.value) {
newCachedTotals[['macau1','macau2','hongkong'][idx]] = r.value.total || 0;
newCachedOutTotals[['macau1','macau2','hongkong'][idx]] = r.value.out || 0;
(r.value.userList || []).forEach(u => { if (u) historyUserSet.add(u); });
}
});
setStatsUserList(Array.from(historyUserSet));
// 历史查询完成
setCachedTotals(newCachedTotals);
setCachedOutTotals(newCachedOutTotals);
setShowSpeedCalcStats(true);
// 历史日期已在查询开始时保存
};
// 渲染统计结果
const renderHistoryStats = () => {
if (!historyQueryStats) return null;
if (historyQueryStats.error) {
return <div style={{textAlign:'center',color:'#999',padding:'20px'}}>{historyQueryStats.error}</div>;
}
const s = historyQueryStats;
const playLabels = {
tehao: '特号', pingte: '平特', pingtewei: '平特尾', texiao: '特肖',
lianxiao2: '二连肖', lianxiao3: '三连肖', lianxiao4: '四连肖', lianxiao5: '五连肖',
baobo: '包波', baodaxiao: '包大小', baodanshuang: '包单双'
};
const playOdds = {
tehao: '47x', pingte: '2x(马1.7x)', pingtewei: '1.75x', texiao: '11x(马10x)',
lianxiao2: '4x', lianxiao3: '10x', lianxiao4: '30x', lianxiao5: '60x',
baobo: '2.5x(红2.4x)', baodaxiao: '1.8x', baodanshuang: '1.8x'
};
return (
<div>
<div style={{background:'#f5f5f5',padding:'12px',borderRadius:'8px',marginBottom:'12px'}}>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:'8px'}}>
<span style={{color:'#666',fontSize:'13px'}}>📅 日期</span>
<span style={{color:'#333',fontWeight:'bold',fontSize:'14px'}}>{historyQueryDate}</span>
</div>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:'8px'}}>
<span style={{color:'#666',fontSize:'13px'}}>📊 总入账</span>
<span style={{color:'#2196F3',fontWeight:'bold',fontSize:'14px'}}>{(s.total||0).toLocaleString()}</span>
</div>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:'8px'}}>
<span style={{color:'#666',fontSize:'13px'}}>💰 总赔付</span>
<span style={{color:'#F44336',fontWeight:'bold',fontSize:'14px'}}>{(s.out||0).toLocaleString()}</span>
</div>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:'8px'}}>
<span style={{color:'#666',fontSize:'13px'}}>📝 账单数</span>
<span style={{color:'#333',fontWeight:'bold',fontSize:'14px'}}>{s.count||0} 条</span>
</div>
<div style={{display:'flex',justifyContent:'space-between'}}>
<span style={{color:'#666',fontSize:'13px'}}>💵 水</span>
<span style={{color:'#FF9800',fontWeight:'bold',fontSize:'14px'}}>{(s.shui||0).toLocaleString()}</span>
</div>
<div style={{display:'flex',justifyContent:'space-between',marginTop:'8px',paddingTop:'8px',borderTop:'1px solid #ddd'}}>
<span style={{color:'#666',fontSize:'13px'}}>💎 余</span>
<span style={{color:'#4CAF50',fontWeight:'bold',fontSize:'16px'}}>{(s.yu||0).toLocaleString()}</span>
</div>
</div>
<div style={{fontSize:'13px',fontWeight:'bold',color:'#333',marginBottom:'8px'}}>各玩法明细</div>
{Object.keys(playLabels).map(key => {
const ps = s.playStats?.[key] || {in:0, out:0};
if (ps.in === 0 && ps.out === 0) return null;
return (
<div key={key} style={{background:'#fff',padding:'10px 12px',borderRadius:'8px',marginBottom:'6px',border:'1px solid #eee'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
<span style={{color:'#333',fontWeight:'bold',fontSize:'13px'}}>{playLabels[key]} {playOdds[key]}</span>
<div style={{display:'flex',gap:'16px'}}>
<span style={{color:'#2196F3',fontSize:'12px'}}>入 {ps.in.toLocaleString()}</span>
<span style={{color:'#F44336',fontSize:'12px'}}>出 {ps.out.toLocaleString()}</span>
</div>
</div>
</div>
);
})}
</div>
);
};
return (
<div className="modal-mask" style={{zIndex:1002}} onClick={()=>{setShowHistoryQuery(false);setHistoryQueryStats(null);setHistoryQueryDate('');}}>
<div className="modal-box" style={{maxWidth:'400px',maxHeight:'80vh',overflowY:'auto'}} onClick={e=>e.stopPropagation()}>
<h3 style={{color:'#333',fontSize:'16px',fontWeight:'bold',textAlign:'center',marginBottom:'16px'}}>📅 历史统计查询</h3>
<div style={{marginBottom:'16px'}}>
<label style={{color:'#666',fontSize:'13px',display:'block',marginBottom:'8px'}}>选择日期(最近5天)</label>
<select style={{width:'100%',padding:'10px 12px',borderRadius:'8px',border:'1px solid #ddd',background:'#fff',color:'#333',fontSize:'15px',outline:'none'}}
value={historyQueryDate}
onChange={e=>setHistoryQueryDate(e.target.value)}>
<option value="">-- 请选择日期 --</option>
{dates.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'none',background:'linear-gradient(135deg,#FF9800,#F57C00)',color:'#fff',fontSize:'15px',fontWeight:'bold',cursor:'pointer',marginBottom:'16px'}}
onClick={queryHistoryStats}>
查询统计
</button>
{renderHistoryStats()}
<button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'1px solid #ddd',background:'#fff',color:'#666',fontSize:'15px',cursor:'pointer',marginTop:'16px'}}
onClick={()=>{setShowHistoryQuery(false);setHistoryQueryStats(null);setHistoryQueryDate('');}}>
关闭
</button>
</div>
</div>
);
})()}
{/* 底部留白 */}
<div style={{height:'80px'}}></div>
</div>
)}
</div>
);
}
// ============ App主组件 ============
function App() {
const [authed, setAuthed] = useState(!!localStorage.getItem('token'));
const [user, setUser] = useState(() => { try { const u = JSON.parse(localStorage.getItem('user')); if (u && u.id && window.AndroidNotify && window.AndroidNotify.saveUserId) window.AndroidNotify.saveUserId(u.id); return u; } catch { return null; } });
const [mentionCount, setMentionCount] = useState(0);
const [friendUnread, setFriendUnread] = useState(0);
const friendUnreadRef = useRef(0);
const tabRef = useRef('lobby');
const [page, setPage] = useState('');
const [tab, setTab] = useState('lobby');
// 【修复-用户资料】App 层用户资料面板状态(跨组件共享)[PROFILE-PANEL-V1]
const [profilePanel, setProfilePanel] = useState(null);
const [recommendPicker, setRecommendPicker] = useState(null); // 推荐给好友弹窗: { targetUser }
const [appFriends, setAppFriends] = useState([]); // App层好友列表,用于推荐弹窗
// 加载好友列表
useEffect(() => {
if (!user?.id) return;
(async () => {
try {
const res = await get('/friends');
if (res.code === 200 && Array.isArray(res.data)) setAppFriends(res.data);
} catch(e) {}
})();
}, [user?.id]);
const [profileMenu, setProfileMenu] = useState(false);
// 微信式资料缓存:本地先展示,按需增量拉取,避免重复请求 [PROFILE-CACHE-V1]
const _profileCacheRef = useRef(new Map()); // id → { data, ts }
const _profileInflightRef = useRef(new Set()); // 正在拉取中的 id 集合(去重)
const PROFILE_CACHE_TTL = 60 * 1000; // 缓存有效期 60 秒
// 【修复-用户资料】打开资料面板时从后端拉取实时完整数据 [USER-PROFILE-FRONTEND-V1]
// 策略:先展示已有(本地或缓存)→ 缓存未命中才拉取 → 静默错误(不报错给用户)
useEffect(() => {
if (!profilePanel || !profilePanel.id) return;
const id = profilePanel.id;
const cache = _profileCacheRef.current;
const inflight = _profileInflightRef.current;
const hit = cache.get(id);
// 命中未过期 → 合并一次即可,不再发请求
if (hit && Date.now() - hit.ts < PROFILE_CACHE_TTL) {
const isFriend = appFriends.some(f => f.id === id);
setProfilePanel(prev => prev && prev.id === id ? { ...prev, ...hit.data, isFriend } : prev);
return;
}
// 已经在拉 → 去重
if (inflight.has(id)) return;
inflight.add(id);
let cancelled = false;
(async () => {
try {
const res = await get('/user/profile/' + id);
if (cancelled) return;
if (res && res.code === 200 && res.data) {
const isFriend = appFriends.some(f => f.id === id);
cache.set(id, { data: res.data, ts: Date.now() });
setProfilePanel(prev => {
if (!prev || prev.id !== id) return prev;
return { ...prev, ...res.data, isFriend };
});
}
} catch (e) {
// 静默:网络失败保持本地数据,不报错
} finally {
inflight.delete(id);
}
})();
return () => { cancelled = true; };
}, [profilePanel && profilePanel.id]);
// 设置好友备注(调用后端 [FRIEND-REMARK-API-V1])
const handleSetRemark = async () => {
setProfileMenu(false);
if (!profilePanel || !profilePanel.id) return;
const defaultVal = profilePanel.remark || '';
const val = prompt('请输入好友备注(最长12字)', defaultVal);
if (val === null) return;
try {
const res = await post('/friends/remark', { friendId: profilePanel.id, remark: val });
if (res && res.code === 200) {
setProfilePanel(prev => prev && prev.id === profilePanel.id ? { ...prev, remark: val } : prev);
showToast('备注已保存');
} else {
showToast(res && res.message ? res.message : '保存失败');
}
} catch (e) { showToast('保存失败'); }
};
// 【修复-tab全存活】记录已访问过的 tab,已挂载后不再卸载,避免切换页面重复请求列表/头像
// 调试编号:[TAB-KEEP-ALIVE-V1]
const [mountedTabs, setMountedTabs] = useState({ lobby: true });
useEffect(() => {
setMountedTabs(prev => prev[tab] ? prev : { ...prev, [tab]: true });
console.log('[TAB-KEEP-ALIVE-V1] activate tab:', tab);
}, [tab]);
const [currentRoom, setCurrentRoom] = useState(null);
const [showBillRecord, setShowBillRecord] = useState(false);
const [billRecordData, setBillRecordData] = useState(null);
const [billRecordLottery, setBillRecordLottery] = useState('all');
const [speedCalcStatsData, setSpeedCalcStatsData] = useState(null);
const [billRecordExpandedPlay, setBillRecordExpandedPlay] = useState(null);
const [sharedMembers, setSharedMembers] = useState([]);
const [popupForceUpdate, setPopupForceUpdate] = useState(0); // 弹窗强制更新触发器
const [forceRefresh, setForceRefresh] = useState(0); // 消息列表强制刷新
// 检查是否为分享链接进入
const [isSharedLink, setIsSharedLink] = useState(() => {
const params = new URLSearchParams(window.location.search);
return !!params.get('ref');
});
const [showGuestPreview, setShowGuestPreview] = useState(() => {
return sessionStorage.getItem('guestPreview') === 'true';
});
// 全局开奖号码状态(所有页面共享)
const [drawNumbers, setDrawNumbers] = useState(() => {
try { return JSON.parse(localStorage.getItem('speedCalcDrawNumbers_' + (user?.id || '')) || '{}'); } catch(e) { return {}; }
});
// 开奖号码同步到服务器(让聊天双方都能查看中奖金额)
useEffect(() => {
if (!user?.id) return;
const timer = setTimeout(async () => {
try {
const nums = drawNumbers;
if (nums.macau1 && nums.macau1.some(n => n !== null && n !== '')) {
await post('/speed-calc/draw-numbers/save', { numbers: nums.macau1, lottery: 'macau1' });
}
if (nums.macau2 && nums.macau2.some(n => n !== null && n !== '')) {
await post('/speed-calc/draw-numbers/save', { numbers: nums.macau2, lottery: 'macau2' });
}
if (nums.hongkong && nums.hongkong.some(n => n !== null && n !== '')) {
await post('/speed-calc/draw-numbers/save', { numbers: nums.hongkong, lottery: 'hongkong' });
}
} catch(e) {}
}, 2000);
return () => clearTimeout(timer);
}, [drawNumbers, user?.id]);
const handleAuth = (data) => {
setUser(data.user);
setAuthed(true);
setTab('lobby');
// 【修复】替换浏览器历史记录,防止按返回键回到登录页
window.history.replaceState(null, '', window.location.href);
try {
if (window.AndroidNotify && window.AndroidNotify.saveLoginState) {
window.AndroidNotify.saveLoginState(data.user.id, localStorage.getItem('token') || '');
} else if (window.AndroidNotify && window.AndroidNotify.saveUserId) {
window.AndroidNotify.saveUserId(data.user.id);
}
} catch(e){}
// 【修复】登录成功后立即从服务器拉取红包领取状态,确保清缓存后也能恢复
(async () => {
try {
const res = await get('/redpacket/claimed-list');
if (res.code === 200 && Array.isArray(res.data) && res.data.length > 0) {
const cached = localStorage.getItem('claimedPacketIds_' + data.user.id);
let existing = new Map();
if (cached) {
try {
const arr = JSON.parse(cached);
if (Array.isArray(arr)) arr.forEach(it => {
if (Array.isArray(it) && it.length >= 2) existing.set(it[0], it[1]);
else if (typeof it === 'string') existing.set(it, 'mine');
});
} catch(e) {}
}
res.data.forEach(id => { if (!existing.has(id)) existing.set(id, 'mine'); });
localStorage.setItem('claimedPacketIds_' + data.user.id, JSON.stringify([...existing.entries()]));
}
} catch(e) {}
})();
// 登录时从 localStorage恢复好友列表(解决清除缓存后私聊丢失的问题)
const restoreFriendsFromLocal = () => {
try {
const localFriends = [];
const userId = data.user.id;
const prefix = 'chat_' + userId + '_';
// 扫描localStorage中所有chat_开头的key
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix) && !key.includes('_server_')) {
// 提取friendId
const friendId = key.substring(prefix.length);
// 检查是否有消息数据
const msgs = JSON.parse(localStorage.getItem(key) || '[]');
if (msgs.length > 0) {
// 从消息中获取对方信息
const lastMsg = msgs[msgs.length - 1];
localFriends.push({
id: friendId,
nickname: lastMsg.senderName || '用户' + friendId.substring(0, 8),
avatar: lastMsg.senderAvatar || '',
role: 'user'
});
}
}
}
if (localFriends.length > 0) {
// 合并服务器返回的好友和本地恢复的好友
setTimeout(() => {
setFriends(prev => {
const existingIds = new Set(prev.map(f => f.id));
const newFriends = localFriends.filter(f => !existingIds.has(f.id));
const merged = [...prev, ...newFriends];
return merged;
});
}, 500);
}
} catch(e) {
console.error('Restore friends from local error:', e);
}
};
// 登录时重新加载好友和群列表(这些函数在 LobbyPage 内定义,App 层不可直接调用)
// [FIX-LOGIN-RELOAD] LobbyPage mount 时会自动触发,无需在此调用
try { if (typeof fetchFriends === 'function') fetchFriends(); } catch(e){}
try { if (typeof fetchRequests === 'function') fetchRequests(); } catch(e){}
try { if (typeof fetchGroups === 'function') fetchGroups(); } catch(e){}
try { if (typeof fetchCustomerService === 'function') fetchCustomerService(); } catch(e){}
// 【CS-ALWAYS-INCLUDE-V2】注册/登录后主动获取客服列表,确保客服显示在最近联系人
(async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const res = await fetch(API + '/friends?t=' + Date.now(), { headers: { 'Authorization': 'Bearer ' + token } });
const data = await res.json();
if (data.code === 200 && Array.isArray(data.data)) {
const csList = data.data.filter(u => u.role === 'customer_service');
if (csList.length > 0) {
// 保存到 localStorage,供 buildRecentContacts 使用
try { localStorage.setItem('csListCache', JSON.stringify(csList)); } catch(e){}
// 触发重建最近联系人
setTimeout(() => { window.dispatchEvent(new CustomEvent('rebuild_recent_contacts')); }, 500);
}
}
} catch(e) { console.warn('[CS-ALWAYS-INCLUDE-V2] 获取客服列表失败:', e); }
})();
// 优化:登录时从服务器加载最近联系人列表(解决清除缓存后丢失的问题)
const loadRecentContactsFromServer = async () => {
try {
const token = localStorage.getItem('token');
if (!token || !user?.id) return;
const res = await fetch(API + '/sync/recent-contacts', {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await res.json();
if (data.code === 200 && Array.isArray(data.data) && data.data.length > 0) {
// 将服务器返回的联系人转换为前端格式(仅作为补充,不覆盖本地完整数据)
const contacts = data.data.map(c => ({
type: c.contactType || 'friend',
data: {
id: c.contactId,
nickname: c.contactId.substring(0, 8) + '...', // 临时昵称
avatar: ''
},
lastTime: new Date(c.lastTime).getTime(),
lastMsg: c.lastMsg || '',
unread: c.unread || 0
}));
// 仅补充本地没有的联系人(不覆盖已有完整数据)
setRecentContacts(prev => {
const existingIds = new Set(prev.map(c => c.data.id));
const newContacts = contacts.filter(c => !existingIds.has(c.data.id));
if (newContacts.length === 0) return prev; // 无新增,不触发重渲染
const merged = [...prev, ...newContacts];
merged.sort((a, b) => b.lastTime - a.lastTime);
try { localStorage.setItem('recentContacts_' + user.id, JSON.stringify(merged)); } catch(e){}
return merged;
});
}
} catch(e) {
console.warn('[loadRecentContactsFromServer] 失败(不影响正常使用):', e);
}
};
loadRecentContactsFromServer();
// 【新增】邀请注册场景:若用户有上级代理(parentId),主动拉取与其聊天记录,以便最近联系人列表能立即显示上级代理
if (data.user && data.user.parentId) {
(async () => {
try {
const token = localStorage.getItem('token');
const r = await fetch(API + '/chat/messages?friendId=' + data.user.parentId, {
headers: { 'Authorization': 'Bearer ' + token }
});
const d = await r.json();
if (d.code === 200 && Array.isArray(d.data) && d.data.length > 0) {
// 保存到本地,使 buildRecentContacts 能扫描到
const localKey = 'chat_' + data.user.id + '_' + data.user.parentId;
localStorage.setItem(localKey, JSON.stringify(d.data));
// 触发重建最近联系人列表
window.dispatchEvent(new CustomEvent('recent_contacts_rebuild'));
}
} catch(e) { console.error('拉取上级代理聊天记录失败:', e); }
})();
}
// 从本地恢复好友(延迟执行,等待fetchFriends完成)
restoreFriendsFromLocal();
// 登录时从服务器加载今天的速算记录
loadServerSpeedCalcRecords(data.user.id);
};
const handleLogout = () => { localStorage.removeItem('token'); localStorage.removeItem('user'); setAuthed(false); setUser(null); setPage('login'); setCurrentRoom(null); };
const updateUser = (data) => { setUser(prev => ({...prev, ...data})); localStorage.setItem('user', JSON.stringify({...user, ...data})); };
// 【BACK-NAV-FIX-V2】用 ref 跟踪弹窗状态,避免 handler 依赖变化导致重注册
const _backNavRef = React.useRef({});
React.useEffect(() => {
_backNavRef.current = { showBillRecord, profilePanel, recommendPicker };
}, [showBillRecord, profilePanel, recommendPicker]);
// 【BACK-NAV-FIX-V2】手机返回键全局处理:零依赖 handler + 即时重推 + Cordova backbutton
React.useEffect(() => {
if (!authed) return;
// 推入3条基础历史,增加容错深度
window.history.pushState({ _appBase: 1 }, '');
window.history.pushState({ _appBase: 2 }, '');
window.history.pushState({ _appBase: 3 }, '');
const doBack = () => {
const s = _backNavRef.current;
if (s.showBillRecord) {
setShowBillRecord(false); window._popupState = null;
window.history.pushState({ _overlay: true }, '');
window.history.pushState({ _overlay: true }, '');
return;
}
if (s.profilePanel) {
setProfilePanel(null); setProfileMenu(false);
window.history.pushState({ _overlay: true }, '');
window.history.pushState({ _overlay: true }, '');
return;
}
if (s.recommendPicker) {
setRecommendPicker(null);
window.history.pushState({ _overlay: true }, '');
window.history.pushState({ _overlay: true }, '');
return;
}
// 主界面,立即重推2条防止下次返回退出
window.history.pushState({ _appBase: true }, '');
window.history.pushState({ _appBase: true }, '');
};
// popstate handler
const handlePop = (e) => { e.preventDefault(); doBack(); };
window.addEventListener('popstate', handlePop);
// 【BACK-NAV-FIX-V2】Cordova backbutton 拦截(部分机型 popstate 不可靠)
const handleCordovaBack = (e) => { e.preventDefault(); doBack(); };
document.addEventListener('backbutton', handleCordovaBack, false);
return () => {
window.removeEventListener('popstate', handlePop);
document.removeEventListener('backbutton', handleCordovaBack);
};
}, [authed]);
// 加载共享成员列表和账单数据
React.useEffect(() => {
if (!user?.id) return;
// 加载共享成员列表
const loadMembers = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const res = await fetch(API + '/speedcalc/shared-members', {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await res.json();
if (data.code === 200 && data.data) {
setSharedMembers(data.data);
try { localStorage.setItem('speedCalcSharedMembers_' + user.id, JSON.stringify(data.data)); } catch(e) {}
console.log('[App] 加载共享成员:', data.data.length, '个');
}
} catch(e) {
console.error('[App] 加载共享成员失败:', e);
}
};
loadMembers();
// 从服务器加载共享成员账单(支持多端同步)
const loadServerBills = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
// 加载今天和昨天两天的数据,确保跨天场景不丢失
const today = getBeijingDate();
const yesterday = getBeijingDate(new Date(Date.now() - 86400000));
// 【修复】同时拉取今天和昨天账单并合并,避免跨天后跨跨跨跨跨跨跨。
const [billsResToday, billsResYesterday] = await Promise.all([
fetch(API + '/speedcalc/shared-bills?date=' + today, { headers: { 'Authorization': 'Bearer ' + token } }),
fetch(API + '/speedcalc/shared-bills?date=' + yesterday, { headers: { 'Authorization': 'Bearer ' + token } })
]);
let mergedBills = [];
if (billsResToday.ok) {
const d = await billsResToday.json();
if (d.code === 200 && Array.isArray(d.data)) mergedBills = mergedBills.concat(d.data);
}
if (billsResYesterday.ok) {
const d2 = await billsResYesterday.json();
if (d2.code === 200 && Array.isArray(d2.data)) mergedBills = mergedBills.concat(d2.data);
}
// 去重:同一笔账单会保存接收方维度(msgId)和发送方维度(msgId_s),按 base msgId 只保留一条
const seen = new Set();
mergedBills = mergedBills.filter(b => {
if (!b) return false;
const key = String(b.msgId || b.id || '').replace(/_s$/, '');
if (!key) return false;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
// 保存到localStorage,供calcSpeedCalcStats使用
const serverBillsKey = 'chat_' + user.id + '_server_bills';
localStorage.setItem(serverBillsKey, JSON.stringify(mergedBills));
console.log('[多端同步] 已加载共享成员账单:', mergedBills.length, '条 (今='+today+', 昨='+yesterday+')');
// 触发统计刷新
window.dispatchEvent(new CustomEvent('speedcalc_stats_changed'));
} catch(e) {
console.error('[多端同步] 加载服务器账单失败:', e);
}
};
// 首次加载
loadServerBills();
// 暴露给全局,供删除账单后刷新用
window._loadServerBills = loadServerBills;
// 【删除定时刷新】只在账单录入成功后触发一次刷新,禁止轮询
// const interval = setInterval(loadServerBills, 30000); // 已删除
// 监听新消息事件,收到新账单后立即刷新
const handleNewMessage = () => {
setTimeout(loadServerBills, 1000); // 延迟1秒确保服务器已保存
};
const handleSharedMembersUpdated = (data) => {
if (!data || !Array.isArray(data.members)) return;
setSharedMembers(data.members);
try { localStorage.setItem('speedCalcSharedMembers_' + user.id, JSON.stringify(data.members)); } catch(e) {}
try { window.dispatchEvent(new CustomEvent('speedcalc_stats_changed')); } catch(e) {}
console.log('[SHARED-MEMBERS-SYNC-V1] 多端同步共享成员配置', data.reason, data.members.length, data);
};
window.addEventListener('new_message', handleNewMessage);
window.addEventListener('new_group_message', handleNewMessage);
const bindSharedMembersSync = () => {
if (window._socket) {
window._socket.off('shared_members_updated', handleSharedMembersUpdated);
window._socket.on('shared_members_updated', handleSharedMembersUpdated);
}
};
bindSharedMembersSync();
const bindSharedMembersSyncTimer = setTimeout(bindSharedMembersSync, 1200);
return () => {
// clearInterval(interval); // 已删除
window.removeEventListener('new_message', handleNewMessage);
window.removeEventListener('new_group_message', handleNewMessage);
clearTimeout(bindSharedMembersSyncTimer);
if (window._socket) window._socket.off('shared_members_updated', handleSharedMembersUpdated);
};
}, [user?.id]);
// 从服务器加载今天的速算记录到localStorage
const loadServerSpeedCalcRecords = async (uid) => {
const userId = uid || user?.id;
if (!userId) return;
try {
const today = getBeijingDate();
const token = localStorage.getItem('token');
if (!token) return;
// 检查本地是否已有聊天账单数据(缓存未清除)
let hasLocalBills = false;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith('chat_' + userId + '_') || key.startsWith('groupMsgs_' + userId + '_')) && !key.includes('_server_')) {
const msgs = JSON.parse(localStorage.getItem(key) || '[]');
const todayBills = msgs.filter(m => {
try { return new Date(m.createdAt).toDateString() === new Date().toDateString() && m.content && m.content.match(/\d/); } catch(e) { return false; }
});
if (todayBills.length > 0) { hasLocalBills = true; break; }
}
}
// 如果本地已有今天的账单数据,不需要从服务器加载(避免重复统计)
if (hasLocalBills) {
console.log('本地已有今天的账单数据,跳过服务器加载');
// 清除旧的服务器缓存key
for (const lot of ['macau1','macau2','hongkong']) {
localStorage.removeItem('chat_' + userId + '_server_' + lot);
}
return;
}
for (const lottery of ['macau1', 'macau2', 'hongkong']) {
const res = await fetch(API + '/user/speed-calc/records?lottery=' + lottery + '&date=' + today, {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await res.json();
if (data.code === 200 && data.data && data.data.length > 0) {
const record = data.data[0];
if (record.billList && record.billList.length > 0) {
// 用专用key存储服务器账单,calcSpeedCalcStats会自动扫描chat_前缀的key
const serverKey = 'chat_' + userId + '_server_' + lottery;
const msgs = record.billList.map(bill => ({
id: bill.id || ('srv_' + Math.random().toString(36).slice(2)),
senderId: bill.senderId,
senderName: bill.senderName,
content: bill.content,
createdAt: bill.time || bill.createdAt || today + 'T00:00:00'
}));
localStorage.setItem(serverKey, JSON.stringify(msgs));
console.log('从服务器加载速算账单:', lottery, msgs.length, '条');
}
// 恢复开奖号码
if (record.drawNumbers && record.drawNumbers.length > 0) {
const dnKey = 'speedCalcDrawNumbers_' + userId;
let dn = {};
try { dn = JSON.parse(localStorage.getItem(dnKey) || '{}'); } catch(e) {}
dn[lottery] = record.drawNumbers;
localStorage.setItem(dnKey, JSON.stringify(dn));
}
}
}
} catch(e) {
console.error('加载服务器速算记录失败:', e);
}
};
// 保存速算账单到服务器
const saveSpeedCalcBillsToServer = async (lottery, statsData, drawNums) => {
if (!user?.id || !statsData || statsData.count === 0) return;
try {
const today = getBeijingDate();
const token = localStorage.getItem('token');
if (!token) return;
await fetch(API + '/user/speed-calc/save', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({
date: today,
lottery: lottery || 'macau1',
drawNumbers: drawNums || [],
statsSummary: { total: statsData.total || 0, count: statsData.count || 0, out: statsData.out || 0 },
billList: (statsData.billList || []).map(b => ({
id: b.id,
senderId: b.senderId,
senderName: b.senderName,
content: b.content,
time: b.time,
total: b.total,
payout: b.payout || 0 // 添加赔付金额
}))
})
});
console.log('速算账单已保存到服务器:', lottery, statsData.count, '条');
} catch(e) {
console.error('保存速算账单到服务器失败:', e);
}
};
// App级每日0点清除开奖号码备份(确保即使用户不在我的页面也能清除)
React.useEffect(() => {
if (!user || !user.id) return;
const checkDaily = () => {
const lastResetKey = 'speedCalcLastReset_' + user.id;
const today = getBeijingDate();
const lastReset = localStorage.getItem(lastResetKey);
if (lastReset !== today) {
localStorage.setItem(lastResetKey, today);
localStorage.removeItem('speedCalcDrawNumbers_' + user.id);
// 清除所有停止状态(新的一天重新开始)
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith('chatStopTime_')) keysToRemove.push(k);
}
keysToRemove.forEach(k => localStorage.removeItem(k));
}
};
checkDaily();
const interval = setInterval(checkDaily, 60 * 60 * 1000);
return () => clearInterval(interval);
}, [user?.id]);
// 加载账单格式配置
const [billFormatsReady, setBillFormatsReady] = useState(0);
React.useEffect(() => {
loadBillFormats().then(() => {
setBillFormatsReady(v => v + 1);
console.log('[格式配置] 加载完成,触发重渲染');
});
}, []);
// 【BACK-NAV-FIX-V2】手机返回键处理:在房间内时返回大厅(即时重推防穿透)
React.useEffect(() => {
if (!currentRoom) return;
window.history.pushState({ room: true }, '');
window.history.pushState({ room: true }, '');
const handlePop = () => {
setCurrentRoom(null);
// 房间关闭后补推基础条目,防止下次返回退出
setTimeout(() => {
window.history.pushState({ _appBase: true }, '');
window.history.pushState({ _appBase: true }, '');
}, 50);
};
window.addEventListener('popstate', handlePop);
return () => window.removeEventListener('popstate', handlePop);
}, [currentRoom]);
// 全局socket监听新私聊消息,更新导航栏角标
React.useEffect(() => {
if (!user) return;
let cleaned = false;
// 【ACTIVE-GROUP-REF-FIX-v2】helper 恢复到 useEffect 顶部,跨作用域供 App 组件内所有 socket handler 访问
// 因 FriendsPage 中的 activeGroupRef 无法直接穿透到 App 组件(React 作用域限制),
// 在 FriendsPage 挂载时将 ref 挂到 window.__activeGroupRef(见 2463 行)
const _getActiveGroup = () => { try { return (window.__activeGroupRef && window.__activeGroupRef.current) || null; } catch(e) { return null; } };
// 请求通知权限
try {
const isApp = navigator.userAgent.indexOf('median') > -1 || !!window.AndroidNotify;
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
Notification.requestPermission();
}
// APP端:不依赖 Notification.permission 判断,直接引导老用户检查通知设置
if (isApp) {
const notifGuided = localStorage.getItem('notif_guided_' + user.id);
if (!notifGuided) {
localStorage.setItem('notif_guided_' + user.id, '1');
setTimeout(() => {
if (confirm('为确保您能及时收到新消息提醒,请确认已开启通知权限。\n\n是否现在去检查?')) {
if (window.AndroidNotify && window.AndroidNotify.openNotificationSettings) {
window.AndroidNotify.openNotificationSettings();
} else {
alert('请手动开启:\n手机设置 → 应用管理 → ABC → 通知 → 允许通知');
}
}
}, 2000);
}
}
} catch(e){}
const handler = (data) => {
if (data.senderId === user.id) return;
if (data.receiverId !== user.id) return;
const msg = data.message || {};
const senderName = msg.senderName || '未知';
// 如果对方发送了活动结束消息,同步设置本地停止状态并强制刷新消息列表
if (msg.content && (msg.content.includes('活动结束') || msg.content.includes('🛑'))) {
const chatKey = 'chat_' + user.id + '_' + data.senderId;
const stopTime = new Date(msg.createdAt || Date.now()).getTime();
setChatStopTime(chatKey, stopTime);
try {
let msgs = JSON.parse(localStorage.getItem(chatKey) || '[]');
// 添加新消息到本地缓存
if (!msgs.find(m => m.id === msg.id)) {
msgs.push(msg);
localStorage.setItem(chatKey, JSON.stringify(msgs));
}
setMessages(msgs);
// 强制刷新UI,确保速算逻辑重新扫描消息列表
setForceRefresh(Date.now());
} catch(e) {}
}
// 如果对方发送了恢复下注消息,同步清除本地停止状态
if (msg.content && (msg.content.includes('恢复下注') || msg.content.includes('🟢') || msg.content.includes('活动开始'))) {
const chatKey = 'chat_' + user.id + '_' + data.senderId;
const now = Date.now();
// 保存停止时间到停时期记录(更新为当前恢复时间)
const curStopVal = localStorage.getItem('chatStopTime_' + chatKey);
if (curStopVal) localStorage.setItem('chatStoppedPeriod_' + chatKey, curStopVal);
localStorage.removeItem('chatStopTime_' + chatKey);
// 记录恢复时间
localStorage.setItem('chatResumeTime_' + chatKey, String(now));
// 同时清除反向 key,避免 getChatStopTime 仍从反向 key 读到旧状态
const revKey = 'chatStopTime_chat_' + data.senderId + '_' + user.id;
const curRevVal = localStorage.getItem(revKey);
if (curRevVal) localStorage.setItem('chatStoppedPeriod_chat_' + data.senderId + '_' + user.id, curRevVal);
localStorage.removeItem(revKey);
localStorage.setItem('chatResumeTime_chat_' + data.senderId + '_' + user.id, String(now));
// 将恢复消息添加到本地缓存并更新messages state
try {
let msgs = JSON.parse(localStorage.getItem(chatKey) || '[]');
if (!msgs.find(m => m.id === msg.id)) {
msgs.push(msg);
localStorage.setItem(chatKey, JSON.stringify(msgs));
}
setMessages(msgs);
} catch(e) {}
// 强制刷新:更新时间戳,触发整个组件重新渲染
setForceRefresh(Date.now());
}
try { playNotifySound(); } catch(e) {}
// 不再手动累加 friendUnread,由 buildRecentContacts 统一计算
try {
const chatKey = 'chat_' + user.id + '_' + data.senderId;
let msgs = JSON.parse(localStorage.getItem(chatKey) || '[]');
// 去重:检查消息是否已存在
const msgId = msg.id || ('tmp_' + Date.now());
if (!msgs.some(m => m.id === msgId)) {
// 严格使用后端返回的 billStats,不再本地重算;未落库账单不显示
const receivedBillStats = msg.billStats || data.billStats || null;
const receivedBillStatsSavedFor = msg.billStatsSavedFor || data.billStatsSavedFor || [];
msgs.push({ id: msgId, senderId: data.senderId, senderName, senderAvatar: msg.senderAvatar||'', receiverId: user.id, content: msg.content||'', type: msg.type||'text', createdAt: msg.createdAt || new Date().toISOString(), billStats: receivedBillStats, billStatsSavedFor: receivedBillStatsSavedFor });
if (msgs.length > 200) msgs = msgs.slice(-200);
localStorage.setItem(chatKey, JSON.stringify(msgs));
// 【账单保存已移到后端自动完成,前端只需触发统计刷新】
try {
const isBill = msg.content && (msg.content.match(/特码|平码|正码|尾连|生肖|大小|单双|红波|蓝波|绻波|合肖|连肖|平特|特肖|杀肖/) || msg.content.match(/^[一-龥]*\d+/));
if (isBill) {
// 账单已由后端在/api/chat/send时自动保存,这里只触发统计刷新
window.dispatchEvent(new CustomEvent('speedcalc_stats_changed'));
}
} catch(e) {}
}
} catch(e){}
try {
// 更新最近联系人列表,但不直接修改 unread,由 buildRecentContacts 统一计算
const cacheKey = 'recentContacts_'+user.id;
let items = JSON.parse(localStorage.getItem(cacheKey) || '[]');
const fid = data.senderId;
let lastMsg = msg.type === 'image' ? '[图片]' : msg.type === 'voice' ? '[语音]' : (msg.content || '');
if (lastMsg.length > 25) lastMsg = lastMsg.substring(0, 25) + '...';
const now = Date.now();
const idx = items.findIndex(rc => rc.type === 'friend' && rc.data?.id === fid);
if (idx >= 0) {
items[idx] = {...items[idx], lastTime: now, lastMsg};
} else {
items.push({ type: 'friend', data: { id: fid, nickname: senderName, avatar: msg.senderAvatar || '' }, lastTime: now, lastMsg, unread: 0 });
}
items.sort((a, b) => b.lastTime - a.lastTime);
localStorage.setItem(cacheKey, JSON.stringify(items));
// 【NEWMSG-REALTIME-V1】直接触发状态更新,确保最近联系人实时显示新消息
window.dispatchEvent(new CustomEvent('recent_contacts_update', { detail: items }));
// 同时触发重建事件,确保未读数正确计算
window.dispatchEvent(new CustomEvent('rebuild_recent_contacts'));
} catch(e){}
try {
const notifyContent = msg.type === 'image' ? '[图片]' : msg.type === 'voice' ? '[语音]' : (msg.content || '新消息');
const newBadge = friendUnreadRef.current + 1;
// 收到消息通知
// 振动反馈(移动端)
try { if (navigator.vibrate) navigator.vibrate([100, 50, 100]); } catch(e){}
// Android WebView 通知
if (window.AndroidNotify && window.AndroidNotify.notifyWithBadge) {
// AndroidNotify
window.AndroidNotify.notifyWithBadge(senderName + ' 发来消息', notifyContent, 'msg_'+data.senderId, newBadge);
} else if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
// 使用 Service Worker 显示通知(前台+后台都能显示)
// Service Worker 通知
navigator.serviceWorker.controller.postMessage({
type: 'SHOW_NOTIFICATION',
title: senderName + ' 发来消息',
body: notifyContent,
tag: 'msg_'+data.senderId,
senderId: data.senderId
});
} else if (typeof Notification !== 'undefined') {
// 浏览器通知权限检查
if (Notification.permission === 'granted') {
// Web 通知(浏览器)
const notif = new Notification(senderName + ' 发来消息', {
body: notifyContent,
tag: 'msg_'+data.senderId,
renotify: true,
silent: false,
requireInteraction: false
});
// 点击通知时聚焦窗口
notif.onclick = () => {
window.focus();
notif.close();
};
// 已发送浏览器通知
} else if (Notification.permission === 'default') {
// 用户还没有选择,请求权限
Notification.requestPermission().then(perm => {
// 权限请求结果
if (perm === 'granted') {
new Notification(senderName + ' 发来消息', { body: notifyContent, tag: 'msg_'+data.senderId, renotify: true });
}
});
}
} else {
// 浏览器不支持通知
}
} catch(e) {
console.error('[Notify] 通知发送失败:', e);
}
try { if (navigator.setAppBadge) navigator.setAppBadge(friendUnreadRef.current + 1); } catch(e){}
};
const groupHandler = (data) => {
console.log('[GROUP-HANDLER-DEBUG] 收到群消息, senderId:', data.senderId, 'groupId:', data.groupId, 'userId:', user.id, 'activeGroup:', _getActiveGroup()?.id || 'null');
// visibleTo 过滤:有 visibleTo 的消息只对指定用户可见
if (data.visibleTo && Array.isArray(data.visibleTo) && !data.visibleTo.includes(user.id)) return;
// 调试 Toast:确认 handler 是否被调用
try { if (typeof showToast === 'function') showToast('📨 群消息到达: ' + (data.senderName || data.groupId)); } catch(e) {}
if (data.senderId === user.id) { console.log('[GROUP-HANDLER-DEBUG] 自己发的,忽略'); return; }
// 【修复-防重复】当前激活群的消息由 handler 处理,groupHandler 只处理非激活群
const gMsg = data.message || data;
const _ag = _getActiveGroup();
if (_ag && _ag.id === data.groupId) {
// 只处理停止/恢复下注等系统消息
if (gMsg.content && (gMsg.content.includes('停止下注') || gMsg.content.includes('🛑'))) {
const gKey = 'groupMsgs_' + user.id + '_' + data.groupId;
setChatStopTime(gKey, Date.now());
try {
let gMsgs = JSON.parse(localStorage.getItem(gKey) || '[]');
setGroupMessages(gMsgs);
} catch(e) {}
}
if (gMsg.content && (gMsg.content.includes('恢复下注') || gMsg.content.includes(''))) {
const gKey = 'groupMsgs_' + user.id + '_' + data.groupId;
const now = Date.now();
const curStopVal = localStorage.getItem('chatStopTime_' + gKey);
if (curStopVal) localStorage.setItem('chatStoppedPeriod_' + gKey, curStopVal);
localStorage.removeItem('chatStopTime_' + gKey);
localStorage.setItem('chatResumeTime_' + gKey, String(now));
try {
let gMsgs = JSON.parse(localStorage.getItem(gKey) || '[]');
setGroupMessages(gMsgs);
} catch(e) {}
}
return; // 不保存到 localStorage,由 handler 负责
}
// 非激活群:保存到 localStorage 并更新未读数
// 刷新总统计
try {
const dn = JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}');
if (showSpeedCalcStats) {
setSpeedCalcStatsData(null);
}
window.dispatchEvent(new CustomEvent('speedcalc_stats_changed'));
} catch(e){}
// 保存群消息到本地【修复:添加重复检查,避免与handler双重保存】
try {
const gKey = 'groupMsgs_'+user.id+'_'+data.groupId;
let msgs = JSON.parse(localStorage.getItem(gKey)||'[]');
const msgObj = data.message || data;
const newMsgId = msgObj.id||('gm_'+Date.now());
// 【重复检查】如果消息已存在则不重复添加
if (!msgs.some(m => m.id === newMsgId)) {
msgs.push({id:newMsgId,groupId:data.groupId,senderId:data.senderId||msgObj.senderId,senderName:data.senderName||msgObj.senderName||'',senderAvatar:data.senderAvatar||msgObj.senderAvatar||'',content:msgObj.content||data.content||'',type:msgObj.type||data.type||'text',createdAt:msgObj.createdAt||data.createdAt||new Date().toISOString(),mentions:data.mentions||[],msgType:msgObj.msgType||data.msgType,billStats:null});
if(msgs.length>200) msgs=msgs.slice(-200);
localStorage.setItem(gKey,JSON.stringify(msgs));
}
// 【账单已由后端自动保存,前端不再调用sync-bill】
try {
const billContent = msgObj.content || data.content || '';
const isBill = billContent && (billContent.match(/特码|平码|正码|尾连|生肖|大小|单双|红波|蓝波|绿波/) || billContent.match(/^\d+/));
if (isBill) {
window.dispatchEvent(new CustomEvent('speedcalc_stats_changed'));
}
} catch(e) {}
}catch(e){}
// 发送事件通知群聊组件实时更新UI
window.dispatchEvent(new CustomEvent('new_group_message', { detail: data }));
// 更新最近联系人缓存
try {
const now=Date.now();
const cacheKey='recentContacts_'+user.id;
let items=JSON.parse(localStorage.getItem(cacheKey)||'[]');
const gMsg=data.message||data;
// 官方群也实时更新联系人列表
if (data.groupId === 'official_group_abc') {
// 【优化-已读持久化】如果官方群窗口正打开,只更新 readState,不累加未读
const isCurrentGroup = _getActiveGroup() && _getActiveGroup().id === data.groupId;
if (isCurrentGroup) {
try {
const rk = JSON.parse(localStorage.getItem('readState_'+user.id) || '{}');
rk['g_' + data.groupId] = now;
localStorage.setItem('readState_'+user.id, JSON.stringify(rk));
} catch(e){}
} else {
// 更新未读数标记
const guKey = 'groupUnread_'+user.id;
let gu = JSON.parse(localStorage.getItem(guKey)||'{}');
gu[data.groupId] = (gu[data.groupId]||0) + 1;
localStorage.setItem(guKey, JSON.stringify(gu));
window.dispatchEvent(new CustomEvent('group_unread_update', { detail: gu }));
// 【修复】官方群也直接更新联系人列表,不再完全依赖异步重建
const idx2=items.findIndex(rc=>rc.type==='group'&&rc.data?.id===data.groupId);
const oMsg=data.message||data;
let oLastMsg='';
if(oMsg.type==='voice') oLastMsg=(oMsg.senderName||'')+': [语音]';
else if(oMsg.type==='image') oLastMsg=(oMsg.senderName||'')+': [图片]';
else if(oMsg.type==='redpacket'||oMsg.msgType==='redpacket') { try { const rd=JSON.parse(oMsg.content||'{}'); oLastMsg=(oMsg.senderName||'')+': [红包] '+(rd.message||rd.content||''); } catch(e){ oLastMsg=(oMsg.senderName||'')+': [红包]'; } }
else if(oMsg.type==='transfer') { try { const td=JSON.parse(oMsg.content||'{}'); oLastMsg=(oMsg.senderName||'')+': [筹码转账] '+td.amount; } catch(e){ oLastMsg=(oMsg.senderName||'')+': [筹码转账]'; } }
else oLastMsg=(oMsg.senderName||'')+': '+(oMsg.content||'');
if(oLastMsg.length>25) oLastMsg=oLastMsg.substring(0,25)+'...';
if(idx2>=0){items[idx2]={...items[idx2],lastTime:now,lastMsg:oLastMsg,unread:gu[data.groupId]||items[idx2].unread||0};}
else{items.push({type:'group',data:{id:data.groupId,name:data.groupName||'官方群'},lastTime:now,lastMsg:oLastMsg,unread:gu[data.groupId]||0});}
items.sort((a,b)=>{const au=(a.unread||0)>0?1:0;const bu=(b.unread||0)>0?1:0;if(au!==bu)return bu-au;return b.lastTime-a.lastTime;});
localStorage.setItem(cacheKey,JSON.stringify(items));
window.dispatchEvent(new CustomEvent('recent_contacts_update', { detail: items }));
}
// 延迟重建,避免覆盖直接更新
if (window._rebuildContactsTimer) clearTimeout(window._rebuildContactsTimer);
window._rebuildContactsTimer = setTimeout(() => {
window.dispatchEvent(new CustomEvent('rebuild_recent_contacts'));
}, 2000);
return;
}
let lastMsg='';
if(gMsg.type==='voice') lastMsg=(gMsg.senderName||'')+': [语音]';
else if(gMsg.type==='image') lastMsg=(gMsg.senderName||'')+': [图片]';
else if(gMsg.type==='transfer') { try { const td=JSON.parse(gMsg.content||'{}'); lastMsg=(gMsg.senderName||'')+': [筹码转账] '+td.amount; } catch(e){ lastMsg=(gMsg.senderName||'')+': [筹码转账]'; } }
else if(gMsg.type==='redpacket'||gMsg.msgType==='redpacket') { try { const rd=JSON.parse(gMsg.content||'{}'); lastMsg=(gMsg.senderName||'')+': [红包] '+(rd.message||rd.content||''); } catch(e){ lastMsg=(gMsg.senderName||'')+': [红包]'; } }
else if(gMsg.msgType==='redpacket_claim'||(gMsg.type==='system'&&gMsg.content&&gMsg.content.includes('"type":"redpacket_claim"'))) { try { const rc=JSON.parse(gMsg.content||'{}'); lastMsg=(gMsg.senderName||'')+': [红包] '+rc.claimerName+' 领取了 '+rc.amount+'元'; } catch(e){ lastMsg=(gMsg.senderName||'')+': [红包领取]'; } }
else lastMsg=(gMsg.senderName||'')+': '+(gMsg.content||'');
if(lastMsg.length>25) lastMsg=lastMsg.substring(0,25)+'...';
// 先更新未读计数(确保联系人列表能显示正确的角标)
const guKey='groupUnread_'+user.id;
let gu={};try{gu=JSON.parse(localStorage.getItem(guKey)||'{}');}catch(e){}
const isCurrentGrp = _getActiveGroup() && _getActiveGroup().id === data.groupId;
if (!isCurrentGrp) {
gu[data.groupId] = (gu[data.groupId]||0) + 1;
localStorage.setItem(guKey, JSON.stringify(gu));
window.dispatchEvent(new CustomEvent('group_unread_update', { detail: gu }));
}
const idx=items.findIndex(rc=>rc.type==='group'&&rc.data?.id===data.groupId);
if(idx>=0){items[idx]={...items[idx],lastTime:now,lastMsg,unread:gu[data.groupId]||items[idx].unread||0};}
else{items.push({type:'group',data:{id:data.groupId,name:data.groupName||''},lastTime:now,lastMsg,unread:gu[data.groupId]||0});}
items.sort((a,b)=>{const au=(a.unread||0)>0?1:0;const bu=(b.unread||0)>0?1:0;if(au!==bu)return bu-au;return b.lastTime-a.lastTime;});
localStorage.setItem(cacheKey,JSON.stringify(items));
console.log('[GROUP-HANDLER-DEBUG] 联系人列表已更新, lastMsg:', lastMsg, 'unread:', gu[data.groupId]);
// 【修复】直接触发UI更新(与私聊handler一致),确保联系人列表实时刷新
window.dispatchEvent(new CustomEvent('recent_contacts_update', { detail: items }));
// 【修复】延迟重建,避免异步 buildRecentContacts 覆盖直接更新的结果
// 防抖:多条消息快速到达时只执行最后一次重建
if (window._rebuildContactsTimer) clearTimeout(window._rebuildContactsTimer);
window._rebuildContactsTimer = setTimeout(() => {
window.dispatchEvent(new CustomEvent('rebuild_recent_contacts'));
}, 2000);
}catch(e){}
// 检查免打扰
const isCurrentGrp = _getActiveGroup() && _getActiveGroup().id === data.groupId;
const muted = JSON.parse(localStorage.getItem('groupMute_'+user.id)||'{}')[data.groupId];
// @提醒处理 - 用 localStorage + dispatchEvent 跨组件通信
if (data.mentions && data.mentions.includes(user.id) && data.senderId !== user.id) {
try {
const mnKey = 'groupMentionNotify_'+user.id;
let mn = JSON.parse(localStorage.getItem(mnKey)||'{}');
mn[data.groupId] = (mn[data.groupId]||0) + 1;
localStorage.setItem(mnKey, JSON.stringify(mn));
window.dispatchEvent(new CustomEvent('group_mention_update', { detail: mn }));
} catch(e){}
try { if (navigator.vibrate) navigator.vibrate([200,100,200]); } catch(e){}
}
// 群未读计数
// 【优化-已读持久化】如果群窗口正打开,更新 readState 而不累加未读
// 注:groupUnread 已在上方联系人列表更新时递增,此处只处理 readState
if (isCurrentGrp) {
try {
const rk = JSON.parse(localStorage.getItem('readState_'+user.id) || '{}');
rk['g_' + data.groupId] = Date.now();
localStorage.setItem('readState_'+user.id, JSON.stringify(rk));
} catch(e){}
}
if (!muted) {
console.log('[GROUP-HANDLER-DEBUG] 播放提示音, groupId:', data.groupId);
try { playNotifySound(); } catch(e) { console.error('[GROUP-HANDLER-DEBUG] 提示音播放失败:', e); }
// 群消息不计入好友未读数
try {
const gMsg = data.message || data;
let notifyContent = '';
if(gMsg.type==='voice') notifyContent=(gMsg.senderName||'')+': [语音]';
else if(gMsg.type==='image') notifyContent=(gMsg.senderName||'')+': [图片]';
else if(gMsg.type==='transfer') notifyContent=(gMsg.senderName||'')+': [筹码转账]';
else if(gMsg.type==='redpacket'||gMsg.msgType==='redpacket') notifyContent=(gMsg.senderName||'')+': [红包]';
else if(gMsg.msgType==='redpacket_claim') notifyContent=(gMsg.senderName||'')+': [红包领取]';
else notifyContent=(gMsg.senderName||'')+': '+(gMsg.content||'新消息');
console.log('[Notify] 群消息:', data.groupName, notifyContent);
// 振动反馈
try { if (navigator.vibrate) navigator.vibrate([100, 50, 100]); } catch(e){}
// Android WebView 通知
if (window.AndroidNotify && window.AndroidNotify.notifyWithBadge) {
console.log('[Notify] 调用 AndroidNotify 群消息');
window.AndroidNotify.notifyWithBadge(data.groupName || '群消息', notifyContent, 'grp_'+(data.groupId||''), friendUnreadRef.current + 1);
} else if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
// 使用 Service Worker 显示通知(前台+后台都能显示)
console.log('[Notify] 使用 Service Worker 群通知');
navigator.serviceWorker.controller.postMessage({
type: 'SHOW_NOTIFICATION',
title: data.groupName || '群消息',
body: notifyContent,
tag: 'grp_'+(data.groupId||''),
groupId: data.groupId
});
} else if (typeof Notification !== 'undefined') {
console.log('[Notify] 群消息通知权限:', Notification.permission);
if (Notification.permission === 'granted') {
const notif = new Notification(data.groupName || '群消息', {
body: notifyContent,
tag: 'grp_'+(data.groupId||''),
renotify: true,
silent: false
});
notif.onclick = () => { window.focus(); notif.close(); };
console.log('[Notify] 已发送群通知');
} else if (Notification.permission === 'default') {
Notification.requestPermission().then(perm => {
if (perm === 'granted') {
new Notification(data.groupName || '群消息', { body: notifyContent, tag: 'grp_'+(data.groupId||''), renotify: true });
}
});
}
}
} catch(e) {
console.error('[Notify] 群通知发送失败:', e);
}
try { if (navigator.setAppBadge) navigator.setAppBadge(friendUnreadRef.current + 1); } catch(e){}
}
};
// 全局账单删除事件(无论用户在哪个页面都能收到)
const globalBillDeleteHandler = (data) => {
try {
const fid = data.senderId === data.userId ? data.friendId : data.senderId;
const key = 'chat_' + user.id + '_' + fid;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.filter(m => m.id !== data.msgId);
localStorage.setItem(key, JSON.stringify(msgs));
// 记录已删除ID(防止服务端重新加载)
try {
let ids = JSON.parse(localStorage.getItem('deletedBillIds_'+user.id+'_'+fid) || '[]');
if (!ids.includes(data.msgId)) { ids.push(data.msgId); localStorage.setItem('deletedBillIds_'+user.id+'_'+fid, JSON.stringify(ids)); }
} catch(e){}
// 如果当前在私聊窗口,也更新UI
if (chatFriendRef.current && chatFriendRef.current.id === fid) {
setMessages(prev => prev.filter(m => m.id !== data.msgId));
}
} catch(e){}
};
const globalBillDeleteGroupHandler = (data) => {
try {
const key = 'groupMsgs_' + user.id + '_' + data.groupId;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.filter(m => m.id !== data.msgId);
localStorage.setItem(key, JSON.stringify(msgs));
// 记录已删除ID
try {
let ids = JSON.parse(localStorage.getItem('deletedBillIds_'+user.id+'_g_'+data.groupId) || '[]');
if (!ids.includes(data.msgId)) { ids.push(data.msgId); localStorage.setItem('deletedBillIds_'+user.id+'_g_'+data.groupId, JSON.stringify(ids)); }
} catch(e){}
// 如果当前在群聊窗口,也更新UI
if (_getActiveGroup() && _getActiveGroup().id === data.groupId) {
setGroupMessages(prev => prev.filter(m => m.id !== data.msgId));
}
} catch(e){}
};
// 全局账单修改事件
const globalBillEditHandler = (data) => {
try {
const fid = data.senderId === data.userId ? data.friendId : data.senderId;
const key = 'chat_' + user.id + '_' + fid;
const newBillStats = data.billStats;
const newBillStatsSavedFor = data.billStatsSavedFor;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.map(m => m.id === data.msgId ? {...m, content: data.newContent, billStats: newBillStats, billStatsSavedFor: newBillStatsSavedFor || m.billStatsSavedFor} : m);
localStorage.setItem(key, JSON.stringify(msgs));
// 如果当前在私聊窗口,也更新UI
if (chatFriendRef.current && chatFriendRef.current.id === fid) {
setMessages(prev => prev.map(m => m.id === data.msgId ? {...m, content: data.newContent, billStats: newBillStats, billStatsSavedFor: newBillStatsSavedFor || m.billStatsSavedFor} : m));
}
} catch(e){}
};
const globalBillEditGroupHandler = (data) => {
try {
const key = 'groupMsgs_' + user.id + '_' + data.groupId;
const newBillStats = data.billStats;
const newBillStatsSavedFor = data.billStatsSavedFor;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
msgs = msgs.map(m => m.id === data.msgId ? {...m, content: data.newContent, billStats: newBillStats, billStatsSavedFor: newBillStatsSavedFor || m.billStatsSavedFor} : m);
localStorage.setItem(key, JSON.stringify(msgs));
// 如果当前在群聊窗口,也更新UI
if (_getActiveGroup() && _getActiveGroup().id === data.groupId) {
setGroupMessages(prev => prev.map(m => m.id === data.msgId ? {...m, content: data.newContent, billStats: newBillStats, billStatsSavedFor: newBillStatsSavedFor || m.billStatsSavedFor} : m));
}
} catch(e){}
};
// 全局红包领取广播处理(非活跃群也能收到提示)
const globalRedpacketClaimHandler = (data) => {
try {
// 如果当前就在该群,由群组件内部的handler处理,这里跳过
if (_getActiveGroup() && _getActiveGroup().id === data.groupId) {
return;
}
// 非活跃群:创建系统消息保存到本地
const key = 'groupMsgs_' + user.id + '_' + data.groupId;
let msgs = JSON.parse(localStorage.getItem(key) || '[]');
const claimMsg = {
id: 'claim_' + data.packetId + '_' + data.userId + '_' + Date.now(),
groupId: data.groupId,
senderId: 'system',
senderName: '系统',
content: JSON.stringify({
type: 'redpacket_claim',
packetId: data.packetId,
claimerName: data.userName,
claimerId: data.userId,
senderName: data.senderName || '',
senderId: data.senderId || '',
amount: data.amount,
mineHit: data.mineHit,
minePenalty: data.minePenalty
}),
type: 'system',
msgType: 'redpacket_claim',
createdAt: new Date().toISOString()
};
msgs.push(claimMsg);
if (msgs.length > 200) msgs = msgs.slice(-200);
localStorage.setItem(key, JSON.stringify(msgs));
// 更新最近联系人列表
try {
const cacheKey = 'recentContacts_' + user.id;
let items = JSON.parse(localStorage.getItem(cacheKey) || '[]');
// 官方群不在此处更新,由buildRecentContacts统一管理
if (data.groupId !== 'official_group_abc') {
const idx = items.findIndex(rc => rc.type === 'group' && rc.data?.id === data.groupId);
if (idx >= 0) {
items[idx] = {...items[idx], lastTime: Date.now(), lastMsg: data.userName + ' 领取了红包'};
items.sort((a, b) => b.lastTime - a.lastTime);
localStorage.setItem(cacheKey, JSON.stringify(items));
}
}
} catch(e) {}
} catch(e) {}
};
// 绑定处理器到socket,带重试确保io加载完成
// 群封禁提示处理
const groupBannedHandler = (data) => {
try {
if (typeof showToast === 'function') {
showToast('🚫 该群违规暂停使用,无法发送消息');
} else {
alert('该群违规暂停使用,无法发送消息');
}
} catch(e) {}
};
const bindHandlers = (sock) => {
sock.off('new_message', handler);
sock.off('group_msg', groupHandler);
sock.off('pending_notifications', pendingHandler);
sock.off('bill_delete', globalBillDeleteHandler);
sock.off('bill_delete_group', globalBillDeleteGroupHandler);
sock.off('bill_edit', globalBillEditHandler);
sock.off('bill_edit_group', globalBillEditGroupHandler);
sock.off('redpacket_claimed', globalRedpacketClaimHandler);
sock.off('shared_member_stop', handleSharedMemberStop);
sock.off('shared_member_resume', handleSharedMemberResume);
sock.off('shared_member_status_changed', handleSharedMemberStatusChanged);
sock.off('group_banned', groupBannedHandler);
sock.on('new_message', handler);
sock.on('group_msg', groupHandler);
sock.on('pending_notifications', pendingHandler);
sock.on('bill_delete', globalBillDeleteHandler);
sock.on('bill_delete_group', globalBillDeleteGroupHandler);
sock.on('bill_edit', globalBillEditHandler);
sock.on('bill_edit_group', globalBillEditGroupHandler);
sock.on('redpacket_claimed', globalRedpacketClaimHandler);
sock.on('shared_member_stop', handleSharedMemberStop);
sock.on('shared_member_resume', handleSharedMemberResume);
sock.on('shared_member_status_changed', handleSharedMemberStatusChanged);
sock.on('group_banned', groupBannedHandler);
};
// 处理服务器推送的离线消息
const pendingHandler = (notifications) => {
if (!notifications || !notifications.length) return;
for (const notif of notifications) {
const msg = { senderId: notif.from, senderName: notif.fromName, content: notif.content, type: notif.type, groupId: notif.groupId, groupName: notif.groupName, createdAt: new Date(notif.timestamp).toISOString() };
if (notif.groupId) {
// 群消息 - 调用 groupHandler
groupHandler({ senderId: notif.from, senderName: notif.fromName, groupId: notif.groupId, message: msg });
} else {
// 私聊消息 - 调用 handler
handler({ senderId: notif.from, senderName: notif.fromName, message: msg });
}
}
};
// 处理共享成员停止事件
const handleSharedMemberStop = (data) => {
console.log('[DEBUG] Received shared_member_stop:', data);
try {
const { friendId, chatKey, stoppedAt, timestamp } = data;
if (!friendId || !chatKey) return;
console.log('[DEBUG] Setting chatStopTime for', chatKey, '=', timestamp);
// 设置本地停止状态
setChatStopTime(chatKey, timestamp);
// 强制刷新当前聊天界面
if (chatFriend && chatFriend.id === friendId) {
const msgs = JSON.parse(localStorage.getItem(chatKey) || '[]');
setMessages(msgs);
setForceRefresh(Date.now());
}
// 更新共享成员列表
setSharedMembers(prev => prev.map(m =>
m.friendId === friendId ? {...m, isStopped: true, stoppedAt} : m
));
} catch(e) {
console.error('Handle shared member stop error:', e);
}
};
// 处理共享成员恢复事件
const handleSharedMemberResume = (data) => {
try {
const { friendId, chatKey, resumedAt, timestamp } = data;
if (!friendId || !chatKey) return;
// 清除本地停止状态
const parts = chatKey.split('_');
if (parts.length >= 3 && parts[0] === 'chat') {
localStorage.removeItem('chatStopTime_' + chatKey);
localStorage.removeItem('chatStoppedPeriod_' + chatKey);
// 清除反向key
const reversedKey = 'chat_' + parts[2] + '_' + parts[1];
localStorage.removeItem('chatStopTime_' + reversedKey);
localStorage.removeItem('chatStoppedPeriod_' + reversedKey);
}
// 强制刷新当前聊天界面
if (chatFriend && chatFriend.id === friendId) {
const msgs = JSON.parse(localStorage.getItem(chatKey) || '[]');
setMessages(msgs);
setForceRefresh(Date.now());
}
// 更新共享成员列表
setSharedMembers(prev => prev.map(m => {
if (m.friendId === friendId) {
const copy = {...m};
delete copy.isStopped;
delete copy.stoppedAt;
return copy;
}
return m;
}));
} catch(e) {
console.error('Handle shared member resume error:', e);
}
};
// 处理共享成员状态变化事件(用于APP端/网页端同步)
const handleSharedMemberStatusChanged = (data) => {
try {
const { friendId, chatKey, action, stoppedAt, resumedAt, timestamp } = data;
if (!friendId || !chatKey || !action) return;
if (action === 'stop') {
// 设置本地停止状态
setChatStopTime(chatKey, timestamp);
// 更新共享成员列表
setSharedMembers(prev => prev.map(m => m.friendId === friendId ? {...m, isStopped: true, stoppedAt} : m));
} else if (action === 'resume') {
// 清除本地停止状态
const parts = chatKey.split('_');
if (parts.length >= 3 && parts[0] === 'chat') {
localStorage.removeItem('chatStopTime_' + chatKey);
localStorage.removeItem('chatStoppedPeriod_' + chatKey);
const reversedKey = 'chat_' + parts[2] + '_' + parts[1];
localStorage.removeItem('chatStopTime_' + reversedKey);
localStorage.removeItem('chatStoppedPeriod_' + reversedKey);
}
// 更新共享成员列表
setSharedMembers(prev => prev.map(m => { if (m.friendId === friendId) { const copy = {...m}; delete copy.isStopped; delete copy.stoppedAt; return copy; } return m;}));
}
// 强制刷新当前聊天界面
if (chatFriend && chatFriend.id === friendId) {
const msgs = JSON.parse(localStorage.getItem(chatKey) || '[]');
setMessages(msgs);
setForceRefresh(Date.now());
}
} catch(e) {
console.error('Handle shared member status changed error:', e);
}
};
const ensureSocketAndBind = () => {
if (cleaned) return;
if (typeof io === 'undefined') { setTimeout(ensureSocketAndBind, 300); return; }
if (!window._socket || !window._socket.connected) {
const sock = io({ transports: ['websocket', 'polling'] });
sock.on('connect', () => {
sock.emit('login', { userId: user.id, nickname: user.nickname, avatar: user.avatar });
bindHandlers(sock);
// 【FREEZE-CHECK-V1】监听强制踢出事件(冻结/封禁)
sock.on('force_logout', (data) => {
alert('网络异常');
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.reload();
});
// 登录后加载红包数据
(async () => {
try {
// 加载已领取的红包ID列表
// 【RP-CARD-v5】先从 localStorage 读取本地缓存作为上屏初值(Map<id,status>格式,兼容旧数组格式)
try {
const cached = localStorage.getItem('claimedPacketIds_' + user.id);
if (cached) {
const arr = JSON.parse(cached);
if (Array.isArray(arr)) {
const m = new Map();
arr.forEach(it => {
if (Array.isArray(it) && it.length >= 2) m.set(it[0], it[1]);
else if (typeof it === 'string') m.set(it, 'mine');
});
setClaimedPacketIds(m);
console.log('[RP-CARD-v5][CACHE-LOAD]', 'size:', m.size);
}
}
} catch(e) {}
const res1 = await get('/redpacket/claimed-list');
if (res1.code === 200) {
// 【RP-CARD-v5】后端 claimed-list 返回“自己领过的” → 作 'mine',不覆盖本地已有的 finished/expired
setClaimedPacketIds(prev => {
const merged = new Map(prev);
((res1.data) || []).forEach(id => { if (!merged.has(id)) merged.set(id, 'mine'); });
try { localStorage.setItem('claimedPacketIds_' + user.id, JSON.stringify([...merged.entries()])); } catch(e) {}
console.log('[RP-CARD-v5][BACKEND-MERGE]', 'localPrev:', prev.size, 'backend:', (res1.data||[]).length, 'merged:', merged.size);
return merged;
});
}
// 加载红包领取通知
const res2 = await get('/redpacket/notifications');
if (res2.code === 200) {
setRedpacketNotifications(res2.data);
}
} catch(e) {}
})();
});
sock.on('reconnect', () => {
sock.emit('login', { userId: user.id, nickname: user.nickname, avatar: user.avatar });
// 重连后同步当前活跃群的离线消息
const ag = _getActiveGroup();
if (ag) {
sock.emit('join_group', { groupId: ag.id });
// 从服务端拉取增量消息
(async () => {
try {
const res = await get('/groups/messages?groupId=' + ag.id);
if (res.code === 200 && res.data?.length > 0) {
const serverMsgs = res.data;
const localMsgs = loadGroupLocal(ag.id);
const localIds = new Set(localMsgs.map(m => m.id));
const newMsgs = serverMsgs.filter(m => !localIds.has(m.id));
if (newMsgs.length > 0) {
const merged = [...localMsgs, ...newMsgs].sort((a,b) => new Date(a.createdAt) - new Date(b.createdAt));
const trimmed = merged.slice(-200);
setGroupMessages(trimmed);
saveGroupLocal(ag.id, trimmed);
}
}
} catch(e) {}
})();
}
});
window._socket = sock;
bindHandlers(sock);
} else {
bindHandlers(window._socket);
}
};
ensureSocketAndBind();
return () => { cleaned = true; if (window._socket) { window._socket.off('new_message', handler); window._socket.off('group_msg', groupHandler); window._socket.off('pending_notifications', pendingHandler); window._socket.off('redpacket_claimed', globalRedpacketClaimHandler); window._socket.off('shared_member_stop', handleSharedMemberStop); window._socket.off('shared_member_resume', handleSharedMemberResume); window._socket.off('shared_member_status_changed', handleSharedMemberStatusChanged); } };
}, [user?.id]);
// 同步friendUnreadRef
React.useEffect(() => { friendUnreadRef.current = friendUnread; }, [friendUnread]);
// 同步未读数到 FriendsPage
React.useEffect(() => {
const handleSync = (e) => { if (e.detail >= 0) setFriendUnread(e.detail); };
window.addEventListener('friend_unread_sync', handleSync);
return () => window.removeEventListener('friend_unread_sync', handleSync);
}, []);
// 全局监听:底部好友红点只按最近联系人里的真实 unread 汇总
React.useEffect(() => {
if (!user?.id) return;
const cacheKey = 'recentContacts_' + user.id;
const checkUnread = () => {
try {
const items = JSON.parse(localStorage.getItem(cacheKey) || '[]');
const total = items.reduce((sum, item) => sum + Math.max(0, parseInt(item.unread || 0)), 0);
setFriendUnread(total);
} catch(e) { setFriendUnread(0); }
};
const iv = setInterval(checkUnread, 3000);
checkUnread();
const onUnreadChange = () => checkUnread();
window.addEventListener('recent_contacts_update', onUnreadChange);
window.addEventListener('rebuild_recent_contacts', onUnreadChange);
window.addEventListener('group_unread_update', onUnreadChange);
window.addEventListener('group_mention_update', onUnreadChange);
return () => {
clearInterval(iv);
window.removeEventListener('recent_contacts_update', onUnreadChange);
window.removeEventListener('rebuild_recent_contacts', onUnreadChange);
window.removeEventListener('group_unread_update', onUnreadChange);
window.removeEventListener('group_mention_update', onUnreadChange);
};
}, [user?.id]);
// Poll @mentions for notification badge
React.useEffect(() => {
if (!user) return;
const fetchMentions = async () => {
try { const r = await get('/room/mentions'); if(r.code===200) setMentionCount(r.data?.length||0); } catch(e){}
};
fetchMentions();
const iv = setInterval(fetchMentions, 10000);
return () => clearInterval(iv);
}, [user?.id]);
// APP启动时自动检查更新
React.useEffect(() => {
if (!user) return;
const checkUpdate = async () => {
try {
const r = await fetch('/app/version.json?t='+Date.now());
const v = await r.json();
const localVer = parseInt(localStorage.getItem('apkVersion')||'0');
if (v.apkVersion > localVer) {
if (confirm('发现新版本 v1.'+v.apkVersion+'.0\n'+(v.updateMsg||'')+'\n\n点击确定立即更新')) {
localStorage.removeItem('apkVersion');
window.location.href = v.apkUrl || '/app/abc-app.apk';
}
}
} catch(e){}
};
// 延迟2秒检查,等页面加载完成
const timer = setTimeout(checkUpdate, 2000);
return () => clearTimeout(timer);
}, [user?.id]);
// 启动时拉取未读消息,更新最近联系人列表
React.useEffect(() => {
if (!user) return;
const fetchUnread = async () => {
try {
const res = await get('/user/unread-contacts');
if (res.code === 200 && res.data && res.data.length > 0) {
const cacheKey = 'recentContacts_' + user.id;
let items = [];
try { items = JSON.parse(localStorage.getItem(cacheKey) || '[]'); } catch(e) {}
let totalUnread = 0;
for (const c of res.data) {
totalUnread += c.unread;
const idx = items.findIndex(rc => rc.type === 'friend' && rc.data?.id === c.senderId);
if (idx >= 0) {
items[idx] = { ...items[idx], lastTime: c.lastTime, lastMsg: c.lastMsg, unread: (items[idx].unread || 0) + c.unread };
} else {
items.push({ type: 'friend', data: { id: c.senderId, nickname: c.nickname, avatar: c.avatar }, lastTime: c.lastTime, lastMsg: c.lastMsg, unread: c.unread });
}
}
items.sort((a, b) => (b.lastTime || 0) - (a.lastTime || 0));
localStorage.setItem(cacheKey, JSON.stringify(items));
// 不再累加,由定时检查统一计算
}
} catch(e) {}
};
const t = setTimeout(fetchUnread, 1000);
return () => clearTimeout(t);
}, [user?.id]);
// 微信转发自动绑定代理关系:已登录用户自动将URL的ref参数替换为当前用户的邀请码【SHARE-REF-FIX-V1】
React.useEffect(() => {
if (!user?.inviteCode) return;
const params = new URLSearchParams(window.location.search);
const currentRef = params.get('ref');
// 如果URL的ref参数不是当前用户的邀请码,自动替换
if (currentRef !== user.inviteCode) {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('ref', user.inviteCode);
// 使用replaceState避免刷新页面
window.history.replaceState({}, '', newUrl.toString());
}
}, [user?.inviteCode]);
if (!authed) {
// 未登录用户:都进入官方群预览(无论是否带ref参数)
if (page !== 'register' && page !== 'login') {
return <GuestOfficialGroupPreview onRegister={()=>setPage('register')} />;
}
return page === 'login'
? <LoginPage onLogin={handleAuth} onSwitch={()=>setPage('register')} />
: <RegisterPage onRegister={handleAuth} onSwitch={()=>setPage('login')} />;
}
if (currentRoom) {
return <GameRoomPage roomId={currentRoom} user={user} onBack={()=>setCurrentRoom(null)} onUpdateUser={updateUser} onForward={(msg)=>{setForwardMsg(msg);setShowForwardModal(true);}} />;
}
return (
<div className="min-h-screen bg-main">
{/* 【修复-tab全存活】首次访问后组件常驻,display 控制可见性,state/列表/头像不丢失 [TAB-KEEP-ALIVE-V1] */}
{mountedTabs.lobby && <div style={{display: tab === 'lobby' ? 'block' : 'none'}}><LobbyPage user={user} onEnterRoom={setCurrentRoom} onRefresh={()=>{}} onNavigate={setTab} /></div>}
{mountedTabs.grid49 && <div style={{display: tab === 'grid49' ? 'block' : 'none'}}><Grid49Page user={user} onUpdateUser={updateUser} /></div>}
{mountedTabs.friends && <div style={{display: tab === 'friends' ? 'block' : 'none'}}><FriendsPage user={user} onShowBillRecord={(data) => { setBillRecordData(data); setShowBillRecord(true); }} sharedMembers={sharedMembers} setProfilePanel={setProfilePanel} setProfileMenu={setProfileMenu} /></div>}
{mountedTabs.promotion && <div style={{display: tab === 'promotion' ? 'block' : 'none'}}><PromotionPage user={user} /></div>}
{mountedTabs.profile && <div style={{display: tab === 'profile' ? 'block' : 'none'}}><ProfilePage user={user} onLogout={handleLogout} onUpdateUser={updateUser} sharedMembers={sharedMembers} setSharedMembers={setSharedMembers} /></div>}
{/* 下注记录详情弹窗(全局) */}
{showBillRecord && billRecordData && (() => {
// 【彻底修复】弹窗状态初始化:UI状态仅首次初始化,computedStats每次打开均重新计算
if (!window._popupState) {
window._popupState = {
// 【修复】开奖号码不持久化,每次打开从当前传入的 drawNumbers 读取(删除后不再恢复)
drawNumbers: (() => {
// 优先使用打开时传入的 drawNumbers
if (billRecordData.drawNumbers) return billRecordData.drawNumbers;
try {
const saved = localStorage.getItem('speedCalcDrawNumbers_' + user.id);
if (saved) return JSON.parse(saved);
} catch(e) {}
return {macau1:[null,null,null,null,null,null,null],macau2:[null,null,null,null,null,null,null],hongkong:[null,null,null,null,null,null,null]};
})(),
lottery: 'macau1',
showSettings: false,
showHistoryQuery: false,
historyQueryDate: '',
historyQueryStats: null,
settings: (() => {
try {
return JSON.parse(localStorage.getItem('chatSettings_'+user.id) || '{}');
} catch(e) { return {}; }
})(),
expandedPlay: null
};
}
// 【后端统一统计】记录弹窗优先使用后端 stats;前端不再重新解析/分类/汇总账单
const _billRecords = billRecordData.billRecords || { macau1: [], macau2: [], hongkong: [] };
const _compStats = (lt) => {
if (billRecordData.backendStats && billRecordData.backendStats[lt]) {
return billRecordData.backendStats[lt];
}
const ltRecords = _billRecords[lt] || [];
return { total: ltRecords.reduce((s, r) => s + (r.total || 0), 0), count: ltRecords.length, out: 0, shui: 0, yu: 0, billList: ltRecords, playStats: makePlayStats(), types: {}, numberStats: {}, userStats: {} };
};
window._popupState._compStats = _compStats;
window._popupState.computedStats = {
macau1: _compStats('macau1'),
macau2: _compStats('macau2'),
hongkong: _compStats('hongkong')
};
const popupDrawNumbers = window._popupState.drawNumbers;
const popupLottery = window._popupState.lottery;
const dn = popupDrawNumbers[popupLottery] || [null,null,null,null,null,null,null];
const showSettings = window._popupState.showSettings;
const showHistoryQuery = window._popupState.showHistoryQuery;
const historyQueryDate = window._popupState.historyQueryDate;
const historyQueryStats = window._popupState.historyQueryStats;
const popupSettings = window._popupState.settings;
const expandedPlay = window._popupState.expandedPlay;
// 强制更新函数(触发App组件重新渲染)
const forceUpdate = () => {
setPopupForceUpdate(n => n + 1);
};
// 更新号码的函数
const refreshBackendPopupStats = async (lt) => {
try {
const token = localStorage.getItem('token');
const date = window._popupState.historyDate || billRecordData.recordDate || getBeijingDate();
const drawParam = encodeURIComponent(JSON.stringify(window._popupState.drawNumbers[lt] || [null,null,null,null,null,null,null]));
// 【HISTORY-OWNER-FIX-V3】按点击速算卡片的用户 + 日期重算,不按聊天窗口混查
const recordOwnerId = billRecordData.recordOwnerId || billRecordData.senderId || user.id;
const recordReceiverId = billRecordData.receiverId || billRecordData.chatFriendId || recordOwnerId;
let url = API + '/speedcalc/stats?lottery=' + lt + '&date=' + date + '&drawNums=' + drawParam;
if (billRecordData.groupId) url += '&groupId=' + encodeURIComponent(billRecordData.groupId);
else url += '&receiverId=' + encodeURIComponent(recordReceiverId) + '&senderFilter=' + encodeURIComponent(recordOwnerId) + '&ownerOnly=1&recordMsgId=' + encodeURIComponent(billRecordData.recordMsgId || '');
console.log('[PAYOUT-DEBUG-V2] 请求URL:', url);
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
const data = await res.json();
console.log('[PAYOUT-DEBUG-V2] 后端返回:', data.code, 'out:', data.data?.out, 'bills:', data.data?.billList?.length);
if (data.code === 200 && data.data) {
if (!billRecordData.backendStats) billRecordData.backendStats = {};
billRecordData.backendStats[lt] = data.data;
window._popupState.computedStats[lt] = data.data;
if (window._popupState.historyDate) {
window._popupState.historyQueryStats = data.data;
if (!window._popupState.allLotteryStats) window._popupState.allLotteryStats = {};
window._popupState.allLotteryStats[lt] = data.data;
}
console.log('[PAYOUT-DEBUG-V2] 统计已更新, out=', data.data.out, 'total=', data.data.total, 'historyDate=', window._popupState.historyDate || '');
} else {
console.warn('[PAYOUT-DEBUG-V2] 后端返回非200:', data.code, data.message);
}
} catch(e) { console.error('[PAYOUT-DEBUG-V2] 后端重算失败:', e.message); }
};
const updateDrawNumber = async (idx, value) => {
popupDrawNumbers[popupLottery][idx] = value;
window._popupState.drawNumbers = {...popupDrawNumbers};
// 【修复】同步更新 localStorage,删除后关闭再打开不再恢复
try {
localStorage.setItem('speedCalcDrawNumbers_' + user.id, JSON.stringify(popupDrawNumbers));
// 如果清空了所有号码,也清除数据库中的保存
const dnArr = popupDrawNumbers[popupLottery] || [];
const hasAnyNumber = dnArr.some(n => n !== null && n !== '');
if (!hasAnyNumber) {
// 清除数据库中的开奖号码
try {
const token = localStorage.getItem('token');
await fetch(API + '/speed-calc/draw-numbers/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ numbers: [null,null,null,null,null,null,null], lottery: popupLottery })
});
} catch(e) {}
}
} catch(e) {}
await refreshBackendPopupStats(popupLottery);
forceUpdate();
};
const updateLottery = (lt) => {
window._popupState.lottery = lt;
// 【修复】切换彩种时,从已过滤records重新计算统计
if (window._popupState._compStats) {
window._popupState.computedStats[lt] = window._popupState._compStats(lt);
}
// 【HISTORY-OWNER-FIX-V3】切换彩种时仍按点击速算卡片用户过滤
if (window._popupState.historyDate) {
const recordOwnerId = billRecordData.recordOwnerId || billRecordData.senderId || user.id;
const recordReceiverId = billRecordData.receiverId || billRecordData.chatFriendId || recordOwnerId;
const dn2 = JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}');
const drawParam = encodeURIComponent(JSON.stringify(dn2[lt] || [null,null,null,null,null,null,null]));
let url = API + '/speedcalc/stats?lottery=' + lt + '&date=' + window._popupState.historyDate + '&drawNums=' + drawParam;
if (billRecordData.groupId) url += '&groupId=' + encodeURIComponent(billRecordData.groupId);
else url += '&receiverId=' + encodeURIComponent(recordReceiverId) + '&senderFilter=' + encodeURIComponent(recordOwnerId) + '&ownerOnly=1&recordMsgId=' + encodeURIComponent(billRecordData.recordMsgId || '');
fetch(url, { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } }).then(r => r.json()).then(data => {
const s = (data.code === 200 && data.data) ? data.data : {total:0,count:0,out:0,shui:0,yu:0,billList:[],playStats:{}};
window._popupState.historyQueryStats = s;
if (!window._popupState.allLotteryStats) window._popupState.allLotteryStats = {};
window._popupState.allLotteryStats[lt] = s;
forceUpdate();
});
}
forceUpdate();
};
const updateSetting = (key, value) => {
window._popupState.settings[key] = value;
localStorage.setItem('chatSettings_'+user.id, JSON.stringify(window._popupState.settings));
forceUpdate();
};
const togglePlay = (key) => {
window._popupState.expandedPlay = expandedPlay === key ? null : key;
forceUpdate();
};
// 计算统计数据
const senderName = billRecordData.senderName;
// 【修复】优先使用从records计算的统计数据(已按聊天窗口过滤),历史查询时才用API数据
const _cs = window._popupState.computedStats || {};
const stats = historyQueryStats || _cs[popupLottery] || {total:0,count:0,out:0,types:{},numberStats:{},billList:[],userStats:{},playStats:{}};
const totalIn = stats.total || 0;
const billList = stats.billList || [];
// 【修复】检查后端是否计算了赔付:如果 stats.out === undefined 说明没传开奖号码
const hasBackendPayout = stats.out !== undefined;
const totalOut = hasBackendPayout ? (stats.out || 0) : 0;
const rebateRate2 = billRecordData.rebateRate ?? popupSettings.rebateRate ?? 4;
const shui = hasBackendPayout ? (stats.shui ?? Math.round(totalIn * rebateRate2 / 100)) : 0;
const yu = hasBackendPayout ? (stats.yu ?? (totalIn - totalOut - shui)) : 0;
const playStats = stats.playStats || {
tehao: {in:0, out:0, winningStake:0}, pingte: {in:0, out:0, winningStake:0}, pingtewei: {in:0, out:0, winningStake:0}, texiao: {in:0, out:0, winningStake:0},
lianxiao2: {in:0, out:0, winningStake:0}, lianxiao3: {in:0, out:0, winningStake:0}, lianxiao4: {in:0, out:0, winningStake:0}, lianxiao5: {in:0, out:0, winningStake:0},
baobo: {in:0, out:0, winningStake:0}, baodaxiao: {in:0, out:0, winningStake:0}, baodanshuang: {in:0, out:0, winningStake:0}
};
// 设置弹窗
if (showSettings) {
return (
<div className="modal-mask" style={{zIndex:1001,background:'rgba(0,0,0,0.8)'}} onClick={()=>{window._popupState.showSettings=false;forceUpdate();}}>
<div className="modal-box" style={{maxWidth:'340px',background:'#1a0000',border:'2px solid #FFD700'}} onClick={e=>e.stopPropagation()}>
<h3 style={{color:'#FFD700',fontSize:'16px',fontWeight:'bold',textAlign:'center',marginBottom:'16px'}}>⚙️ 统计设置</h3>
<div className="space-y-3">
<div style={{background:'#2a0000',padding:'8px 12px',borderRadius:'8px',marginBottom:'12px'}}>
<span style={{color:'#FF9800',fontSize:'12px'}}> 赔率已内置固定值(特号47、平特2/码2.7、特肖11/码10、连肖4/10/30/60、包波2.5/红波4.4、大小1.8、单双1.8),无需手动设置</span>
</div>
{/* 只保留返点比例 */}
<div>
<label style={{color:'#888',fontSize:'13px',display:'block',marginBottom:'4px'}}>返点比例(%)</label>
<input type="number" style={{width:'100%',padding:'10px 12px',borderRadius:'8px',border:'1px solid #555',background:'#2a0000',color:'#FFD700',fontSize:'15px',outline:'none'}}
value={popupSettings.rebateRate ?? 4}
step="0.1"
onChange={e=>{
const val = parseFloat(e.target.value);
updateSetting('rebateRate', isNaN(val) ? 0 : val);
}} />
</div>
<button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'none',background:'linear-gradient(135deg,#FFD700,#FFA500)',color:'#000',fontSize:'15px',fontWeight:'bold',cursor:'pointer',marginTop:'16px'}} onClick={()=>{window._popupState.showSettings=false;forceUpdate();}}>保存并关闭</button>
{(user.vipLevel === 'super' || user.vipLevel === 'basic' || billRecordData.senderName) && <button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'2px solid #FF9800',background:'#2a0000',color:'#FF9800',fontSize:'15px',fontWeight:'bold',cursor:'pointer',marginTop:'8px'}} onClick={()=>{window._popupState.showSettings=false;window._popupState.showHistoryQuery=true;window._popupState.historyQueryStats=null;forceUpdate();}}>📅 历史统计查询</button>}
</div>
</div>
</div>
);
}
// 历史统计查询弹窗
if (showHistoryQuery) {
// 生成最近5天日期
const dates = [];
for (let i = 0; i < 5; i++) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
dates.push(dateStr);
}
// 查询历史统计
const queryHistoryStats = () => {
if (!historyQueryDate) {
alert('请选择日期');
return;
}
// 【HISTORY-OWNER-FIX-V3】历史查询按点击速算卡片用户 + 日期筛选,和正常查询只差日期
const recordOwnerId = billRecordData.recordOwnerId || billRecordData.senderId || user.id;
const recordReceiverId = billRecordData.receiverId || billRecordData.chatFriendId || recordOwnerId;
const hLottery = window._popupState.lottery || 'macau1';
const hdn = JSON.parse(localStorage.getItem('speedCalcDrawNumbers_'+user.id)||'{}');
const drawParam = encodeURIComponent(JSON.stringify(hdn[hLottery] || [null,null,null,null,null,null,null]));
let url = API + '/speedcalc/stats?lottery=' + hLottery + '&date=' + historyQueryDate + '&drawNums=' + drawParam;
if (billRecordData.groupId) url += '&groupId=' + encodeURIComponent(billRecordData.groupId);
else url += '&receiverId=' + encodeURIComponent(recordReceiverId) + '&senderFilter=' + encodeURIComponent(recordOwnerId) + '&ownerOnly=1&recordMsgId=' + encodeURIComponent(billRecordData.recordMsgId || '');
console.log('[HISTORY-OWNER-FIX-V3] 历史查询URL:', url);
fetch(url, { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } }).then(r => r.json()).then(data => {
const s = (data.code === 200 && data.data) ? data.data : {total:0,count:0,out:0,shui:0,yu:0,billList:[],playStats:{}};
window._popupState.historyQueryStats = s;
window._popupState.historyDate = historyQueryDate;
window._popupState.showHistoryQuery = false;
window._popupState.historyQueryDate = '';
forceUpdate();
});
};
return (
<div className="modal-mask" style={{zIndex:1100,background:'rgba(0,0,0,0.8)'}} onClick={()=>{window._popupState.showHistoryQuery=false;window._popupState.historyQueryDate='';forceUpdate();}}>
<div className="modal-box" style={{maxWidth:'400px',background:'#1a0000',border:'2px solid #FFD700',position:'relative'}} onClick={e=>e.stopPropagation()}>
{/* 右上角关闭按钮 */}
<button style={{position:'absolute',top:'8px',right:'8px',background:'rgba(255,255,255,0.1)',border:'none',color:'#fff',width:'32px',height:'32px',borderRadius:'50%',fontSize:'18px',cursor:'pointer',display:'flex',alignItems:'center',justifyContent:'center'}} onClick={()=>{window._popupState.showHistoryQuery=false;window._popupState.historyQueryDate='';forceUpdate();}}>×</button>
<h3 style={{color:'#FFD700',fontSize:'16px',fontWeight:'bold',textAlign:'center',marginBottom:'16px',paddingRight:'32px'}}>📅 历史统计查询</h3>
<div style={{marginBottom:'16px'}}>
<label style={{color:'#888',fontSize:'13px',display:'block',marginBottom:'8px'}}>选择日期(最近5天)</label>
<select style={{width:'100%',padding:'10px 12px',borderRadius:'8px',border:'1px solid #555',background:'#2a0000',color:'#FFD700',fontSize:'15px',outline:'none'}}
value={historyQueryDate}
onChange={e=>{window._popupState.historyQueryDate=e.target.value;forceUpdate();}}>
<option value="">-- 请选择日期 --</option>
{dates.map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</div>
<button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'none',background:'linear-gradient(135deg,#2196F3,#1976D2)',color:'#fff',fontSize:'15px',fontWeight:'bold',cursor:'pointer'}} onClick={queryHistoryStats}>查询</button>
<button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'2px solid #FF9800',background:'#2a0000',color:'#FF9800',fontSize:'15px',fontWeight:'bold',cursor:'pointer',marginTop:'8px'}} onClick={()=>{window._popupState.showHistoryQuery=false;window._popupState.showSettings=true;forceUpdate();}}>返回设置</button>
</div>
</div>
);
}
return (
<div className="modal-mask" onClick={()=>{setShowBillRecord(false);window._popupState=null;}} style={{background:'rgba(0,0,0,0.8)'}}>
<div className="modal-box" style={{maxHeight:'90vh',overflowY:'auto',background:'#1a0000',border:'2px solid #FFD700',borderRadius:'16px',padding:'0',paddingBottom:'80px',position:'relative'}} onClick={e=>e.stopPropagation()}>
<h3 className="text-gold font-bold text-center mb-3" style={{padding:'16px 16px 8px',margin:0,fontSize:'20px'}}> 下注记录</h3>
<div className="text-gold2 text-xs text-center mb-3" style={{padding:'0 16px 12px'}}>
{billRecordData.chatName} · {senderName}
{historyQueryStats && <span style={{color:'#FF9800',marginLeft:'8px'}}>📅 {historyQueryDate}</span>}
</div>
{/* 彩种切换 */}
<div style={{display:'flex',background:'#000',margin:'0 12px',borderRadius:'8px',overflow:'hidden',marginBottom:'12px'}}>
{[{k:'macau1',v:'澳门1'},{k:'macau2',v:'澳门2'},{k:'hongkong',v:'香港'}].map(l => {
const lotteryStats = (window._popupState.historyQueryStats && window._popupState.allLotteryStats?.[l.k]) || window._popupState.computedStats?.[l.k];
const lotteryTotal = lotteryStats ? (lotteryStats.total || 0) : 0;
return (
<button key={l.k} onClick={()=>updateLottery(l.k)} style={{flex:1,padding:'10px',border:'none',fontSize:'13px',cursor:'pointer',background:popupLottery===l.k?'#2196F3':'transparent',color:popupLottery===l.k?'#fff':'#888',fontWeight:popupLottery===l.k?'bold':'normal'}}>
{l.v}{lotteryTotal > 0 && <span style={{fontSize:'10px',color:popupLottery===l.k?'#FFD700':'#666',marginLeft:'4px'}}>入:{lotteryTotal}</span>}
</button>
);
})}
</div>
{/* 开奖号码输入 */}
<div style={{padding:'0 16px 12px'}}>
<div style={{display:'flex',gap:'6px',justifyContent:'center'}}>
{Array.from({length:7},(_,i)=>i).map(idx => {
const num = dn[idx];
const zodiac = num && NUMBER_TO_ZODIAC[num] ? NUMBER_TO_ZODIAC[num] : '';
return (
<div key={idx} style={{display:'flex',flexDirection:'column',alignItems:'center'}}>
<input type="number" min="1" max="49" placeholder={idx < 6 ? '平' : '特'}
style={{width:'36px',height:'36px',borderRadius:'8px',border:'1px solid #555',background:'#2a0000',color:'#FFD700',fontSize:'14px',textAlign:'center',outline:'none'}}
value={num || ''}
onChange={e=>{
const v=e.target.value===''?'':parseInt(e.target.value);
if(v===''||(!isNaN(v)&&v>=1&&v<=49)){
updateDrawNumber(idx, v===''?'':v);
}
}} />
{zodiac && <div style={{fontSize:'9px',color:'#888',marginTop:'2px'}}>{zodiac}</div>}
</div>
);
})}
</div>
</div>
{/* 总统计:入/出/水/余 + 设置按钮 */}
<div style={{background:'#2a0000',padding:'12px 16px',margin:'0 12px 12px',borderRadius:'8px'}}>
<div style={{display:'flex',gap:'12px',flexWrap:'wrap',marginBottom:'8px'}}>
<div><span style={{color:'#888',fontSize:'12px'}}>入: </span><span style={{color:'#FFD700',fontSize:'14px',fontWeight:'bold'}}>{totalIn.toLocaleString()}</span></div>
{hasBackendPayout && <><div><span style={{color:'#888',fontSize:'12px'}}>出: </span><span style={{color:'#FFD700',fontSize:'14px',fontWeight:'bold'}}>{totalOut.toLocaleString()}</span></div>
<div><span style={{color:'#888',fontSize:'12px'}}>水: </span><span style={{color:'#2196F3',fontSize:'14px',fontWeight:'bold'}}>{shui.toLocaleString()}</span></div>
<div><span style={{color:'#888',fontSize:'12px'}}>余: </span><span style={{color:'#9C27B0',fontSize:'14px',fontWeight:'bold'}}>{yu.toLocaleString()}</span></div></>}
</div>
<div style={{display:'flex',gap:'8px'}}>
<button onClick={()=>{window._popupState.showSettings=true;forceUpdate();}} style={{flex:1,padding:'8px',borderRadius:'8px',border:'1px solid #555',background:'#000',color:'#888',fontSize:'12px',cursor:'pointer'}}>️ 统计设置</button>
{historyQueryStats && <button onClick={()=>{window._popupState.historyQueryStats=null;window._popupState.historyQueryDate='';window._popupState.historyDate='';forceUpdate();}} style={{flex:1,padding:'8px',borderRadius:'8px',border:'1px solid #FF9800',background:'#2a0000',color:'#FF9800',fontSize:'12px',cursor:'pointer'}}>取消历史</button>}
</div>
</div>
{/* 玩法统计详情(可展开) */}
<div style={{padding:'0 16px 16px'}}>
{[
{key:'tehao',label:'特号',odds:'47'},
{key:'pingte',label:'平特',odds:'2(码2.7)'},
{key:'pingtewei',label:'平特尾',odds:'1.75'},
{key:'texiao',label:'特肖',odds:'11(码10)'},
{key:'lianxiao2',label:'二连肖',odds:'4'},
{key:'lianxiao3',label:'三连肖',odds:'10'},
{key:'lianxiao4',label:'四连肖',odds:'30'},
{key:'lianxiao5',label:'五连肖',odds:'60'},
{key:'baobo',label:'包波',odds:'2.5(红4.4)'},
{key:'baodaxiao',label:'包大小',odds:'1.8'},
{key:'baodanshuang',label:'包单双',odds:'1.8'},
].map((ps,idx,arr) => {
const isExpanded = expandedPlay === ps.key;
const playBills = billList.filter(b =>
(b.items || []).some(it => it.playType === ps.key)
).sort((a, b) => new Date(b.time) - new Date(a.time)); // 按时间降序,最新在最上面
const psData = playStats[ps.key] || {in:0, out:0, winningStake:0};
const displayOut = psData.out || 0;
const displayWinningStake = psData.winningStake || 0;
const groupedPlayBills = {};
playBills.forEach(bill => {
const key = bill.senderId || bill.senderName || 'unknown';
const name = bill.senderName || (bill.senderId === user.id ? user.nickname : '未知');
if (!groupedPlayBills[key]) groupedPlayBills[key] = { name, total: 0, count: 0, bills: [] };
groupedPlayBills[key].total += bill.total || 0;
groupedPlayBills[key].count += 1;
groupedPlayBills[key].bills.push(bill);
});
const groupedPlayBillList = Object.keys(groupedPlayBills).map(k => ({ key: k, ...groupedPlayBills[k] }));
return (
<div key={ps.key} style={{marginBottom:'6px'}}>
<div
style={{padding:'12px',background:'#2a0000',borderRadius:'8px',display:'flex',justifyContent:'space-between',alignItems:'center',cursor:'pointer'}}
onClick={() => togglePlay(ps.key)}
>
<span style={{color:'#fff',fontSize:'14px'}}>
{ps.label}<span style={{color:'#666',fontSize:'11px',marginLeft:'6px'}}>{ps.odds}x</span>
<span style={{color:'#888',fontSize:'10px',marginLeft:'6px'}}>({playBills.length}条)</span>
{isExpanded ? ' ▼' : ' ▶'}
</span>
<div>
<span style={{color:'#888',fontSize:'12px'}}>入:{psData.in.toLocaleString()}</span>
{hasBackendPayout && displayWinningStake > 0 && <span style={{color:'#4CAF50',fontSize:'12px',marginLeft:'16px'}}>中:{displayWinningStake.toLocaleString()}×{ps.odds}={displayOut.toLocaleString()}</span>}
{hasBackendPayout && displayWinningStake === 0 && displayOut > 0 && <span style={{color:'#888',fontSize:'12px',marginLeft:'16px'}}>出:{displayOut.toLocaleString()}</span>}
</div>
</div>
{isExpanded && playBills.length > 0 && (
<div style={{padding:'8px',background:'#2a0000',borderRadius:'0 0 8px 8px',marginTop:'-4px'}}>
{groupedPlayBillList.map(group => (
<div key={group.key} style={{marginBottom:'10px',border:'1px solid rgba(255,215,0,0.18)',borderRadius:'8px',overflow:'hidden'}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',padding:'7px 9px',background:'rgba(255,215,0,0.08)',borderBottom:'1px solid rgba(255,215,0,0.12)'}}>
<span style={{color:'#FFD700',fontWeight:'bold',fontSize:'12px'}}>👤 {group.name}</span>
<span style={{color:'#4CAF50',fontWeight:'bold',fontSize:'11px'}}>入:{group.total.toLocaleString()} {group.count}条</span>
</div>
{group.bills.map((bill, bi) => {
const playItems = (bill.items || []).filter(it => it.playType === ps.key);
// 【修复】检查是否有开奖号码:如果所有 item._payout 都是 undefined(后端没算),说明没开奖号码
const hasDrawNums = playItems.some(it => it._payout !== undefined);
const totalPayout = playItems.reduce((sum, it) => sum + (it._payout || 0), 0);
const hasWin = hasDrawNums && totalPayout > 0;
return (
<div key={(bill.id || bill.msgId || bi)} style={{
padding:'8px 10px',
marginBottom:'6px',
background: hasWin ? '#3a2200' : '#1a0000',
borderLeft: hasWin ? '3px solid #FF9800' : '3px solid #555',
borderRadius:'6px',
fontSize:'12px'
}}>
<div style={{display:'flex',justifyContent:'space-between',marginBottom:'4px'}}>
<span style={{color:'#FFD700',fontWeight:'bold'}}>{bill.senderName || group.name}</span>
<div style={{display:'flex',alignItems:'center',gap:'8px'}}>
<span style={{color:'#888',fontSize:'10px'}}>{new Date(bill.time).toLocaleString('zh-CN', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'})}</span>
<span style={{color:'#666',fontSize:'12px',cursor:'pointer',padding:'2px 4px'}} onClick={(e)=>{e.stopPropagation();if(!confirm('确定删除这条账单?'))return;const token=localStorage.getItem('token');fetch(API+'/speedcalc/bills/'+encodeURIComponent(bill.msgId||bill.id),{method:'DELETE',headers:{'Authorization':'Bearer '+token}}).then(r=>r.json()).then(d=>{if(d.code===200){if(window._loadServerBills)window._loadServerBills();}else{alert(d.message||'删除失败');}}).catch(()=>alert('删除失败'));}}>✕</span>
</div>
</div>
<div style={{color:'#aaa',fontSize:'11px',marginBottom:'2px'}}>{bill.content}</div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginTop:'4px'}}>
<span style={{color:'#4CAF50',fontWeight:'bold'}}>收:{(bill.total || 0).toLocaleString()}</span>
{!hasDrawNums ? (
<span style={{color:'#888'}}>待开奖</span>
) : hasWin ? (
<span style={{color:'#FF9800',fontWeight:'bold'}}>🎉 中:{totalPayout.toLocaleString()}</span>
) : (
<span style={{color:'#888'}}>未中</span>
)}
</div>
</div>
);
})}
</div>
))}
</div>
)}
</div>
);
})}
</div>
{/* 固定悬浮关闭按钮 */}
<div style={{position:'fixed',bottom:'20px',left:'50%',transform:'translateX(-50%)',width:'calc(100% - 32px)',maxWidth:'500px',zIndex:1000}}>
<button onClick={()=>{setShowBillRecord(false);window._popupState=null;}} style={{width:'100%',padding:'14px',borderRadius:'12px',border:'none',background:'linear-gradient(135deg,#FFD700,#FFA500)',color:'#000',fontWeight:'bold',fontSize:'16px',boxShadow:'0 4px 12px rgba(0,0,0,0.3)'}}>关闭</button>
</div>
</div>
</div>
);
})()}
{/* 底部导航 */}
<div className="bottom-nav">
<div className={`nav-item ${tab==='lobby'?'active':''}`} onClick={()=>{setTab('lobby');tabRef.current='lobby';}}>
<span className="icon">🏠</span><span>大厅</span>
</div>
<div className={`nav-item ${tab==='friends'?'active':''}`} onClick={()=>{setTab('friends');tabRef.current='friends';try{if(window.AndroidNotify&&window.AndroidNotify.clearBadge)window.AndroidNotify.clearBadge();if(navigator.clearAppBadge)navigator.clearAppBadge();}catch(e){}}} style={{position:'relative'}}>
<span className="icon">💬</span><span>好友</span>
{friendUnread > 0 && <div style={{position:'absolute',top:'0px',right:'50%',marginRight:'-20px',background:'#FF3B30',color:'#fff',borderRadius:'10px',minWidth:'18px',height:'18px',fontSize:'11px',display:'flex',alignItems:'center',justifyContent:'center',fontWeight:'bold',padding:'0 4px',border:'2px solid #f5f5f5'}}>{friendUnread > 99 ? '99+' : friendUnread}</div>}
</div>
<div className={`nav-item ${tab==='promotion'?'active':''}`} onClick={()=>{setTab('promotion');tabRef.current='promotion';}}>
<span className="icon">🎁</span><span>推广</span>
</div>
<div className={`nav-item ${tab==='profile'?'active':''}`} onClick={()=>{setTab('profile');tabRef.current='profile';}}>
<span className="icon">👤</span><span>个人中心</span>
</div>
</div>
{/* 【修复-用户资料】用户资料面板(左侧滑出,类似微信资料页)[PROFILE-PANEL-V1] */}
{profilePanel && (
<div key={profilePanel.id || 'p'} style={{animation:'slideInLeft 0.25s ease-out'}}>
<>
{/* 遮罩 */}
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.5)',zIndex:998}} onClick={()=>{setProfilePanel(null);setProfileMenu(false);}} />
{/* 面板 */}
<div style={{position:'fixed',top:0,left:0,width:'100%',height:'100%',background:'#1a0000',zIndex:999,overflowY:'auto',boxShadow:'4px 0 20px rgba(0,0,0,0.5)',animation:'slideInLeft 0.25s ease-out'}}>
{/* 顶部栏 */}
<div style={{display:'flex',alignItems:'center',padding:'12px',background:'#3A0000',borderBottom:'1px solid rgba(255,215,0,0.3)'}}>
<button style={{background:'none',border:'none',color:'#FFD700',fontSize:'22px',cursor:'pointer'}} onClick={()=>{setProfilePanel(null);setProfileMenu(false);}}>←</button>
<span style={{color:'#fff',fontWeight:'bold',flex:1,fontSize:'16px'}}>用户资料</span>
{/* 右上角三个小横点菜单 */}
<div style={{position:'relative'}}>
<button style={{background:'none',border:'none',color:'#FFD700',fontSize:'22px',cursor:'pointer',padding:'4px',lineHeight:1}} onClick={()=>setProfileMenu(!profileMenu)}>⋮</button>
{profileMenu && (
<div style={{position:'absolute',right:0,top:'100%',background:'#2a0000',border:'1px solid rgba(255,215,0,0.3)',borderRadius:'8px',boxShadow:'0 4px 12px rgba(0,0,0,0.5)',zIndex:100,minWidth:'150px',overflow:'hidden'}}>
<div style={{padding:'12px 16px',color:'#fff',fontSize:'14px',cursor:'pointer',borderBottom:'1px solid rgba(255,255,255,0.1)',background:'rgba(255,215,0,0.05)'}} onClick={handleSetRemark}>
️ 备注好友名称{profilePanel.remark ? ' (' + profilePanel.remark + ')' : ''}
</div>
<div style={{padding:'12px 16px',color:'#fff',fontSize:'14px',cursor:'pointer',borderBottom:'1px solid rgba(255,255,255,0.1)'}} onClick={()=>{setProfileMenu(false);setRecommendPicker({targetUser: profilePanel});}}>
📤 推荐给好友
</div>
<div style={{padding:'12px 16px',color:'#fff',fontSize:'14px',cursor:'pointer'}} onClick={()=>{
setProfileMenu(false);
if (navigator.share) {
navigator.share({title: profilePanel.nickname, text: '来ABC娱乐和我一起玩吧!'});
} else if (navigator.clipboard) {
navigator.clipboard.writeText(window.location.href).then(() => showToast('链接已复制到剪贴板')).catch(() => {});
} else { showToast('添加到桌面功能开发中'); }
}}>
📱 添加到桌面
</div>
</div>
)}
</div>
</div>
{/* 用户信息区 */}
<div style={{padding:'24px 16px',textAlign:'center'}}>
<div style={{width:'80px',height:'80px',borderRadius:'50%',overflow:'hidden',margin:'0 auto 12px',background:profilePanel.role==='customer_service'?'linear-gradient(135deg,#F44336,#C62828)':'rgba(255,255,255,0.1)',border:'2px solid #FFD700'}}>
{profilePanel.avatar ? (
<img src={profilePanel.avatar} style={{width:'100%',height:'100%',objectFit:'cover'}} onError={(e)=>{e.target.style.display='none';e.target.nextSibling.style.display='flex';}} />
) : null}
<div style={{width:'100%',height:'100%',display:profilePanel.avatar?'none':'flex',alignItems:'center',justifyContent:'center',fontSize:'36px',color:profilePanel.role==='customer_service'?'#fff':'#999'}}>
{profilePanel.role==='customer_service'?'💼':'👤'}
</div>
</div>
<div style={{color:'#fff',fontSize:'20px',fontWeight:'bold',marginBottom:'4px'}}>{profilePanel.remark ? profilePanel.remark : (profilePanel.nickname || '未知用户')}</div>
{profilePanel.remark && <div style={{color:'#aaa',fontSize:'12px',marginTop:'2px'}}>昵称:{profilePanel.nickname || '-'}</div>}
{profilePanel.role==='customer_service' && <span style={{fontSize:'12px',color:'#FFD700',background:'rgba(255,215,0,0.15)',padding:'2px 8px',borderRadius:'8px'}}>客服</span>}
<div style={{color:'#999',fontSize:'13px',marginTop:'12px'}}>用户ID: {profilePanel.id || '-'}</div>
{profilePanel.phone && <div style={{color:'#999',fontSize:'13px',marginTop:'4px'}}>手机: {profilePanel.phone}</div>}
{profilePanel.createdAt && <div style={{color:'#999',fontSize:'13px',marginTop:'4px'}}>注册时间: {new Date(profilePanel.createdAt).toLocaleDateString('zh-CN')}</div>}
{profilePanel.isFriend === false && <div style={{color:'#FF9800',fontSize:'12px',marginTop:'8px'}}>对方还不是你的好友</div>}
</div>
{/* 操作按钮区 */}
<div style={{padding:'16px',borderTop:'1px solid rgba(255,215,0,0.1)'}}>
{profilePanel.isFriend === false ? (
<button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'none',background:'linear-gradient(135deg,#4CAF50,#2E7D32)',color:'#fff',fontSize:'15px',fontWeight:'bold',cursor:'pointer',marginBottom:'8px'}} onClick={async ()=>{
try {
const res = await post('/friends/add', { query: profilePanel.id });
showToast(res.message);
if (res.code === 200) { setProfilePanel(null); setProfileMenu(false); }
} catch(e) { showToast('申请发送失败'); }
}}>➕ 申请加为好友</button>
) : (
<button style={{width:'100%',padding:'12px',borderRadius:'10px',border:'none',background:'linear-gradient(135deg,#FFD700,#FFA500)',color:'#000',fontSize:'15px',fontWeight:'bold',cursor:'pointer',marginBottom:'8px'}} onClick={()=>{
setProfilePanel(null);
setProfileMenu(false);
openChat(profilePanel);
}}>💬 发送消息</button>
)}
</div>
</div>
</>
</div>
)}
{/* 【CARD-V1】推荐给好友弹窗:选择联系人发送名片 */}
{recommendPicker && (
<div style={{position:'fixed',top:0,left:0,right:0,bottom:0,background:'rgba(0,0,0,0.6)',zIndex:1100,display:'flex',alignItems:'center',justifyContent:'center'}} onClick={()=>setRecommendPicker(null)}>
<div style={{background:'#1a0000',border:'1px solid rgba(255,215,0,0.3)',borderRadius:'16px',width:'85%',maxWidth:'360px',maxHeight:'70vh',overflow:'hidden',display:'flex',flexDirection:'column'}} onClick={e=>e.stopPropagation()}>
{/* 标题栏 */}
<div style={{padding:'16px',borderBottom:'1px solid rgba(255,215,0,0.2)',display:'flex',alignItems:'center',justifyContent:'space-between'}}>
<span style={{color:'#fff',fontSize:'16px',fontWeight:'bold'}}>📤 推荐给好友</span>
<button style={{background:'none',border:'none',color:'#999',fontSize:'20px',cursor:'pointer'}} onClick={()=>setRecommendPicker(null)}>✕</button>
</div>
{/* 被推荐人信息 */}
<div style={{padding:'12px 16px',borderBottom:'1px solid rgba(255,255,255,0.08)',display:'flex',alignItems:'center',gap:'10px'}}>
<div style={{width:'40px',height:'40px',borderRadius:'50%',overflow:'hidden',flexShrink:0,background:'rgba(255,255,255,0.1)'}}>
{recommendPicker.targetUser?.avatar ? <img src={recommendPicker.targetUser.avatar} style={{width:'100%',height:'100%',objectFit:'cover'}} /> : <div style={{width:'100%',height:'100%',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'18px',color:'#999'}}>👤</div>}
</div>
<div style={{flex:1}}>
<div style={{color:'#FFD700',fontSize:'13px'}}>推荐:{recommendPicker.targetUser?.nickname || '未知'}</div>
<div style={{color:'#999',fontSize:'11px'}}>选择要推荐给哪个好友</div>
</div>
</div>
{/* 最近联系人列表 */}
<div style={{flex:1,overflowY:'auto',padding:'8px'}}>
{appFriends.filter(f => f.id !== recommendPicker.targetUser?.id).length === 0 ? (
<div style={{textAlign:'center',padding:'24px',color:'#999',fontSize:'13px'}}>暂无可推荐的好友</div>
) : (
appFriends.filter(f => f.id !== recommendPicker.targetUser?.id).map(f => (
<div key={f.id} style={{display:'flex',alignItems:'center',gap:'10px',padding:'10px 12px',borderRadius:'10px',cursor:'pointer',marginBottom:'2px'}}
onMouseEnter={e=>e.currentTarget.style.background='rgba(255,215,0,0.08)'}
onMouseLeave={e=>e.currentTarget.style.background='transparent'}
onClick={async ()=>{
const targetFriend = f;
const cardUser = recommendPicker.targetUser;
const cardContent = JSON.stringify({ type:'card', cardUser: { id: cardUser.id, nickname: cardUser.nickname, avatar: cardUser.avatar } });
try {
const token = localStorage.getItem('token');
const res = await fetch(API + '/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ receiverId: targetFriend.id, content: cardContent, type: 'card' })
}).then(r => r.json());
if (res.code === 200) {
showToast('已将 ' + (cardUser.nickname || '') + ' 的名片推荐给 ' + (targetFriend.remark || targetFriend.nickname));
// 发送方本地也插入一条 card 消息,下次打开聊天可看到名片卡片
try {
const localKey = 'chat_' + user.id + '_' + targetFriend.id;
let localMsgs = JSON.parse(localStorage.getItem(localKey) || '[]');
localMsgs.push({
id: (res.data && res.data.id) || ('tmp_card_' + Date.now()),
senderId: user.id,
senderName: user.nickname,
receiverId: targetFriend.id,
content: cardContent,
type: 'card',
createdAt: new Date().toISOString(),
billStats: null
});
localStorage.setItem(localKey, JSON.stringify(localMsgs));
} catch(e) {}
setRecommendPicker(null);
} else {
showToast('发送失败:' + (res.message || '未知错误'));
}
} catch(e) { showToast('发送失败'); }
}}>
<div style={{width:'40px',height:'40px',borderRadius:'50%',overflow:'hidden',flexShrink:0,background:'rgba(255,255,255,0.1)'}}>
{f.avatar ? <img src={f.avatar} style={{width:'100%',height:'100%',objectFit:'cover'}} /> : <div style={{width:'100%',height:'100%',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'16px',color:'#999'}}>👤</div>}
</div>
<div style={{flex:1}}>
<div style={{color:'#fff',fontSize:'14px',fontWeight:'bold'}}>{f.remark || f.nickname}</div>
{f.remark && <div style={{color:'#999',fontSize:'11px'}}>{f.nickname}</div>}
</div>
<span style={{color:'#FFD700',fontSize:'11px',border:'1px solid rgba(255,215,0,0.4)',borderRadius:'12px',padding:'3px 10px'}}>推荐</span>
</div>
))
)}
</div>
</div>
</div>
)}
</div>
);
}
// 【CRASH-GUARD-v1】ErrorBoundary:捕获 React 渲染崩溃,避免整个 APP 卡死白屏
class CrashGuard extends React.Component {
constructor(props) { super(props); this.state = { hasError: false, errMsg: '', errStack: '' }; }
static getDerivedStateFromError(error) { return { hasError: true, errMsg: (error && error.message) || String(error), errStack: (error && error.stack) || '' }; }
componentDidCatch(error, info) {
console.error('[CRASH-GUARD-v1] React tree crashed:', error, info);
try { window.__lastReactCrash = { message: error && error.message, stack: error && error.stack, info: info && info.componentStack }; } catch(e) {}
}
_hardReset = () => {
try { localStorage.clear(); } catch(e) {}
try { sessionStorage.clear(); } catch(e) {}
try {
if ('caches' in window) { caches.keys().then(ks => Promise.all(ks.map(k => caches.delete(k)))).finally(() => location.reload(true)); }
else { location.reload(true); }
} catch(e) { location.reload(true); }
};
_softReload = () => { try { location.reload(true); } catch(e) { location.href = '/'; } };
render() {
if (this.state.hasError) {
return React.createElement('div', { style: { position:'fixed', inset:0, background:'rgba(0,0,0,0.92)', color:'#fff', zIndex:2147483647, padding:'30px 20px', boxSizing:'border-box', overflowY:'auto', fontFamily:'sans-serif' } },
React.createElement('div', { style:{ fontSize:'22px', fontWeight:'bold', color:'#FFD700', marginBottom:'14px' } }, '⚠️ 应用遇到错误'),
React.createElement('div', { style:{ fontSize:'13px', color:'#FFB3B3', background:'rgba(255,0,0,0.15)', padding:'10px', borderRadius:'8px', marginBottom:'14px', wordBreak:'break-all', maxHeight:'30vh', overflowY:'auto' } }, this.state.errMsg || '未知错误'),
React.createElement('div', { style:{ fontSize:'12px', color:'rgba(255,255,255,0.7)', marginBottom:'18px', lineHeight:1.6 } }, '点击下方按钮可恢复。如果多次重试仍有问题,请点击“清缓存重载”。'),
React.createElement('button', { onClick:this._softReload, style:{ display:'block', width:'100%', padding:'14px', fontSize:'16px', fontWeight:'bold', background:'#4CAF50', color:'#fff', border:'none', borderRadius:'10px', marginBottom:'12px', cursor:'pointer' } }, '🔄 重新加载'),
React.createElement('button', { onClick:this._hardReset, style:{ display:'block', width:'100%', padding:'14px', fontSize:'16px', fontWeight:'bold', background:'#FF5722', color:'#fff', border:'none', borderRadius:'10px', marginBottom:'12px', cursor:'pointer' } }, '🧹 清缓存重载(推荐)'),
React.createElement('button', { onClick:()=>{ location.href='/'; }, style:{ display:'block', width:'100%', padding:'14px', fontSize:'16px', background:'#607D8B', color:'#fff', border:'none', borderRadius:'10px', cursor:'pointer' } }, '🏠 返回首页'),
React.createElement('div', { style:{ fontSize:'10px', color:'rgba(255,255,255,0.4)', marginTop:'18px', wordBreak:'break-all', maxHeight:'20vh', overflowY:'auto' } }, this.state.errStack)
);
}
return this.props.children;
}
}
// 【CRASH-GUARD-v2】2026/05/30 移除顶部红色全局错误横幅(仅保留 ErrorBoundary,避免频繁跳出干扰 UI;activeGroupRef 错误根治后不再需要这个兑底)
(function() {
window.addEventListener('error', (e) => { console.warn('[CRASH-GUARD-v2][window.error]', e && e.message); });
window.addEventListener('unhandledrejection', (e) => { const msg = e && e.reason && (e.reason.message || String(e.reason)); console.warn('[CRASH-GUARD-v2][unhandledrejection]', msg); });
console.log('[CRASH-GUARD-v2] Silent global error logger installed (崩溃 UI 仅由 ErrorBoundary 接管)');
})();
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(CrashGuard, null, React.createElement(App)));
</script>
<script>
// 移除加载动画并清除兜底定时器
try { if (window._clearLoadingFallback) window._clearLoadingFallback(); } catch(e) {}
setTimeout(() => { const ld = document.getElementById('_loading_'); if(ld) ld.remove(); }, 300);
// PWA Service Worker注册:带版本号,避免旧 sw.js 继续控制页面
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'FORCE_RELOAD_ONCE') {
const key = 'swReloaded_' + event.data.version;
if (!sessionStorage.getItem(key)) {
sessionStorage.setItem(key, '1');
// 确认有网络再刷新,避免离线时白屏
if (navigator.onLine) {
location.reload(true);
} else {
window.addEventListener('online', () => location.reload(true), { once: true });
}
}
}
});
navigator.serviceWorker.register('/sw.js?v=20260606-auto-update').then(reg => {
try { reg.update(); } catch(e) {}
});
}
// PWA安装提示监听
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
window.deferredPrompt = e;
});
// 等待DOMContentLoaded事件
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
// 页面加载完成后的初始化
});
}
</script>
</body>
</html>
Sitemap
Кол-во: 0
XML-карта сайта для поисковиков
?
Sitemap.xml помогает поисковику быстрее находить и индексировать страницы. Особенно важен для крупных сайтов и новых страниц, на которые ещё нет входящих ссылок.
Robots.txt не содержит ссылку на карту сайта. Рекомендуется добавить карту сайта и указать ссылку на нее в robots.txt.
Внутренние ссылки
Не найдено
Ссылки на другие страницы своего сайта
?
Внутренние ссылки распределяют ссылочный вес между страницами и помогают поисковику обходить сайт. Пустые анкоры и ссылки на запрещённые robots.txt страницы — типичные ошибки.
Внутренних ссылок на странице 0 оптимально.
Внешние ссылки
Не найдено
Ссылки на сторонние сайты
?
Исходящие внешние ссылки передают часть ссылочного веса на чужие сайты. Ссылки на авторитетные ресурсы безопасны; ссылки на мусорные сайты могут навредить репутации страницы.
Внешних ссылок на странице не найдено.
Конкуренты Готовность: 0%
Конкуренты в Яндексе
Кол-во: 0
Топ сайтов-конкурентов в Яндексе
?
Сайты, чаще всего появляющиеся в ТОПе Яндекса по запросам из семантического ядра этой страницы.
Мы не нашли у вас конкурентов в Яндексе. Сайт или очень молодой или плохо продвигается.
Конкурентов в ТОП-10 Яндекса не нашлось.
Конкуренты в Google
Кол-во: 0
Топ сайтов-конкурентов в Google
?
Сайты, чаще всего появляющиеся в ТОПе Google по запросам из семантического ядра этой страницы.
Конкуренты в Google тоже не найдены. Займитесь продвижением сайта!
Конкурентов в ТОП-10 Google не нашлось.
ЗоЗПП: права потребителей Готовность: 100%
Нарушения
Не выявлены
Признаков дистанционной продажи товаров (интернет-магазина) не обнаружено — требования ЗоЗПП о раскрытии информации продавца к сайту не применяются. Нарушений нет.
ФЗ-149: рекомендательные технологии Готовность: 100%
Нарушения
Не выявлены
Рекомендательные блоки («с этим покупают», «похожие товары» и т.п.) на сайте не обнаружены — требования ст. 10.7 ФЗ-149 к сайту не применяются. Нарушений нет.
ФЗ-38: реклама Готовность: 100%
Нарушения
Не выявлены
Рекламных тематик с обязательными оговорками (медицина, БАД, кредиты и займы, новостройки) на сайте не обнаружено. Нарушений нет.
ФЗ-436: защита детей Готовность: 100%
Нарушения
Не выявлены
Признаков информационной продукции (новости, видео, книги, игры, курсы) не обнаружено — обязательная возрастная маркировка по ФЗ-436 сайту не требуется. Нарушений нет.
Вердикт
Оптимизация сайта mnspeak.com выполнена всего на 42%. Чтобы исправить ситуацию и попасть в ТОП необходимо:
Исправьте ошибки в мета-тегах.
Исправьте ошибки оптимизации.
Исправьте ошибки индексации.
Поделитесь с друзьями: